mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Fix merge conflict of Merge branch 'freight/develop' into freight/feature/edr_org_seeder
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { In, Not } from 'typeorm';
|
||||||
|
|
||||||
|
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||||
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
|
import {
|
||||||
|
CARGO_TYPES_REPOSITORY,
|
||||||
|
ICargoTypesRepository,
|
||||||
|
} from '../rule-engine/interfaces/cargo-types.repository.interface';
|
||||||
|
import {
|
||||||
|
CONTAINER_TYPES_REPOSITORY,
|
||||||
|
IContainerTypesRepository,
|
||||||
|
} from '../rule-engine/interfaces/container-types.repository.interface';
|
||||||
|
import {
|
||||||
|
IServiceTypesRepository,
|
||||||
|
SERVICE_TYPES_REPOSITORY,
|
||||||
|
} from '../rule-engine/interfaces/service-types.repository.interface';
|
||||||
|
import {
|
||||||
|
IShippingLinesRepository,
|
||||||
|
SHIPPING_LINES_REPOSITORY,
|
||||||
|
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
|
||||||
|
import {
|
||||||
|
IYardsRepository,
|
||||||
|
YARDS_REPOSITORY,
|
||||||
|
} from '../rule-engine/interfaces/yards.repository.interface';
|
||||||
|
import {
|
||||||
|
BookingReferenceCargoTypeChildDto,
|
||||||
|
BookingReferenceCargoTypeGroupDto,
|
||||||
|
BookingReferenceContainerSizeGroupDto,
|
||||||
|
BookingReferenceContainerTypeDto,
|
||||||
|
BookingReferenceDataDto,
|
||||||
|
BookingReferenceServiceDto,
|
||||||
|
BookingReferenceShippingLineDto,
|
||||||
|
BookingReferenceYardDto,
|
||||||
|
} from './dto/booking-reference-data.dto';
|
||||||
|
|
||||||
|
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
|
||||||
|
|
||||||
|
export function buildCargoTypeTree(
|
||||||
|
rows: CargoType[],
|
||||||
|
): BookingReferenceCargoTypeGroupDto[] {
|
||||||
|
const active = rows.filter((r) => r.isActive);
|
||||||
|
const parents = active
|
||||||
|
.filter((r) => !r.parentGroupId)
|
||||||
|
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
|
||||||
|
|
||||||
|
return parents.map((parent) => {
|
||||||
|
const children = active
|
||||||
|
.filter((r) => r.parentGroupId === parent.id)
|
||||||
|
.sort(
|
||||||
|
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(child): BookingReferenceCargoTypeChildDto => ({
|
||||||
|
id: child.id,
|
||||||
|
name: child.cargoTypeName,
|
||||||
|
code: child.code,
|
||||||
|
show_free_text_box: child.showFreeTextBox,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const group: BookingReferenceCargoTypeGroupDto = {
|
||||||
|
id: parent.id,
|
||||||
|
name: parent.cargoTypeName,
|
||||||
|
code: parent.code,
|
||||||
|
};
|
||||||
|
if (children.length > 0) {
|
||||||
|
group.children = children;
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupContainersBySize(
|
||||||
|
rows: ContainerType[],
|
||||||
|
): BookingReferenceContainerSizeGroupDto[] {
|
||||||
|
const active = rows.filter((r) => r.isActive);
|
||||||
|
const bySize = new Map<string, ContainerType[]>();
|
||||||
|
|
||||||
|
for (const ct of active) {
|
||||||
|
const sizeKey =
|
||||||
|
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
|
||||||
|
const list = bySize.get(sizeKey) ?? [];
|
||||||
|
list.push(ct);
|
||||||
|
bySize.set(sizeKey, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortSizeKey = (key: string): number => {
|
||||||
|
if (key === 'other') return Number.MAX_SAFE_INTEGER;
|
||||||
|
const n = parseInt(key, 10);
|
||||||
|
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...bySize.entries()]
|
||||||
|
.sort(([a], [b]) => sortSizeKey(a) - sortSizeKey(b))
|
||||||
|
.map(([size, types]) => ({
|
||||||
|
size,
|
||||||
|
types: types
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
|
||||||
|
a.code.localeCompare(b.code),
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(ct): BookingReferenceContainerTypeDto => ({
|
||||||
|
id: ct.id,
|
||||||
|
name: ct.label?.trim() ? ct.label : ct.code,
|
||||||
|
code: ct.code,
|
||||||
|
is_reefer: ct.isReefer ?? false,
|
||||||
|
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingReferenceDataService {
|
||||||
|
constructor(
|
||||||
|
@Inject(YARDS_REPOSITORY)
|
||||||
|
private readonly yardsRepository: IYardsRepository,
|
||||||
|
@Inject(CONTAINER_TYPES_REPOSITORY)
|
||||||
|
private readonly containerTypesRepository: IContainerTypesRepository,
|
||||||
|
@Inject(SERVICE_TYPES_REPOSITORY)
|
||||||
|
private readonly serviceTypesRepository: IServiceTypesRepository,
|
||||||
|
@Inject(SHIPPING_LINES_REPOSITORY)
|
||||||
|
private readonly shippingLinesRepository: IShippingLinesRepository,
|
||||||
|
@Inject(CARGO_TYPES_REPOSITORY)
|
||||||
|
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||||
|
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||||
|
await Promise.all([
|
||||||
|
this.yardsRepository.findAll({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
code: Not(In([...LEGACY_YARD_CODES])),
|
||||||
|
},
|
||||||
|
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||||
|
}),
|
||||||
|
this.containerTypesRepository.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||||
|
}),
|
||||||
|
this.serviceTypesRepository.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||||
|
}),
|
||||||
|
this.shippingLinesRepository.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { label: 'ASC', code: 'ASC' },
|
||||||
|
}),
|
||||||
|
this.cargoTypesRepository.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
yard: yards.map(
|
||||||
|
(y): BookingReferenceYardDto => ({
|
||||||
|
id: y.id,
|
||||||
|
name: y.label,
|
||||||
|
code: y.code,
|
||||||
|
country: y.country,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
containers: groupContainersBySize(containerTypes),
|
||||||
|
service: serviceTypes.map(
|
||||||
|
(s): BookingReferenceServiceDto => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.serviceName,
|
||||||
|
code: s.code,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
shipping_line: shippingLines.map(
|
||||||
|
(sl): BookingReferenceShippingLineDto => ({
|
||||||
|
id: sl.id,
|
||||||
|
name: sl.label,
|
||||||
|
code: sl.code,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
cargo_type: buildCargoTypeTree(cargoTypes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,14 @@ import {
|
|||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiConsumes,
|
ApiConsumes,
|
||||||
|
ApiOkResponse,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
||||||
import { BookingsService } from "./bookings.service";
|
import { BookingsService } from "./bookings.service";
|
||||||
|
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
|
||||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
||||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
||||||
@@ -32,7 +35,10 @@ import { UpdateStatusDto } from "./dto/update-status.dto";
|
|||||||
@Controller("bookings")
|
@Controller("bookings")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BookingsController {
|
export class BookingsController {
|
||||||
constructor(private readonly bookingsService: BookingsService) { }
|
constructor(
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
private readonly bookingReferenceDataService: BookingReferenceDataService,
|
||||||
|
) {}
|
||||||
|
|
||||||
// ── 1. Create booking (multipart/form-data) ──────────────────────────
|
// ── 1. Create booking (multipart/form-data) ──────────────────────────
|
||||||
@Post()
|
@Post()
|
||||||
@@ -100,6 +106,19 @@ export class BookingsController {
|
|||||||
return this.bookingsService.findAll(filter);
|
return this.bookingsService.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Booking form catalog (must be before :id) ─────────────────────────
|
||||||
|
@Get("reference-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Booking form catalog",
|
||||||
|
description:
|
||||||
|
"Returns yards, container types (grouped by size), service types, shipping lines, " +
|
||||||
|
"and hierarchical cargo types for the booking UI in a single payload.",
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||||
|
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||||
|
return this.bookingReferenceDataService.getReferenceData();
|
||||||
|
}
|
||||||
|
|
||||||
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
|
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
|
||||||
@Get("by-reference/:reference")
|
@Get("by-reference/:reference")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CustomersModule } from '../customers/customers.module';
|
|||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
@@ -30,7 +31,12 @@ import { Booking } from './entities/booking.entity';
|
|||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [BookingsService, BookingsRepository, ConsolidationService],
|
providers: [
|
||||||
|
BookingsService,
|
||||||
|
BookingsRepository,
|
||||||
|
ConsolidationService,
|
||||||
|
BookingReferenceDataService,
|
||||||
|
],
|
||||||
exports: [BookingsService],
|
exports: [BookingsService],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class BookingReferenceYardDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Mojo Dry Port' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'MOJO' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Ethiopia' })
|
||||||
|
country!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferenceContainerTypeDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Dry' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '20GP' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
is_reefer!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
|
||||||
|
wagons_per_unit!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferenceContainerSizeGroupDto {
|
||||||
|
@ApiProperty({ example: '20ft' })
|
||||||
|
size!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [BookingReferenceContainerTypeDto] })
|
||||||
|
types!: BookingReferenceContainerTypeDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferenceServiceDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Rail Transport Only' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'RAIL' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferenceShippingLineDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'MSC' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'MSC' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferenceCargoTypeChildDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Coffee' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'BULK_COFFEE' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
show_free_text_box!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferenceCargoTypeGroupDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Bulk Cargo' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'BULK' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [BookingReferenceCargoTypeChildDto] })
|
||||||
|
children?: BookingReferenceCargoTypeChildDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferenceDataDto {
|
||||||
|
@ApiProperty({ type: [BookingReferenceYardDto] })
|
||||||
|
yard!: BookingReferenceYardDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [BookingReferenceContainerSizeGroupDto] })
|
||||||
|
containers!: BookingReferenceContainerSizeGroupDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [BookingReferenceServiceDto] })
|
||||||
|
service!: BookingReferenceServiceDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [BookingReferenceShippingLineDto] })
|
||||||
|
shipping_line!: BookingReferenceShippingLineDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [BookingReferenceCargoTypeGroupDto] })
|
||||||
|
cargo_type!: BookingReferenceCargoTypeGroupDto[];
|
||||||
|
}
|
||||||
@@ -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,7 +39,8 @@ export class CargoTypesController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a cargo type' })
|
@ApiOperation({ summary: 'Create a cargo type' })
|
||||||
create(@Body() dto: CreateCargoTypeDto) {
|
create(@Body() dto: any) {
|
||||||
|
return dto;
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
|
CARGO_TYPES_REPOSITORY,
|
||||||
|
CONTAINER_TYPES_REPOSITORY,
|
||||||
|
SERVICE_TYPES_REPOSITORY,
|
||||||
|
SHIPPING_LINES_REPOSITORY,
|
||||||
|
YARDS_REPOSITORY,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class RuleEngineModule {}
|
export class RuleEngineModule {}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.6",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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 { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
||||||
import { LayoutDashboard, Network, Paperclip, Settings } from "lucide-react";
|
import { LayoutDashboard, Network, Paperclip, Settings } from "lucide-react";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
@@ -16,7 +17,17 @@ import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
|||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine";
|
import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseSidebarItems: SidebarItem[] = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
href: "/dashboard/overview",
|
href: "/dashboard/overview",
|
||||||
@@ -54,7 +65,12 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
label: "Dropdown Settings",
|
label: "Dropdown Settings",
|
||||||
href: "/dashboard/dropdown-settings",
|
href: "/dashboard/dropdown-settings",
|
||||||
icon: <Settings />,
|
icon: <Settings />,
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
label: "Rule Engine",
|
||||||
|
href: "/dashboard/rule-engine",
|
||||||
|
icon: <Settings />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasPermission = (
|
const hasPermission = (
|
||||||
@@ -63,51 +79,21 @@ const hasPermission = (
|
|||||||
) => {
|
) => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (user.permissions?.some((p) => p.key === key)) return true;
|
if (user.permissions?.some((p) => p.key === key)) return true;
|
||||||
|
|
||||||
return (user.employee ?? []).some((emp) =>
|
return (user.employee ?? []).some((emp) =>
|
||||||
(emp.positions ?? []).some((pos) =>
|
(emp.positions ?? []).some((pos) =>
|
||||||
(pos.permissions ?? []).some((p) => p.key === key),
|
(pos.permissions ?? []).some((p) => p.key === key),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DashboardShell = () => {
|
const DashboardShell = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{
|
...baseSidebarItems,
|
||||||
label: "Overview",
|
|
||||||
href: "/dashboard/overview",
|
|
||||||
icon: <LayoutDashboard />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "User management",
|
|
||||||
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: "Rule Engine",
|
|
||||||
href: "/dashboard/rule-engine",
|
|
||||||
icon: <Settings />,
|
|
||||||
},
|
|
||||||
...(hasPermission(user, "can:demo:user1")
|
...(hasPermission(user, "can:demo:user1")
|
||||||
? ([
|
? ([
|
||||||
{
|
{
|
||||||
@@ -155,34 +141,50 @@ const App = () => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Routes>
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Routes>
|
||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
|
||||||
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="rule-engine" element={<RuleEnginePage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
<Route path="user-management/employees" element={<EmployeesPage />} />
|
<Route path="user-management/employees" element={<EmployeesPage />} />
|
||||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||||
<Route path="user-management/roles" element={<RolesPage />} />
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
<Route path="user1" element={<DemoUser1Page />} />
|
|
||||||
<Route path="user2" element={<DemoUser2Page />} />
|
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
||||||
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
|
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
||||||
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
|
<Route path="rule-engine" element={<RuleEnginePage />} />
|
||||||
</Route>
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="user1" element={<DemoUser1Page />} />
|
||||||
</Routes>
|
<Route path="user2" element={<DemoUser2Page />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="org-structure"
|
||||||
|
element={<Navigate to="/dashboard/user-management" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="org-structure/*"
|
||||||
|
element={<Navigate to="/dashboard/user-management" replace />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,718 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
// 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;
|
||||||
@@ -0,0 +1,874 @@
|
|||||||
|
// 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,221 @@
|
|||||||
|
import { JSXElementConstructor, MouseEventHandler, ReactElement, ReactNode, SetStateAction, useState } from 'react';
|
||||||
|
|
||||||
|
export const ContractTypePage = () => {
|
||||||
|
const [expandedSections, setExpandedSections] = useState({
|
||||||
|
contractType: true,
|
||||||
|
serviceType: false,
|
||||||
|
cargoType: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [contractTypes, setContractTypes] = useState([
|
||||||
|
{ id: 1, name: 'Shipper', description: 'Company that sends the freight' },
|
||||||
|
{ id: 2, name: 'Consignee', description: 'Company that receives the freight' },
|
||||||
|
{ id: 3, name: 'Third Party', description: 'Company that is neither the shipper nor the consignee but is involved in the freight process' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [serviceTypes, setServiceTypes] = useState([
|
||||||
|
{ id: 1, name: 'Standard', description: 'Regular shipping service' },
|
||||||
|
{ id: 2, name: 'Express', description: 'Fast delivery service' },
|
||||||
|
{ id: 3, name: 'Economy', description: 'Cost-effective shipping option' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [cargoTypes, setCargoTypes] = useState([
|
||||||
|
{ id: 1, name: 'General Cargo', description: 'Standard packaged goods' },
|
||||||
|
{ id: 2, name: 'Temperature Controlled', description: 'Goods requiring specific temperature' },
|
||||||
|
{ id: 3, name: 'Hazardous Materials', description: 'Dangerous goods requiring special handling' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [newContractType, setNewContractType] = useState({ name: '', description: '' });
|
||||||
|
const [newServiceType, setNewServiceType] = useState({ name: '', description: '' });
|
||||||
|
const [newCargoType, setNewCargoType] = useState({ name: '', description: '' });
|
||||||
|
const [showAddForms, setShowAddForms] = useState({
|
||||||
|
contractType: false,
|
||||||
|
serviceType: false,
|
||||||
|
|
||||||
|
cargoType: false
|
||||||
|
});
|
||||||
|
|
||||||
|
type SectionKey = 'contractType' | 'serviceType' | 'cargoType';
|
||||||
|
|
||||||
|
const toggleSection = (section: SectionKey) => {
|
||||||
|
setExpandedSections(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: !prev[section]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAddForm = (section: SectionKey) => {
|
||||||
|
setShowAddForms(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: !prev[section]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddContractType = () => {
|
||||||
|
if (newContractType.name && newContractType.description) {
|
||||||
|
setContractTypes([
|
||||||
|
...contractTypes,
|
||||||
|
{ id: Date.now(), ...newContractType }
|
||||||
|
]);
|
||||||
|
setNewContractType({ name: '', description: '' });
|
||||||
|
toggleAddForm('contractType');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddServiceType = () => {
|
||||||
|
if (newServiceType.name && newServiceType.description) {
|
||||||
|
setServiceTypes([
|
||||||
|
...serviceTypes,
|
||||||
|
{ id: Date.now(), ...newServiceType }
|
||||||
|
]);
|
||||||
|
setNewServiceType({ name: '', description: '' });
|
||||||
|
toggleAddForm('serviceType');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCargoType = () => {
|
||||||
|
if (newCargoType.name && newCargoType.description) {
|
||||||
|
setCargoTypes([
|
||||||
|
...cargoTypes,
|
||||||
|
{ id: Date.now(), ...newCargoType }
|
||||||
|
]);
|
||||||
|
setNewCargoType({ name: '', description: '' });
|
||||||
|
toggleAddForm('cargoType');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (type: string, id: number) => {
|
||||||
|
if (type === 'contract') {
|
||||||
|
setContractTypes(contractTypes.filter(item => item.id !== id));
|
||||||
|
} else if (type === 'service') {
|
||||||
|
setServiceTypes(serviceTypes.filter(item => item.id !== id));
|
||||||
|
} else if (type === 'cargo') {
|
||||||
|
setCargoTypes(cargoTypes.filter(item => item.id !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (type: any, id: any) => {
|
||||||
|
// Implement edit functionality as needed
|
||||||
|
alert(`Edit ${type} type with id: ${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTable = (title: string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined, types: any[], onAdd: { (): void; (): void; (): void; }, newItem: { name: any; description: any; }, setNewItem: { (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (arg0: any): void; }, showAddForm: boolean, typeKey: string, addHandler: MouseEventHandler<HTMLButtonElement> | undefined) => (
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
marginBottom: '10px'
|
||||||
|
}}
|
||||||
|
onClick={() => toggleSection(typeKey)}
|
||||||
|
>
|
||||||
|
<span style={{ marginRight: '10px', fontSize: '20px', color: '#138a49' }}>
|
||||||
|
{expandedSections[typeKey] ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedSections[typeKey] && (
|
||||||
|
<div style={{ marginLeft: '20px' }}>
|
||||||
|
<button onClick={() => toggleAddForm(typeKey)}>
|
||||||
|
Add {title.replace(' Types', ' type')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
padding: '10px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<h4>Add New {title.replace(' Types', '')}</h4>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={newItem.name}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
|
||||||
|
style={{ marginRight: '10px', padding: '5px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Description"
|
||||||
|
value={newItem.description}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
|
||||||
|
style={{ marginRight: '10px', padding: '5px' }}
|
||||||
|
/>
|
||||||
|
<button onClick={addHandler}>Save</button>
|
||||||
|
<button onClick={() => toggleAddForm(typeKey)} style={{ marginLeft: '5px' }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '10px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: '#f2f2f2' }}>
|
||||||
|
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>ID</th>
|
||||||
|
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Name</th>
|
||||||
|
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Description</th>
|
||||||
|
<th style={{ border: '1px solid #ddd', padding: '8px', textAlign: 'left' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{types.map((type) => (
|
||||||
|
<tr key={type.id}>
|
||||||
|
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.id}</td>
|
||||||
|
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.name}</td>
|
||||||
|
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{type.description}</td>
|
||||||
|
<td style={{ border: '1px solid #ddd', padding: '8px' }}>
|
||||||
|
<button onClick={() => handleEdit(typeKey, type.id)} style={{ marginRight: '5px' }}>Edit</button>
|
||||||
|
<button onClick={() => handleDelete(typeKey === 'contractType' ? 'contract' : typeKey === 'serviceType' ? 'service' : 'cargo', type.id)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{renderTable(
|
||||||
|
'Contract Types',
|
||||||
|
contractTypes,
|
||||||
|
handleAddContractType,
|
||||||
|
newContractType,
|
||||||
|
setNewContractType,
|
||||||
|
showAddForms.contractType,
|
||||||
|
'contractType',
|
||||||
|
handleAddContractType
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderTable(
|
||||||
|
'Service Types',
|
||||||
|
serviceTypes,
|
||||||
|
handleAddServiceType,
|
||||||
|
newServiceType,
|
||||||
|
setNewServiceType,
|
||||||
|
showAddForms.serviceType,
|
||||||
|
'serviceType',
|
||||||
|
handleAddServiceType
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderTable(
|
||||||
|
'Cargo Types',
|
||||||
|
cargoTypes,
|
||||||
|
handleAddCargoType,
|
||||||
|
newCargoType,
|
||||||
|
setNewCargoType,
|
||||||
|
showAddForms.cargoType,
|
||||||
|
'cargoType',
|
||||||
|
handleAddCargoType
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,5 +85,40 @@ export const URL_CONSTANTS = {
|
|||||||
OTP: {
|
OTP: {
|
||||||
SEND: "/api/otp/send",
|
SEND: "/api/otp/send",
|
||||||
VERIFY: "/api/otp/verify",
|
VERIFY: "/api/otp/verify",
|
||||||
}
|
},
|
||||||
|
RULE_ENGINE: {
|
||||||
|
// Cargo Types
|
||||||
|
CARGO_TYPES: "/cargo-types",
|
||||||
|
CARGO_TYPE_BY_ID: (id: string | number) => `/cargo-types/${id}`,
|
||||||
|
|
||||||
|
// Container Types
|
||||||
|
CONTAINER_TYPES: "/container-types",
|
||||||
|
CONTAINER_TYPE_BY_ID: (id: string | number) =>
|
||||||
|
`/container-types/${id}`,
|
||||||
|
|
||||||
|
// Priority Rules
|
||||||
|
PRIORITY_RULES: "/priority-rules",
|
||||||
|
PRIORITY_RULE_BY_ID: (id: string | number) =>
|
||||||
|
`/priority-rules/${id}`,
|
||||||
|
|
||||||
|
// Service Types
|
||||||
|
SERVICE_TYPES: "/service-types",
|
||||||
|
SERVICE_TYPE_BY_ID: (id: string | number) =>
|
||||||
|
`/service-types/${id}`,
|
||||||
|
|
||||||
|
// Surcharge Types
|
||||||
|
SURCHARGE_TYPES: "/surcharge-types",
|
||||||
|
SURCHARGE_TYPE_BY_ID: (id: string | number) =>
|
||||||
|
`/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_RULE_BY_ID: (id: string | number) =>
|
||||||
|
`/weight-limit-rules/${id}`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { ContractTypePage, } from "@/components/ruleEngine/ContractType";
|
// src/pages/ruleEngine/RuleEngine.tsx
|
||||||
|
import ContractTypePage from "@/components/ruleEngine/ContractType";
|
||||||
|
|
||||||
export const RuleEnginePage = () => {
|
export const RuleEnginePage = () => {
|
||||||
return <div>
|
return (
|
||||||
<h3>
|
<div className="p-6">
|
||||||
Rule Engine Page
|
<ContractTypePage />
|
||||||
</h3>
|
</div>
|
||||||
|
);
|
||||||
<div>
|
|
||||||
<ContractTypePage />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>;
|
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// import { CargoType } from "@edr-freight-web/types";
|
||||||
|
import { api as client } from "../../auth/http";
|
||||||
|
import { URL_CONSTANTS } from "../../constants/URLS";
|
||||||
|
|
||||||
|
export const createCargoType = (data: any) => {
|
||||||
|
return client.post(URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES, { data });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const getCargoType = () => {
|
||||||
|
return client.get(URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCargoTypeById = (id: string) => {
|
||||||
|
return client.get(`${URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES}/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCargoType = (id: string, data: any) => {
|
||||||
|
return client.put(`${URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES}/${id}`, { data });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const deleteCargoType = (id: string) => {
|
||||||
|
return client.delete(`${URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES}/${id}`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { api as client } from "../../auth/http";
|
||||||
|
import { URL_CONSTANTS } from "../../constants/URLS";
|
||||||
|
|
||||||
|
export const createContainerType = (data: any) =>
|
||||||
|
client.post(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES, { data });
|
||||||
|
|
||||||
|
export const getContainerTypes = () =>
|
||||||
|
client.get(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES);
|
||||||
|
|
||||||
|
export const getContainerTypeById = (id: string) =>
|
||||||
|
client.get(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const updateContainerType = (id: string, data: any) =>
|
||||||
|
client.put(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id), { data });
|
||||||
|
|
||||||
|
export const deleteContainerType = (id: string) =>
|
||||||
|
client.delete(URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// vite.config.ts
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "file:///C:/laragon/www/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:///C:/laragon/www/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:///C:/laragon/www/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:///C:/laragon/www/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,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJDOlxcXFxsYXJhZ29uXFxcXHd3d1xcXFxlZHItcGxhdGZvcm1cXFxcYXBwc1xcXFxlZHItZnJlaWdodC13ZWJcXFxcYmFja29mZmljZVwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiQzpcXFxcbGFyYWdvblxcXFx3d3dcXFxcZWRyLXBsYXRmb3JtXFxcXGFwcHNcXFxcZWRyLWZyZWlnaHQtd2ViXFxcXGJhY2tvZmZpY2VcXFxcdml0ZS5jb25maWcudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL0M6L2xhcmFnb24vd3d3L2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9iYWNrb2ZmaWNlL3ZpdGUuY29uZmlnLnRzXCI7aW1wb3J0IHBhdGggZnJvbSBcIm5vZGU6cGF0aFwiO1xyXG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XHJcblxyXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiO1xyXG5pbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XHJcbmltcG9ydCB0YWlsd2luZGNzcyBmcm9tIFwiQHRhaWx3aW5kY3NzL3ZpdGVcIjtcclxuXHJcbmNvbnN0IF9fZGlybmFtZSA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xyXG5cclxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcclxuICBwbHVnaW5zOiBbcmVhY3QoKSwgdGFpbHdpbmRjc3MoKV0sXHJcbiAgcmVzb2x2ZToge1xyXG4gICAgYWxpYXM6IHtcclxuICAgICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXHJcbiAgICB9LFxyXG4gIH0sXHJcbiAgc2VydmVyOiB7XHJcbiAgICBwb3J0OiA1MTgzLFxyXG4gICAgaG9zdDogXCIwLjAuMC4wXCIsXHJcbiAgfSxcclxufSk7XHJcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBaVgsT0FBTyxVQUFVO0FBQ2xZLFNBQVMscUJBQXFCO0FBRTlCLFNBQVMsb0JBQW9CO0FBQzdCLE9BQU8sV0FBVztBQUNsQixPQUFPLGlCQUFpQjtBQUxtTixJQUFNLDJDQUEyQztBQU81UixJQUFNLFlBQVksS0FBSyxRQUFRLGNBQWMsd0NBQWUsQ0FBQztBQUU3RCxJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixTQUFTLENBQUMsTUFBTSxHQUFHLFlBQVksQ0FBQztBQUFBLEVBQ2hDLFNBQVM7QUFBQSxJQUNQLE9BQU87QUFBQSxNQUNMLEtBQUssS0FBSyxRQUFRLFdBQVcsT0FBTztBQUFBLElBQ3RDO0FBQUEsRUFDRjtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ04sTUFBTTtBQUFBLElBQ04sTUFBTTtBQUFBLEVBQ1I7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
LoaderCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@edr/ui-common";
|
import { Button } from "@edr/ui-common";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
|
BookingFormInputValues,
|
||||||
STEPS,
|
STEPS,
|
||||||
bookingFormSchema,
|
bookingFormSchema,
|
||||||
calcWagons,
|
calcWagons,
|
||||||
@@ -32,6 +41,10 @@ export default function NewBookingPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const { customer } = useAuth();
|
const { customer } = useAuth();
|
||||||
|
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||||
|
api.bookings.referenceData.queryOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (payload: CreateBookingPayload) =>
|
mutationFn: (payload: CreateBookingPayload) =>
|
||||||
api.bookings.create.call(payload),
|
api.bookings.create.call(payload),
|
||||||
@@ -41,7 +54,7 @@ export default function NewBookingPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<BookingFormValues>({
|
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
|
||||||
defaultValues: initialBookingFormValues,
|
defaultValues: initialBookingFormValues,
|
||||||
resolver: zodResolver(bookingFormSchema),
|
resolver: zodResolver(bookingFormSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
@@ -49,18 +62,12 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const containers = form.watch("containers");
|
|
||||||
|
|
||||||
const direction = useMemo(
|
const direction = useMemo(
|
||||||
() => getRouteDirection(originYard, destinationYard),
|
() => getRouteDirection(originYard, destinationYard),
|
||||||
[originYard, destinationYard],
|
[originYard, destinationYard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const wagons = useMemo(() => {
|
|
||||||
if (!containers || containers.length === 0) return null;
|
|
||||||
return calcWagons(containers);
|
|
||||||
}, [containers]);
|
|
||||||
|
|
||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
@@ -78,8 +85,6 @@ export default function NewBookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = data.previousContractRef;
|
|
||||||
|
|
||||||
const totalWeight =
|
const totalWeight =
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? data.containers.reduce(
|
? data.containers.reduce(
|
||||||
@@ -88,59 +93,115 @@ export default function NewBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
const apiPayload = {
|
// ── Reference data lookups ──────────────────────────────────────────
|
||||||
reference,
|
const yards = referenceData?.yard ?? [];
|
||||||
customerId: customer!.id,
|
const services = referenceData?.service ?? [];
|
||||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
const shippingLines = referenceData?.shipping_line ?? [];
|
||||||
totalAmount: 0,
|
const cargoTree = referenceData?.cargo_type ?? [];
|
||||||
contractType:
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
|
||||||
previousContractId: data.previousContractRef || undefined,
|
const findYardId = (name: string): string =>
|
||||||
serviceType:
|
yards.find((y) => y.name === name)?.id ?? "";
|
||||||
data.service.serviceType === "rail"
|
|
||||||
? "RAIL_ONLY"
|
const findServiceTypeId = (): string => {
|
||||||
: "RAIL_AND_FORWARDING",
|
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
|
||||||
...(data.service.serviceType === "rail"
|
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
|
||||||
? {}
|
};
|
||||||
: {
|
|
||||||
firstMileEnabled: data.firstMile.enabled,
|
const findShippingLineId = (name: string): string | undefined =>
|
||||||
firstMilePickupAddress: data.firstMile.pickUpAddress ?? undefined,
|
shippingLines.find((l) => l.name === name)?.id;
|
||||||
lastMileEnabled: data.lastMile.enabled,
|
|
||||||
lastMileDeliveryAddress: data.lastMile.deliveryAddress ?? undefined,
|
const findCargoTypeId = (name: string): string | undefined => {
|
||||||
equipmentReturn:
|
for (const group of cargoTree) {
|
||||||
data.equipmentReturn === "with_return"
|
const child = group.children?.find((c) => c.name === name);
|
||||||
? ("WITH_RETURN" as const)
|
if (child) return child.id;
|
||||||
: ("WITHOUT_RETURN" as const),
|
}
|
||||||
customsClearingEnabled: data.customsClearingEnabled,
|
return undefined;
|
||||||
}),
|
};
|
||||||
originStation: data.originYard,
|
|
||||||
destinationStation: data.destinationYard,
|
const findContainerCargoTypeId = (): string => {
|
||||||
cargoTotalWeightVgm: totalWeight,
|
const group = cargoTree.find(
|
||||||
freightType: data.cargoType === "container" ? "BREAK_BULK" : "BULK",
|
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
|
||||||
freightSubtype:
|
);
|
||||||
data.cargoType === "container"
|
console.log(group, cargoTree);
|
||||||
? undefined
|
return group?.id ?? "";
|
||||||
: data.freightType === "bulk"
|
};
|
||||||
|
|
||||||
|
const findContainerTypeId = (name: string): string => {
|
||||||
|
for (const group of containerGroups) {
|
||||||
|
const ct = group.types.find((t) => t.name === name);
|
||||||
|
if (ct) return ct.id;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const cargoTypeId =
|
||||||
|
data.cargoType === "container"
|
||||||
|
? findContainerCargoTypeId()
|
||||||
|
: (findCargoTypeId(
|
||||||
|
data.freightType === "bulk"
|
||||||
? data.bulkCommodity
|
? data.bulkCommodity
|
||||||
: data.breakBulkType,
|
: data.breakBulkType,
|
||||||
isHazardous: data.isHazardous,
|
) ?? "");
|
||||||
isRefrigerated: data.isRefrigerated,
|
|
||||||
|
const cargoFreeText =
|
||||||
|
data.cargoType === "container"
|
||||||
|
? undefined
|
||||||
|
: data.freightType === "bulk" && data.bulkCommodity === "Others"
|
||||||
|
? data.bulkCommodityOther
|
||||||
|
: data.freightType === "break_bulk" && data.breakBulkType === "Others"
|
||||||
|
? data.breakBulkTypeOther
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// ── Build API payload ───────────────────────────────────────────────
|
||||||
|
const apiPayload: CreateBookingPayload = {
|
||||||
|
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||||
|
contractType:
|
||||||
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
|
serviceTypeId: findServiceTypeId(),
|
||||||
|
equipmentReturn:
|
||||||
|
data.equipmentReturn === "with_return"
|
||||||
|
? "WITH_RETURN"
|
||||||
|
: "WITHOUT_RETURN",
|
||||||
|
originYardId: findYardId(data.originYard),
|
||||||
|
destinationYardId: findYardId(data.destinationYard),
|
||||||
tradeDirection:
|
tradeDirection:
|
||||||
getRouteDirection(data.originYard, data.destinationYard) === "export"
|
direction === "export"
|
||||||
? "EXPORT"
|
? "EXPORT"
|
||||||
: "IMPORT",
|
: direction === "domestic"
|
||||||
|
? "DOMESTIC"
|
||||||
|
: "IMPORT",
|
||||||
|
cargoTypeId,
|
||||||
|
cargoTotalWeightVgm: totalWeight,
|
||||||
|
isHazardous: data.isHazardous,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: "USD",
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
...(data.cargoType === "container" && data.containers.length > 0
|
containers:
|
||||||
? {
|
data.cargoType === "container"
|
||||||
containers: data.containers.map((c) => ({
|
? data.containers.map((c) => ({
|
||||||
type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const),
|
containerTypeId: findContainerTypeId(c.containerType),
|
||||||
qty: Number(c.qty || 1),
|
quantity: Number(c.qty || 1),
|
||||||
vgm: Number(c.vgm || 0),
|
vgmPerUnitTons: Number(c.vgm || 0),
|
||||||
})),
|
}))
|
||||||
}
|
: [],
|
||||||
|
...(customer ? { customerId: customer.id } : {}),
|
||||||
|
...(data.previousContractRef
|
||||||
|
? { previousContractId: data.previousContractRef }
|
||||||
: {}),
|
: {}),
|
||||||
} satisfies CreateBookingPayload;
|
...(data.contractType === "renewal" && data.previousContractRef
|
||||||
|
? { pnrCode: data.previousContractRef }
|
||||||
|
: {}),
|
||||||
|
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
|
||||||
|
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
||||||
|
: {}),
|
||||||
|
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
|
||||||
|
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||||
|
: {}),
|
||||||
|
...(data.shippingLine
|
||||||
|
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
||||||
|
: {}),
|
||||||
|
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
createMutation.mutate(apiPayload);
|
createMutation.mutate(apiPayload);
|
||||||
});
|
});
|
||||||
@@ -179,11 +240,35 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||||
|
{createMutation.isError && (
|
||||||
|
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Submission failed</p>
|
||||||
|
<p className="mt-1 text-red-600">
|
||||||
|
{createMutation.error instanceof Error
|
||||||
|
? createMutation.error.message
|
||||||
|
: "An unexpected error occurred. Please try again."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{step === 1 && <Step1ContractType form={form} />}
|
{step === 1 && <Step1ContractType form={form} />}
|
||||||
{step === 2 && <Step2ServiceType form={form} />}
|
{step === 2 && <Step2ServiceType form={form} />}
|
||||||
{step === 3 && <Step4Route form={form} />}
|
{step === 3 && (
|
||||||
|
<Step4Route
|
||||||
|
form={form}
|
||||||
|
referenceData={referenceData}
|
||||||
|
isLoading={refDataLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<Step5CargoDetails form={form} direction={direction} />
|
<Step5CargoDetails
|
||||||
|
form={form}
|
||||||
|
direction={direction}
|
||||||
|
referenceData={referenceData}
|
||||||
|
isLoading={refDataLoading}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 5 && (
|
{step === 5 && (
|
||||||
<Step8Review form={form} setStep={setStep} direction={direction} />
|
<Step8Review form={form} setStep={setStep} direction={direction} />
|
||||||
@@ -210,9 +295,19 @@ export default function NewBookingPage() {
|
|||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="submit" form="new-booking-form">
|
<Button
|
||||||
<Check />
|
type="submit"
|
||||||
Submit Contract Request
|
form="new-booking-form"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check />
|
||||||
|
)}
|
||||||
|
{createMutation.isPending
|
||||||
|
? "Submitting..."
|
||||||
|
: "Submit Contract Request"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export const bookingFormSchema = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||||
|
export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
|
||||||
|
|
||||||
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||||
previousContractRef: "",
|
previousContractRef: "",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import type { BookingFormValues } from "./schema";
|
import type { BookingFormInputValues, BookingFormValues } from "./schema";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
||||||
@@ -122,7 +122,7 @@ export function SelectField({
|
|||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
field: ControllerRenderProps<BookingFormValues>;
|
field: ControllerRenderProps<BookingFormInputValues>;
|
||||||
error?: RhfFieldError;
|
error?: RhfFieldError;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { FileText, RefreshCw } from "lucide-react";
|
import { FileText, RefreshCw } from "lucide-react";
|
||||||
import { Field } from "@edr/ui-common";
|
import { Field } from "@edr/ui-common";
|
||||||
import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema";
|
import {
|
||||||
|
BookingFormInputValues,
|
||||||
|
MOCK_VALID_CONTRACTS,
|
||||||
|
type BookingFormValues,
|
||||||
|
} from "./schema";
|
||||||
import {
|
import {
|
||||||
AlertBox,
|
AlertBox,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
@@ -11,7 +15,11 @@ import {
|
|||||||
StepHeader,
|
StepHeader,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step1ContractType({ form }: { form: BookingForm }) {
|
export function Step1ContractType({ form }: { form: BookingForm }) {
|
||||||
const contractType = form.watch("contractType");
|
const contractType = form.watch("contractType");
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { useEffect, useRef } from "react";
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { FileText, Package, Train, Truck } from "lucide-react";
|
import { FileText, Package, Train, Truck } from "lucide-react";
|
||||||
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
|
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
|
||||||
import { type BookingFormValues } from "./schema";
|
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||||
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step2ServiceType({ form }: { form: BookingForm }) {
|
export function Step2ServiceType({ form }: { form: BookingForm }) {
|
||||||
const serviceType = form.watch("serviceType");
|
const serviceType = form.watch("serviceType");
|
||||||
|
|||||||
@@ -1,37 +1,50 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||||
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
|
import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
SHIPPING_LINES,
|
BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
STATIONS,
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import {
|
import { SelectField, StepHeader, StepLabel } from "./shared";
|
||||||
AlertBox,
|
|
||||||
SelectField,
|
|
||||||
SelectOptions,
|
|
||||||
StepHeader,
|
|
||||||
StepLabel,
|
|
||||||
} from "./shared";
|
|
||||||
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
|
||||||
import { DropdownOption } from "@/types/dropdownSettings";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
const STATION_DROPDOWN_CODE = "stations_ter";
|
export function Step4Route({
|
||||||
|
form,
|
||||||
export function Step4Route({ form }: { form: BookingForm }) {
|
referenceData,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
form: BookingForm;
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const {
|
|
||||||
data: stationSetting,
|
const yardOptions = useMemo(() => {
|
||||||
isLoading: stationsLoading,
|
if (!referenceData?.yard) return [];
|
||||||
isError: stationsError,
|
return referenceData.yard.map((y) => ({
|
||||||
error: stationsFetchError,
|
value: y.name,
|
||||||
} = useDropdownSettingByCode(STATION_DROPDOWN_CODE);
|
label: y.name,
|
||||||
const stationOptions = getStationOptions(stationSetting?.children);
|
country: y.country,
|
||||||
|
}));
|
||||||
|
}, [referenceData]);
|
||||||
|
|
||||||
|
const shippingLineOptions = useMemo(() => {
|
||||||
|
if (!referenceData?.shipping_line) return [];
|
||||||
|
return referenceData.shipping_line.map((sl) => ({
|
||||||
|
value: sl.name,
|
||||||
|
label: sl.name,
|
||||||
|
}));
|
||||||
|
}, [referenceData]);
|
||||||
|
|
||||||
const direction = getRouteDirection(originYard, destinationYard);
|
const direction = getRouteDirection(originYard, destinationYard);
|
||||||
const directionStyle: Record<string, string> = {
|
const directionStyle: Record<string, string> = {
|
||||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||||
@@ -43,7 +56,6 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
import: "Import workflow (outside country to inside country)",
|
import: "Import workflow (outside country to inside country)",
|
||||||
domestic: "Domestic corridor",
|
domestic: "Domestic corridor",
|
||||||
};
|
};
|
||||||
const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (direction === "domestic") {
|
if (direction === "domestic") {
|
||||||
@@ -51,6 +63,8 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
}
|
}
|
||||||
}, [direction]);
|
}, [direction]);
|
||||||
|
|
||||||
|
const stationSelectDisabled = yardOptions.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
@@ -58,65 +72,59 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
description="Select the origin and destination yards."
|
description="Select the origin and destination yards."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{isLoading ? (
|
||||||
<StepLabel>Route</StepLabel>
|
<LoadingSkeleton />
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
) : (
|
||||||
<Controller
|
<div className="space-y-3">
|
||||||
name="originYard"
|
<StepLabel>Route</StepLabel>
|
||||||
control={form.control}
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
render={({ field, fieldState }) => (
|
<Controller
|
||||||
<SelectField
|
name="originYard"
|
||||||
field={field}
|
control={form.control}
|
||||||
error={fieldState.error}
|
render={({ field, fieldState }) => (
|
||||||
label="Origin Yard*"
|
<SelectField
|
||||||
placeholder="Select origin..."
|
field={field}
|
||||||
disabled={stationSelectDisabled}
|
error={fieldState.error}
|
||||||
>
|
label="Origin Yard*"
|
||||||
<StationSelectOptions
|
placeholder="Select origin..."
|
||||||
options={stationOptions}
|
disabled={stationSelectDisabled}
|
||||||
excludeValue={destinationYard}
|
>
|
||||||
isLoading={stationsLoading}
|
<YardSelectOptions
|
||||||
/>
|
options={yardOptions}
|
||||||
</SelectField>
|
excludeValue={destinationYard}
|
||||||
)}
|
/>
|
||||||
/>
|
</SelectField>
|
||||||
<Controller
|
)}
|
||||||
name="destinationYard"
|
/>
|
||||||
control={form.control}
|
<Controller
|
||||||
render={({ field, fieldState }) => (
|
name="destinationYard"
|
||||||
<SelectField
|
control={form.control}
|
||||||
field={field}
|
render={({ field, fieldState }) => (
|
||||||
error={fieldState.error}
|
<SelectField
|
||||||
label="Destination Yard *"
|
field={field}
|
||||||
placeholder="Select destination..."
|
error={fieldState.error}
|
||||||
disabled={stationSelectDisabled}
|
label="Destination Yard *"
|
||||||
>
|
placeholder="Select destination..."
|
||||||
<StationSelectOptions
|
disabled={stationSelectDisabled}
|
||||||
options={stationOptions}
|
>
|
||||||
excludeValue={originYard}
|
<YardSelectOptions
|
||||||
isLoading={stationsLoading}
|
options={yardOptions}
|
||||||
/>
|
excludeValue={originYard}
|
||||||
</SelectField>
|
/>
|
||||||
)}
|
</SelectField>
|
||||||
/>
|
)}
|
||||||
</div>
|
/>
|
||||||
{stationsError && (
|
|
||||||
<AlertBox tone="error">
|
|
||||||
Failed to load stations from the API.{" "}
|
|
||||||
{stationsFetchError instanceof Error
|
|
||||||
? stationsFetchError.message
|
|
||||||
: "Try again later."}
|
|
||||||
</AlertBox>
|
|
||||||
)}
|
|
||||||
{direction && (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
|
|
||||||
>
|
|
||||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
{directionLabel[direction]}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{direction && (
|
||||||
</div>
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
|
||||||
|
>
|
||||||
|
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{directionLabel[direction]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{direction && direction != "domestic" && (
|
{direction && direction != "domestic" && (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -129,7 +137,11 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
label="Shipping Line"
|
label="Shipping Line"
|
||||||
placeholder="Select shipping line..."
|
placeholder="Select shipping line..."
|
||||||
>
|
>
|
||||||
<SelectOptions options={SHIPPING_LINES} />
|
{shippingLineOptions.map((sl) => (
|
||||||
|
<SelectItem key={sl.value} value={sl.value}>
|
||||||
|
{sl.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -179,23 +191,35 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
|
function LoadingSkeleton() {
|
||||||
return [...(options ?? [])].sort((a, b) => a.order - b.order);
|
return (
|
||||||
|
<div className="space-y-4 rounded-xl border border-border p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StationSelectOptions({
|
function YardSelectOptions({
|
||||||
options,
|
options,
|
||||||
excludeValue,
|
excludeValue,
|
||||||
isLoading,
|
|
||||||
}: {
|
}: {
|
||||||
options: DropdownOption[];
|
options: Array<{ value: string; label: string; country: string }>;
|
||||||
excludeValue: string;
|
excludeValue: string;
|
||||||
isLoading: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
if (isLoading) {
|
if (options.length === 0) {
|
||||||
return (
|
return (
|
||||||
<SelectItem value="__stations_loading" disabled>
|
<SelectItem value="__yards_empty" disabled>
|
||||||
Loading stations...
|
No yards available
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -204,22 +228,10 @@ function StationSelectOptions({
|
|||||||
(option) => option.value !== excludeValue,
|
(option) => option.value !== excludeValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (availableOptions.length === 0) {
|
|
||||||
return (
|
|
||||||
<SelectItem value="__stations_empty" disabled>
|
|
||||||
No stations available
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{availableOptions.map((option) => (
|
{availableOptions.map((option) => (
|
||||||
<SelectItem
|
<SelectItem key={option.value} value={option.value}>
|
||||||
key={option.id}
|
|
||||||
value={option.value}
|
|
||||||
disabled={option.disabled}
|
|
||||||
>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||||
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
|
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
|
||||||
import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
|
|
||||||
import {
|
import {
|
||||||
BREAK_BULK_TYPES,
|
Button,
|
||||||
BULK_COMMODITIES,
|
Field,
|
||||||
CONTAINER_TYPES,
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
Input,
|
||||||
|
Skeleton,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
BookingFormInputValues,
|
||||||
calcWagons,
|
calcWagons,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type RouteDirection,
|
type RouteDirection,
|
||||||
@@ -13,19 +20,27 @@ import {
|
|||||||
AlertBox,
|
AlertBox,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
SelectField,
|
SelectField,
|
||||||
SelectOptions,
|
SelectItem,
|
||||||
StepHeader,
|
StepHeader,
|
||||||
StepLabel,
|
StepLabel,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step5CargoDetails({
|
export function Step5CargoDetails({
|
||||||
form,
|
form,
|
||||||
direction,
|
direction,
|
||||||
|
referenceData,
|
||||||
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
direction: RouteDirection;
|
direction: RouteDirection;
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
const freightType = form.watch("freightType");
|
const freightType = form.watch("freightType");
|
||||||
@@ -38,6 +53,20 @@ export function Step5CargoDetails({
|
|||||||
name: "containers",
|
name: "containers",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const containerTypeOptions = useMemo(() => {
|
||||||
|
if (!referenceData?.containers) return [];
|
||||||
|
return referenceData.containers.flatMap((group) =>
|
||||||
|
group.types.map((t) => t.name),
|
||||||
|
);
|
||||||
|
}, [referenceData]);
|
||||||
|
|
||||||
|
const bulkCommodityOptions = useMemo(() => {
|
||||||
|
if (!referenceData?.cargo_type) return [];
|
||||||
|
return referenceData.cargo_type.flatMap(
|
||||||
|
(group) => group.children?.map((c) => c.name) ?? [],
|
||||||
|
);
|
||||||
|
}, [referenceData]);
|
||||||
|
|
||||||
function getOverweightAlert(
|
function getOverweightAlert(
|
||||||
type: "20ft" | "40ft",
|
type: "20ft" | "40ft",
|
||||||
vgm: number,
|
vgm: number,
|
||||||
@@ -54,6 +83,26 @@ export function Step5CargoDetails({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StepHeader
|
||||||
|
title="Cargo Details"
|
||||||
|
description="Define your cargo type, weight, and container configuration."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 rounded-xl border border-border p-4">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
@@ -181,7 +230,11 @@ export function Step5CargoDetails({
|
|||||||
label="Commodity *"
|
label="Commodity *"
|
||||||
placeholder="Select commodity *"
|
placeholder="Select commodity *"
|
||||||
>
|
>
|
||||||
<SelectOptions options={BULK_COMMODITIES} />
|
{bulkCommodityOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -216,7 +269,11 @@ export function Step5CargoDetails({
|
|||||||
label="Break-bulk type *"
|
label="Break-bulk type *"
|
||||||
placeholder="Select type *"
|
placeholder="Select type *"
|
||||||
>
|
>
|
||||||
<SelectOptions options={BREAK_BULK_TYPES} />
|
{bulkCommodityOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -408,7 +465,11 @@ export function Step5CargoDetails({
|
|||||||
label="Container Type *"
|
label="Container Type *"
|
||||||
placeholder="Select type..."
|
placeholder="Select type..."
|
||||||
>
|
>
|
||||||
<SelectOptions options={CONTAINER_TYPES} />
|
{containerTypeOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,13 +11,17 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
|
BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type RouteDirection,
|
type RouteDirection,
|
||||||
type WagonCalcResult,
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step8Review({
|
export function Step8Review({
|
||||||
form,
|
form,
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ export const api = {
|
|||||||
bookingsService.create,
|
bookingsService.create,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
referenceData: endpoint<void, Freight.BookingReferenceData>(
|
||||||
|
"bookings",
|
||||||
|
"referenceData",
|
||||||
|
bookingsService.getReferenceData,
|
||||||
|
),
|
||||||
|
|
||||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||||
bookingsService.remove(id),
|
bookingsService.remove(id),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const bookingsService = {
|
|||||||
const { data } = await client.post("/api/bookings", payload);
|
const { data } = await client.post("/api/bookings", payload);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
|
||||||
|
const { data } = await client.get("/api/bookings/reference-data");
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
remove: async (id: string): Promise<void> => {
|
remove: async (id: string): Promise<void> => {
|
||||||
await client.delete(`/bookings/${id}`);
|
await client.delete(`/bookings/${id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -216,45 +216,94 @@ export interface IInvoice extends BaseEntity {
|
|||||||
dueAt: string;
|
dueAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Reference Data (booking form catalog) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BookingReferenceYard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReferenceContainerType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
is_reefer: boolean;
|
||||||
|
wagons_per_unit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReferenceContainerSizeGroup {
|
||||||
|
size: string;
|
||||||
|
types: BookingReferenceContainerType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReferenceService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReferenceShippingLine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReferenceCargoTypeChild {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
show_free_text_box: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReferenceCargoTypeGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
children?: BookingReferenceCargoTypeChild[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReferenceData {
|
||||||
|
yard: BookingReferenceYard[];
|
||||||
|
containers: BookingReferenceContainerSizeGroup[];
|
||||||
|
service: BookingReferenceService[];
|
||||||
|
shipping_line: BookingReferenceShippingLine[];
|
||||||
|
cargo_type: BookingReferenceCargoTypeGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DTOs ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateBookingContainerDto {
|
||||||
|
containerTypeId: string;
|
||||||
|
quantity: number;
|
||||||
|
vgmPerUnitTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateBookingDto {
|
export interface CreateBookingDto {
|
||||||
reference: string;
|
reference?: string;
|
||||||
customerId: string;
|
customerId?: string;
|
||||||
trainId?: string;
|
trainId?: string;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
totalAmount: number;
|
|
||||||
paymentStatus?: string;
|
|
||||||
contractType: "NEW" | "RENEWAL";
|
contractType: "NEW" | "RENEWAL";
|
||||||
previousContractId?: string;
|
previousContractId?: string;
|
||||||
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
serviceTypeId: string;
|
||||||
|
|
||||||
firstMileEnabled?: boolean;
|
|
||||||
firstMilePickupAddress?: string;
|
firstMilePickupAddress?: string;
|
||||||
lastMileEnabled?: boolean;
|
|
||||||
lastMileDeliveryAddress?: string;
|
lastMileDeliveryAddress?: string;
|
||||||
|
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
|
||||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
originYardId: string;
|
||||||
customsClearingEnabled?: boolean;
|
destinationYardId: string;
|
||||||
originStation: string;
|
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||||
destinationStation: string;
|
cargoTypeId: string;
|
||||||
|
cargoFreeText?: string;
|
||||||
|
shippingLineId?: string;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
|
|
||||||
freightType: "BULK" | "BREAK_BULK";
|
|
||||||
freightSubtype?: string;
|
|
||||||
|
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
isRefrigerated?: boolean;
|
paymentCurrency: "ETB" | "USD";
|
||||||
|
pnrCode?: string;
|
||||||
tradeDirection: "IMPORT" | "EXPORT";
|
|
||||||
paymentCurrency: string;
|
|
||||||
allowConsolidation?: boolean;
|
|
||||||
|
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
financialTerms?: string;
|
financialTerms?: string;
|
||||||
|
containers: CreateBookingContainerDto[];
|
||||||
containers?: Array<{
|
allowConsolidation?: boolean;
|
||||||
type: "20FT" | "40FT";
|
|
||||||
qty: number;
|
|
||||||
vgm: number;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type {
|
|||||||
|
|
||||||
export * from "./components/button";
|
export * from "./components/button";
|
||||||
export * from "./components/input";
|
export * from "./components/input";
|
||||||
|
export * from "./components/skeleton";
|
||||||
export * from "./components/textarea";
|
export * from "./components/textarea";
|
||||||
export * from "./components/label";
|
export * from "./components/label";
|
||||||
export * from "./components/card";
|
export * from "./components/card";
|
||||||
|
|||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -201,6 +201,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.6
|
specifier: 19.2.6
|
||||||
version: 19.2.6(react@19.2.6)
|
version: 19.2.6(react@19.2.6)
|
||||||
|
react-hot-toast:
|
||||||
|
specifier: ^2.6.0
|
||||||
|
version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.27.0
|
specifier: ^6.27.0
|
||||||
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -6281,6 +6284,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
|
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
goober@2.1.19:
|
||||||
|
resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==}
|
||||||
|
peerDependencies:
|
||||||
|
csstype: ^3.0.10
|
||||||
|
|
||||||
gopd@1.2.0:
|
gopd@1.2.0:
|
||||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -8408,6 +8416,13 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
|
||||||
|
react-hot-toast@2.6.0:
|
||||||
|
resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6
|
||||||
|
|
||||||
react-i18next@15.7.4:
|
react-i18next@15.7.4:
|
||||||
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
|
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -17011,6 +17026,10 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
goober@2.1.19(csstype@3.2.3):
|
||||||
|
dependencies:
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
gopd@1.2.0: {}
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
@@ -19373,6 +19392,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
|
||||||
|
react-hot-toast@2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
csstype: 3.2.3
|
||||||
|
goober: 2.1.19(csstype@3.2.3)
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
react-i18next@15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
|
react-i18next@15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.2
|
||||||
|
|||||||
Reference in New Issue
Block a user