mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
Merge pull request #65 from Tria-plc/freight_feature/rule_engine_and_design
Freight feature/rule engine and design
This commit is contained in:
15
apps/edr-freight-api/src/common/utils/generate-code.util.ts
Normal file
15
apps/edr-freight-api/src/common/utils/generate-code.util.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Derives a stable, uppercase, underscore-separated code from a human-readable name.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* "Hazard Surcharge" → "HAZARD_SURCHARGE"
|
||||||
|
* "20ft Dry Container" → "20FT_DRY_CONTAINER"
|
||||||
|
* "Kality Yard (ET)" → "KALITY_YARD_ET"
|
||||||
|
*/
|
||||||
|
export function generateCode(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align weight_limit_rules.trade_direction with app code: IMPORT, EXPORT, BOTH (not ANY).
|
||||||
|
*/
|
||||||
|
export class NormalizeWeightLimitTradeDirectionBoth1749000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'NormalizeWeightLimitTradeDirectionBoth1749000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
UPDATE freight.weight_limit_rules
|
||||||
|
SET trade_direction = 'BOTH'
|
||||||
|
WHERE trade_direction::text = 'ANY';
|
||||||
|
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// No-op: ANY is not a valid enum value in PostgreSQL.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
// import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
import { CargoTypesService } from '../services/cargo-types.service';
|
import { CargoTypesService } from '../services/cargo-types.service';
|
||||||
|
|
||||||
@@ -39,8 +39,7 @@ export class CargoTypesController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a cargo type' })
|
@ApiOperation({ summary: 'Create a cargo type' })
|
||||||
create(@Body() dto: any) {
|
create(@Body() dto: CreateCargoTypeDto) {
|
||||||
return dto;
|
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateCargoTypeDto {
|
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 })
|
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateContainerTypeDto {
|
export class CreateContainerTypeDto {
|
||||||
@ApiProperty({ description: 'Unique container code, e.g. 20DV, 40HC', maxLength: 20 })
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
code!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreatePriorityRuleDto {
|
export class CreatePriorityRuleDto {
|
||||||
@ApiProperty({ description: 'Unique rule code, e.g. USD_PAYER, GOV_REQUEST', maxLength: 40 })
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(40)
|
|
||||||
code!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||||
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
|
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'ANY'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
const CURRENCIES = ['ETB', 'USD'] as const;
|
const CURRENCIES = ['ETB', 'USD'] as const;
|
||||||
|
|
||||||
export class CreateRateDto {
|
export class CreateRateDto {
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateServiceTypeDto {
|
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 })
|
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ const TRIGGER_CONDITIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export class CreateSurchargeTypeDto {
|
export class CreateSurchargeTypeDto {
|
||||||
@ApiProperty({ description: 'Unique code, e.g. HAZARD, REEFER, OVERWEIGHT', maxLength: 40 })
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(40)
|
|
||||||
code!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'ANY'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
|
|
||||||
export class CreateWeightLimitRuleDto {
|
export class CreateWeightLimitRuleDto {
|
||||||
@ApiProperty({ description: 'FK to container_types.id' })
|
@ApiProperty({ description: 'FK to container_types.id' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
containerTypeId!: string;
|
containerTypeId!: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or ANY' })
|
@ApiProperty({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or BOTH' })
|
||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
tradeDirection!: string;
|
tradeDirection!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateYardDto {
|
export class CreateYardDto {
|
||||||
@ApiProperty({ description: 'Unique yard code, e.g. KALITY, DJIB_PORT', maxLength: 20 })
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
code!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
|
|||||||
.createQueryBuilder('rule')
|
.createQueryBuilder('rule')
|
||||||
.innerJoinAndSelect('rule.containerType', 'ct')
|
.innerJoinAndSelect('rule.containerType', 'ct')
|
||||||
.where('rule.container_type_id = :containerTypeId', { containerTypeId })
|
.where('rule.container_type_id = :containerTypeId', { containerTypeId })
|
||||||
.andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :any)', {
|
.andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', {
|
||||||
dir: tradeDirection,
|
dir: tradeDirection,
|
||||||
any: 'ANY',
|
both: 'BOTH',
|
||||||
})
|
})
|
||||||
.andWhere('rule.effective_from <= :now', { now })
|
.andWhere('rule.effective_from <= :now', { now })
|
||||||
.andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now })
|
.andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ILike } from 'typeorm';
|
import { ILike } from 'typeorm';
|
||||||
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
import { CargoType } from '../entities/cargo-type.entity';
|
import { CargoType } from '../entities/cargo-type.entity';
|
||||||
@@ -58,14 +59,15 @@ export class CargoTypesService {
|
|||||||
|
|
||||||
/** Create a new cargo type. */
|
/** Create a new cargo type. */
|
||||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||||
const existing = await this.repository.findByCode(dto.code);
|
const code = generateCode(dto.cargoTypeName);
|
||||||
if (existing) throw new ConflictException(`Cargo type with code "${dto.code}" already exists`);
|
const existing = await this.repository.findByCode(code);
|
||||||
|
if (existing) throw new ConflictException(`Cargo type with name "${dto.cargoTypeName}" conflicts with existing code "${code}"`);
|
||||||
if (dto.parentGroupId) {
|
if (dto.parentGroupId) {
|
||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||||
}
|
}
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
code: dto.code,
|
code,
|
||||||
cargoTypeName: dto.cargoTypeName,
|
cargoTypeName: dto.cargoTypeName,
|
||||||
parentGroupId: dto.parentGroupId ?? null,
|
parentGroupId: dto.parentGroupId ?? null,
|
||||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||||
@@ -78,12 +80,6 @@ export class CargoTypesService {
|
|||||||
/** Update an existing cargo type. */
|
/** Update an existing cargo type. */
|
||||||
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
|
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
|
||||||
await this.findById(id);
|
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) {
|
||||||
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
|
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
|
||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||||
import { ContainerType } from '../entities/container-type.entity';
|
import { ContainerType } from '../entities/container-type.entity';
|
||||||
@@ -27,7 +28,7 @@ export class ContainerTypesService {
|
|||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
const [data, total] = await this.repository.findAndCount({
|
||||||
where,
|
where,
|
||||||
order: { code: 'ASC' },
|
order: { displayOrder: 'ASC' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
@@ -43,10 +44,11 @@ export class ContainerTypesService {
|
|||||||
|
|
||||||
/** Create a new container type. */
|
/** Create a new container type. */
|
||||||
async create(dto: CreateContainerTypeDto): Promise<ContainerType> {
|
async create(dto: CreateContainerTypeDto): Promise<ContainerType> {
|
||||||
const existing = await this.repository.findByCode(dto.code);
|
const code = generateCode(dto.label);
|
||||||
if (existing) throw new ConflictException(`Container type with code "${dto.code}" already exists`);
|
const existing = await this.repository.findByCode(code);
|
||||||
|
if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
code: dto.code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
sizeFt: dto.sizeFt,
|
sizeFt: dto.sizeFt,
|
||||||
wagonsPerUnit: dto.wagonsPerUnit,
|
wagonsPerUnit: dto.wagonsPerUnit,
|
||||||
@@ -60,12 +62,6 @@ export class ContainerTypesService {
|
|||||||
/** Update an existing container type. */
|
/** Update an existing container type. */
|
||||||
async update(id: string, dto: UpdateContainerTypeDto): Promise<ContainerType> {
|
async update(id: string, dto: UpdateContainerTypeDto): Promise<ContainerType> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
if (dto.code) {
|
|
||||||
const conflict = await this.repository.findByCode(dto.code);
|
|
||||||
if (conflict && conflict.id !== id) {
|
|
||||||
throw new ConflictException(`Container type with code "${dto.code}" already exists`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updated = await this.repository.update(id, dto);
|
const updated = await this.repository.update(id, dto);
|
||||||
if (!updated) throw new NotFoundException(`Container type ${id} not found`);
|
if (!updated) throw new NotFoundException(`Container type ${id} not found`);
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
|
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
|
||||||
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
|
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
|
||||||
import { PriorityRule } from '../entities/priority-rule.entity';
|
import { PriorityRule } from '../entities/priority-rule.entity';
|
||||||
@@ -27,7 +28,7 @@ export class PriorityRulesService {
|
|||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
const [data, total] = await this.repository.findAndCount({
|
||||||
where,
|
where,
|
||||||
order: { code: 'ASC' },
|
order: { label: 'ASC' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
@@ -43,12 +44,13 @@ export class PriorityRulesService {
|
|||||||
|
|
||||||
/** Create a new priority rule. */
|
/** Create a new priority rule. */
|
||||||
async create(dto: CreatePriorityRuleDto): Promise<PriorityRule> {
|
async create(dto: CreatePriorityRuleDto): Promise<PriorityRule> {
|
||||||
const existing = await this.repository.findAll({ where: { code: dto.code } });
|
const code = generateCode(dto.label);
|
||||||
|
const existing = await this.repository.findAll({ where: { code } });
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
throw new ConflictException(`Priority rule with code "${dto.code}" already exists`);
|
throw new ConflictException(`Priority rule with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||||
}
|
}
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
code: dto.code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
score: dto.score,
|
score: dto.score,
|
||||||
conditionCurrency: dto.conditionCurrency ?? null,
|
conditionCurrency: dto.conditionCurrency ?? null,
|
||||||
@@ -59,7 +61,8 @@ export class PriorityRulesService {
|
|||||||
/** Update an existing priority rule. */
|
/** Update an existing priority rule. */
|
||||||
async update(id: string, dto: UpdatePriorityRuleDto): Promise<PriorityRule> {
|
async update(id: string, dto: UpdatePriorityRuleDto): Promise<PriorityRule> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
const updated = await this.repository.update(id, dto);
|
const { ...patch } = dto;
|
||||||
|
const updated = await this.repository.update(id, patch);
|
||||||
if (!updated) throw new NotFoundException(`Priority rule ${id} not found`);
|
if (!updated) throw new NotFoundException(`Priority rule ${id} not found`);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ILike } from 'typeorm';
|
import { ILike } from 'typeorm';
|
||||||
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||||
import { ServiceType } from '../entities/service-type.entity';
|
import { ServiceType } from '../entities/service-type.entity';
|
||||||
@@ -55,10 +56,11 @@ export class ServiceTypesService {
|
|||||||
|
|
||||||
/** Create a new service type. */
|
/** Create a new service type. */
|
||||||
async create(dto: CreateServiceTypeDto): Promise<ServiceType> {
|
async create(dto: CreateServiceTypeDto): Promise<ServiceType> {
|
||||||
const existing = await this.repository.findByCode(dto.code);
|
const code = generateCode(dto.serviceName);
|
||||||
if (existing) throw new ConflictException(`Service type with code "${dto.code}" already exists`);
|
const existing = await this.repository.findByCode(code);
|
||||||
|
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
code: dto.code,
|
code,
|
||||||
serviceName: dto.serviceName,
|
serviceName: dto.serviceName,
|
||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
canBeBookedAlone: dto.canBeBookedAlone ?? true,
|
canBeBookedAlone: dto.canBeBookedAlone ?? true,
|
||||||
@@ -74,13 +76,8 @@ export class ServiceTypesService {
|
|||||||
/** Update an existing service type. */
|
/** Update an existing service type. */
|
||||||
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
|
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
if (dto.code) {
|
const { ...patch } = dto;
|
||||||
const conflict = await this.repository.findByCode(dto.code);
|
const updated = await this.repository.update(id, patch);
|
||||||
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`);
|
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
|
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
|
||||||
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
|
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
|
||||||
import { SurchargeType } from '../entities/surcharge-type.entity';
|
import { SurchargeType } from '../entities/surcharge-type.entity';
|
||||||
@@ -43,10 +44,11 @@ export class SurchargeTypesService {
|
|||||||
|
|
||||||
/** Create a new surcharge type. */
|
/** Create a new surcharge type. */
|
||||||
async create(dto: CreateSurchargeTypeDto): Promise<SurchargeType> {
|
async create(dto: CreateSurchargeTypeDto): Promise<SurchargeType> {
|
||||||
const existing = await this.repository.findByCode(dto.code);
|
const code = generateCode(dto.label);
|
||||||
if (existing) throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`);
|
const existing = await this.repository.findByCode(code);
|
||||||
|
if (existing) throw new ConflictException(`Surcharge type with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
code: dto.code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'],
|
triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'],
|
||||||
rateId: dto.rateId,
|
rateId: dto.rateId,
|
||||||
@@ -57,14 +59,7 @@ export class SurchargeTypesService {
|
|||||||
/** Update an existing surcharge type. */
|
/** Update an existing surcharge type. */
|
||||||
async update(id: string, dto: UpdateSurchargeTypeDto): Promise<SurchargeType> {
|
async update(id: string, dto: UpdateSurchargeTypeDto): Promise<SurchargeType> {
|
||||||
await this.findById(id);
|
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 patch: Partial<SurchargeType> = {};
|
const patch: Partial<SurchargeType> = {};
|
||||||
if (dto.code !== undefined) patch.code = dto.code;
|
|
||||||
if (dto.label !== undefined) patch.label = dto.label;
|
if (dto.label !== undefined) patch.label = dto.label;
|
||||||
if (dto.triggerCondition !== undefined) patch.triggerCondition = dto.triggerCondition as SurchargeType['triggerCondition'];
|
if (dto.triggerCondition !== undefined) patch.triggerCondition = dto.triggerCondition as SurchargeType['triggerCondition'];
|
||||||
if (dto.rateId !== undefined) patch.rateId = dto.rateId;
|
if (dto.rateId !== undefined) patch.rateId = dto.rateId;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||||
import { Yard } from '../entities/yard.entity';
|
import { Yard } from '../entities/yard.entity';
|
||||||
@@ -26,7 +27,7 @@ export class YardsService {
|
|||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
const [data, total] = await this.repository.findAndCount({
|
||||||
where,
|
where,
|
||||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
order: { displayOrder: 'ASC', label: 'ASC' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
@@ -42,10 +43,11 @@ export class YardsService {
|
|||||||
|
|
||||||
/** Create a yard. */
|
/** Create a yard. */
|
||||||
async create(dto: CreateYardDto): Promise<Yard> {
|
async create(dto: CreateYardDto): Promise<Yard> {
|
||||||
const existing = await this.repository.findByCode(dto.code);
|
const code = generateCode(dto.label);
|
||||||
if (existing) throw new ConflictException(`Yard with code "${dto.code}" already exists`);
|
const existing = await this.repository.findByCode(code);
|
||||||
|
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
code: dto.code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
@@ -56,12 +58,6 @@ export class YardsService {
|
|||||||
/** Update a yard. */
|
/** Update a yard. */
|
||||||
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
if (dto.code) {
|
|
||||||
const conflict = await this.repository.findByCode(dto.code);
|
|
||||||
if (conflict && conflict.id !== id) {
|
|
||||||
throw new ConflictException(`Yard with code "${dto.code}" already exists`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updated = await this.repository.update(id, dto);
|
const updated = await this.repository.update(id, dto);
|
||||||
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
@@ -1,2 +1,9 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@edr/ui-common/theme.css" layer(theme);
|
@import "@edr/ui-common/theme.css" layer(theme);
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|||||||
BIN
apps/edr-freight-web/backoffice/public/assets/login.png
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -1,23 +1,34 @@
|
|||||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
import {
|
||||||
import { FileText, LayoutDashboard, Network, Paperclip, Settings } from "lucide-react";
|
Boxes,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
Network,
|
||||||
|
Paperclip,
|
||||||
|
Settings,
|
||||||
|
SlidersHorizontal,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||||
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
|
||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
|
||||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -29,55 +40,81 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseSidebarItems: SidebarItem[] = [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
title: "Main menu",
|
||||||
href: "/dashboard/overview",
|
mutedTitle: true,
|
||||||
icon: <LayoutDashboard />,
|
items: [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
href: "/dashboard/overview",
|
||||||
|
icon: <LayoutDashboard />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Booking requests",
|
||||||
|
href: "/dashboard/booking-requests",
|
||||||
|
icon: <FileText />,
|
||||||
|
},
|
||||||
|
...demoItems,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Booking Requests",
|
title: "Administration",
|
||||||
href: "/dashboard/booking-requests",
|
items: [
|
||||||
icon: <FileText />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "User management",
|
|
||||||
href: "/dashboard/user-management",
|
|
||||||
icon: <Network />,
|
|
||||||
children: [
|
|
||||||
{
|
{
|
||||||
label: "Users",
|
label: "User management",
|
||||||
href: "/dashboard/user-management/users",
|
href: "/dashboard/user-management",
|
||||||
|
icon: <Network />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
href: "/dashboard/user-management/users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Employees",
|
||||||
|
href: "/dashboard/user-management/employees",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Permissions",
|
||||||
|
href: "/dashboard/user-management/permissions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Roles",
|
||||||
|
href: "/dashboard/user-management/roles",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Employees",
|
label: "File settings",
|
||||||
href: "/dashboard/user-management/employees",
|
href: "/dashboard/file-settings",
|
||||||
|
icon: <Paperclip />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Permissions",
|
label: "Dropdown settings",
|
||||||
href: "/dashboard/user-management/permissions",
|
href: "/dashboard/dropdown-settings",
|
||||||
},
|
icon: <Settings />,
|
||||||
{
|
|
||||||
label: "Roles",
|
|
||||||
href: "/dashboard/user-management/roles",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "File Settings",
|
title: "Freight configuration",
|
||||||
href: "/dashboard/file-settings",
|
mutedTitle: true,
|
||||||
icon: <Paperclip />,
|
items: [
|
||||||
},
|
{
|
||||||
{
|
label: "Configuration",
|
||||||
label: "Dropdown Settings",
|
href: "/dashboard/configuration",
|
||||||
href: "/dashboard/dropdown-settings",
|
icon: <Boxes />,
|
||||||
icon: <Settings />,
|
children: getCategorySidebarChildren("configuration"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Rule Engine",
|
label: "Rules",
|
||||||
href: "/dashboard/rule-engine",
|
href: "/dashboard/rules",
|
||||||
icon: <Settings />,
|
icon: <SlidersHorizontal />,
|
||||||
|
children: getCategorySidebarChildren("rules"),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasPermission = (
|
const hasPermission = (
|
||||||
@@ -99,34 +136,33 @@ const DashboardShell = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const demoItems: SidebarItem[] = [
|
||||||
...baseSidebarItems,
|
|
||||||
...(hasPermission(user, "can:demo:user1")
|
...(hasPermission(user, "can:demo:user1")
|
||||||
? ([
|
? [
|
||||||
{
|
{
|
||||||
label: "User1",
|
label: "User1",
|
||||||
href: "/dashboard/user1",
|
href: "/dashboard/user1",
|
||||||
icon: <Settings />,
|
icon: <Settings />,
|
||||||
},
|
},
|
||||||
] as SidebarItem[])
|
]
|
||||||
: []),
|
: []),
|
||||||
...(hasPermission(user, "can:demo:user2")
|
...(hasPermission(user, "can:demo:user2")
|
||||||
? ([
|
? [
|
||||||
{
|
{
|
||||||
label: "User2",
|
label: "User2",
|
||||||
href: "/dashboard/user2",
|
href: "/dashboard/user2",
|
||||||
icon: <Settings />,
|
icon: <Settings />,
|
||||||
},
|
},
|
||||||
] as SidebarItem[])
|
]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const sidebarSections = buildSidebarSections(demoItems);
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<FreightDashboardLayout
|
||||||
title="EDR Freight Backoffice"
|
sidebarSections={sidebarSections}
|
||||||
sidebarItems={sidebarItems}
|
|
||||||
activeHref={location.pathname}
|
activeHref={location.pathname}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
enableThemeToggle
|
enableThemeToggle
|
||||||
@@ -135,7 +171,7 @@ const DashboardShell = () => {
|
|||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</DashboardLayout>
|
</FreightDashboardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,7 +213,24 @@ const App = () => {
|
|||||||
|
|
||||||
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
||||||
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
||||||
<Route path="rule-engine" element={<RuleEnginePage />} />
|
|
||||||
|
<Route
|
||||||
|
path="configuration"
|
||||||
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
|
/>
|
||||||
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="rules"
|
||||||
|
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
|
||||||
|
/>
|
||||||
|
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="rule-engine"
|
||||||
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
|
/>
|
||||||
|
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||||
|
|
||||||
<Route path="user1" element={<DemoUser1Page />} />
|
<Route path="user1" element={<DemoUser1Page />} />
|
||||||
<Route path="user2" element={<DemoUser2Page />} />
|
<Route path="user2" element={<DemoUser2Page />} />
|
||||||
@@ -198,4 +251,4 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
ChevronDown,
|
||||||
|
Languages,
|
||||||
|
LogOut,
|
||||||
|
MessageSquare,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import type { PageMeta } from "./types";
|
||||||
|
|
||||||
|
const iconButtonClass =
|
||||||
|
"relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm transition hover:border-primary/30 hover:bg-gray-50 hover:text-gray-900";
|
||||||
|
|
||||||
|
export interface FreightDashboardHeaderProps {
|
||||||
|
pageMeta: PageMeta;
|
||||||
|
headerRight?: ReactNode;
|
||||||
|
enableThemeToggle?: boolean;
|
||||||
|
userName?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
userInitials?: string;
|
||||||
|
onLogout?: () => void;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FreightDashboardHeader = ({
|
||||||
|
pageMeta,
|
||||||
|
headerRight,
|
||||||
|
enableThemeToggle = false,
|
||||||
|
userName = "User",
|
||||||
|
userEmail,
|
||||||
|
userInitials,
|
||||||
|
onLogout,
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
|
}: FreightDashboardHeaderProps) => {
|
||||||
|
const initials =
|
||||||
|
userInitials ??
|
||||||
|
userName
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((n) => n[0].toUpperCase())
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUserMenuOpen) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
|
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setIsUserMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isUserMenuOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-[82px] shrink-0 items-center justify-between gap-4 px-6">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-xl font-bold tracking-tight text-gray-900">{pageMeta.title}</h1>
|
||||||
|
<p className="mt-0.5 truncate text-sm text-gray-500">{pageMeta.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{enableThemeToggle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
className={iconButtonClass}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button type="button" aria-label="Change language" className={iconButtonClass}>
|
||||||
|
<Languages className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" aria-label="Messages" className={iconButtonClass}>
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" aria-label="Notifications" className={iconButtonClass}>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div ref={userMenuRef} className="relative ml-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isUserMenuOpen}
|
||||||
|
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition",
|
||||||
|
isUserMenuOpen
|
||||||
|
? "border-primary/30 bg-primary/5"
|
||||||
|
: "hover:border-primary/20 hover:bg-gray-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"hidden h-4 w-4 text-gray-400 transition sm:block",
|
||||||
|
isUserMenuOpen && "rotate-180 text-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isUserMenuOpen ? (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="border-b border-gray-100 px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{userName}</p>
|
||||||
|
{userEmail ? <p className="text-xs text-gray-500">{userEmail}</p> : null}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="#profile"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
onLogout?.();
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{headerRight}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FreightDashboardHeader;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import FreightDashboardHeader from "./FreightDashboardHeader";
|
||||||
|
import FreightSidebar from "./FreightSidebar";
|
||||||
|
import { getPageMeta } from "./route-meta";
|
||||||
|
import type { SidebarSection } from "./types";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
const THEME_STORAGE_KEY = "edr-theme";
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (stored === "dark" || stored === "light") return stored;
|
||||||
|
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreightDashboardLayoutProps {
|
||||||
|
sidebarSections: SidebarSection[];
|
||||||
|
activeHref?: string;
|
||||||
|
onNavigate?: (href: string) => void;
|
||||||
|
headerRight?: ReactNode;
|
||||||
|
enableThemeToggle?: boolean;
|
||||||
|
userName?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
userInitials?: string;
|
||||||
|
onLogout?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelClass =
|
||||||
|
"rounded-2xl border border-gray-200/80 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.06)]";
|
||||||
|
|
||||||
|
const FreightDashboardLayout = ({
|
||||||
|
sidebarSections,
|
||||||
|
activeHref = "",
|
||||||
|
onNavigate,
|
||||||
|
headerRight,
|
||||||
|
enableThemeToggle = false,
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
userInitials,
|
||||||
|
onLogout,
|
||||||
|
children,
|
||||||
|
}: FreightDashboardLayoutProps) => {
|
||||||
|
const pageMeta = getPageMeta(activeHref);
|
||||||
|
const [theme, setTheme] = useState<Theme>(() =>
|
||||||
|
enableThemeToggle ? getInitialTheme() : "light",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableThemeToggle) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (theme === "dark") {
|
||||||
|
root.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
|
}, [theme, enableThemeToggle]);
|
||||||
|
|
||||||
|
const toggleTheme = () => setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[100dvh] overflow-hidden bg-[#eceef2] p-3 antialiased md:p-4"
|
||||||
|
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 w-full gap-3 md:gap-3">
|
||||||
|
<FreightSidebar
|
||||||
|
sections={sidebarSections}
|
||||||
|
activeHref={activeHref}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col gap-3 md:gap-3">
|
||||||
|
<div className={`shrink-0 ${panelClass}`}>
|
||||||
|
<FreightDashboardHeader
|
||||||
|
pageMeta={pageMeta}
|
||||||
|
headerRight={headerRight}
|
||||||
|
enableThemeToggle={enableThemeToggle}
|
||||||
|
userName={userName}
|
||||||
|
userEmail={userEmail}
|
||||||
|
userInitials={userInitials}
|
||||||
|
onLogout={onLogout}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main
|
||||||
|
className={`min-h-0 flex-1 overflow-y-auto overscroll-contain ${panelClass} p-4 md:p-6`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FreightDashboardLayout;
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import type { SidebarItem, SidebarSection } from "./types";
|
||||||
|
|
||||||
|
const EDR_LOGO = "/assets/logo.svg";
|
||||||
|
|
||||||
|
export interface FreightSidebarProps {
|
||||||
|
sections: SidebarSection[];
|
||||||
|
activeHref?: string;
|
||||||
|
onNavigate?: (href: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarItemKey = (item: SidebarItem, parentKey: string) =>
|
||||||
|
item.href ?? `${parentKey}::${item.label}`;
|
||||||
|
|
||||||
|
const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
|
||||||
|
items.flatMap((item) => {
|
||||||
|
const hrefs: string[] = [];
|
||||||
|
if (item.href) hrefs.push(item.href.toLowerCase());
|
||||||
|
if (item.children?.length) hrefs.push(...collectSidebarHrefs(item.children));
|
||||||
|
return hrefs;
|
||||||
|
});
|
||||||
|
|
||||||
|
const flattenSectionItems = (sections: SidebarSection[]) =>
|
||||||
|
sections.flatMap((section) => section.items);
|
||||||
|
|
||||||
|
const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProps) => {
|
||||||
|
const items = useMemo(() => flattenSectionItems(sections), [sections]);
|
||||||
|
const activePath = activeHref?.toLowerCase() ?? "";
|
||||||
|
|
||||||
|
const isHrefActive = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
const normalized = href.toLowerCase();
|
||||||
|
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
||||||
|
},
|
||||||
|
[activePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const branchContainsActive = useCallback(
|
||||||
|
(branch: SidebarItem[]) =>
|
||||||
|
collectSidebarHrefs(branch).some((href) => isHrefActive(href)),
|
||||||
|
[isHrefActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultExpanded = useMemo(() => {
|
||||||
|
const acc: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
const walk = (entries: SidebarItem[], parentKey: string) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.children?.length) continue;
|
||||||
|
const key = sidebarItemKey(entry, parentKey);
|
||||||
|
acc[key] =
|
||||||
|
branchContainsActive(entry.children) ||
|
||||||
|
(entry.href ? isHrefActive(entry.href) : false);
|
||||||
|
walk(entry.children, key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.children?.length) continue;
|
||||||
|
const key = item.href ?? item.label;
|
||||||
|
acc[key] =
|
||||||
|
activePath === key.toLowerCase() ||
|
||||||
|
activePath.startsWith(`${key.toLowerCase()}/`) ||
|
||||||
|
branchContainsActive(item.children);
|
||||||
|
walk(item.children, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, [activePath, branchContainsActive, isHrefActive, items]);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
||||||
|
}, [defaultExpanded]);
|
||||||
|
|
||||||
|
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||||
|
if (onNavigate) {
|
||||||
|
event.preventDefault();
|
||||||
|
onNavigate(href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpanded = (key: string) => {
|
||||||
|
setExpanded((current) => ({ ...current, [key]: !current[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinkClass = (active: boolean, depth: number) =>
|
||||||
|
cn(
|
||||||
|
"flex items-center justify-between rounded-md px-3 py-2.5 text-base font-medium leading-snug transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
|
: "text-gray-900 hover:bg-gray-100",
|
||||||
|
depth > 0 && "text-[15px]",
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconClass = (active: boolean, sectionActive: boolean) =>
|
||||||
|
cn(
|
||||||
|
"flex h-5 w-5 shrink-0 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||||
|
active
|
||||||
|
? "text-primary-foreground"
|
||||||
|
: sectionActive
|
||||||
|
? "text-gray-900"
|
||||||
|
: "text-gray-900",
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNavBranch = (children: SidebarItem[], depth: number, parentKey: string) =>
|
||||||
|
children.map((child) => {
|
||||||
|
const key = sidebarItemKey(child, parentKey);
|
||||||
|
const isGroup = Boolean(child.children?.length) && !child.href;
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
const isOpen = expanded[key] ?? false;
|
||||||
|
const groupActive = branchContainsActive(child.children!);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => toggleExpanded(key)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide transition-colors",
|
||||||
|
groupActive
|
||||||
|
? "bg-gray-100 text-gray-900"
|
||||||
|
: "text-gray-900 hover:bg-gray-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{child.label}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 text-gray-900 transition-transform",
|
||||||
|
isOpen ? "rotate-0" : "-rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 border-l border-gray-200",
|
||||||
|
depth === 0 ? "ml-3 pl-2" : "ml-2 pl-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderNavBranch(child.children!, depth + 1, key)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!child.href) return null;
|
||||||
|
|
||||||
|
const childHref = child.href.toLowerCase();
|
||||||
|
const childActiveHref = isHrefActive(childHref);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={child.href}
|
||||||
|
onClick={(event) => navigateTo(event, child.href!)}
|
||||||
|
aria-current={childActiveHref ? "page" : undefined}
|
||||||
|
className={navLinkClass(childActiveHref, depth)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{child.label}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0",
|
||||||
|
childActiveHref ? "text-primary-foreground/80" : "text-gray-900",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderTopLevelItem = (item: SidebarItem) => {
|
||||||
|
if (!item.href) return null;
|
||||||
|
|
||||||
|
const hasChildren = Boolean(item.children?.length);
|
||||||
|
const itemHref = item.href.toLowerCase();
|
||||||
|
const childActive = hasChildren ? branchContainsActive(item.children!) : false;
|
||||||
|
const isCurrentItem = hasChildren
|
||||||
|
? activePath === itemHref
|
||||||
|
: isHrefActive(itemHref);
|
||||||
|
const isSectionActive = childActive && !isCurrentItem;
|
||||||
|
const isActive = isCurrentItem || isSectionActive;
|
||||||
|
const isOpen = expanded[item.href] ?? false;
|
||||||
|
const leafActive = isCurrentItem && !hasChildren;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.href} className="flex flex-col gap-0.5">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center rounded-md transition-colors",
|
||||||
|
leafActive
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
|
: isSectionActive || (hasChildren && isCurrentItem)
|
||||||
|
? "bg-gray-100 text-gray-900"
|
||||||
|
: "text-gray-900 hover:bg-gray-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
onClick={(event) => navigateTo(event, item.href!)}
|
||||||
|
aria-current={isCurrentItem ? "page" : undefined}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-base font-medium leading-snug"
|
||||||
|
>
|
||||||
|
{item.icon ? (
|
||||||
|
<span className={iconClass(leafActive, isActive)}>{item.icon}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Toggle ${item.label}`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => toggleExpanded(item.href!)}
|
||||||
|
className={cn(
|
||||||
|
"mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
|
||||||
|
leafActive
|
||||||
|
? "text-primary-foreground hover:bg-white/10"
|
||||||
|
: "text-gray-900 hover:bg-gray-200/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 transition-transform",
|
||||||
|
isOpen ? "rotate-0" : "-rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mr-3 flex h-4 w-4 shrink-0 items-center justify-center",
|
||||||
|
leafActive ? "text-primary-foreground/80" : "text-gray-900",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && isOpen ? (
|
||||||
|
<div className="ml-3 flex flex-col gap-1 border-l border-gray-200 pl-2">
|
||||||
|
{renderNavBranch(item.children!, 0, item.href)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full max-h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-100 px-5 py-5">
|
||||||
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto" />
|
||||||
|
<span className="text-lg font-semibold tracking-tight text-gray-900">EDR Freight</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-3 py-4">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div key={section.title} className="flex flex-col gap-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"px-3 pb-1 text-xs font-semibold uppercase tracking-wide",
|
||||||
|
// Use a very light gray for ALL section titles, not just when mutedTitle is specified
|
||||||
|
"text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</p>
|
||||||
|
{section.items.map((item) => renderTopLevelItem(item))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FreightSidebar;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as FreightDashboardLayout } from "./FreightDashboardLayout";
|
||||||
|
export type { FreightDashboardLayoutProps } from "./FreightDashboardLayout";
|
||||||
|
export { default as FreightSidebar } from "./FreightSidebar";
|
||||||
|
export { default as FreightDashboardHeader } from "./FreightDashboardHeader";
|
||||||
|
export { getPageMeta } from "./route-meta";
|
||||||
|
export type { SidebarItem, SidebarSection, PageMeta } from "./types";
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { PageMeta } from "./types";
|
||||||
|
import {
|
||||||
|
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||||
|
RULE_ENGINE_RESOURCES,
|
||||||
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
|
|
||||||
|
const APP_TITLE = "EDR Freight Backoffice";
|
||||||
|
const APP_SUBTITLE = "Manage freight operations and platform settings";
|
||||||
|
|
||||||
|
const configurationRouteMeta = RULE_ENGINE_RESOURCES.filter(
|
||||||
|
(r) => r.category === "configuration",
|
||||||
|
).map((resource) => ({
|
||||||
|
prefix: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${resource.slug}`,
|
||||||
|
meta: {
|
||||||
|
title: resource.label,
|
||||||
|
subtitle: resource.subtitle,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rulesRouteMeta = RULE_ENGINE_RESOURCES.filter((r) => r.category === "rules").map(
|
||||||
|
(resource) => ({
|
||||||
|
prefix: `${RULE_ENGINE_CATEGORY_BASE_PATH.rules}/${resource.slug}`,
|
||||||
|
meta: {
|
||||||
|
title: resource.label,
|
||||||
|
subtitle: resource.subtitle,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/overview",
|
||||||
|
meta: {
|
||||||
|
title: "Overview",
|
||||||
|
subtitle: "Dashboard summary and key metrics",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/user-management/employees",
|
||||||
|
meta: {
|
||||||
|
title: "Employees",
|
||||||
|
subtitle: "Manage employee accounts and assignments",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/user-management/permissions",
|
||||||
|
meta: {
|
||||||
|
title: "Permissions",
|
||||||
|
subtitle: "Configure access permissions for roles and users",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/user-management/roles",
|
||||||
|
meta: {
|
||||||
|
title: "Roles",
|
||||||
|
subtitle: "Manage roles and their permission sets",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/user-management",
|
||||||
|
meta: {
|
||||||
|
title: "User management",
|
||||||
|
subtitle: "Organization structure, employees, roles, and permissions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/file-settings",
|
||||||
|
meta: {
|
||||||
|
title: "File Settings",
|
||||||
|
subtitle: "Configure file upload rules and document fields",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/dropdown-settings",
|
||||||
|
meta: {
|
||||||
|
title: "Dropdown Settings",
|
||||||
|
subtitle: "Manage dropdown options used across the platform",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
||||||
|
meta: {
|
||||||
|
title: "Configuration",
|
||||||
|
subtitle: "Master data: cargo, containers, services, surcharges, yards, and shipping lines",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...configurationRouteMeta,
|
||||||
|
{
|
||||||
|
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.rules,
|
||||||
|
meta: {
|
||||||
|
title: "Rules",
|
||||||
|
subtitle: "Priority, weight limits, rates, and approval workflows",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...rulesRouteMeta,
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/user1",
|
||||||
|
meta: {
|
||||||
|
title: "Demo User 1",
|
||||||
|
subtitle: "Demo workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/user2",
|
||||||
|
meta: {
|
||||||
|
title: "Demo User 2",
|
||||||
|
subtitle: "Demo workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getPageMeta = (pathname: string): PageMeta => {
|
||||||
|
const normalized = pathname.toLowerCase();
|
||||||
|
const sorted = [...ROUTE_META].sort((a, b) => b.prefix.length - a.prefix.length);
|
||||||
|
const match = sorted.find(({ prefix }) => normalized.startsWith(prefix.toLowerCase()));
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return match.meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: APP_TITLE,
|
||||||
|
subtitle: APP_SUBTITLE,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface SidebarItem {
|
||||||
|
label: string;
|
||||||
|
/** Omit for non-navigable group headers (e.g. Rule Engine categories). */
|
||||||
|
href?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
children?: SidebarItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarSection {
|
||||||
|
/** Section label shown above a group of nav items (e.g. "Main menu"). */
|
||||||
|
title: string;
|
||||||
|
items: SidebarItem[];
|
||||||
|
/** When true, section title uses muted grey instead of dark text. */
|
||||||
|
mutedTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageMeta {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
// src/components/ruleEngine/ContractType.tsx
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
// ==================== Toast Notification Component ====================
|
|
||||||
const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(onClose, 3000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50`}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== API Service ====================
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
|
||||||
|
|
||||||
const apiService = {
|
|
||||||
// Cargo Types
|
|
||||||
getCargoTypes: () => fetch(`${API_BASE_URL}/cargo-types`).then(res => res.json()),
|
|
||||||
createCargoType: (data: any) => fetch(`${API_BASE_URL}/cargo-types`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
updateCargoType: (id: string, data: any) => fetch(`${API_BASE_URL}/cargo-types/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
deleteCargoType: (id: string) => fetch(`${API_BASE_URL}/cargo-types/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
}).then(res => res.json()),
|
|
||||||
|
|
||||||
// Container Types
|
|
||||||
getContainerTypes: () => fetch(`${API_BASE_URL}/container-types`).then(res => res.json()),
|
|
||||||
createContainerType: (data: any) => fetch(`${API_BASE_URL}/container-types`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
updateContainerType: (id: string, data: any) => fetch(`${API_BASE_URL}/container-types/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
deleteContainerType: (id: string) => fetch(`${API_BASE_URL}/container-types/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
}).then(res => res.json()),
|
|
||||||
|
|
||||||
// Priority Rules
|
|
||||||
getPriorityRules: () => fetch(`${API_BASE_URL}/priority-rules`).then(res => res.json()),
|
|
||||||
createPriorityRule: (data: any) => fetch(`${API_BASE_URL}/priority-rules`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
updatePriorityRule: (id: string, data: any) => fetch(`${API_BASE_URL}/priority-rules/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
deletePriorityRule: (id: string) => fetch(`${API_BASE_URL}/priority-rules/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
}).then(res => res.json()),
|
|
||||||
|
|
||||||
// Service Types
|
|
||||||
getServiceTypes: () => fetch(`${API_BASE_URL}/service-types`).then(res => res.json()),
|
|
||||||
createServiceType: (data: any) => fetch(`${API_BASE_URL}/service-types`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
updateServiceType: (id: string, data: any) => fetch(`${API_BASE_URL}/service-types/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
deleteServiceType: (id: string) => fetch(`${API_BASE_URL}/service-types/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
}).then(res => res.json()),
|
|
||||||
|
|
||||||
// Surcharge Types
|
|
||||||
getSurchargeTypes: () => fetch(`${API_BASE_URL}/surcharge-types`).then(res => res.json()),
|
|
||||||
createSurchargeType: (data: any) => fetch(`${API_BASE_URL}/surcharge-types`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
updateSurchargeType: (id: string, data: any) => fetch(`${API_BASE_URL}/surcharge-types/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
deleteSurchargeType: (id: string) => fetch(`${API_BASE_URL}/surcharge-types/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
}).then(res => res.json()),
|
|
||||||
|
|
||||||
// Surcharges
|
|
||||||
getSurcharges: () => fetch(`${API_BASE_URL}/surcharges`).then(res => res.json()),
|
|
||||||
createSurcharge: (data: any) => fetch(`${API_BASE_URL}/surcharges`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
updateSurcharge: (id: string, data: any) => fetch(`${API_BASE_URL}/surcharges/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
deleteSurcharge: (id: string) => fetch(`${API_BASE_URL}/surcharges/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
}).then(res => res.json()),
|
|
||||||
|
|
||||||
// Weight Limit Rules
|
|
||||||
getWeightLimitRules: () => fetch(`${API_BASE_URL}/weight-limit-rules`).then(res => res.json()),
|
|
||||||
createWeightLimitRule: (data: any) => fetch(`${API_BASE_URL}/weight-limit-rules`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
updateWeightLimitRule: (id: string, data: any) => fetch(`${API_BASE_URL}/weight-limit-rules/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(res => res.json()),
|
|
||||||
deleteWeightLimitRule: (id: string) => fetch(`${API_BASE_URL}/weight-limit-rules/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
}).then(res => res.json()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Entity Table Component ====================
|
|
||||||
const EntityTable = ({
|
|
||||||
title,
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
isLoading
|
|
||||||
}: any) => {
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
const filteredData = data?.filter((item: any) =>
|
|
||||||
Object.values(item).some(value =>
|
|
||||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-bold text-green-600">{expanded ? '▼' : '▶'}</span>
|
|
||||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<div className="p-8 text-center text-gray-500">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-green-500 border-t-transparent"></div>
|
|
||||||
<p className="mt-2">Loading...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden transition-all duration-300">
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-gray-50 to-white border-b border-gray-200 cursor-pointer hover:bg-gray-50 transition-colors group"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-bold text-green-600 transition-transform group-hover:scale-110">
|
|
||||||
{expanded ? '▼' : '▶'}
|
|
||||||
</span>
|
|
||||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">
|
|
||||||
{filteredData.length} items
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
|
||||||
<button
|
|
||||||
className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-all hover:bg-green-700 hover:shadow-md"
|
|
||||||
onClick={onAdd}
|
|
||||||
>
|
|
||||||
+ Add {title.slice(0, -1)}
|
|
||||||
</button>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search..."
|
|
||||||
className="w-80 px-3 py-2 pl-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
{columns.map((col: any) => (
|
|
||||||
<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{col.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{filteredData.map((item: any) => (
|
|
||||||
<tr key={item.id} className="hover:bg-gray-50 transition-colors">
|
|
||||||
{columns.map((col: any) => (
|
|
||||||
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{col.render ? col.render(item[col.key], item) : item[col.key]}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
||||||
<button
|
|
||||||
className="bg-yellow-500 text-gray-900 px-3 py-1 rounded text-xs font-medium transition-all hover:bg-yellow-600 mr-2"
|
|
||||||
onClick={() => onEdit(item)}
|
|
||||||
>
|
|
||||||
✏️ Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="bg-red-600 text-white px-3 py-1 rounded text-xs font-medium transition-all hover:bg-red-700"
|
|
||||||
onClick={() => onDelete(item)}
|
|
||||||
>
|
|
||||||
🗑️ Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filteredData.length === 0 && (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<p className="mt-2">No data found</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Main Component ====================
|
|
||||||
const ContractTypePage = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState('cargo-types');
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editingItem, setEditingItem] = useState<any>(null);
|
|
||||||
const [currentEntity, setCurrentEntity] = useState('');
|
|
||||||
const [formData, setFormData] = useState<any>({});
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const showToast = (message: string, type: 'success' | 'error') => {
|
|
||||||
setToast({ message, type });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch all data
|
|
||||||
const { data: cargoTypes = [], isLoading: cargoLoading } = useQuery({
|
|
||||||
queryKey: ['cargo-types'],
|
|
||||||
queryFn: apiService.getCargoTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: containerTypes = [], isLoading: containerLoading } = useQuery({
|
|
||||||
queryKey: ['container-types'],
|
|
||||||
queryFn: apiService.getContainerTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: priorityRules = [], isLoading: priorityLoading } = useQuery({
|
|
||||||
queryKey: ['priority-rules'],
|
|
||||||
queryFn: apiService.getPriorityRules,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: serviceTypes = [], isLoading: serviceLoading } = useQuery({
|
|
||||||
queryKey: ['service-types'],
|
|
||||||
queryFn: apiService.getServiceTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: surchargeTypes = [], isLoading: surchargeTypeLoading } = useQuery({
|
|
||||||
queryKey: ['surcharge-types'],
|
|
||||||
queryFn: apiService.getSurchargeTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: surcharges = [], isLoading: surchargeLoading } = useQuery({
|
|
||||||
queryKey: ['surcharges'],
|
|
||||||
queryFn: apiService.getSurcharges,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: weightLimitRules = [], isLoading: weightLimitLoading } = useQuery({
|
|
||||||
queryKey: ['weight-limit-rules'],
|
|
||||||
queryFn: apiService.getWeightLimitRules,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mutations for Cargo Types
|
|
||||||
const createCargoType = useMutation({
|
|
||||||
mutationFn: apiService.createCargoType,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['cargo-types'] });
|
|
||||||
showToast('Cargo type created successfully', 'success');
|
|
||||||
setModalOpen(false);
|
|
||||||
setFormData({});
|
|
||||||
},
|
|
||||||
onError: () => showToast('Failed to create cargo type', 'error'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCargoType = useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: any }) => apiService.updateCargoType(id, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['cargo-types'] });
|
|
||||||
showToast('Cargo type updated successfully', 'success');
|
|
||||||
setModalOpen(false);
|
|
||||||
setFormData({});
|
|
||||||
setEditingItem(null);
|
|
||||||
},
|
|
||||||
onError: () => showToast('Failed to update cargo type', 'error'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteCargoType = useMutation({
|
|
||||||
mutationFn: apiService.deleteCargoType,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['cargo-types'] });
|
|
||||||
showToast('Cargo type deleted successfully', 'success');
|
|
||||||
},
|
|
||||||
onError: () => showToast('Failed to delete cargo type', 'error'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAdd = (entity: string) => {
|
|
||||||
setCurrentEntity(entity);
|
|
||||||
setEditingItem(null);
|
|
||||||
setFormData(getDefaultFormData(entity));
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (entity: string, item: any) => {
|
|
||||||
setCurrentEntity(entity);
|
|
||||||
setEditingItem(item);
|
|
||||||
setFormData(item);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (entity: string, item: any) => {
|
|
||||||
if (window.confirm(`Are you sure you want to delete this ${entity}?`)) {
|
|
||||||
if (entity === 'cargo-types') deleteCargoType.mutate(item.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (currentEntity === 'cargo-types') {
|
|
||||||
if (editingItem) {
|
|
||||||
updateCargoType.mutate({ id: editingItem.id, data: formData });
|
|
||||||
} else {
|
|
||||||
createCargoType.mutate(formData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDefaultFormData = (entity: string) => {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
return { code: '', cargoTypeName: '', showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 1 };
|
|
||||||
default:
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEntityData = (entity: string) => {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types': return cargoTypes;
|
|
||||||
case 'container-types': return containerTypes;
|
|
||||||
case 'priority-rules': return priorityRules;
|
|
||||||
case 'service-types': return serviceTypes;
|
|
||||||
case 'surcharge-types': return surchargeTypes;
|
|
||||||
case 'surcharges': return surcharges;
|
|
||||||
case 'weight-limit-rules': return weightLimitRules;
|
|
||||||
default: return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEntityLoading = (entity: string) => {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types': return cargoLoading;
|
|
||||||
case 'container-types': return containerLoading;
|
|
||||||
case 'priority-rules': return priorityLoading;
|
|
||||||
case 'service-types': return serviceLoading;
|
|
||||||
case 'surcharge-types': return surchargeTypeLoading;
|
|
||||||
case 'surcharges': return surchargeLoading;
|
|
||||||
case 'weight-limit-rules': return weightLimitLoading;
|
|
||||||
default: return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColumns = (entity: string) => {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
return [
|
|
||||||
{ key: 'code', label: 'Code' },
|
|
||||||
{ key: 'cargoTypeName', label: 'Name' },
|
|
||||||
{ key: 'displayOrder', label: 'Order' },
|
|
||||||
{
|
|
||||||
key: 'isActive',
|
|
||||||
label: 'Status',
|
|
||||||
render: (val: boolean) => (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{val ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
case 'container-types':
|
|
||||||
return [
|
|
||||||
{ key: 'sizeCode', label: 'Size Code' },
|
|
||||||
{ key: 'description', label: 'Description' },
|
|
||||||
{ key: 'containersPerWagon', label: 'Containers/Wagon' },
|
|
||||||
{
|
|
||||||
key: 'isActive',
|
|
||||||
label: 'Status',
|
|
||||||
render: (val: boolean) => (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{val ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
case 'priority-rules':
|
|
||||||
return [
|
|
||||||
{ key: 'priorityType', label: 'Priority Type' },
|
|
||||||
{ key: 'ruleName', label: 'Rule Name' },
|
|
||||||
{ key: 'bonusPoints', label: 'Bonus Points' },
|
|
||||||
{
|
|
||||||
key: 'isActive',
|
|
||||||
label: 'Status',
|
|
||||||
render: (val: boolean) => (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{val ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
case 'service-types':
|
|
||||||
return [
|
|
||||||
{ key: 'code', label: 'Code' },
|
|
||||||
{ key: 'serviceName', label: 'Service Name' },
|
|
||||||
{ key: 'displayOrder', label: 'Order' },
|
|
||||||
{
|
|
||||||
key: 'isActive',
|
|
||||||
label: 'Status',
|
|
||||||
render: (val: boolean) => (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{val ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
case 'surcharge-types':
|
|
||||||
return [
|
|
||||||
{ key: 'code', label: 'Code' },
|
|
||||||
{ key: 'name', label: 'Name' },
|
|
||||||
{
|
|
||||||
key: 'isActive',
|
|
||||||
label: 'Status',
|
|
||||||
render: (val: boolean) => (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{val ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
case 'surcharges':
|
|
||||||
return [
|
|
||||||
{ key: 'feeName', label: 'Fee Name' },
|
|
||||||
{ key: 'calculationMethod', label: 'Method' },
|
|
||||||
{
|
|
||||||
key: 'rate',
|
|
||||||
label: 'Rate',
|
|
||||||
render: (val: number, item: any) => `${val} ${item.currency}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'isActive',
|
|
||||||
label: 'Status',
|
|
||||||
render: (val: boolean) => (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{val ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
return [
|
|
||||||
{ key: 'tradeDirection', label: 'Direction' },
|
|
||||||
{
|
|
||||||
key: 'maxWeightTons',
|
|
||||||
label: 'Max Weight',
|
|
||||||
render: (val: number) => `${val} tons`
|
|
||||||
},
|
|
||||||
{ key: 'exceededAction', label: 'Action' },
|
|
||||||
{
|
|
||||||
key: 'isActive',
|
|
||||||
label: 'Status',
|
|
||||||
render: (val: boolean) => (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${val ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{val ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'cargo-types', label: 'Cargo Types' },
|
|
||||||
{ id: 'container-types', label: 'Container Types' },
|
|
||||||
{ id: 'priority-rules', label: 'Priority Rules' },
|
|
||||||
{ id: 'service-types', label: 'Service Types' },
|
|
||||||
{ id: 'surcharge-types', label: 'Surcharge Types' },
|
|
||||||
{ id: 'surcharges', label: 'Surcharges' },
|
|
||||||
{ id: 'weight-limit-rules', label: 'Weight Limit Rules' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isLoading = cargoLoading || containerLoading || priorityLoading || serviceLoading || surchargeTypeLoading || surchargeLoading || weightLimitLoading;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-96">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-green-500 border-t-transparent"></div>
|
|
||||||
<p className="mt-4 text-gray-600">Loading master data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="contract-type-page">
|
|
||||||
{toast && (
|
|
||||||
<Toast
|
|
||||||
message={toast.message}
|
|
||||||
type={toast.type}
|
|
||||||
onClose={() => setToast(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
|
||||||
<div className="flex space-x-1 overflow-x-auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'text-green-600 border-b-2 border-green-600'
|
|
||||||
: 'text-gray-600 hover:text-green-600 hover:border-b-2 hover:border-green-300'
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<div key={tab.id} className={activeTab === tab.id ? 'block' : 'hidden'}>
|
|
||||||
<EntityTable
|
|
||||||
title={tab.label}
|
|
||||||
data={getEntityData(tab.id)}
|
|
||||||
columns={getColumns(tab.id)}
|
|
||||||
onAdd={() => handleAdd(tab.id)}
|
|
||||||
onEdit={(item: any) => handleEdit(tab.id, item)}
|
|
||||||
onDelete={(item: any) => handleDelete(tab.id, item)}
|
|
||||||
isLoading={getEntityLoading(tab.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{modalOpen && currentEntity === 'cargo-types' && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
|
||||||
<div className="flex justify-between items-center px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
{editingItem ? 'Edit Cargo Type' : 'Add Cargo Type'}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setModalOpen(false);
|
|
||||||
setEditingItem(null);
|
|
||||||
setFormData({});
|
|
||||||
}}
|
|
||||||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
value={formData.code || ''}
|
|
||||||
onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
value={formData.cargoTypeName || ''}
|
|
||||||
onChange={(e) => setFormData({...formData, cargoTypeName: e.target.value})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
value={formData.displayOrder || 1}
|
|
||||||
onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3 flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="showFreeTextBox"
|
|
||||||
className="mr-2"
|
|
||||||
checked={formData.showFreeTextBox || false}
|
|
||||||
onChange={(e) => setFormData({...formData, showFreeTextBox: e.target.checked})}
|
|
||||||
/>
|
|
||||||
<label htmlFor="showFreeTextBox" className="text-sm text-gray-700">Show Free Text Box</label>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3 flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="requiresDirectorApproval"
|
|
||||||
className="mr-2"
|
|
||||||
checked={formData.requiresDirectorApproval || false}
|
|
||||||
onChange={(e) => setFormData({...formData, requiresDirectorApproval: e.target.checked})}
|
|
||||||
/>
|
|
||||||
<label htmlFor="requiresDirectorApproval" className="text-sm text-gray-700">Requires Director Approval</label>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3 flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="isActive"
|
|
||||||
className="mr-2"
|
|
||||||
checked={formData.isActive !== false}
|
|
||||||
onChange={(e) => setFormData({...formData, isActive: e.target.checked})}
|
|
||||||
/>
|
|
||||||
<label htmlFor="isActive" className="text-sm text-gray-700">Active</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setModalOpen(false);
|
|
||||||
setEditingItem(null);
|
|
||||||
setFormData({});
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createCargoType.isPending || updateCargoType.isPending}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{createCargoType.isPending || updateCargoType.isPending ? 'Saving...' : editingItem ? 'Update' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContractTypePage;
|
|
||||||
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
// src/components/ruleEngine/ContractType.tsx
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
// ==================== MOCK DATA (Replace with your API calls later) ====================
|
|
||||||
const mockCargoTypes = [
|
|
||||||
{ id: '1', code: 'BULK', cargoTypeName: 'Bulk Cargo', displayOrder: 1, isActive: true },
|
|
||||||
{ id: '2', code: 'BREAK_BULK', cargoTypeName: 'Break Bulk', displayOrder: 2, isActive: true },
|
|
||||||
{ id: '3', code: 'CONTAINER', cargoTypeName: 'Containerized', displayOrder: 3, isActive: false },
|
|
||||||
{ id: '4', code: 'LIQUID', cargoTypeName: 'Liquid Bulk', displayOrder: 4, isActive: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockContainerTypes = [
|
|
||||||
{ id: '1', sizeCode: '20FT', description: '20 Foot Standard Container', containersPerWagon: 2, isActive: true },
|
|
||||||
{ id: '2', sizeCode: '40FT', description: '40 Foot Standard Container', containersPerWagon: 1, isActive: true },
|
|
||||||
{ id: '3', sizeCode: '20RF', description: '20 Foot Refrigerated', containersPerWagon: 2, isActive: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockPriorityRules = [
|
|
||||||
{ id: '1', priorityType: 'HIGH', ruleName: 'High Priority Booking', bonusPoints: 100, isActive: true },
|
|
||||||
{ id: '2', priorityType: 'URGENT', ruleName: 'Urgent Delivery', bonusPoints: 200, isActive: true },
|
|
||||||
{ id: '3', priorityType: 'LOW', ruleName: 'Standard Booking', bonusPoints: 0, isActive: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockServiceTypes = [
|
|
||||||
{ id: '1', code: 'RAIL', serviceName: 'Rail Only', displayOrder: 1, isActive: true },
|
|
||||||
{ id: '2', code: 'RAIL_FIRST', serviceName: 'Rail + First Mile', displayOrder: 2, isActive: true },
|
|
||||||
{ id: '3', code: 'RAIL_LAST', serviceName: 'Rail + Last Mile', displayOrder: 3, isActive: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockSurchargeTypes = [
|
|
||||||
{ id: '1', code: 'HAZ', name: 'Hazardous Material', isActive: true },
|
|
||||||
{ id: '2', code: 'REF', name: 'Refrigerated', isActive: true },
|
|
||||||
{ id: '3', code: 'OVR', name: 'Overweight', isActive: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockSurcharges = [
|
|
||||||
{ id: '1', feeName: 'Hazardous Fee', calculationMethod: 'FLAT', rate: 150, currency: 'USD', isActive: true },
|
|
||||||
{ id: '2', feeName: 'Refrigeration Fee', calculationMethod: 'PER_TON', rate: 25, currency: 'USD', isActive: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockWeightLimitRules = [
|
|
||||||
{ id: '1', tradeDirection: 'IMPORT', maxWeightTons: 20, exceededAction: 'WARNING_ONLY', isActive: true },
|
|
||||||
{ id: '2', tradeDirection: 'EXPORT', maxWeightTons: 22, exceededAction: 'BLOCK', isActive: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ==================== Toast Component ====================
|
|
||||||
const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => {
|
|
||||||
setTimeout(onClose, 3000);
|
|
||||||
const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500';
|
|
||||||
return (
|
|
||||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50`}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Entity Table Component ====================
|
|
||||||
const EntityTable = ({ title, data, columns, onAdd, onEdit, onDelete }: any) => {
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
const filteredData = Array.isArray(data) ? data.filter((item: any) =>
|
|
||||||
Object.values(item).some(value =>
|
|
||||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
) : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-bold text-green-600">{expanded ? '▼' : '▶'}</span>
|
|
||||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">
|
|
||||||
{filteredData.length} items
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
|
||||||
<button
|
|
||||||
className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700"
|
|
||||||
onClick={onAdd}
|
|
||||||
>
|
|
||||||
+ Add {title.slice(0, -1)}
|
|
||||||
</button>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search..."
|
|
||||||
className="w-80 px-3 py-2 pl-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
{columns.map((col: any) => (
|
|
||||||
<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{col.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{filteredData.map((item: any) => (
|
|
||||||
<tr key={item.id} className="hover:bg-gray-50">
|
|
||||||
{columns.map((col: any) => (
|
|
||||||
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{col.render ? col.render(item[col.key], item) : item[col.key]}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
||||||
<button
|
|
||||||
className="bg-yellow-500 text-gray-900 px-3 py-1 rounded text-xs mr-2 hover:bg-yellow-600"
|
|
||||||
onClick={() => onEdit(item)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="bg-red-600 text-white px-3 py-1 rounded text-xs hover:bg-red-700"
|
|
||||||
onClick={() => onDelete(item)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filteredData.length === 0 && (
|
|
||||||
<div className="text-center py-12 text-gray-500">No data found</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Main Component ====================
|
|
||||||
const ContractTypePage = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState('cargo-types');
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
||||||
|
|
||||||
// State for each entity
|
|
||||||
const [cargoTypes, setCargoTypes] = useState(mockCargoTypes);
|
|
||||||
const [containerTypes, setContainerTypes] = useState(mockContainerTypes);
|
|
||||||
const [priorityRules, setPriorityRules] = useState(mockPriorityRules);
|
|
||||||
const [serviceTypes, setServiceTypes] = useState(mockServiceTypes);
|
|
||||||
const [surchargeTypes, setSurchargeTypes] = useState(mockSurchargeTypes);
|
|
||||||
const [surcharges, setSurcharges] = useState(mockSurcharges);
|
|
||||||
const [weightLimitRules, setWeightLimitRules] = useState(mockWeightLimitRules);
|
|
||||||
|
|
||||||
const showToast = (message: string, type: 'success' | 'error') => {
|
|
||||||
setToast({ message, type });
|
|
||||||
setTimeout(() => setToast(null), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = (entity: string) => {
|
|
||||||
const newId = String(Date.now());
|
|
||||||
let newItem;
|
|
||||||
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
newItem = { id: newId, code: 'NEW', cargoTypeName: 'New Type', displayOrder: cargoTypes.length + 1, isActive: true };
|
|
||||||
setCargoTypes([...cargoTypes, newItem]);
|
|
||||||
break;
|
|
||||||
case 'container-types':
|
|
||||||
newItem = { id: newId, sizeCode: 'NEW', description: 'New Container', containersPerWagon: 1, isActive: true };
|
|
||||||
setContainerTypes([...containerTypes, newItem]);
|
|
||||||
break;
|
|
||||||
case 'priority-rules':
|
|
||||||
newItem = { id: newId, priorityType: 'MEDIUM', ruleName: 'New Rule', bonusPoints: 0, isActive: true };
|
|
||||||
setPriorityRules([...priorityRules, newItem]);
|
|
||||||
break;
|
|
||||||
case 'service-types':
|
|
||||||
newItem = { id: newId, code: 'NEW', serviceName: 'New Service', displayOrder: serviceTypes.length + 1, isActive: true };
|
|
||||||
setServiceTypes([...serviceTypes, newItem]);
|
|
||||||
break;
|
|
||||||
case 'surcharge-types':
|
|
||||||
newItem = { id: newId, code: 'NEW', name: 'New Surcharge Type', isActive: true };
|
|
||||||
setSurchargeTypes([...surchargeTypes, newItem]);
|
|
||||||
break;
|
|
||||||
case 'surcharges':
|
|
||||||
newItem = { id: newId, feeName: 'New Fee', calculationMethod: 'FLAT', rate: 0, currency: 'USD', isActive: true };
|
|
||||||
setSurcharges([...surcharges, newItem]);
|
|
||||||
break;
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
newItem = { id: newId, tradeDirection: 'IMPORT', maxWeightTons: 20, exceededAction: 'WARNING_ONLY', isActive: true };
|
|
||||||
setWeightLimitRules([...weightLimitRules, newItem]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
showToast(`${entity} added successfully`, 'success');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (entity: string, item: any) => {
|
|
||||||
showToast(`Edit ${item.code || item.sizeCode || item.ruleName || item.serviceName || item.name || item.feeName}`, 'success');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (entity: string, item: any) => {
|
|
||||||
if (confirm('Are you sure you want to delete this item?')) {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
setCargoTypes(cargoTypes.filter(c => c.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'container-types':
|
|
||||||
setContainerTypes(containerTypes.filter(c => c.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'priority-rules':
|
|
||||||
setPriorityRules(priorityRules.filter(p => p.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'service-types':
|
|
||||||
setServiceTypes(serviceTypes.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'surcharge-types':
|
|
||||||
setSurchargeTypes(surchargeTypes.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'surcharges':
|
|
||||||
setSurcharges(surcharges.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
setWeightLimitRules(weightLimitRules.filter(w => w.id !== item.id));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
showToast(`${entity} deleted successfully`, 'success');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColumns = (entity: string) => {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
return [
|
|
||||||
{ key: 'code', label: 'Code' },
|
|
||||||
{ key: 'cargoTypeName', label: 'Name' },
|
|
||||||
{ key: 'displayOrder', label: 'Order' },
|
|
||||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
|
||||||
];
|
|
||||||
case 'container-types':
|
|
||||||
return [
|
|
||||||
{ key: 'sizeCode', label: 'Size Code' },
|
|
||||||
{ key: 'description', label: 'Description' },
|
|
||||||
{ key: 'containersPerWagon', label: 'Containers/Wagon' },
|
|
||||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
|
||||||
];
|
|
||||||
case 'priority-rules':
|
|
||||||
return [
|
|
||||||
{ key: 'priorityType', label: 'Priority Type' },
|
|
||||||
{ key: 'ruleName', label: 'Rule Name' },
|
|
||||||
{ key: 'bonusPoints', label: 'Bonus Points' },
|
|
||||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
|
||||||
];
|
|
||||||
case 'service-types':
|
|
||||||
return [
|
|
||||||
{ key: 'code', label: 'Code' },
|
|
||||||
{ key: 'serviceName', label: 'Service Name' },
|
|
||||||
{ key: 'displayOrder', label: 'Order' },
|
|
||||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
|
||||||
];
|
|
||||||
case 'surcharge-types':
|
|
||||||
return [
|
|
||||||
{ key: 'code', label: 'Code' },
|
|
||||||
{ key: 'name', label: 'Name' },
|
|
||||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
|
||||||
];
|
|
||||||
case 'surcharges':
|
|
||||||
return [
|
|
||||||
{ key: 'feeName', label: 'Fee Name' },
|
|
||||||
{ key: 'calculationMethod', label: 'Method' },
|
|
||||||
{ key: 'rate', label: 'Rate', render: (val: number, item: any) => `${val} ${item.currency}` },
|
|
||||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
|
||||||
];
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
return [
|
|
||||||
{ key: 'tradeDirection', label: 'Direction' },
|
|
||||||
{ key: 'maxWeightTons', label: 'Max Weight', render: (val: number) => `${val} tons` },
|
|
||||||
{ key: 'exceededAction', label: 'Action' },
|
|
||||||
{ key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' }
|
|
||||||
];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEntityData = (entity: string) => {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types': return cargoTypes;
|
|
||||||
case 'container-types': return containerTypes;
|
|
||||||
case 'priority-rules': return priorityRules;
|
|
||||||
case 'service-types': return serviceTypes;
|
|
||||||
case 'surcharge-types': return surchargeTypes;
|
|
||||||
case 'surcharges': return surcharges;
|
|
||||||
case 'weight-limit-rules': return weightLimitRules;
|
|
||||||
default: return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'cargo-types', label: 'Cargo Types' },
|
|
||||||
{ id: 'container-types', label: 'Container Types' },
|
|
||||||
{ id: 'priority-rules', label: 'Priority Rules' },
|
|
||||||
{ id: 'service-types', label: 'Service Types' },
|
|
||||||
{ id: 'surcharge-types', label: 'Surcharge Types' },
|
|
||||||
{ id: 'surcharges', label: 'Surcharges' },
|
|
||||||
{ id: 'weight-limit-rules', label: 'Weight Limit Rules' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="contract-type-page">
|
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800">Rule Engine - Master Data</h3>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Manage cargo types, container types, priority rules, and more</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
|
||||||
<div className="flex space-x-1 overflow-x-auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'text-green-600 border-b-2 border-green-600'
|
|
||||||
: 'text-gray-600 hover:text-green-600 hover:border-b-2 hover:border-green-300'
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<div key={tab.id} className={activeTab === tab.id ? 'block' : 'hidden'}>
|
|
||||||
<EntityTable
|
|
||||||
title={tab.label}
|
|
||||||
data={getEntityData(tab.id)}
|
|
||||||
columns={getColumns(tab.id)}
|
|
||||||
onAdd={() => handleAdd(tab.id)}
|
|
||||||
onEdit={(item: any) => handleEdit(tab.id, item)}
|
|
||||||
onDelete={(item: any) => handleDelete(tab.id, item)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContractTypePage;
|
|
||||||
@@ -1,874 +0,0 @@
|
|||||||
// src/components/ruleEngine/ContractType.tsx
|
|
||||||
import { createCargoType } from '@/services/rule.engine/cargoType';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
// ==================== API Service ====================
|
|
||||||
const API_BASE_URL = 'http://localhost:3001/api';
|
|
||||||
|
|
||||||
const apiFetch = async (endpoint: string, options?: RequestInit): Promise<any> => {
|
|
||||||
try {
|
|
||||||
const url = `${API_BASE_URL}${endpoint}`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${errorText || response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`API Error (${endpoint}):`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiService = {
|
|
||||||
getCargoTypes: (): Promise<any[]> => apiFetch('/cargo-types'),
|
|
||||||
createCargoType: (data: any): Promise<any> => apiFetch('/cargo-types', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateCargoType: (id: string, data: any): Promise<any> => apiFetch(`/cargo-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteCargoType: (id: string): Promise<any> => apiFetch(`/cargo-types/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getContainerTypes: (): Promise<any[]> => apiFetch('/container-types'),
|
|
||||||
createContainerType: (data: any): Promise<any> => apiFetch('/container-types', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateContainerType: (id: string, data: any): Promise<any> => apiFetch(`/container-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteContainerType: (id: string): Promise<any> => apiFetch(`/container-types/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getPriorityRules: (): Promise<any[]> => apiFetch('/priority-rules'),
|
|
||||||
createPriorityRule: (data: any): Promise<any> => apiFetch('/priority-rules', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updatePriorityRule: (id: string, data: any): Promise<any> => apiFetch(`/priority-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deletePriorityRule: (id: string): Promise<any> => apiFetch(`/priority-rules/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getServiceTypes: (): Promise<any[]> => apiFetch('/service-types'),
|
|
||||||
createServiceType: (data: any): Promise<any> => apiFetch('/service-types', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateServiceType: (id: string, data: any): Promise<any> => apiFetch(`/service-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteServiceType: (id: string): Promise<any> => apiFetch(`/service-types/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getSurchargeTypes: (): Promise<any[]> => apiFetch('/surcharge-types'),
|
|
||||||
createSurchargeType: (data: any): Promise<any> => apiFetch('/surcharge-types', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateSurchargeType: (id: string, data: any): Promise<any> => apiFetch(`/surcharge-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteSurchargeType: (id: string): Promise<any> => apiFetch(`/surcharge-types/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getSurcharges: (): Promise<any[]> => apiFetch('/surcharges'),
|
|
||||||
createSurcharge: (data: any): Promise<any> => apiFetch('/surcharges', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateSurcharge: (id: string, data: any): Promise<any> => apiFetch(`/surcharges/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteSurcharge: (id: string): Promise<any> => apiFetch(`/surcharges/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getWeightLimitRules: (): Promise<any[]> => apiFetch('/weight-limit-rules'),
|
|
||||||
createWeightLimitRule: (data: any): Promise<any> => apiFetch('/weight-limit-rules', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateWeightLimitRule: (id: string, data: any): Promise<any> => apiFetch(`/weight-limit-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteWeightLimitRule: (id: string): Promise<any> => apiFetch(`/weight-limit-rules/${id}`, { method: 'DELETE' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Toast Component ====================
|
|
||||||
const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(onClose, 3000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500';
|
|
||||||
return (
|
|
||||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50`}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Modal Component ====================
|
|
||||||
const Modal = ({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) => {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
<div className="flex justify-between items-center px-6 py-4 border-b border-gray-200 sticky top-0 bg-white">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Form Components ====================
|
|
||||||
|
|
||||||
// 1. Cargo Type Form
|
|
||||||
const CargoTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
code: initialData?.code || '',
|
|
||||||
cargoTypeName: initialData?.cargoTypeName || '',
|
|
||||||
parentGroupId: initialData?.parentGroupId || '',
|
|
||||||
showFreeTextBox: initialData?.showFreeTextBox || false,
|
|
||||||
requiresDirectorApproval: initialData?.requiresDirectorApproval || false,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
displayOrder: initialData?.displayOrder || 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const submitData = {
|
|
||||||
code: formData.code.toUpperCase(),
|
|
||||||
cargoTypeName: formData.cargoTypeName,
|
|
||||||
parentGroupId: formData.parentGroupId || undefined,
|
|
||||||
showFreeTextBox: formData.showFreeTextBox,
|
|
||||||
requiresDirectorApproval: formData.requiresDirectorApproval,
|
|
||||||
isActive: formData.isActive,
|
|
||||||
displayOrder: Number(formData.displayOrder),
|
|
||||||
};
|
|
||||||
onSubmit(submitData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.cargoTypeName} onChange={(e) => setFormData({...formData, cargoTypeName: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Parent Group ID</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.parentGroupId} onChange={(e) => setFormData({...formData, parentGroupId: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.displayOrder} onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.showFreeTextBox} onChange={(e) => setFormData({...formData, showFreeTextBox: e.target.checked})} /> Show Free Text Box</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.requiresDirectorApproval} onChange={(e) => setFormData({...formData, requiresDirectorApproval: e.target.checked})} /> Requires Director Approval</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Container Type Form
|
|
||||||
const ContainerTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
sizeCode: initialData?.sizeCode || '',
|
|
||||||
description: initialData?.description || '',
|
|
||||||
containersPerWagon: initialData?.containersPerWagon || 1,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Size Code *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.sizeCode} onChange={(e) => setFormData({...formData, sizeCode: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Containers Per Wagon *</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.containersPerWagon} onChange={(e) => setFormData({...formData, containersPerWagon: parseInt(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Priority Rule Form
|
|
||||||
const PriorityRuleForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
priorityType: initialData?.priorityType || 'MEDIUM',
|
|
||||||
ruleName: initialData?.ruleName || '',
|
|
||||||
description: initialData?.description || '',
|
|
||||||
activationCondition: initialData?.activationCondition || '',
|
|
||||||
bonusPoints: initialData?.bonusPoints || 0,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Priority Type *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.priorityType} onChange={(e) => setFormData({...formData, priorityType: e.target.value})}>
|
|
||||||
<option value="HIGH">HIGH</option>
|
|
||||||
<option value="MEDIUM">MEDIUM</option>
|
|
||||||
<option value="LOW">LOW</option>
|
|
||||||
<option value="URGENT">URGENT</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Bonus Points</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.bonusPoints} onChange={(e) => setFormData({...formData, bonusPoints: parseInt(e.target.value)})} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rule Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.ruleName} onChange={(e) => setFormData({...formData, ruleName: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Activation Condition</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.activationCondition} onChange={(e) => setFormData({...formData, activationCondition: e.target.value})} placeholder="e.g., weight > 1000" />
|
|
||||||
</div>
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Service Type Form
|
|
||||||
const ServiceTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
code: initialData?.code || '',
|
|
||||||
serviceName: initialData?.serviceName || '',
|
|
||||||
description: initialData?.description || '',
|
|
||||||
canBeBookedAlone: initialData?.canBeBookedAlone !== undefined ? initialData.canBeBookedAlone : true,
|
|
||||||
includesFirstMile: initialData?.includesFirstMile || false,
|
|
||||||
includesLastMile: initialData?.includesLastMile || false,
|
|
||||||
includesCustoms: initialData?.includesCustoms || false,
|
|
||||||
priorityBonusPoints: initialData?.priorityBonusPoints || 0,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
displayOrder: initialData?.displayOrder || 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Service Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.serviceName} onChange={(e) => setFormData({...formData, serviceName: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Priority Bonus Points</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.priorityBonusPoints} onChange={(e) => setFormData({...formData, priorityBonusPoints: parseInt(e.target.value)})} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.displayOrder} onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.canBeBookedAlone} onChange={(e) => setFormData({...formData, canBeBookedAlone: e.target.checked})} /> Can Be Booked Alone</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesFirstMile} onChange={(e) => setFormData({...formData, includesFirstMile: e.target.checked})} /> Includes First Mile</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesLastMile} onChange={(e) => setFormData({...formData, includesLastMile: e.target.checked})} /> Includes Last Mile</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesCustoms} onChange={(e) => setFormData({...formData, includesCustoms: e.target.checked})} /> Includes Customs</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. Surcharge Type Form
|
|
||||||
const SurchargeTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
code: initialData?.code || '',
|
|
||||||
name: initialData?.name || '',
|
|
||||||
description: initialData?.description || '',
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.name} onChange={(e) => setFormData({...formData, name: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. Surcharge Form
|
|
||||||
const SurchargeForm = ({ initialData, onSubmit, onCancel, isSubmitting, surchargeTypes }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
surchargeTypeId: initialData?.surchargeTypeId || '',
|
|
||||||
feeName: initialData?.feeName || '',
|
|
||||||
triggerDescription: initialData?.triggerDescription || '',
|
|
||||||
calculationMethod: initialData?.calculationMethod || 'FLAT',
|
|
||||||
rate: initialData?.rate || 0,
|
|
||||||
currency: initialData?.currency || 'USD',
|
|
||||||
applyToRail: initialData?.applyToRail || false,
|
|
||||||
applyToFirstMile: initialData?.applyToFirstMile || false,
|
|
||||||
applyToLastMile: initialData?.applyToLastMile || false,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Surcharge Type *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.surchargeTypeId} onChange={(e) => setFormData({...formData, surchargeTypeId: e.target.value})} required>
|
|
||||||
<option value="">Select Surcharge Type</option>
|
|
||||||
{surchargeTypes?.map((type: any) => (<option key={type.id} value={type.id}>{type.name}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Fee Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.feeName} onChange={(e) => setFormData({...formData, feeName: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Calculation Method *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.calculationMethod} onChange={(e) => setFormData({...formData, calculationMethod: e.target.value})}>
|
|
||||||
<option value="PER_TON">Per Ton</option>
|
|
||||||
<option value="FLAT">Flat</option>
|
|
||||||
<option value="PERCENTAGE">Percentage</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rate *</label>
|
|
||||||
<input type="number" step="0.01" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.rate} onChange={(e) => setFormData({...formData, rate: parseFloat(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Currency *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.currency} onChange={(e) => setFormData({...formData, currency: e.target.value.toUpperCase()})} maxLength={3} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Trigger Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.triggerDescription} onChange={(e) => setFormData({...formData, triggerDescription: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToRail} onChange={(e) => setFormData({...formData, applyToRail: e.target.checked})} /> Apply to Rail</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToFirstMile} onChange={(e) => setFormData({...formData, applyToFirstMile: e.target.checked})} /> Apply to First Mile</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToLastMile} onChange={(e) => setFormData({...formData, applyToLastMile: e.target.checked})} /> Apply to Last Mile</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 7. Weight Limit Rule Form
|
|
||||||
const WeightLimitRuleForm = ({ initialData, onSubmit, onCancel, isSubmitting, containerTypes, surcharges }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
containerTypeId: initialData?.containerTypeId || '',
|
|
||||||
tradeDirection: initialData?.tradeDirection || 'IMPORT',
|
|
||||||
maxWeightTons: initialData?.maxWeightTons || 20,
|
|
||||||
warningThresholdTons: initialData?.warningThresholdTons || 18,
|
|
||||||
exceededAction: initialData?.exceededAction || 'WARNING_ONLY',
|
|
||||||
surchargeId: initialData?.surchargeId || '',
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Container Type *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.containerTypeId} onChange={(e) => setFormData({...formData, containerTypeId: e.target.value})} required>
|
|
||||||
<option value="">Select Container Type</option>
|
|
||||||
{containerTypes?.map((type: any) => (<option key={type.id} value={type.id}>{type.sizeCode}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Trade Direction *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.tradeDirection} onChange={(e) => setFormData({...formData, tradeDirection: e.target.value})}>
|
|
||||||
<option value="IMPORT">Import</option>
|
|
||||||
<option value="EXPORT">Export</option>
|
|
||||||
<option value="DOMESTIC">Domestic</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Exceeded Action</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.exceededAction} onChange={(e) => setFormData({...formData, exceededAction: e.target.value})}>
|
|
||||||
<option value="WARNING_ONLY">Warning Only</option>
|
|
||||||
<option value="BLOCK">Block</option>
|
|
||||||
<option value="SURCHARGE">Surcharge</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Weight (Tons) *</label>
|
|
||||||
<input type="number" step="0.1" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.maxWeightTons} onChange={(e) => setFormData({...formData, maxWeightTons: parseFloat(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Warning Threshold (Tons) *</label>
|
|
||||||
<input type="number" step="0.1" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.warningThresholdTons} onChange={(e) => setFormData({...formData, warningThresholdTons: parseFloat(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{formData.exceededAction === 'SURCHARGE' && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Surcharge</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.surchargeId} onChange={(e) => setFormData({...formData, surchargeId: e.target.value})}>
|
|
||||||
<option value="">Select Surcharge</option>
|
|
||||||
{surcharges?.map((surcharge: any) => (<option key={surcharge.id} value={surcharge.id}>{surcharge.feeName}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Entity Table Component ====================
|
|
||||||
const EntityTable = ({ title, data, columns, onAdd, onEdit, onDelete, isLoading }: any) => {
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
const filteredData = Array.isArray(data) ? data.filter((item: any) =>
|
|
||||||
Object.values(item).some(value =>
|
|
||||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
) : [];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-bold text-green-600">▼</span>
|
|
||||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center items-center py-8">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-green-500 border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100" onClick={() => setExpanded(!expanded)}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-bold text-green-600">{expanded ? '▼' : '▶'}</span>
|
|
||||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">{filteredData.length} items</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
|
||||||
<button className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700" onClick={onAdd}>+ Add {title.slice(0, -1)}</button>
|
|
||||||
<div className="relative">
|
|
||||||
<input type="text" placeholder="Search..." className="w-80 px-3 py-2 pl-10 border border-gray-300 rounded-md" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
|
|
||||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0118 0z" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
{columns.map((col: any) => (<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{col.label}</th>))}
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{filteredData.map((item: any) => (
|
|
||||||
<tr key={item.id} className="hover:bg-gray-50">
|
|
||||||
{columns.map((col: any) => (<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{col.render ? col.render(item[col.key], item) : item[col.key]}</td>))}
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
||||||
<button className="bg-yellow-500 text-gray-900 px-3 py-1 rounded text-xs mr-2 hover:bg-yellow-600" onClick={() => onEdit(item)}>Edit</button>
|
|
||||||
<button className="bg-red-600 text-white px-3 py-1 rounded text-xs hover:bg-red-700" onClick={() => onDelete(item)}>Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filteredData.length === 0 && (<div className="text-center py-12 text-gray-500">No data found. Click "Add" to create one.</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Main Component ====================
|
|
||||||
const ContractTypePage = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState('cargo-types');
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editingItem, setEditingItem] = useState<any>(null);
|
|
||||||
const [currentEntity, setCurrentEntity] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const [cargoTypes, setCargoTypes] = useState<any[]>([]);
|
|
||||||
const [containerTypes, setContainerTypes] = useState<any[]>([]);
|
|
||||||
const [priorityRules, setPriorityRules] = useState<any[]>([]);
|
|
||||||
const [serviceTypes, setServiceTypes] = useState<any[]>([]);
|
|
||||||
const [surchargeTypes, setSurchargeTypes] = useState<any[]>([]);
|
|
||||||
const [surcharges, setSurcharges] = useState<any[]>([]);
|
|
||||||
const [weightLimitRules, setWeightLimitRules] = useState<any[]>([]);
|
|
||||||
|
|
||||||
const showToast = (message: string, type: 'success' | 'error') => setToast({ message, type });
|
|
||||||
|
|
||||||
useEffect(() => { loadAllData(); }, []);
|
|
||||||
|
|
||||||
const loadAllData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [cargo, container, priority, service, surchargeType, surcharge, weight] = await Promise.all([
|
|
||||||
apiService.getCargoTypes().catch(() => []),
|
|
||||||
apiService.getContainerTypes().catch(() => []),
|
|
||||||
apiService.getPriorityRules().catch(() => []),
|
|
||||||
apiService.getServiceTypes().catch(() => []),
|
|
||||||
apiService.getSurchargeTypes().catch(() => []),
|
|
||||||
apiService.getSurcharges().catch(() => []),
|
|
||||||
apiService.getWeightLimitRules().catch(() => []),
|
|
||||||
]);
|
|
||||||
setCargoTypes(cargo);
|
|
||||||
setContainerTypes(container);
|
|
||||||
setPriorityRules(priority);
|
|
||||||
setServiceTypes(service);
|
|
||||||
setSurchargeTypes(surchargeType);
|
|
||||||
setSurcharges(surcharge);
|
|
||||||
setWeightLimitRules(weight);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = (entity: string) => {
|
|
||||||
setCurrentEntity(entity);
|
|
||||||
setEditingItem(null);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (entity: string, item: any) => {
|
|
||||||
setCurrentEntity(entity);
|
|
||||||
setEditingItem(item);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitForm = async (formData: any) => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
let result: any;
|
|
||||||
|
|
||||||
switch(currentEntity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateCargoType(editingItem.id, formData);
|
|
||||||
setCargoTypes(cargoTypes.map(c => c.id === editingItem.id ? result : c));
|
|
||||||
} else {
|
|
||||||
result = await createCargoType(formData);
|
|
||||||
setCargoTypes([...cargoTypes, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'container-types':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateContainerType(editingItem.id, formData);
|
|
||||||
setContainerTypes(containerTypes.map(c => c.id === editingItem.id ? result : c));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createContainerType(formData);
|
|
||||||
setContainerTypes([...containerTypes, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'priority-rules':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updatePriorityRule(editingItem.id, formData);
|
|
||||||
setPriorityRules(priorityRules.map(p => p.id === editingItem.id ? result : p));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createPriorityRule(formData);
|
|
||||||
setPriorityRules([...priorityRules, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'service-types':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateServiceType(editingItem.id, formData);
|
|
||||||
setServiceTypes(serviceTypes.map(s => s.id === editingItem.id ? result : s));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createServiceType(formData);
|
|
||||||
setServiceTypes([...serviceTypes, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'surcharge-types':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateSurchargeType(editingItem.id, formData);
|
|
||||||
setSurchargeTypes(surchargeTypes.map(s => s.id === editingItem.id ? result : s));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createSurchargeType(formData);
|
|
||||||
setSurchargeTypes([...surchargeTypes, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'surcharges':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateSurcharge(editingItem.id, formData);
|
|
||||||
setSurcharges(surcharges.map(s => s.id === editingItem.id ? result : s));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createSurcharge(formData);
|
|
||||||
setSurcharges([...surcharges, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateWeightLimitRule(editingItem.id, formData);
|
|
||||||
setWeightLimitRules(weightLimitRules.map(w => w.id === editingItem.id ? result : w));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createWeightLimitRule(formData);
|
|
||||||
setWeightLimitRules([...weightLimitRules, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(`${currentEntity} ${editingItem ? 'updated' : 'created'} successfully!`, 'success');
|
|
||||||
setModalOpen(false);
|
|
||||||
setEditingItem(null);
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Submit error:', error);
|
|
||||||
showToast(error.message || `Failed to ${editingItem ? 'update' : 'create'}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (entity: string, item: any) => {
|
|
||||||
if (!confirm(`Delete this ${entity}?`)) return;
|
|
||||||
try {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
await apiService.deleteCargoType(item.id);
|
|
||||||
setCargoTypes(cargoTypes.filter(c => c.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'container-types':
|
|
||||||
await apiService.deleteContainerType(item.id);
|
|
||||||
setContainerTypes(containerTypes.filter(c => c.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'priority-rules':
|
|
||||||
await apiService.deletePriorityRule(item.id);
|
|
||||||
setPriorityRules(priorityRules.filter(p => p.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'service-types':
|
|
||||||
await apiService.deleteServiceType(item.id);
|
|
||||||
setServiceTypes(serviceTypes.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'surcharge-types':
|
|
||||||
await apiService.deleteSurchargeType(item.id);
|
|
||||||
setSurchargeTypes(surchargeTypes.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'surcharges':
|
|
||||||
await apiService.deleteSurcharge(item.id);
|
|
||||||
setSurcharges(surcharges.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
await apiService.deleteWeightLimitRule(item.id);
|
|
||||||
setWeightLimitRules(weightLimitRules.filter(w => w.id !== item.id));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
showToast(`${entity} deleted successfully!`, 'success');
|
|
||||||
} catch (error: any) {
|
|
||||||
showToast(error.message || `Failed to delete`, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColumns = (entity: string) => {
|
|
||||||
const baseStatus = { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' };
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
return [{ key: 'code', label: 'Code' }, { key: 'cargoTypeName', label: 'Name' }, { key: 'displayOrder', label: 'Order' }, baseStatus];
|
|
||||||
case 'container-types':
|
|
||||||
return [{ key: 'sizeCode', label: 'Size Code' }, { key: 'description', label: 'Description' }, { key: 'containersPerWagon', label: 'Containers/Wagon' }, baseStatus];
|
|
||||||
case 'priority-rules':
|
|
||||||
return [{ key: 'priorityType', label: 'Priority Type' }, { key: 'ruleName', label: 'Rule Name' }, { key: 'bonusPoints', label: 'Bonus Points' }, baseStatus];
|
|
||||||
case 'service-types':
|
|
||||||
return [{ key: 'code', label: 'Code' }, { key: 'serviceName', label: 'Service Name' }, { key: 'displayOrder', label: 'Order' }, baseStatus];
|
|
||||||
case 'surcharge-types':
|
|
||||||
return [{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, baseStatus];
|
|
||||||
case 'surcharges':
|
|
||||||
return [{ key: 'feeName', label: 'Fee Name' }, { key: 'calculationMethod', label: 'Method' }, { key: 'rate', label: 'Rate', render: (val: number, item: any) => `${val} ${item.currency}` }, baseStatus];
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
return [{ key: 'tradeDirection', label: 'Direction' }, { key: 'maxWeightTons', label: 'Max Weight', render: (val: number) => `${val} tons` }, { key: 'exceededAction', label: 'Action' }, baseStatus];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEntityData = (entity: string) => {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types': return cargoTypes;
|
|
||||||
case 'container-types': return containerTypes;
|
|
||||||
case 'priority-rules': return priorityRules;
|
|
||||||
case 'service-types': return serviceTypes;
|
|
||||||
case 'surcharge-types': return surchargeTypes;
|
|
||||||
case 'surcharges': return surcharges;
|
|
||||||
case 'weight-limit-rules': return weightLimitRules;
|
|
||||||
default: return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'cargo-types', label: 'Cargo Types', Form: CargoTypeForm },
|
|
||||||
{ id: 'container-types', label: 'Container Types', Form: ContainerTypeForm },
|
|
||||||
{ id: 'priority-rules', label: 'Priority Rules', Form: PriorityRuleForm },
|
|
||||||
{ id: 'service-types', label: 'Service Types', Form: ServiceTypeForm },
|
|
||||||
{ id: 'surcharge-types', label: 'Surcharge Types', Form: SurchargeTypeForm },
|
|
||||||
{ id: 'surcharges', label: 'Surcharges', Form: SurchargeForm },
|
|
||||||
{ id: 'weight-limit-rules', label: 'Weight Limit Rules', Form: WeightLimitRuleForm },
|
|
||||||
];
|
|
||||||
|
|
||||||
const currentTab = tabs.find(t => t.id === currentEntity);
|
|
||||||
const FormComponent = currentTab?.Form;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="contract-type-page">
|
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800">Rule Engine - Master Data</h3>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Manage cargo types, container types, priority rules, and more</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
|
||||||
<div className="flex space-x-1 overflow-x-auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap ${activeTab === tab.id ? 'text-green-600 border-b-2 border-green-600' : 'text-gray-600 hover:text-green-600 hover:border-b-2 hover:border-green-300'}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<div key={tab.id} className={activeTab === tab.id ? 'block' : 'hidden'}>
|
|
||||||
<EntityTable
|
|
||||||
title={tab.label}
|
|
||||||
data={getEntityData(tab.id)}
|
|
||||||
columns={getColumns(tab.id)}
|
|
||||||
onAdd={() => handleAdd(tab.id)}
|
|
||||||
onEdit={(item: any) => handleEdit(tab.id, item)}
|
|
||||||
onDelete={(item: any) => handleDelete(tab.id, item)}
|
|
||||||
isLoading={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
isOpen={modalOpen}
|
|
||||||
onClose={() => { setModalOpen(false); setEditingItem(null); }}
|
|
||||||
title={editingItem ? `Edit ${currentEntity?.replace('-', ' ')}` : `Add ${currentEntity?.replace('-', ' ')}`}
|
|
||||||
>
|
|
||||||
{FormComponent && (
|
|
||||||
<FormComponent
|
|
||||||
initialData={editingItem}
|
|
||||||
onSubmit={handleSubmitForm}
|
|
||||||
onCancel={() => { setModalOpen(false); setEditingItem(null); }}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
surchargeTypes={surchargeTypes}
|
|
||||||
containerTypes={containerTypes}
|
|
||||||
surcharges={surcharges}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContractTypePage;
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,874 +0,0 @@
|
|||||||
// src/components/ruleEngine/ContractType.tsx
|
|
||||||
import { createCargoType } from '@/services/rule.engine/cargoType';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
// ==================== API Service ====================
|
|
||||||
const API_BASE_URL = 'http://localhost:3001/api';
|
|
||||||
|
|
||||||
const apiFetch = async (endpoint: string, options?: RequestInit): Promise<any> => {
|
|
||||||
try {
|
|
||||||
const url = `${API_BASE_URL}${endpoint}`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${errorText || response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`API Error (${endpoint}):`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiService = {
|
|
||||||
getCargoTypes: (): Promise<any[]> => apiFetch('/cargo-types'),
|
|
||||||
createCargoType: (data: any): Promise<any> => apiFetch('/cargo-types', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateCargoType: (id: string, data: any): Promise<any> => apiFetch(`/cargo-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteCargoType: (id: string): Promise<any> => apiFetch(`/cargo-types/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getContainerTypes: (): Promise<any[]> => apiFetch('/container-types'),
|
|
||||||
createContainerType: (data: any): Promise<any> => apiFetch('/container-types', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateContainerType: (id: string, data: any): Promise<any> => apiFetch(`/container-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteContainerType: (id: string): Promise<any> => apiFetch(`/container-types/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getPriorityRules: (): Promise<any[]> => apiFetch('/priority-rules'),
|
|
||||||
createPriorityRule: (data: any): Promise<any> => apiFetch('/priority-rules', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updatePriorityRule: (id: string, data: any): Promise<any> => apiFetch(`/priority-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deletePriorityRule: (id: string): Promise<any> => apiFetch(`/priority-rules/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getServiceTypes: (): Promise<any[]> => apiFetch('/service-types'),
|
|
||||||
createServiceType: (data: any): Promise<any> => apiFetch('/service-types', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateServiceType: (id: string, data: any): Promise<any> => apiFetch(`/service-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteServiceType: (id: string): Promise<any> => apiFetch(`/service-types/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getSurchargeTypes: (): Promise<any[]> => apiFetch('/surcharge-types'),
|
|
||||||
createSurchargeType: (data: any): Promise<any> => apiFetch('/surcharge-types', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateSurchargeType: (id: string, data: any): Promise<any> => apiFetch(`/surcharge-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteSurchargeType: (id: string): Promise<any> => apiFetch(`/surcharge-types/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getSurcharges: (): Promise<any[]> => apiFetch('/surcharges'),
|
|
||||||
createSurcharge: (data: any): Promise<any> => apiFetch('/surcharges', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateSurcharge: (id: string, data: any): Promise<any> => apiFetch(`/surcharges/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteSurcharge: (id: string): Promise<any> => apiFetch(`/surcharges/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getWeightLimitRules: (): Promise<any[]> => apiFetch('/weight-limit-rules'),
|
|
||||||
createWeightLimitRule: (data: any): Promise<any> => apiFetch('/weight-limit-rules', { method: 'POST', body: JSON.stringify(data) }),
|
|
||||||
updateWeightLimitRule: (id: string, data: any): Promise<any> => apiFetch(`/weight-limit-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
deleteWeightLimitRule: (id: string): Promise<any> => apiFetch(`/weight-limit-rules/${id}`, { method: 'DELETE' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Toast Component ====================
|
|
||||||
const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(onClose, 3000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500';
|
|
||||||
return (
|
|
||||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50`}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Modal Component ====================
|
|
||||||
const Modal = ({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) => {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
<div className="flex justify-between items-center px-6 py-4 border-b border-gray-200 sticky top-0 bg-white">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Form Components ====================
|
|
||||||
|
|
||||||
// 1. Cargo Type Form
|
|
||||||
const CargoTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
code: initialData?.code || '',
|
|
||||||
cargoTypeName: initialData?.cargoTypeName || '',
|
|
||||||
parentGroupId: initialData?.parentGroupId || '',
|
|
||||||
showFreeTextBox: initialData?.showFreeTextBox || false,
|
|
||||||
requiresDirectorApproval: initialData?.requiresDirectorApproval || false,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
displayOrder: initialData?.displayOrder || 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const submitData = {
|
|
||||||
code: formData.code.toUpperCase(),
|
|
||||||
cargoTypeName: formData.cargoTypeName,
|
|
||||||
parentGroupId: formData.parentGroupId || undefined,
|
|
||||||
showFreeTextBox: formData.showFreeTextBox,
|
|
||||||
requiresDirectorApproval: formData.requiresDirectorApproval,
|
|
||||||
isActive: formData.isActive,
|
|
||||||
displayOrder: Number(formData.displayOrder),
|
|
||||||
};
|
|
||||||
onSubmit(submitData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.cargoTypeName} onChange={(e) => setFormData({...formData, cargoTypeName: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Parent Group ID</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.parentGroupId} onChange={(e) => setFormData({...formData, parentGroupId: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.displayOrder} onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.showFreeTextBox} onChange={(e) => setFormData({...formData, showFreeTextBox: e.target.checked})} /> Show Free Text Box</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.requiresDirectorApproval} onChange={(e) => setFormData({...formData, requiresDirectorApproval: e.target.checked})} /> Requires Director Approval</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Container Type Form
|
|
||||||
const ContainerTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
sizeCode: initialData?.sizeCode || '',
|
|
||||||
description: initialData?.description || '',
|
|
||||||
containersPerWagon: initialData?.containersPerWagon || 1,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Size Code *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.sizeCode} onChange={(e) => setFormData({...formData, sizeCode: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Containers Per Wagon *</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.containersPerWagon} onChange={(e) => setFormData({...formData, containersPerWagon: parseInt(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Priority Rule Form
|
|
||||||
const PriorityRuleForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
priorityType: initialData?.priorityType || 'MEDIUM',
|
|
||||||
ruleName: initialData?.ruleName || '',
|
|
||||||
description: initialData?.description || '',
|
|
||||||
activationCondition: initialData?.activationCondition || '',
|
|
||||||
bonusPoints: initialData?.bonusPoints || 0,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Priority Type *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.priorityType} onChange={(e) => setFormData({...formData, priorityType: e.target.value})}>
|
|
||||||
<option value="HIGH">HIGH</option>
|
|
||||||
<option value="MEDIUM">MEDIUM</option>
|
|
||||||
<option value="LOW">LOW</option>
|
|
||||||
<option value="URGENT">URGENT</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Bonus Points</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.bonusPoints} onChange={(e) => setFormData({...formData, bonusPoints: parseInt(e.target.value)})} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rule Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.ruleName} onChange={(e) => setFormData({...formData, ruleName: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Activation Condition</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.activationCondition} onChange={(e) => setFormData({...formData, activationCondition: e.target.value})} placeholder="e.g., weight > 1000" />
|
|
||||||
</div>
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Service Type Form
|
|
||||||
const ServiceTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
code: initialData?.code || '',
|
|
||||||
serviceName: initialData?.serviceName || '',
|
|
||||||
description: initialData?.description || '',
|
|
||||||
canBeBookedAlone: initialData?.canBeBookedAlone !== undefined ? initialData.canBeBookedAlone : true,
|
|
||||||
includesFirstMile: initialData?.includesFirstMile || false,
|
|
||||||
includesLastMile: initialData?.includesLastMile || false,
|
|
||||||
includesCustoms: initialData?.includesCustoms || false,
|
|
||||||
priorityBonusPoints: initialData?.priorityBonusPoints || 0,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
displayOrder: initialData?.displayOrder || 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Service Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.serviceName} onChange={(e) => setFormData({...formData, serviceName: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Priority Bonus Points</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.priorityBonusPoints} onChange={(e) => setFormData({...formData, priorityBonusPoints: parseInt(e.target.value)})} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Display Order</label>
|
|
||||||
<input type="number" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.displayOrder} onChange={(e) => setFormData({...formData, displayOrder: parseInt(e.target.value)})} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.canBeBookedAlone} onChange={(e) => setFormData({...formData, canBeBookedAlone: e.target.checked})} /> Can Be Booked Alone</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesFirstMile} onChange={(e) => setFormData({...formData, includesFirstMile: e.target.checked})} /> Includes First Mile</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesLastMile} onChange={(e) => setFormData({...formData, includesLastMile: e.target.checked})} /> Includes Last Mile</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.includesCustoms} onChange={(e) => setFormData({...formData, includesCustoms: e.target.checked})} /> Includes Customs</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. Surcharge Type Form
|
|
||||||
const SurchargeTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
code: initialData?.code || '',
|
|
||||||
name: initialData?.name || '',
|
|
||||||
description: initialData?.description || '',
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Code *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.code} onChange={(e) => setFormData({...formData, code: e.target.value.toUpperCase()})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.name} onChange={(e) => setFormData({...formData, name: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={3} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. Surcharge Form
|
|
||||||
const SurchargeForm = ({ initialData, onSubmit, onCancel, isSubmitting, surchargeTypes }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
surchargeTypeId: initialData?.surchargeTypeId || '',
|
|
||||||
feeName: initialData?.feeName || '',
|
|
||||||
triggerDescription: initialData?.triggerDescription || '',
|
|
||||||
calculationMethod: initialData?.calculationMethod || 'FLAT',
|
|
||||||
rate: initialData?.rate || 0,
|
|
||||||
currency: initialData?.currency || 'USD',
|
|
||||||
applyToRail: initialData?.applyToRail || false,
|
|
||||||
applyToFirstMile: initialData?.applyToFirstMile || false,
|
|
||||||
applyToLastMile: initialData?.applyToLastMile || false,
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Surcharge Type *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.surchargeTypeId} onChange={(e) => setFormData({...formData, surchargeTypeId: e.target.value})} required>
|
|
||||||
<option value="">Select Surcharge Type</option>
|
|
||||||
{surchargeTypes?.map((type: any) => (<option key={type.id} value={type.id}>{type.name}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Fee Name *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.feeName} onChange={(e) => setFormData({...formData, feeName: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Calculation Method *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.calculationMethod} onChange={(e) => setFormData({...formData, calculationMethod: e.target.value})}>
|
|
||||||
<option value="PER_TON">Per Ton</option>
|
|
||||||
<option value="FLAT">Flat</option>
|
|
||||||
<option value="PERCENTAGE">Percentage</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rate *</label>
|
|
||||||
<input type="number" step="0.01" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.rate} onChange={(e) => setFormData({...formData, rate: parseFloat(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Currency *</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.currency} onChange={(e) => setFormData({...formData, currency: e.target.value.toUpperCase()})} maxLength={3} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Trigger Description</label>
|
|
||||||
<textarea className="w-full px-3 py-2 border border-gray-300 rounded-md" rows={2} value={formData.triggerDescription} onChange={(e) => setFormData({...formData, triggerDescription: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToRail} onChange={(e) => setFormData({...formData, applyToRail: e.target.checked})} /> Apply to Rail</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToFirstMile} onChange={(e) => setFormData({...formData, applyToFirstMile: e.target.checked})} /> Apply to First Mile</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.applyToLastMile} onChange={(e) => setFormData({...formData, applyToLastMile: e.target.checked})} /> Apply to Last Mile</label>
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 7. Weight Limit Rule Form
|
|
||||||
const WeightLimitRuleForm = ({ initialData, onSubmit, onCancel, isSubmitting, containerTypes, surcharges }: any) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
containerTypeId: initialData?.containerTypeId || '',
|
|
||||||
tradeDirection: initialData?.tradeDirection || 'IMPORT',
|
|
||||||
maxWeightTons: initialData?.maxWeightTons || 20,
|
|
||||||
warningThresholdTons: initialData?.warningThresholdTons || 18,
|
|
||||||
exceededAction: initialData?.exceededAction || 'WARNING_ONLY',
|
|
||||||
surchargeId: initialData?.surchargeId || '',
|
|
||||||
isActive: initialData?.isActive !== undefined ? initialData.isActive : true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Container Type *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.containerTypeId} onChange={(e) => setFormData({...formData, containerTypeId: e.target.value})} required>
|
|
||||||
<option value="">Select Container Type</option>
|
|
||||||
{containerTypes?.map((type: any) => (<option key={type.id} value={type.id}>{type.sizeCode}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Trade Direction *</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.tradeDirection} onChange={(e) => setFormData({...formData, tradeDirection: e.target.value})}>
|
|
||||||
<option value="IMPORT">Import</option>
|
|
||||||
<option value="EXPORT">Export</option>
|
|
||||||
<option value="DOMESTIC">Domestic</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Exceeded Action</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.exceededAction} onChange={(e) => setFormData({...formData, exceededAction: e.target.value})}>
|
|
||||||
<option value="WARNING_ONLY">Warning Only</option>
|
|
||||||
<option value="BLOCK">Block</option>
|
|
||||||
<option value="SURCHARGE">Surcharge</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Weight (Tons) *</label>
|
|
||||||
<input type="number" step="0.1" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.maxWeightTons} onChange={(e) => setFormData({...formData, maxWeightTons: parseFloat(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Warning Threshold (Tons) *</label>
|
|
||||||
<input type="number" step="0.1" className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.warningThresholdTons} onChange={(e) => setFormData({...formData, warningThresholdTons: parseFloat(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{formData.exceededAction === 'SURCHARGE' && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Surcharge</label>
|
|
||||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md" value={formData.surchargeId} onChange={(e) => setFormData({...formData, surchargeId: e.target.value})}>
|
|
||||||
<option value="">Select Surcharge</option>
|
|
||||||
{surcharges?.map((surcharge: any) => (<option key={surcharge.id} value={surcharge.id}>{surcharge.feeName}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="flex items-center"><input type="checkbox" className="mr-2" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} /> Active</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
||||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-700 bg-white border rounded-md hover:bg-gray-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={isSubmitting} className="px-4 py-2 text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Entity Table Component ====================
|
|
||||||
const EntityTable = ({ title, data, columns, onAdd, onEdit, onDelete, isLoading }: any) => {
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
const filteredData = Array.isArray(data) ? data.filter((item: any) =>
|
|
||||||
Object.values(item).some(value =>
|
|
||||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
) : [];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-bold text-green-600">▼</span>
|
|
||||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center items-center py-8">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-green-500 border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm mb-6 overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100" onClick={() => setExpanded(!expanded)}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-bold text-green-600">{expanded ? '▼' : '▶'}</span>
|
|
||||||
<h3 className="text-base font-semibold text-gray-800">{title}</h3>
|
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">{filteredData.length} items</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-6 gap-4 flex-wrap">
|
|
||||||
<button className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700" onClick={onAdd}>+ Add {title.slice(0, -1)}</button>
|
|
||||||
<div className="relative">
|
|
||||||
<input type="text" placeholder="Search..." className="w-80 px-3 py-2 pl-10 border border-gray-300 rounded-md" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
|
|
||||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0118 0z" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
{columns.map((col: any) => (<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{col.label}</th>))}
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{filteredData.map((item: any) => (
|
|
||||||
<tr key={item.id} className="hover:bg-gray-50">
|
|
||||||
{columns.map((col: any) => (<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{col.render ? col.render(item[col.key], item) : item[col.key]}</td>))}
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
||||||
<button className="bg-yellow-500 text-gray-900 px-3 py-1 rounded text-xs mr-2 hover:bg-yellow-600" onClick={() => onEdit(item)}>Edit</button>
|
|
||||||
<button className="bg-red-600 text-white px-3 py-1 rounded text-xs hover:bg-red-700" onClick={() => onDelete(item)}>Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filteredData.length === 0 && (<div className="text-center py-12 text-gray-500">No data found. Click "Add" to create one.</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Main Component ====================
|
|
||||||
const ContractTypePage = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState('cargo-types');
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editingItem, setEditingItem] = useState<any>(null);
|
|
||||||
const [currentEntity, setCurrentEntity] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const [cargoTypes, setCargoTypes] = useState<any[]>([]);
|
|
||||||
const [containerTypes, setContainerTypes] = useState<any[]>([]);
|
|
||||||
const [priorityRules, setPriorityRules] = useState<any[]>([]);
|
|
||||||
const [serviceTypes, setServiceTypes] = useState<any[]>([]);
|
|
||||||
const [surchargeTypes, setSurchargeTypes] = useState<any[]>([]);
|
|
||||||
const [surcharges, setSurcharges] = useState<any[]>([]);
|
|
||||||
const [weightLimitRules, setWeightLimitRules] = useState<any[]>([]);
|
|
||||||
|
|
||||||
const showToast = (message: string, type: 'success' | 'error') => setToast({ message, type });
|
|
||||||
|
|
||||||
useEffect(() => { loadAllData(); }, []);
|
|
||||||
|
|
||||||
const loadAllData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [cargo, container, priority, service, surchargeType, surcharge, weight] = await Promise.all([
|
|
||||||
apiService.getCargoTypes().catch(() => []),
|
|
||||||
apiService.getContainerTypes().catch(() => []),
|
|
||||||
apiService.getPriorityRules().catch(() => []),
|
|
||||||
apiService.getServiceTypes().catch(() => []),
|
|
||||||
apiService.getSurchargeTypes().catch(() => []),
|
|
||||||
apiService.getSurcharges().catch(() => []),
|
|
||||||
apiService.getWeightLimitRules().catch(() => []),
|
|
||||||
]);
|
|
||||||
setCargoTypes(cargo);
|
|
||||||
setContainerTypes(container);
|
|
||||||
setPriorityRules(priority);
|
|
||||||
setServiceTypes(service);
|
|
||||||
setSurchargeTypes(surchargeType);
|
|
||||||
setSurcharges(surcharge);
|
|
||||||
setWeightLimitRules(weight);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = (entity: string) => {
|
|
||||||
setCurrentEntity(entity);
|
|
||||||
setEditingItem(null);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (entity: string, item: any) => {
|
|
||||||
setCurrentEntity(entity);
|
|
||||||
setEditingItem(item);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitForm = async (formData: any) => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
let result: any;
|
|
||||||
|
|
||||||
switch(currentEntity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateCargoType(editingItem.id, formData);
|
|
||||||
setCargoTypes(cargoTypes.map(c => c.id === editingItem.id ? result : c));
|
|
||||||
} else {
|
|
||||||
result = await createCargoType(formData);
|
|
||||||
setCargoTypes([...cargoTypes, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'container-types':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateContainerType(editingItem.id, formData);
|
|
||||||
setContainerTypes(containerTypes.map(c => c.id === editingItem.id ? result : c));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createContainerType(formData);
|
|
||||||
setContainerTypes([...containerTypes, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'priority-rules':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updatePriorityRule(editingItem.id, formData);
|
|
||||||
setPriorityRules(priorityRules.map(p => p.id === editingItem.id ? result : p));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createPriorityRule(formData);
|
|
||||||
setPriorityRules([...priorityRules, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'service-types':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateServiceType(editingItem.id, formData);
|
|
||||||
setServiceTypes(serviceTypes.map(s => s.id === editingItem.id ? result : s));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createServiceType(formData);
|
|
||||||
setServiceTypes([...serviceTypes, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'surcharge-types':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateSurchargeType(editingItem.id, formData);
|
|
||||||
setSurchargeTypes(surchargeTypes.map(s => s.id === editingItem.id ? result : s));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createSurchargeType(formData);
|
|
||||||
setSurchargeTypes([...surchargeTypes, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'surcharges':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateSurcharge(editingItem.id, formData);
|
|
||||||
setSurcharges(surcharges.map(s => s.id === editingItem.id ? result : s));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createSurcharge(formData);
|
|
||||||
setSurcharges([...surcharges, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
if (editingItem) {
|
|
||||||
result = await apiService.updateWeightLimitRule(editingItem.id, formData);
|
|
||||||
setWeightLimitRules(weightLimitRules.map(w => w.id === editingItem.id ? result : w));
|
|
||||||
} else {
|
|
||||||
result = await apiService.createWeightLimitRule(formData);
|
|
||||||
setWeightLimitRules([...weightLimitRules, result]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(`${currentEntity} ${editingItem ? 'updated' : 'created'} successfully!`, 'success');
|
|
||||||
setModalOpen(false);
|
|
||||||
setEditingItem(null);
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Submit error:', error);
|
|
||||||
showToast(error.message || `Failed to ${editingItem ? 'update' : 'create'}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (entity: string, item: any) => {
|
|
||||||
if (!confirm(`Delete this ${entity}?`)) return;
|
|
||||||
try {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
await apiService.deleteCargoType(item.id);
|
|
||||||
setCargoTypes(cargoTypes.filter(c => c.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'container-types':
|
|
||||||
await apiService.deleteContainerType(item.id);
|
|
||||||
setContainerTypes(containerTypes.filter(c => c.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'priority-rules':
|
|
||||||
await apiService.deletePriorityRule(item.id);
|
|
||||||
setPriorityRules(priorityRules.filter(p => p.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'service-types':
|
|
||||||
await apiService.deleteServiceType(item.id);
|
|
||||||
setServiceTypes(serviceTypes.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'surcharge-types':
|
|
||||||
await apiService.deleteSurchargeType(item.id);
|
|
||||||
setSurchargeTypes(surchargeTypes.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'surcharges':
|
|
||||||
await apiService.deleteSurcharge(item.id);
|
|
||||||
setSurcharges(surcharges.filter(s => s.id !== item.id));
|
|
||||||
break;
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
await apiService.deleteWeightLimitRule(item.id);
|
|
||||||
setWeightLimitRules(weightLimitRules.filter(w => w.id !== item.id));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
showToast(`${entity} deleted successfully!`, 'success');
|
|
||||||
} catch (error: any) {
|
|
||||||
showToast(error.message || `Failed to delete`, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColumns = (entity: string) => {
|
|
||||||
const baseStatus = { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' };
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types':
|
|
||||||
return [{ key: 'code', label: 'Code' }, { key: 'cargoTypeName', label: 'Name' }, { key: 'displayOrder', label: 'Order' }, baseStatus];
|
|
||||||
case 'container-types':
|
|
||||||
return [{ key: 'sizeCode', label: 'Size Code' }, { key: 'description', label: 'Description' }, { key: 'containersPerWagon', label: 'Containers/Wagon' }, baseStatus];
|
|
||||||
case 'priority-rules':
|
|
||||||
return [{ key: 'priorityType', label: 'Priority Type' }, { key: 'ruleName', label: 'Rule Name' }, { key: 'bonusPoints', label: 'Bonus Points' }, baseStatus];
|
|
||||||
case 'service-types':
|
|
||||||
return [{ key: 'code', label: 'Code' }, { key: 'serviceName', label: 'Service Name' }, { key: 'displayOrder', label: 'Order' }, baseStatus];
|
|
||||||
case 'surcharge-types':
|
|
||||||
return [{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, baseStatus];
|
|
||||||
case 'surcharges':
|
|
||||||
return [{ key: 'feeName', label: 'Fee Name' }, { key: 'calculationMethod', label: 'Method' }, { key: 'rate', label: 'Rate', render: (val: number, item: any) => `${val} ${item.currency}` }, baseStatus];
|
|
||||||
case 'weight-limit-rules':
|
|
||||||
return [{ key: 'tradeDirection', label: 'Direction' }, { key: 'maxWeightTons', label: 'Max Weight', render: (val: number) => `${val} tons` }, { key: 'exceededAction', label: 'Action' }, baseStatus];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEntityData = (entity: string) => {
|
|
||||||
switch(entity) {
|
|
||||||
case 'cargo-types': return cargoTypes;
|
|
||||||
case 'container-types': return containerTypes;
|
|
||||||
case 'priority-rules': return priorityRules;
|
|
||||||
case 'service-types': return serviceTypes;
|
|
||||||
case 'surcharge-types': return surchargeTypes;
|
|
||||||
case 'surcharges': return surcharges;
|
|
||||||
case 'weight-limit-rules': return weightLimitRules;
|
|
||||||
default: return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'cargo-types', label: 'Cargo Types', Form: CargoTypeForm },
|
|
||||||
{ id: 'container-types', label: 'Container Types', Form: ContainerTypeForm },
|
|
||||||
{ id: 'priority-rules', label: 'Priority Rules', Form: PriorityRuleForm },
|
|
||||||
{ id: 'service-types', label: 'Service Types', Form: ServiceTypeForm },
|
|
||||||
{ id: 'surcharge-types', label: 'Surcharge Types', Form: SurchargeTypeForm },
|
|
||||||
{ id: 'surcharges', label: 'Surcharges', Form: SurchargeForm },
|
|
||||||
{ id: 'weight-limit-rules', label: 'Weight Limit Rules', Form: WeightLimitRuleForm },
|
|
||||||
];
|
|
||||||
|
|
||||||
const currentTab = tabs.find(t => t.id === currentEntity);
|
|
||||||
const FormComponent = currentTab?.Form;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="contract-type-page">
|
|
||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800">Rule Engine - Master Data</h3>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Manage cargo types, container types, priority rules, and more</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
|
||||||
<div className="flex space-x-1 overflow-x-auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap ${activeTab === tab.id ? 'text-green-600 border-b-2 border-green-600' : 'text-gray-600 hover:text-green-600 hover:border-b-2 hover:border-green-300'}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<div key={tab.id} className={activeTab === tab.id ? 'block' : 'hidden'}>
|
|
||||||
<EntityTable
|
|
||||||
title={tab.label}
|
|
||||||
data={getEntityData(tab.id)}
|
|
||||||
columns={getColumns(tab.id)}
|
|
||||||
onAdd={() => handleAdd(tab.id)}
|
|
||||||
onEdit={(item: any) => handleEdit(tab.id, item)}
|
|
||||||
onDelete={(item: any) => handleDelete(tab.id, item)}
|
|
||||||
isLoading={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
isOpen={modalOpen}
|
|
||||||
onClose={() => { setModalOpen(false); setEditingItem(null); }}
|
|
||||||
title={editingItem ? `Edit ${currentEntity?.replace('-', ' ')}` : `Add ${currentEntity?.replace('-', ' ')}`}
|
|
||||||
>
|
|
||||||
{FormComponent && (
|
|
||||||
<FormComponent
|
|
||||||
initialData={editingItem}
|
|
||||||
onSubmit={handleSubmitForm}
|
|
||||||
onCancel={() => { setModalOpen(false); setEditingItem(null); }}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
surchargeTypes={surchargeTypes}
|
|
||||||
containerTypes={containerTypes}
|
|
||||||
surcharges={surcharges}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContractTypePage;
|
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||||
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
import { DataTableFooter } from "@edr/ui-common";
|
||||||
|
import type { Table } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import RuleEngineRecordActions from "./RuleEngineRecordActions";
|
||||||
|
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
|
||||||
|
import { formatCell } from "./ruleEngineFormat";
|
||||||
|
import { ruleEngineCard } from "./ruleEngineStyles";
|
||||||
|
|
||||||
|
export interface RuleEngineCardGridProps {
|
||||||
|
config: RuleEngineResourceConfig;
|
||||||
|
rows: RuleEngineRecord[];
|
||||||
|
status: "loading" | "error" | "success";
|
||||||
|
emptyMessage: string;
|
||||||
|
itemLabel: string;
|
||||||
|
table: Table<RuleEngineRecord>;
|
||||||
|
pagination: {
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
};
|
||||||
|
onEdit: (record: RuleEngineRecord) => void;
|
||||||
|
onDelete: (record: RuleEngineRecord) => void;
|
||||||
|
onViewChain?: () => void;
|
||||||
|
onSubmitRate?: (id: string) => void;
|
||||||
|
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleEngineCardGrid = ({
|
||||||
|
config,
|
||||||
|
rows,
|
||||||
|
status,
|
||||||
|
emptyMessage,
|
||||||
|
itemLabel,
|
||||||
|
table,
|
||||||
|
pagination,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onViewChain,
|
||||||
|
onSubmitRate,
|
||||||
|
onApproveRate,
|
||||||
|
}: RuleEngineCardGridProps) => {
|
||||||
|
const presentation = resolveCardPresentation(config);
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<p className="text-sm font-medium text-foreground">Failed to load data</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Please refresh the page or try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className={ruleEngineCard.skeleton}>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-md bg-muted" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 w-2/3 rounded-sm bg-muted" />
|
||||||
|
<div className="h-3 w-1/3 rounded-sm bg-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="h-3 w-full rounded-sm bg-muted" />
|
||||||
|
<div className="h-3 w-4/5 rounded-sm bg-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "success" && rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<p className="text-sm font-medium text-foreground">{emptyMessage}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Try adjusting your search or add a new record.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4">
|
||||||
|
{rows.map((record) => {
|
||||||
|
const title = String(record[presentation.titleKey] ?? "Untitled");
|
||||||
|
const subtitle = presentation.subtitleKey
|
||||||
|
? String(record[presentation.subtitleKey] ?? "")
|
||||||
|
: "";
|
||||||
|
const code = presentation.codeKey
|
||||||
|
? String(record[presentation.codeKey] ?? "")
|
||||||
|
: "";
|
||||||
|
const statusValue = presentation.statusKey
|
||||||
|
? record[presentation.statusKey]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={record.id} className={ruleEngineCard.article}>
|
||||||
|
<div className={ruleEngineCard.header}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={ruleEngineCard.avatar} aria-hidden>
|
||||||
|
{cardInitials(title)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className={ruleEngineCard.title}>{title}</h3>
|
||||||
|
{presentation.statusKey
|
||||||
|
? formatCell(statusValue, "activeBadge")
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
{(code || subtitle) && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
{code ? formatCell(code, "code") : null}
|
||||||
|
{subtitle ? (
|
||||||
|
<span className={ruleEngineCard.meta}>
|
||||||
|
{presentation.subtitleKey === "stepOrder"
|
||||||
|
? `Step ${subtitle}`
|
||||||
|
: subtitle}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{presentation.detailColumns.length > 0 ? (
|
||||||
|
<dl className="grid flex-1 gap-x-4 gap-y-3 px-4 py-3.5 sm:grid-cols-2">
|
||||||
|
{presentation.detailColumns.map((col) => (
|
||||||
|
<div key={col.id} className="min-w-0">
|
||||||
|
<dt className={ruleEngineCard.detailLabel}>{col.header}</dt>
|
||||||
|
<dd className={ruleEngineCard.detailValue}>
|
||||||
|
{formatCell(record[col.accessorKey], col.format)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 px-4 py-2" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={ruleEngineCard.footer}>
|
||||||
|
<RuleEngineRecordActions
|
||||||
|
record={record}
|
||||||
|
config={config}
|
||||||
|
layout="compact"
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onViewChain={onViewChain}
|
||||||
|
onSubmitRate={onSubmitRate}
|
||||||
|
onApproveRate={onApproveRate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border bg-card">
|
||||||
|
<DataTableFooter
|
||||||
|
table={table}
|
||||||
|
pagination={pagination}
|
||||||
|
options={{
|
||||||
|
labels: {
|
||||||
|
showing: "Showing",
|
||||||
|
ofLabel: "of",
|
||||||
|
items: itemLabel,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleEngineCardGrid;
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldLabel,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Separator,
|
||||||
|
Switch,
|
||||||
|
Textarea,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RULE_ENGINE_SELECT_NONE,
|
||||||
|
type FormFieldDef,
|
||||||
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
|
||||||
|
import { ruleEngineField, ruleEngineSurface } from "./ruleEngineStyles";
|
||||||
|
|
||||||
|
export interface RuleEngineFormDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fields: FormFieldDef[];
|
||||||
|
initialRecord?: RuleEngineRecord | null;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
selectOptionsLoading?: boolean;
|
||||||
|
onSubmit: (values: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildInitialValues = (
|
||||||
|
fields: FormFieldDef[],
|
||||||
|
record?: RuleEngineRecord | null,
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const raw = record?.[field.name];
|
||||||
|
if (raw !== undefined && raw !== null) {
|
||||||
|
if (field.type === "date" && typeof raw === "string") {
|
||||||
|
values[field.name] = raw.slice(0, 10);
|
||||||
|
} else {
|
||||||
|
values[field.name] = raw;
|
||||||
|
}
|
||||||
|
} else if (field.type === "boolean") {
|
||||||
|
values[field.name] = false;
|
||||||
|
} else if (field.type === "number") {
|
||||||
|
values[field.name] = "";
|
||||||
|
} else {
|
||||||
|
values[field.name] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSelectValue = (
|
||||||
|
field: FormFieldDef,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): string | undefined => {
|
||||||
|
const raw = values[field.name];
|
||||||
|
const isEmpty = raw === "" || raw === null || raw === undefined;
|
||||||
|
|
||||||
|
if (field.optional && isEmpty) {
|
||||||
|
return RULE_ENGINE_SELECT_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RuleEngineFormDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fields,
|
||||||
|
initialRecord,
|
||||||
|
isSubmitting,
|
||||||
|
selectOptionsLoading = false,
|
||||||
|
onSubmit,
|
||||||
|
}: RuleEngineFormDialogProps) => {
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
||||||
|
buildInitialValues(fields, initialRecord),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setValues(buildInitialValues(fields, initialRecord));
|
||||||
|
}
|
||||||
|
}, [open, fields, initialRecord]);
|
||||||
|
|
||||||
|
const setField = (name: string, value: unknown) => {
|
||||||
|
setValues((current) => ({ ...current, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const raw = values[field.name];
|
||||||
|
if (field.type === "number") {
|
||||||
|
if (raw === "" || raw === undefined) continue;
|
||||||
|
payload[field.name] = Number(raw);
|
||||||
|
} else if (field.type === "boolean") {
|
||||||
|
payload[field.name] = Boolean(raw);
|
||||||
|
} else if (
|
||||||
|
field.type === "select" &&
|
||||||
|
(raw === "" || raw === RULE_ENGINE_SELECT_NONE)
|
||||||
|
) {
|
||||||
|
if (!field.required) continue;
|
||||||
|
} else if (raw === "" || raw === undefined) {
|
||||||
|
if (!field.required) continue;
|
||||||
|
payload[field.name] = raw;
|
||||||
|
} else {
|
||||||
|
payload[field.name] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.some((f) => f.name === "code" && typeof payload.code === "string")) {
|
||||||
|
payload.code = String(payload.code).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className={ruleEngineSurface.dialog}>
|
||||||
|
<DialogHeader className="space-y-1">
|
||||||
|
<DialogTitle className="text-lg font-semibold">{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-1">
|
||||||
|
<div className="max-h-[min(60vh,28rem)] space-y-4 overflow-y-auto pr-1">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Field key={field.name} orientation="vertical" className="gap-1.5">
|
||||||
|
{field.type === "boolean" ? (
|
||||||
|
<div className={ruleEngineField.switchRow}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}>
|
||||||
|
{field.label}
|
||||||
|
</FieldLabel>
|
||||||
|
<p className={ruleEngineField.switchHint}>
|
||||||
|
{Boolean(values[field.name]) ? "Enabled" : "Disabled"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={field.name}
|
||||||
|
checked={Boolean(values[field.name])}
|
||||||
|
onCheckedChange={(checked) => setField(field.name, checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}>
|
||||||
|
{field.label}
|
||||||
|
{field.required ? (
|
||||||
|
<span className={ruleEngineField.requiredMark}> *</span>
|
||||||
|
) : null}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
{field.type === "select" ? (
|
||||||
|
<Select
|
||||||
|
value={resolveSelectValue(field, values)}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setField(
|
||||||
|
field.name,
|
||||||
|
v === RULE_ENGINE_SELECT_NONE ? "" : v,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={selectOptionsLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id={field.name}
|
||||||
|
className={ruleEngineField.selectTrigger}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
selectOptionsLoading
|
||||||
|
? "Loading options..."
|
||||||
|
: (field.placeholder ?? "Select an option")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className={ruleEngineField.selectContent}>
|
||||||
|
{(field.options ?? [])
|
||||||
|
.filter((opt) => opt.value !== "")
|
||||||
|
.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : field.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
id={field.name}
|
||||||
|
value={String(values[field.name] ?? "")}
|
||||||
|
onChange={(e) => setField(field.name, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className={ruleEngineField.textarea}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
type={
|
||||||
|
field.type === "number"
|
||||||
|
? "number"
|
||||||
|
: field.type === "date"
|
||||||
|
? "date"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
value={String(values[field.name] ?? "")}
|
||||||
|
onChange={(e) => setField(field.name, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className={ruleEngineField.input}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FieldContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-md"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="rounded-md" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleEngineFormDialog;
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Send,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||||
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
export interface RuleEngineRecordActionsProps {
|
||||||
|
record: RuleEngineRecord;
|
||||||
|
config: RuleEngineResourceConfig;
|
||||||
|
onEdit: (record: RuleEngineRecord) => void;
|
||||||
|
onDelete: (record: RuleEngineRecord) => void;
|
||||||
|
onViewChain?: () => void;
|
||||||
|
onSubmitRate?: (id: string) => void;
|
||||||
|
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||||
|
layout?: "row" | "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleEngineRecordActions = ({
|
||||||
|
record,
|
||||||
|
config,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onViewChain,
|
||||||
|
onSubmitRate,
|
||||||
|
onApproveRate,
|
||||||
|
layout = "row",
|
||||||
|
}: RuleEngineRecordActionsProps) => {
|
||||||
|
const status = String(record.status ?? "");
|
||||||
|
const hasRateActions =
|
||||||
|
config.slug === "rates" && (status === "DRAFT" || status === "PENDING_APPROVAL");
|
||||||
|
|
||||||
|
const iconBtnClass =
|
||||||
|
layout === "compact"
|
||||||
|
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={iconBtnClass}
|
||||||
|
onClick={() => onEdit(record)}
|
||||||
|
aria-label="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{config.slug === "approval-rules" && onViewChain ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={iconBtnClass}
|
||||||
|
onClick={onViewChain}
|
||||||
|
aria-label="View approval chain"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasRateActions ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={iconBtnClass}
|
||||||
|
aria-label="More actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{status === "DRAFT" && onSubmitRate ? (
|
||||||
|
<DropdownMenuItem onSelect={() => onSubmitRate(record.id)}>
|
||||||
|
<Send />
|
||||||
|
Submit for approval
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{status === "PENDING_APPROVAL" && onApproveRate ? (
|
||||||
|
<DropdownMenuItem onSelect={() => onApproveRate(record)}>
|
||||||
|
<CheckCircle2 />
|
||||||
|
Approve
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`${iconBtnClass} hover:bg-red-50 hover:text-red-600`}
|
||||||
|
onClick={() => onDelete(record)}
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleEngineRecordActions;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Filter, LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button, Input } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
|
||||||
|
import { ruleEngineToolbar } from "./ruleEngineStyles";
|
||||||
|
|
||||||
|
export interface RuleEngineToolbarProps {
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
onAdd: () => void;
|
||||||
|
addLabel?: string;
|
||||||
|
viewMode: RuleEngineViewMode;
|
||||||
|
onViewModeChange: (mode: RuleEngineViewMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleEngineToolbar = ({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
searchPlaceholder,
|
||||||
|
onAdd,
|
||||||
|
addLabel = "Add",
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
}: RuleEngineToolbarProps) => (
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="relative min-w-0 flex-1 lg:max-w-md">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className={ruleEngineToolbar.search}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={ruleEngineToolbar.viewToggleGroup}
|
||||||
|
role="group"
|
||||||
|
aria-label="View mode"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onViewModeChange("table")}
|
||||||
|
className={cn(
|
||||||
|
ruleEngineToolbar.viewToggleBtn,
|
||||||
|
viewMode === "table"
|
||||||
|
? ruleEngineToolbar.viewToggleActive
|
||||||
|
: ruleEngineToolbar.viewToggleIdle,
|
||||||
|
)}
|
||||||
|
aria-pressed={viewMode === "table"}
|
||||||
|
>
|
||||||
|
<Table2 className="h-4 w-4" />
|
||||||
|
Table
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onViewModeChange("cards")}
|
||||||
|
className={cn(
|
||||||
|
ruleEngineToolbar.viewToggleBtn,
|
||||||
|
viewMode === "cards"
|
||||||
|
? ruleEngineToolbar.viewToggleActive
|
||||||
|
: ruleEngineToolbar.viewToggleIdle,
|
||||||
|
)}
|
||||||
|
aria-pressed={viewMode === "cards"}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
Cards
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className={cn(ruleEngineToolbar.actionBtn, "gap-2 px-3")}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RuleEngineToolbar;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type {
|
||||||
|
ResourceColumn,
|
||||||
|
RuleEngineResourceConfig,
|
||||||
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
|
|
||||||
|
const TITLE_KEY_PRIORITY = [
|
||||||
|
"cargoTypeName",
|
||||||
|
"serviceName",
|
||||||
|
"label",
|
||||||
|
"actionLabel",
|
||||||
|
"rateType",
|
||||||
|
"containerTypeId",
|
||||||
|
"requiredRole",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SUBTITLE_KEY_PRIORITY = [
|
||||||
|
"code",
|
||||||
|
"stepOrder",
|
||||||
|
"currency",
|
||||||
|
"tradeDirection",
|
||||||
|
"requiredRole",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface RuleEngineCardPresentation {
|
||||||
|
titleKey: string;
|
||||||
|
subtitleKey?: string;
|
||||||
|
codeKey?: string;
|
||||||
|
statusKey?: string;
|
||||||
|
detailColumns: ResourceColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveCardPresentation = (
|
||||||
|
config: RuleEngineResourceConfig,
|
||||||
|
): RuleEngineCardPresentation => {
|
||||||
|
const titleKey =
|
||||||
|
config.cardTitleKey ??
|
||||||
|
TITLE_KEY_PRIORITY.find((key) =>
|
||||||
|
config.columns.some((col) => col.accessorKey === key),
|
||||||
|
) ??
|
||||||
|
config.columns.find((col) => col.format !== "code" && col.accessorKey !== "isActive")
|
||||||
|
?.accessorKey ??
|
||||||
|
"id";
|
||||||
|
|
||||||
|
const codeKey =
|
||||||
|
config.cardCodeKey ??
|
||||||
|
config.columns.find((col) => col.format === "code")?.accessorKey;
|
||||||
|
|
||||||
|
const statusKey = config.columns.find((col) => col.format === "activeBadge")?.accessorKey;
|
||||||
|
|
||||||
|
const subtitleKey =
|
||||||
|
config.cardSubtitleKey ??
|
||||||
|
SUBTITLE_KEY_PRIORITY.find(
|
||||||
|
(key) =>
|
||||||
|
key !== titleKey &&
|
||||||
|
key !== codeKey &&
|
||||||
|
config.columns.some((col) => col.accessorKey === key),
|
||||||
|
);
|
||||||
|
|
||||||
|
const detailColumns = config.columns.filter((col) => {
|
||||||
|
if (col.accessorKey === titleKey) return false;
|
||||||
|
if (codeKey && col.accessorKey === codeKey) return false;
|
||||||
|
if (subtitleKey && col.accessorKey === subtitleKey) return false;
|
||||||
|
if (statusKey && col.accessorKey === statusKey) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { titleKey, subtitleKey, codeKey, statusKey, detailColumns };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cardInitials = (title: string): string => {
|
||||||
|
const words = title.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return "?";
|
||||||
|
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||||
|
return `${words[0][0] ?? ""}${words[1][0] ?? ""}`.toUpperCase();
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
||||||
|
import { Badge } from "@edr/ui-common";
|
||||||
|
|
||||||
|
const statusBadgeClass = (active: boolean) =>
|
||||||
|
cn(
|
||||||
|
"rounded-sm px-2 py-0.5 text-xs font-medium",
|
||||||
|
active
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
|
: "border-border bg-muted text-muted-foreground",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "boolean") {
|
||||||
|
return value ? "Yes" : "No";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "activeBadge") {
|
||||||
|
const active = Boolean(value);
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={statusBadgeClass(active)}>
|
||||||
|
{active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "rateStatus") {
|
||||||
|
const status = String(value);
|
||||||
|
const tone =
|
||||||
|
status === "LIVE"
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
|
: status === "DRAFT"
|
||||||
|
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||||
|
: "border-sky-200 bg-sky-50 text-sky-800";
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn("rounded-sm font-medium", tone)}>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "code") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-sm border border-border bg-muted/80 font-mono text-[11px] font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{String(value)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "date") {
|
||||||
|
const d = new Date(String(value));
|
||||||
|
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/** Material-inspired surfaces using shadcn tokens — modest radius, outlined fields. */
|
||||||
|
|
||||||
|
export const ruleEngineSurface = {
|
||||||
|
pageCard:
|
||||||
|
"overflow-hidden rounded-lg border border-border bg-card shadow-sm",
|
||||||
|
pageCardToolbar: "border-b border-border bg-muted/30 px-4 py-3 sm:px-5 sm:py-3.5",
|
||||||
|
dialog: "max-h-[90vh] overflow-y-auto rounded-lg border-border sm:max-w-lg",
|
||||||
|
dialogSm: "rounded-lg border-border sm:max-w-md",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ruleEngineField = {
|
||||||
|
label: "text-sm font-medium text-foreground",
|
||||||
|
requiredMark: "text-destructive",
|
||||||
|
input:
|
||||||
|
"h-10 w-full rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
|
||||||
|
textarea:
|
||||||
|
"min-h-[96px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
|
||||||
|
selectTrigger:
|
||||||
|
"h-10 w-full rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 data-[placeholder]:text-muted-foreground",
|
||||||
|
selectContent: "rounded-md border-border shadow-md",
|
||||||
|
switchRow: "flex min-h-10 items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3",
|
||||||
|
switchHint: "text-xs text-muted-foreground",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ruleEngineToolbar = {
|
||||||
|
search:
|
||||||
|
"h-10 rounded-md border border-input bg-background pl-10 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
|
||||||
|
viewToggleGroup:
|
||||||
|
"flex h-10 items-center rounded-md border border-border bg-muted/40 p-0.5",
|
||||||
|
viewToggleBtn:
|
||||||
|
"inline-flex h-8 items-center gap-1.5 rounded-sm px-3 text-sm font-medium transition-colors",
|
||||||
|
viewToggleActive: "bg-background text-foreground shadow-sm",
|
||||||
|
viewToggleIdle: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
|
||||||
|
actionBtn: "h-10 rounded-md shadow-xs",
|
||||||
|
primaryBtn: "h-10 gap-2 rounded-md px-4 text-sm font-medium shadow-xs",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ruleEngineCard = {
|
||||||
|
article:
|
||||||
|
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
|
||||||
|
header: "border-b border-border bg-muted/25 px-4 py-3.5",
|
||||||
|
avatar:
|
||||||
|
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/12 text-sm font-semibold text-primary",
|
||||||
|
title: "truncate text-[15px] font-semibold text-foreground",
|
||||||
|
meta: "text-xs text-muted-foreground",
|
||||||
|
detailLabel:
|
||||||
|
"text-[11px] font-medium uppercase tracking-wide text-muted-foreground",
|
||||||
|
detailValue: "mt-0.5 text-sm text-foreground",
|
||||||
|
footer: "mt-auto border-t border-border bg-muted/20 px-3 py-2.5",
|
||||||
|
skeleton: "animate-pulse rounded-lg border border-border bg-muted/30 p-4",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ruleEngineTable = {
|
||||||
|
headerCell:
|
||||||
|
"h-11 bg-muted/50 text-xs font-semibold uppercase tracking-wide text-muted-foreground",
|
||||||
|
bodyCell: "py-3.5 text-sm text-foreground",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
|
|
||||||
|
export type RuleEngineViewMode = "table" | "cards";
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "edr-freight-rule-engine-view:";
|
||||||
|
|
||||||
|
const readStored = (slug: RuleEngineResourceSlug): RuleEngineViewMode => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`${STORAGE_PREFIX}${slug}`);
|
||||||
|
return raw === "cards" ? "cards" : "table";
|
||||||
|
} catch {
|
||||||
|
return "table";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRuleEngineViewMode = (slug: RuleEngineResourceSlug) => {
|
||||||
|
const [viewMode, setViewModeState] = useState<RuleEngineViewMode>(() => readStored(slug));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setViewModeState(readStored(slug));
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
const setViewMode = useCallback(
|
||||||
|
(mode: RuleEngineViewMode) => {
|
||||||
|
setViewModeState(mode);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${STORAGE_PREFIX}${slug}`, mode);
|
||||||
|
} catch {
|
||||||
|
/* ignore quota / private mode */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[slug],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { viewMode, setViewMode };
|
||||||
|
};
|
||||||
@@ -16,5 +16,10 @@ export const QUERY_KEYS = {
|
|||||||
ROOT: "customers",
|
ROOT: "customers",
|
||||||
LIST: "list",
|
LIST: "list",
|
||||||
BY_ID: "by-id"
|
BY_ID: "by-id"
|
||||||
}
|
},
|
||||||
}
|
RULE_ENGINE: {
|
||||||
|
ROOT: "rule-engine",
|
||||||
|
list: (resource: string) => ["rule-engine", resource, "list"] as const,
|
||||||
|
chain: ["rule-engine", "approval-rules", "chain"] as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,39 +86,40 @@ export const URL_CONSTANTS = {
|
|||||||
SEND: "/api/otp/send",
|
SEND: "/api/otp/send",
|
||||||
VERIFY: "/api/otp/verify",
|
VERIFY: "/api/otp/verify",
|
||||||
},
|
},
|
||||||
|
|
||||||
RULE_ENGINE: {
|
RULE_ENGINE: {
|
||||||
// Cargo Types
|
|
||||||
CARGO_TYPES: "/cargo-types",
|
CARGO_TYPES: "/cargo-types",
|
||||||
CARGO_TYPE_BY_ID: (id: string | number) => `/cargo-types/${id}`,
|
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,
|
||||||
|
|
||||||
// Container Types
|
|
||||||
CONTAINER_TYPES: "/container-types",
|
CONTAINER_TYPES: "/container-types",
|
||||||
CONTAINER_TYPE_BY_ID: (id: string | number) =>
|
CONTAINER_TYPE_BY_ID: (id: string) => `/container-types/${id}`,
|
||||||
`/container-types/${id}`,
|
|
||||||
|
|
||||||
// Priority Rules
|
|
||||||
PRIORITY_RULES: "/priority-rules",
|
PRIORITY_RULES: "/priority-rules",
|
||||||
PRIORITY_RULE_BY_ID: (id: string | number) =>
|
PRIORITY_RULE_BY_ID: (id: string) => `/priority-rules/${id}`,
|
||||||
`/priority-rules/${id}`,
|
|
||||||
|
|
||||||
// Service Types
|
|
||||||
SERVICE_TYPES: "/service-types",
|
SERVICE_TYPES: "/service-types",
|
||||||
SERVICE_TYPE_BY_ID: (id: string | number) =>
|
SERVICE_TYPE_BY_ID: (id: string) => `/service-types/${id}`,
|
||||||
`/service-types/${id}`,
|
|
||||||
|
|
||||||
// Surcharge Types
|
|
||||||
SURCHARGE_TYPES: "/surcharge-types",
|
SURCHARGE_TYPES: "/surcharge-types",
|
||||||
SURCHARGE_TYPE_BY_ID: (id: string | number) =>
|
SURCHARGE_TYPE_BY_ID: (id: string) => `/surcharge-types/${id}`,
|
||||||
`/surcharge-types/${id}`,
|
|
||||||
|
|
||||||
// Surcharges
|
|
||||||
SURCHARGES: "/surcharges",
|
|
||||||
SURCHARGE_BY_ID: (id: string | number) =>
|
|
||||||
`/surcharges/${id}`,
|
|
||||||
|
|
||||||
// Weight Limit Rules
|
|
||||||
WEIGHT_LIMIT_RULES: "/weight-limit-rules",
|
WEIGHT_LIMIT_RULES: "/weight-limit-rules",
|
||||||
WEIGHT_LIMIT_RULE_BY_ID: (id: string | number) =>
|
WEIGHT_LIMIT_RULE_BY_ID: (id: string) => `/weight-limit-rules/${id}`,
|
||||||
`/weight-limit-rules/${id}`,
|
|
||||||
|
YARDS: "/yards",
|
||||||
|
YARD_BY_ID: (id: string) => `/yards/${id}`,
|
||||||
|
|
||||||
|
SHIPPING_LINES: "/shipping-lines",
|
||||||
|
SHIPPING_LINE_BY_ID: (id: string) => `/shipping-lines/${id}`,
|
||||||
|
|
||||||
|
RATES: "/rates",
|
||||||
|
RATES_LIVE: "/rates/live",
|
||||||
|
RATE_BY_ID: (id: string) => `/rates/${id}`,
|
||||||
|
RATE_SUBMIT: (id: string) => `/rates/${id}/submit`,
|
||||||
|
RATE_APPROVE: (id: string) => `/rates/${id}/approve`,
|
||||||
|
|
||||||
|
APPROVAL_RULES: "/approval-rules",
|
||||||
|
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
||||||
|
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY";
|
||||||
|
import {
|
||||||
|
ruleEngineService,
|
||||||
|
type RuleEngineListParams,
|
||||||
|
} from "@/services/ruleEngine/ruleEngine.service";
|
||||||
|
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
|
||||||
|
import type {
|
||||||
|
ApproveRatePayload,
|
||||||
|
RuleEngineRecord,
|
||||||
|
RuleEngineResourceSlug,
|
||||||
|
} from "@/types/rule-engine";
|
||||||
|
|
||||||
|
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
|
||||||
|
|
||||||
|
export const useRuleEngineList = (
|
||||||
|
resource: RuleEngineResourceSlug,
|
||||||
|
params: RuleEngineListParams,
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [...QUERY_KEYS.RULE_ENGINE.list(resource), params],
|
||||||
|
queryFn: () => ruleEngineService.list<RuleEngineRecord>(resource, params),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [
|
||||||
|
...QUERY_KEYS.RULE_ENGINE.list("cargo-types"),
|
||||||
|
"parent-options",
|
||||||
|
excludeId ?? "",
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
|
||||||
|
page: 1,
|
||||||
|
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
select: (result) => {
|
||||||
|
const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE };
|
||||||
|
const parents = (result.data ?? [])
|
||||||
|
.filter((row) => row.id && String(row.id) !== excludeId)
|
||||||
|
.map((row) => {
|
||||||
|
const name = String(row.cargoTypeName ?? "").trim();
|
||||||
|
const code = String(row.code ?? "").trim();
|
||||||
|
const label =
|
||||||
|
name && code ? `${name} (${code})` : name || code || String(row.id);
|
||||||
|
return { label, value: String(row.id) };
|
||||||
|
});
|
||||||
|
return [noneOption, ...parents];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useApprovalChain = (enabled: boolean) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: QUERY_KEYS.RULE_ENGINE.chain,
|
||||||
|
queryFn: () => ruleEngineService.getApprovalChain(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const invalidate = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource) });
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, unknown>) =>
|
||||||
|
ruleEngineService.create(resource, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Created successfully");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to create record"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}) => ruleEngineService.update(resource, id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Updated successfully");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to update record"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => ruleEngineService.remove(resource, id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Deleted successfully");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to delete record"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { create, update, remove };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRateWorkflow = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const invalidate = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list("rates") });
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: (id: string) => ruleEngineService.submitRate(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Rate submitted for approval");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to submit rate"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approve = useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: ApproveRatePayload }) =>
|
||||||
|
ruleEngineService.approveRate(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Rate approved");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to approve rate"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { submit, approve };
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ import "@edr/ui-common/styles.css";
|
|||||||
import "../index.css";
|
import "../index.css";
|
||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
|
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { AuthProvider } from "./auth/AuthProvider";
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -45,6 +47,7 @@ createRoot(rootElement).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster position="top-right" />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
import { parsePhoneNumberFromString } from "libphonenumber-js";
|
import { parsePhoneNumberFromString } from "libphonenumber-js";
|
||||||
import { Eye, EyeOff, Mail, Smartphone, UserRound } from "lucide-react";
|
import { Eye, EyeOff, Mail, Smartphone, UserRound, ArrowUpRight, Globe, ChevronDown } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
@@ -49,6 +49,108 @@ const normalizeIdentifier = (mode: LoginMode, value: string) => {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LOGIN_IMAGE = "/assets/login.png";
|
||||||
|
const EDR_LOGO = "/assets/logo.svg";
|
||||||
|
|
||||||
|
const fieldClass =
|
||||||
|
"h-11 w-full rounded-lg border border-gray-200 bg-[#eef4f8] px-4 text-sm text-gray-900 placeholder:text-gray-400 outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/15";
|
||||||
|
|
||||||
|
const primaryButtonClass =
|
||||||
|
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60";
|
||||||
|
|
||||||
|
const LeftPanelDecor = () => (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||||
|
<svg
|
||||||
|
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
||||||
|
viewBox="0 0 400 400"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
||||||
|
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RightPanelDecor = () => (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||||
|
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
||||||
|
<svg className="absolute inset-0 h-full w-full text-gray-200/40" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="login-grid" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#login-grid)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LeftPanel = () => (
|
||||||
|
<div className="relative flex h-36 shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] sm:h-44 md:h-52 lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
||||||
|
<img
|
||||||
|
src={LOGIN_IMAGE}
|
||||||
|
alt="Ethio Djibouti Railway"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
|
||||||
|
<LeftPanelDecor />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||||
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-7 w-auto brightness-0 invert sm:h-9" />
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||||
|
>
|
||||||
|
Support
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
|
||||||
|
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||||
|
<span className="text-sm font-semibold text-white">
|
||||||
|
Empower Your Freight Operations
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-white/85">
|
||||||
|
Sign in to manage bookings, track cargo, and run logistics operations on the
|
||||||
|
Ethio Djibouti Railway freight platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LanguageSelector = () => (
|
||||||
|
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
||||||
|
<Globe className="h-4 w-4 text-gray-500" />
|
||||||
|
<span>Eng</span>
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormFooter = () => (
|
||||||
|
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
||||||
|
<span className="shrink-0">© 2026 EDR Freight</span>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
||||||
|
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||||
|
Terms & Conditions
|
||||||
|
</a>
|
||||||
|
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||||
|
Help & Support
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login, verifyMfa } = useAuth();
|
const { login, verifyMfa } = useAuth();
|
||||||
@@ -63,7 +165,6 @@ const LoginPage = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const currentMode = loginModes.find((item) => item.value === mode)!;
|
const currentMode = loginModes.find((item) => item.value === mode)!;
|
||||||
const ModeIcon = currentMode.icon;
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -103,146 +204,199 @@ const LoginPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const loginForm = (
|
||||||
<div className="min-h-screen px-6 py-10">
|
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
||||||
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="mb-4 flex justify-center sm:mb-6">
|
||||||
<section className="flex items-center">
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||||
<div className="w-full rounded-[2rem] border border-border/60 bg-card p-8 shadow-[0_20px_60px_rgba(15,23,42,0.12)] md:p-10">
|
|
||||||
{!needsMfa ? (
|
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-[#0f766e]">
|
|
||||||
Sign in
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-foreground">
|
|
||||||
EDR Backoffice
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Use your email, phone number, or username to access the internal freight dashboard.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-foreground">
|
|
||||||
Sign in method
|
|
||||||
<select
|
|
||||||
value={mode}
|
|
||||||
onChange={(event) => setMode(event.target.value as LoginMode)}
|
|
||||||
className="h-12 rounded-xl border border-input bg-background px-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
|
||||||
>
|
|
||||||
{loginModes.map((item) => (
|
|
||||||
<option key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-foreground">
|
|
||||||
{currentMode.label}
|
|
||||||
<div className="relative">
|
|
||||||
<ModeIcon className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
value={identifier}
|
|
||||||
onChange={(event) => setIdentifier(event.target.value)}
|
|
||||||
placeholder={currentMode.placeholder}
|
|
||||||
className="h-12 w-full rounded-xl border border-input bg-background pl-11 pr-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-foreground">
|
|
||||||
Password
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
className="h-12 w-full rounded-xl border border-input bg-background px-4 pr-12 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword((current) => !current)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
|
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-[#0f766e] px-4 text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{submitting ? "Signing in..." : "Sign in"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="space-y-6" onSubmit={handleVerifyMfa}>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-[#0f766e]">
|
|
||||||
Multi-factor verification
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-foreground">
|
|
||||||
Confirm one-time code
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
We sent a verification code for {normalizedIdentifier}. Enter it below to complete sign in.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-foreground">
|
|
||||||
Verification code
|
|
||||||
<input
|
|
||||||
value={otp}
|
|
||||||
onChange={(event) => setOtp(event.target.value)}
|
|
||||||
placeholder="Enter the code"
|
|
||||||
className="h-12 w-full rounded-xl border border-input bg-background px-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-xl border border-input bg-background px-4 text-sm font-medium text-foreground transition hover:bg-accent"
|
|
||||||
onClick={() => {
|
|
||||||
setNeedsMfa(false);
|
|
||||||
setOtp("");
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-xl bg-[#0f766e] px-4 text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{submitting ? "Verifying..." : "Verify code"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">Get Started</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
Log in to access the freight backoffice & explore all logistics resources.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-800">Sign in method</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
onChange={(event) => setMode(event.target.value as LoginMode)}
|
||||||
|
className={`${fieldClass} appearance-none pr-10`}
|
||||||
|
>
|
||||||
|
{loginModes.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-800">
|
||||||
|
{currentMode.label} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={identifier}
|
||||||
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
|
placeholder={currentMode.placeholder}
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-800">
|
||||||
|
Password <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
className={`${fieldClass} pr-11`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((current) => !current)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 text-primary focus:ring-primary/20 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm leading-snug text-gray-600">
|
||||||
|
I agree to EDR Freight{" "}
|
||||||
|
<a href="#" className="font-semibold text-primary hover:underline">
|
||||||
|
Terms & Conditions
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button type="submit" disabled={submitting} className={primaryButtonClass}>
|
||||||
|
{submitting ? "Signing in..." : "Sign In"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
Need an account?{" "}
|
||||||
|
<a href="#" className="font-semibold text-primary hover:underline">
|
||||||
|
Contact your admin
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mfaForm = (
|
||||||
|
<form className="flex w-full flex-col" onSubmit={handleVerifyMfa}>
|
||||||
|
<div className="mb-4 flex justify-center sm:mb-6">
|
||||||
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
Multi-factor verification
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
We sent a verification code to{" "}
|
||||||
|
<span className="font-medium text-gray-700">{normalizedIdentifier}</span>. Enter it below
|
||||||
|
to complete sign in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-800">
|
||||||
|
Verification code <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={otp}
|
||||||
|
onChange={(event) => setOtp(event.target.value)}
|
||||||
|
placeholder="Enter the code"
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex w-full gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNeedsMfa(false);
|
||||||
|
setOtp("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="h-11 min-w-0 flex-1 rounded-full border border-gray-200 bg-white text-sm font-semibold text-gray-700 transition-colors hover:border-gray-300 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={submitting} className={`${primaryButtonClass} min-w-0 flex-1`}>
|
||||||
|
{submitting ? "Verifying..." : "Verify"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
|
||||||
|
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||||
|
<LeftPanel />
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
||||||
|
<RightPanelDecor />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||||
|
<div className="my-auto w-full rounded-2xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||||
|
{!needsMfa ? loginForm : mfaForm}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
// src/pages/ruleEngine/RuleEngine.tsx
|
|
||||||
import ContractTypePage from "@/components/ruleEngine/ContractType";
|
|
||||||
|
|
||||||
export const RuleEnginePage = () => {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<ContractTypePage />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Navigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_CONFIGURATION_SLUG,
|
||||||
|
getRuleEngineResource,
|
||||||
|
ruleEngineResourcePath,
|
||||||
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
|
|
||||||
|
/** Redirects old `/dashboard/rule-engine/:resource` URLs to category-based paths. */
|
||||||
|
const RuleEngineLegacyRedirect = () => {
|
||||||
|
const { resource } = useParams<{ resource: string }>();
|
||||||
|
const config = resource ? getRuleEngineResource(resource) : undefined;
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <Navigate to={`/dashboard/configuration/${DEFAULT_CONFIGURATION_SLUG}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to={ruleEngineResourcePath(config.slug)} replace />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleEngineLegacyRedirect;
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||||
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||||
|
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
||||||
|
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
||||||
|
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||||
|
import {
|
||||||
|
ruleEngineField,
|
||||||
|
ruleEngineSurface,
|
||||||
|
ruleEngineTable,
|
||||||
|
} from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
|
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
||||||
|
import {
|
||||||
|
DEFAULT_CONFIGURATION_SLUG,
|
||||||
|
DEFAULT_RULES_SLUG,
|
||||||
|
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||||
|
RULE_ENGINE_SELECT_NONE,
|
||||||
|
getRuleEngineResource,
|
||||||
|
type RuleEngineNavCategory,
|
||||||
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
|
import {
|
||||||
|
useApprovalChain,
|
||||||
|
useCargoTypeParentOptions,
|
||||||
|
useRateWorkflow,
|
||||||
|
useRuleEngineList,
|
||||||
|
useRuleEngineMutations,
|
||||||
|
} from "@/hooks/rule-engine/useRuleEngine";
|
||||||
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
getCoreRowModel,
|
||||||
|
usePagination,
|
||||||
|
useReactTable,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
|
||||||
|
const normalized = pathname.toLowerCase();
|
||||||
|
if (normalized.startsWith("/dashboard/configuration")) return "configuration";
|
||||||
|
if (normalized.startsWith("/dashboard/rules")) return "rules";
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RuleEngineResourcePage = () => {
|
||||||
|
const { resource: resourceSlug } = useParams<{ resource: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const category = pathCategory(location.pathname);
|
||||||
|
const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined;
|
||||||
|
|
||||||
|
const defaultPath = category
|
||||||
|
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${
|
||||||
|
category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
|
||||||
|
}`
|
||||||
|
: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`;
|
||||||
|
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
|
||||||
|
const [chainOpen, setChainOpen] = useState(false);
|
||||||
|
const [approveTarget, setApproveTarget] = useState<RuleEngineRecord | null>(null);
|
||||||
|
const [ceoId, setCeoId] = useState("");
|
||||||
|
|
||||||
|
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||||
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
|
);
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: config?.supportsSearch ? search.trim() || undefined : undefined,
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
}),
|
||||||
|
[config?.supportsSearch, search, pagination.pageIndex, pagination.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useRuleEngineList(
|
||||||
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
|
listParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { create, update, remove } = useRuleEngineMutations(
|
||||||
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
|
);
|
||||||
|
const { submit, approve } = useRateWorkflow();
|
||||||
|
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
|
||||||
|
chainOpen && config?.slug === "approval-rules",
|
||||||
|
);
|
||||||
|
|
||||||
|
const editingId = editing?.id ? String(editing.id) : undefined;
|
||||||
|
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||||
|
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||||
|
|
||||||
|
const formFields = useMemo(() => {
|
||||||
|
if (!config || config.slug !== "cargo-types") return config?.formFields ?? [];
|
||||||
|
const fallback = [{ label: "None", value: RULE_ENGINE_SELECT_NONE }];
|
||||||
|
return config.formFields.map((field) =>
|
||||||
|
field.name === "parentGroupId"
|
||||||
|
? { ...field, options: cargoParentOptions ?? fallback }
|
||||||
|
: field,
|
||||||
|
);
|
||||||
|
}, [config, cargoParentOptions]);
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
const meta = data?.meta;
|
||||||
|
const pageCount = meta?.totalPages ?? 1;
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
if (config?.supportsSearch || !search.trim()) return rows;
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return rows.filter((row) =>
|
||||||
|
JSON.stringify(row).toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [rows, search, config?.supportsSearch]);
|
||||||
|
|
||||||
|
const paginationState = useMemo(
|
||||||
|
() => ({
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: meta?.total ?? filteredRows.length,
|
||||||
|
}),
|
||||||
|
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardTable = useReactTable({
|
||||||
|
data: filteredRows,
|
||||||
|
columns: [] as ColumnDef<RuleEngineRecord>[],
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo((): ColumnDef<RuleEngineRecord>[] => {
|
||||||
|
if (!config) return [];
|
||||||
|
|
||||||
|
const headerClassName = ruleEngineTable.headerCell;
|
||||||
|
const cellClassName = ruleEngineTable.bodyCell;
|
||||||
|
|
||||||
|
const base: ColumnDef<RuleEngineRecord>[] = config.columns.map((col) => ({
|
||||||
|
id: col.id,
|
||||||
|
header: col.header,
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => formatCell(row.original[col.accessorKey], col.format),
|
||||||
|
}));
|
||||||
|
|
||||||
|
base.push({
|
||||||
|
id: "actions",
|
||||||
|
header: "Details",
|
||||||
|
size: 120,
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<RuleEngineRecordActions
|
||||||
|
record={row.original}
|
||||||
|
config={config}
|
||||||
|
onEdit={(record) => {
|
||||||
|
setEditing(record);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
onViewChain={
|
||||||
|
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||||
|
}
|
||||||
|
onSubmitRate={(id) => submit.mutate(id)}
|
||||||
|
onApproveRate={setApproveTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}, [config, submit]);
|
||||||
|
|
||||||
|
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||||
|
|
||||||
|
if (!resourceSlug || !category) {
|
||||||
|
return <Navigate to={defaultPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config || config.category !== category) {
|
||||||
|
return <Navigate to={defaultPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (record: RuleEngineRecord) => {
|
||||||
|
setEditing(record);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||||
|
if (editing?.id) {
|
||||||
|
update.mutate(
|
||||||
|
{ id: editing.id, payload: values },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
create.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemLabel = config.label.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card className={ruleEngineSurface.pageCard}>
|
||||||
|
<div className={ruleEngineSurface.pageCardToolbar}>
|
||||||
|
<RuleEngineToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={(v) => {
|
||||||
|
setSearch(v);
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}}
|
||||||
|
searchPlaceholder={config.searchPlaceholder}
|
||||||
|
onAdd={openCreate}
|
||||||
|
addLabel={`Add ${config.label.replace(/s$/, "")}`}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "table" ? (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={filteredRows}
|
||||||
|
status={tableStatus}
|
||||||
|
error={
|
||||||
|
isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load data",
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
emptyMessage={`No ${itemLabel} found.`}
|
||||||
|
pagination={paginationState}
|
||||||
|
tableOptions={{
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
|
||||||
|
footerClassName="border-t border-border bg-card"
|
||||||
|
footer={({ table, pagination: footerPagination }) => (
|
||||||
|
<DataTableFooter
|
||||||
|
table={table}
|
||||||
|
pagination={footerPagination}
|
||||||
|
options={{
|
||||||
|
labels: {
|
||||||
|
showing: "Showing",
|
||||||
|
ofLabel: "of",
|
||||||
|
items: itemLabel,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RuleEngineCardGrid
|
||||||
|
config={config}
|
||||||
|
rows={filteredRows}
|
||||||
|
status={tableStatus}
|
||||||
|
emptyMessage={`No ${itemLabel} found.`}
|
||||||
|
itemLabel={itemLabel}
|
||||||
|
table={cardTable}
|
||||||
|
pagination={paginationState}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
onViewChain={
|
||||||
|
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||||
|
}
|
||||||
|
onSubmitRate={(id) => submit.mutate(id)}
|
||||||
|
onApproveRate={setApproveTarget}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<RuleEngineFormDialog
|
||||||
|
open={formOpen}
|
||||||
|
onOpenChange={setFormOpen}
|
||||||
|
title={editing ? `Edit ${config.label.replace(/s$/, "")}` : `Add ${config.label.replace(/s$/, "")}`}
|
||||||
|
description={
|
||||||
|
editing
|
||||||
|
? `Update this ${config.label.toLowerCase()} record.`
|
||||||
|
: `Create a new ${config.label.toLowerCase()} record.`
|
||||||
|
}
|
||||||
|
fields={formFields}
|
||||||
|
initialRecord={editing}
|
||||||
|
isSubmitting={create.isPending || update.isPending}
|
||||||
|
selectOptionsLoading={
|
||||||
|
config.slug === "cargo-types" && cargoParentOptionsLoading
|
||||||
|
}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteTarget)} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||||
|
<DialogContent className={ruleEngineSurface.dialogSm}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete record?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will soft-delete the selected {config.label.toLowerCase()} record.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
remove.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{remove.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(approveTarget)} onOpenChange={(o) => !o && setApproveTarget(null)}>
|
||||||
|
<DialogContent className={ruleEngineSurface.dialogSm}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Approve rate</DialogTitle>
|
||||||
|
<DialogDescription>Enter the CEO staff ID to approve this rate.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ceoId" className={ruleEngineField.label}>
|
||||||
|
CEO staff ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ceoId"
|
||||||
|
value={ceoId}
|
||||||
|
onChange={(e) => setCeoId(e.target.value)}
|
||||||
|
placeholder="UUID"
|
||||||
|
className={ruleEngineField.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setApproveTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!ceoId.trim() || approve.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!approveTarget) return;
|
||||||
|
approve.mutate(
|
||||||
|
{ id: approveTarget.id, payload: { approvedByCeoId: ceoId.trim() } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setApproveTarget(null);
|
||||||
|
setCeoId("");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{approve.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Approve"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={chainOpen} onOpenChange={setChainOpen}>
|
||||||
|
<DialogContent className={ruleEngineSurface.dialog}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Approval chain</DialogTitle>
|
||||||
|
<DialogDescription>Configured approval steps from the API.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{chainLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{(chainData ?? []).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No approval rules configured.</p>
|
||||||
|
) : (
|
||||||
|
(chainData ?? []).map((step, index) => (
|
||||||
|
<li
|
||||||
|
key={String(step.id ?? index)}
|
||||||
|
className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Role: {String(step.requiredRole ?? "—")}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleEngineResourcePage;
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import type { SidebarItem } from "@/components/layout/types";
|
||||||
|
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
|
|
||||||
|
export type RuleEngineNavCategory = "configuration" | "rules";
|
||||||
|
|
||||||
|
export type ColumnFormat = "text" | "code" | "boolean" | "activeBadge" | "rateStatus" | "date" | "number";
|
||||||
|
|
||||||
|
export type FormFieldType = "text" | "number" | "boolean" | "date" | "select" | "textarea";
|
||||||
|
|
||||||
|
export interface ResourceColumn {
|
||||||
|
id: string;
|
||||||
|
header: string;
|
||||||
|
accessorKey: string;
|
||||||
|
format?: ColumnFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radix Select cannot use empty string as an item value; use this for optional "none" choices. */
|
||||||
|
export const RULE_ENGINE_SELECT_NONE = "__none__";
|
||||||
|
|
||||||
|
export interface FormFieldDef {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: FormFieldType;
|
||||||
|
required?: boolean;
|
||||||
|
optional?: boolean;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleEngineResourceConfig {
|
||||||
|
slug: RuleEngineResourceSlug;
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
category: RuleEngineNavCategory;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
columns: ResourceColumn[];
|
||||||
|
formFields: FormFieldDef[];
|
||||||
|
supportsSearch?: boolean;
|
||||||
|
/** Primary line on card view (inferred from columns when omitted). */
|
||||||
|
cardTitleKey?: string;
|
||||||
|
/** Secondary line under title on card view (inferred when omitted). */
|
||||||
|
cardSubtitleKey?: string;
|
||||||
|
/** Code badge on card header (inferred from code column when omitted). */
|
||||||
|
cardCodeKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RULE_ENGINE_CATEGORY_BASE_PATH: Record<RuleEngineNavCategory, string> = {
|
||||||
|
configuration: "/dashboard/configuration",
|
||||||
|
rules: "/dashboard/rules",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRADE_DIRECTIONS = [
|
||||||
|
{ label: "Import", value: "IMPORT" },
|
||||||
|
{ label: "Export", value: "EXPORT" },
|
||||||
|
{ label: "Both", value: "BOTH" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const APPROVAL_ROLES = [
|
||||||
|
{ label: "Line staff", value: "LINE_STAFF" },
|
||||||
|
{ label: "Director", value: "DIRECTOR" },
|
||||||
|
{ label: "CEO", value: "CEO" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SURCHARGE_TRIGGERS = [
|
||||||
|
{ label: "Hazardous cargo", value: "CARGO_FLAG_HAZARDOUS" },
|
||||||
|
{ label: "Reefer cargo", value: "CARGO_FLAG_REEFER" },
|
||||||
|
{ label: "VGM exceeds limit", value: "VGM_EXCEEDS_LIMIT" },
|
||||||
|
{ label: "Shipping line mapped", value: "SHIPPING_LINE_MAPPED" },
|
||||||
|
{ label: "Consolidation enabled", value: "CONSOLIDATION_ENABLED" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RATE_TYPES = [
|
||||||
|
"CONTAINER_IMPORT",
|
||||||
|
"CONTAINER_EXPORT",
|
||||||
|
"BULK_IMPORT",
|
||||||
|
"BULK_EXPORT",
|
||||||
|
"INTERCITY_BULK",
|
||||||
|
"INTERCITY_CONTAINER",
|
||||||
|
"FIRST_MILE",
|
||||||
|
"LAST_MILE",
|
||||||
|
"DEMURRAGE",
|
||||||
|
"LASHING",
|
||||||
|
"DOUBLE_HANDLING",
|
||||||
|
"CONTAINER_WITH_RETURN",
|
||||||
|
"CANCELLATION_FEE",
|
||||||
|
"OVERWEIGHT_PER_TON",
|
||||||
|
"HAZARD_SURCHARGE",
|
||||||
|
"REEFER_SURCHARGE",
|
||||||
|
"PIL_EXTRA_FEE",
|
||||||
|
].map((v) => ({ label: v.replace(/_/g, " "), value: v }));
|
||||||
|
|
||||||
|
const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].map((v) => ({
|
||||||
|
label: v.replace(/_/g, " "),
|
||||||
|
value: v,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CURRENCIES = [
|
||||||
|
{ label: "ETB", value: "ETB" },
|
||||||
|
{ label: "USD", value: "USD" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
|
||||||
|
id: key,
|
||||||
|
header,
|
||||||
|
accessorKey: key,
|
||||||
|
format: "code",
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeColumn: ResourceColumn = {
|
||||||
|
id: "isActive",
|
||||||
|
header: "Status",
|
||||||
|
accessorKey: "isActive",
|
||||||
|
format: "activeBadge",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||||
|
{
|
||||||
|
slug: "cargo-types",
|
||||||
|
label: "Cargo Types",
|
||||||
|
category: "configuration",
|
||||||
|
subtitle: "Manage freight cargo classification and approval rules",
|
||||||
|
searchPlaceholder: "Search cargo types by name or code...",
|
||||||
|
supportsSearch: true,
|
||||||
|
columns: [
|
||||||
|
codeColumn("code"),
|
||||||
|
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
|
||||||
|
{
|
||||||
|
id: "requiresDirectorApproval",
|
||||||
|
header: "Director approval",
|
||||||
|
accessorKey: "requiresDirectorApproval",
|
||||||
|
format: "boolean",
|
||||||
|
},
|
||||||
|
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
|
||||||
|
activeColumn,
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||||
|
{
|
||||||
|
name: "parentGroupId",
|
||||||
|
label: "Parent group",
|
||||||
|
type: "select",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "Select parent cargo type (optional)",
|
||||||
|
},
|
||||||
|
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||||
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "container-types",
|
||||||
|
label: "Container Types",
|
||||||
|
category: "configuration",
|
||||||
|
subtitle: "Configure container sizes and wagon capacity",
|
||||||
|
searchPlaceholder: "Search container types...",
|
||||||
|
columns: [
|
||||||
|
codeColumn("code"),
|
||||||
|
{ id: "label", header: "Label", accessorKey: "label" },
|
||||||
|
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
|
||||||
|
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
|
||||||
|
activeColumn,
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "label", label: "Label", type: "text", required: true },
|
||||||
|
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||||
|
{ name: "wagonsPerUnit", label: "Wagons per unit", type: "number", required: true },
|
||||||
|
{ name: "isReefer", label: "Reefer", type: "boolean" },
|
||||||
|
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "priority-rules",
|
||||||
|
label: "Priority Rules",
|
||||||
|
category: "rules",
|
||||||
|
subtitle: "Booking priority scoring rules",
|
||||||
|
searchPlaceholder: "Search priority rules...",
|
||||||
|
columns: [
|
||||||
|
codeColumn("code"),
|
||||||
|
{ id: "label", header: "Label", accessorKey: "label" },
|
||||||
|
{ id: "score", header: "Score", accessorKey: "score", format: "number" },
|
||||||
|
{ id: "conditionCurrency", header: "Currency", accessorKey: "conditionCurrency" },
|
||||||
|
activeColumn,
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "label", label: "Label", type: "text", required: true },
|
||||||
|
{ name: "score", label: "Score", type: "number", required: true },
|
||||||
|
{ name: "conditionCurrency", label: "Condition currency", type: "text", placeholder: "USD (optional)" },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "service-types",
|
||||||
|
label: "Service Types",
|
||||||
|
category: "configuration",
|
||||||
|
subtitle: "Freight service offerings and booking options",
|
||||||
|
searchPlaceholder: "Search service types...",
|
||||||
|
supportsSearch: true,
|
||||||
|
columns: [
|
||||||
|
codeColumn("code"),
|
||||||
|
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
|
||||||
|
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
|
||||||
|
activeColumn,
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "serviceName", label: "Service name", type: "text", required: true },
|
||||||
|
{ name: "description", label: "Description", type: "textarea" },
|
||||||
|
{ name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" },
|
||||||
|
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
|
||||||
|
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
|
||||||
|
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||||
|
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "surcharge-types",
|
||||||
|
label: "Surcharge Types",
|
||||||
|
category: "configuration",
|
||||||
|
subtitle: "Auto-applied surcharge definitions",
|
||||||
|
searchPlaceholder: "Search surcharge types...",
|
||||||
|
columns: [
|
||||||
|
codeColumn("code"),
|
||||||
|
{ id: "label", header: "Label", accessorKey: "label" },
|
||||||
|
{ id: "triggerCondition", header: "Trigger", accessorKey: "triggerCondition" },
|
||||||
|
{ id: "rateId", header: "Rate ID", accessorKey: "rateId" },
|
||||||
|
activeColumn,
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "label", label: "Label", type: "text", required: true },
|
||||||
|
{
|
||||||
|
name: "triggerCondition",
|
||||||
|
label: "Trigger condition",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: SURCHARGE_TRIGGERS,
|
||||||
|
},
|
||||||
|
{ name: "rateId", label: "Rate ID", type: "text", required: true, placeholder: "UUID of LIVE rate" },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "weight-limit-rules",
|
||||||
|
label: "Weight Limit Rules",
|
||||||
|
category: "rules",
|
||||||
|
subtitle: "VGM limits by container and trade direction",
|
||||||
|
searchPlaceholder: "Search weight limit rules...",
|
||||||
|
columns: [
|
||||||
|
{ id: "containerTypeId", header: "Container", accessorKey: "containerTypeId" },
|
||||||
|
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
|
||||||
|
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
|
||||||
|
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||||
|
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "containerTypeId", label: "Container type ID", type: "text", required: true },
|
||||||
|
{
|
||||||
|
name: "tradeDirection",
|
||||||
|
label: "Trade direction",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: TRADE_DIRECTIONS,
|
||||||
|
},
|
||||||
|
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
|
||||||
|
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||||
|
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "yards",
|
||||||
|
label: "Yards",
|
||||||
|
category: "configuration",
|
||||||
|
subtitle: "Terminal and yard locations",
|
||||||
|
searchPlaceholder: "Search yards...",
|
||||||
|
columns: [
|
||||||
|
codeColumn("code"),
|
||||||
|
{ id: "label", header: "Label", accessorKey: "label" },
|
||||||
|
{ id: "country", header: "Country", accessorKey: "country" },
|
||||||
|
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
|
||||||
|
activeColumn,
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "label", label: "Label", type: "text", required: true },
|
||||||
|
{ name: "country", label: "Country", type: "text", required: true },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "shipping-lines",
|
||||||
|
label: "Shipping Lines",
|
||||||
|
category: "configuration",
|
||||||
|
subtitle: "Shipping line codes and pricing mappings",
|
||||||
|
searchPlaceholder: "Search shipping lines...",
|
||||||
|
columns: [
|
||||||
|
codeColumn("code"),
|
||||||
|
{ id: "label", header: "Label", accessorKey: "label" },
|
||||||
|
{ id: "mappedToCode", header: "Mapped to", accessorKey: "mappedToCode" },
|
||||||
|
{
|
||||||
|
id: "showExtraFeeNotice",
|
||||||
|
header: "Extra fee notice",
|
||||||
|
accessorKey: "showExtraFeeNotice",
|
||||||
|
format: "boolean",
|
||||||
|
},
|
||||||
|
activeColumn,
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "code", label: "Code", type: "text", required: true },
|
||||||
|
{ name: "label", label: "Label", type: "text", required: true },
|
||||||
|
{ name: "mappedToCode", label: "Mapped to code", type: "text" },
|
||||||
|
{ name: "showExtraFeeNotice", label: "Show extra fee notice", type: "boolean" },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "rates",
|
||||||
|
label: "Rates",
|
||||||
|
category: "rules",
|
||||||
|
cardTitleKey: "rateType",
|
||||||
|
cardSubtitleKey: "currency",
|
||||||
|
subtitle: "Freight rates and approval workflow",
|
||||||
|
searchPlaceholder: "Search rates by type or status...",
|
||||||
|
columns: [
|
||||||
|
{ id: "rateType", header: "Type", accessorKey: "rateType", format: "code" },
|
||||||
|
{ id: "currency", header: "Currency", accessorKey: "currency" },
|
||||||
|
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" },
|
||||||
|
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||||
|
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||||
|
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "rateType", label: "Rate type", type: "select", required: true, options: RATE_TYPES },
|
||||||
|
{ name: "containerTypeId", label: "Container type ID", type: "text", placeholder: "Optional UUID" },
|
||||||
|
{
|
||||||
|
name: "tradeDirection",
|
||||||
|
label: "Trade direction",
|
||||||
|
type: "select",
|
||||||
|
options: TRADE_DIRECTIONS,
|
||||||
|
},
|
||||||
|
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
|
||||||
|
{ name: "rateValue", label: "Rate value", type: "number", required: true },
|
||||||
|
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
||||||
|
{ name: "proposedByStaffId", label: "Proposed by (staff ID)", type: "text", required: true },
|
||||||
|
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||||
|
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "approval-rules",
|
||||||
|
label: "Approval Rules",
|
||||||
|
category: "rules",
|
||||||
|
cardTitleKey: "actionLabel",
|
||||||
|
cardSubtitleKey: "requiredRole",
|
||||||
|
subtitle: "Multi-step booking approval chain",
|
||||||
|
searchPlaceholder: "Search approval rules...",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
id: "requiresDirectorApproval",
|
||||||
|
header: "Director chain",
|
||||||
|
accessorKey: "requiresDirectorApproval",
|
||||||
|
format: "boolean",
|
||||||
|
},
|
||||||
|
{ id: "stepOrder", header: "Step", accessorKey: "stepOrder", format: "number" },
|
||||||
|
{ id: "requiredRole", header: "Role", accessorKey: "requiredRole" },
|
||||||
|
{ id: "actionLabel", header: "Action", accessorKey: "actionLabel" },
|
||||||
|
{ id: "blocksRole", header: "Blocks", accessorKey: "blocksRole" },
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" },
|
||||||
|
{ name: "stepOrder", label: "Step order", type: "number", required: true },
|
||||||
|
{
|
||||||
|
name: "requiredRole",
|
||||||
|
label: "Required role",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: APPROVAL_ROLES,
|
||||||
|
},
|
||||||
|
{ name: "actionLabel", label: "Action label", type: "text", required: true },
|
||||||
|
{
|
||||||
|
name: "blocksRole",
|
||||||
|
label: "Blocks role",
|
||||||
|
type: "select",
|
||||||
|
optional: true,
|
||||||
|
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...APPROVAL_ROLES],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RULE_ENGINE_RESOURCE_MAP = Object.fromEntries(
|
||||||
|
RULE_ENGINE_RESOURCES.map((r) => [r.slug, r]),
|
||||||
|
) as Record<RuleEngineResourceSlug, RuleEngineResourceConfig>;
|
||||||
|
|
||||||
|
export const getRuleEngineResource = (slug: string): RuleEngineResourceConfig | undefined =>
|
||||||
|
RULE_ENGINE_RESOURCE_MAP[slug as RuleEngineResourceSlug];
|
||||||
|
|
||||||
|
export const ruleEngineResourcePath = (slug: RuleEngineResourceSlug): string => {
|
||||||
|
const resource = RULE_ENGINE_RESOURCE_MAP[slug];
|
||||||
|
return `${RULE_ENGINE_CATEGORY_BASE_PATH[resource.category]}/${slug}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategorySidebarChildren = (
|
||||||
|
category: RuleEngineNavCategory,
|
||||||
|
): SidebarItem[] =>
|
||||||
|
RULE_ENGINE_RESOURCES.filter((r) => r.category === category).map((r) => ({
|
||||||
|
label: r.label,
|
||||||
|
href: ruleEngineResourcePath(r.slug),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types";
|
||||||
|
export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-rules";
|
||||||
|
|
||||||
|
/** @deprecated Use DEFAULT_CONFIGURATION_SLUG */
|
||||||
|
export const DEFAULT_RULE_ENGINE_SLUG = DEFAULT_CONFIGURATION_SLUG;
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { api as client } from "@/auth/http";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type {
|
||||||
|
ApproveRatePayload,
|
||||||
|
RuleEngineListMeta,
|
||||||
|
RuleEngineListResult,
|
||||||
|
RuleEngineRecord,
|
||||||
|
RuleEngineResourceSlug,
|
||||||
|
} from "@/types/rule-engine";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
|
||||||
|
export interface RuleEngineListParams {
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||||
|
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||||
|
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||||
|
"priority-rules": URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULES,
|
||||||
|
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||||
|
"surcharge-types": URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPES,
|
||||||
|
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
||||||
|
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
|
||||||
|
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
|
||||||
|
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
|
||||||
|
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
|
||||||
|
};
|
||||||
|
|
||||||
|
const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||||
|
switch (resource) {
|
||||||
|
case "cargo-types":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.CARGO_TYPE_BY_ID(id);
|
||||||
|
case "container-types":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
|
||||||
|
case "priority-rules":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULE_BY_ID(id);
|
||||||
|
case "service-types":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPE_BY_ID(id);
|
||||||
|
case "surcharge-types":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPE_BY_ID(id);
|
||||||
|
case "weight-limit-rules":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
|
||||||
|
case "yards":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.YARD_BY_ID(id);
|
||||||
|
case "shipping-lines":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINE_BY_ID(id);
|
||||||
|
case "rates":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.RATE_BY_ID(id);
|
||||||
|
case "approval-rules":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULE_BY_ID(id);
|
||||||
|
default:
|
||||||
|
return `${RESOURCE_BASE[resource]}/${id}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultMeta = (dataLength: number, page = 1, pageSize = 20): RuleEngineListMeta => ({
|
||||||
|
total: dataLength,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.max(1, Math.ceil(dataLength / pageSize)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeList = <T extends RuleEngineRecord>(
|
||||||
|
payload: unknown,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
): RuleEngineListResult<T> => {
|
||||||
|
const body = unwrap(payload as { data: unknown }) as unknown;
|
||||||
|
|
||||||
|
if (body && typeof body === "object" && "data" in body && Array.isArray((body as RuleEngineListResult<T>).data)) {
|
||||||
|
const typed = body as RuleEngineListResult<T>;
|
||||||
|
return {
|
||||||
|
data: typed.data,
|
||||||
|
meta: typed.meta ?? defaultMeta(typed.data.length, page, pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
return { data: body as T[], meta: defaultMeta(body.length, page, pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: [], meta: defaultMeta(0, page, pageSize) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeEntity = <T extends RuleEngineRecord>(payload: unknown): T => {
|
||||||
|
return unwrap(payload as { data: T }) as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ruleEngineService = {
|
||||||
|
list: async <T extends RuleEngineRecord>(
|
||||||
|
resource: RuleEngineResourceSlug,
|
||||||
|
params?: RuleEngineListParams,
|
||||||
|
): Promise<RuleEngineListResult<T>> => {
|
||||||
|
const page = params?.page ?? 1;
|
||||||
|
const pageSize = params?.pageSize ?? 20;
|
||||||
|
const response = await client.get(RESOURCE_BASE[resource], {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search: params?.search,
|
||||||
|
isActive: params?.isActive,
|
||||||
|
status: params?.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return normalizeList<T>(response.data, page, pageSize);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async <T extends RuleEngineRecord>(
|
||||||
|
resource: RuleEngineResourceSlug,
|
||||||
|
id: string,
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await client.get(byIdPath(resource, id));
|
||||||
|
return normalizeEntity<T>(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async <T extends RuleEngineRecord>(
|
||||||
|
resource: RuleEngineResourceSlug,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await client.post(RESOURCE_BASE[resource], payload);
|
||||||
|
return normalizeEntity<T>(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async <T extends RuleEngineRecord>(
|
||||||
|
resource: RuleEngineResourceSlug,
|
||||||
|
id: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await client.patch(byIdPath(resource, id), payload);
|
||||||
|
return normalizeEntity<T>(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (resource: RuleEngineResourceSlug, id: string): Promise<void> => {
|
||||||
|
await client.delete(byIdPath(resource, id));
|
||||||
|
},
|
||||||
|
|
||||||
|
submitRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
|
||||||
|
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id));
|
||||||
|
return normalizeEntity<T>(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
approveRate: async <T extends RuleEngineRecord>(
|
||||||
|
id: string,
|
||||||
|
payload: ApproveRatePayload,
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id), payload);
|
||||||
|
return normalizeEntity<T>(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getApprovalChain: async (
|
||||||
|
requiresDirectorApproval = true,
|
||||||
|
): Promise<RuleEngineRecord[]> => {
|
||||||
|
const response = await client.get(URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_CHAIN, {
|
||||||
|
params: { requiresDirectorApproval },
|
||||||
|
});
|
||||||
|
const body = unwrap(response.data) as unknown;
|
||||||
|
if (Array.isArray(body)) return body as RuleEngineRecord[];
|
||||||
|
if (body && typeof body === "object" && "data" in body && Array.isArray((body as { data: unknown }).data)) {
|
||||||
|
return (body as { data: RuleEngineRecord[] }).data;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export type RuleEngineResourceSlug =
|
||||||
|
| "cargo-types"
|
||||||
|
| "container-types"
|
||||||
|
| "priority-rules"
|
||||||
|
| "service-types"
|
||||||
|
| "surcharge-types"
|
||||||
|
| "weight-limit-rules"
|
||||||
|
| "yards"
|
||||||
|
| "shipping-lines"
|
||||||
|
| "rates"
|
||||||
|
| "approval-rules";
|
||||||
|
|
||||||
|
export interface RuleEngineListMeta {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleEngineListResult<T> {
|
||||||
|
data: T[];
|
||||||
|
meta: RuleEngineListMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuleEngineRecord = Record<string, unknown> & { id: string };
|
||||||
|
|
||||||
|
export interface ApproveRatePayload {
|
||||||
|
approvedByCeoId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// vite.config.ts
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0/node_modules/vite/dist/node/index.js";
|
||||||
|
import react from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/@vitejs+plugin-react@4.7.0_vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0_/node_modules/@vitejs/plugin-react/dist/index.js";
|
||||||
|
import tailwindcss from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/@tailwindcss+vite@4.3.0_vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0_/node_modules/@tailwindcss/vite/dist/index.mjs";
|
||||||
|
var __vite_injected_original_import_meta_url = "file:///home/marshal/Desktop/EDR/edr-platform/apps/edr-freight-web/backoffice/vite.config.ts";
|
||||||
|
var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url));
|
||||||
|
var vite_config_default = defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5183,
|
||||||
|
host: "0.0.0.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export {
|
||||||
|
vite_config_default as default
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9tYXJzaGFsL0Rlc2t0b3AvRURSL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9iYWNrb2ZmaWNlXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvaG9tZS9tYXJzaGFsL0Rlc2t0b3AvRURSL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9iYWNrb2ZmaWNlL3ZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9ob21lL21hcnNoYWwvRGVza3RvcC9FRFIvZWRyLXBsYXRmb3JtL2FwcHMvZWRyLWZyZWlnaHQtd2ViL2JhY2tvZmZpY2Uvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5cbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSBcIkB0YWlsd2luZGNzcy92aXRlXCI7XG5cbmNvbnN0IF9fZGlybmFtZSA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKSwgdGFpbHdpbmRjc3MoKV0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXG4gICAgfSxcbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogNTE4MyxcbiAgICBob3N0OiBcIjAuMC4wLjBcIixcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFvWSxPQUFPLFVBQVU7QUFDclosU0FBUyxxQkFBcUI7QUFFOUIsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxXQUFXO0FBQ2xCLE9BQU8saUJBQWlCO0FBTDROLElBQU0sMkNBQTJDO0FBT3JTLElBQU0sWUFBWSxLQUFLLFFBQVEsY0FBYyx3Q0FBZSxDQUFDO0FBRTdELElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxNQUFNLEdBQUcsWUFBWSxDQUFDO0FBQUEsRUFDaEMsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxLQUFLLFFBQVEsV0FBVyxPQUFPO0FBQUEsSUFDdEM7QUFBQSxFQUNGO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNO0FBQUEsSUFDTixNQUFNO0FBQUEsRUFDUjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// vite.config.ts
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0/node_modules/vite/dist/node/index.js";
|
||||||
|
import react from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/@vitejs+plugin-react@4.7.0_vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0_/node_modules/@vitejs/plugin-react/dist/index.js";
|
||||||
|
import tailwindcss from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/@tailwindcss+vite@4.3.0_vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0_/node_modules/@tailwindcss/vite/dist/index.mjs";
|
||||||
|
var __vite_injected_original_import_meta_url = "file:///home/marshal/Desktop/EDR/edr-platform/apps/edr-freight-web/portal/vite.config.ts";
|
||||||
|
var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url));
|
||||||
|
var vite_config_default = defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: "0.0.0.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export {
|
||||||
|
vite_config_default as default
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9tYXJzaGFsL0Rlc2t0b3AvRURSL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWxcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL21hcnNoYWwvRGVza3RvcC9FRFIvZWRyLXBsYXRmb3JtL2FwcHMvZWRyLWZyZWlnaHQtd2ViL3BvcnRhbC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9tYXJzaGFsL0Rlc2t0b3AvRURSL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWwvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5cbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSBcIkB0YWlsd2luZGNzcy92aXRlXCI7XG5cbmNvbnN0IF9fZGlybmFtZSA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKSwgdGFpbHdpbmRjc3MoKV0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXG4gICAgfSxcbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogNTE3MyxcbiAgICBob3N0OiBcIjAuMC4wLjBcIixcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUF3WCxPQUFPLFVBQVU7QUFDelksU0FBUyxxQkFBcUI7QUFFOUIsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxXQUFXO0FBQ2xCLE9BQU8saUJBQWlCO0FBTG9OLElBQU0sMkNBQTJDO0FBTzdSLElBQU0sWUFBWSxLQUFLLFFBQVEsY0FBYyx3Q0FBZSxDQUFDO0FBRTdELElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxNQUFNLEdBQUcsWUFBWSxDQUFDO0FBQUEsRUFDaEMsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxLQUFLLFFBQVEsV0FBVyxPQUFPO0FBQUEsSUFDdEM7QUFBQSxFQUNGO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNO0FBQUEsSUFDTixNQUFNO0FBQUEsRUFDUjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// vite.config.ts
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0/node_modules/vite/dist/node/index.js";
|
||||||
|
import react from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/@vitejs+plugin-react@4.7.0_vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0_/node_modules/@vitejs/plugin-react/dist/index.js";
|
||||||
|
import tailwindcss from "file:///home/marshal/Desktop/EDR/edr-platform/node_modules/.pnpm/@tailwindcss+vite@4.3.0_vite@5.4.21_@types+node@20.19.41_lightningcss@1.32.0_terser@5.48.0_/node_modules/@tailwindcss/vite/dist/index.mjs";
|
||||||
|
var __vite_injected_original_import_meta_url = "file:///home/marshal/Desktop/EDR/edr-platform/apps/edr-freight-web/portal/vite.config.ts";
|
||||||
|
var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url));
|
||||||
|
var vite_config_default = defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: "0.0.0.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export {
|
||||||
|
vite_config_default as default
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9tYXJzaGFsL0Rlc2t0b3AvRURSL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWxcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL21hcnNoYWwvRGVza3RvcC9FRFIvZWRyLXBsYXRmb3JtL2FwcHMvZWRyLWZyZWlnaHQtd2ViL3BvcnRhbC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9tYXJzaGFsL0Rlc2t0b3AvRURSL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWwvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5cbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSBcIkB0YWlsd2luZGNzcy92aXRlXCI7XG5cbmNvbnN0IF9fZGlybmFtZSA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKSwgdGFpbHdpbmRjc3MoKV0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXG4gICAgfSxcbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogNTE3MyxcbiAgICBob3N0OiBcIjAuMC4wLjBcIixcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUF3WCxPQUFPLFVBQVU7QUFDelksU0FBUyxxQkFBcUI7QUFFOUIsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxXQUFXO0FBQ2xCLE9BQU8saUJBQWlCO0FBTG9OLElBQU0sMkNBQTJDO0FBTzdSLElBQU0sWUFBWSxLQUFLLFFBQVEsY0FBYyx3Q0FBZSxDQUFDO0FBRTdELElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxNQUFNLEdBQUcsWUFBWSxDQUFDO0FBQUEsRUFDaEMsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxLQUFLLFFBQVEsV0FBVyxPQUFPO0FBQUEsSUFDdEM7QUFBQSxFQUNGO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNO0FBQUEsSUFDTixNQUFNO0FBQUEsRUFDUjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||||
Reference in New Issue
Block a user