From bffa5020ac3312b6ab87a98d204bb7d8243c0c78 Mon Sep 17 00:00:00 2001 From: hagiye Date: Sun, 7 Jun 2026 21:56:33 +0300 Subject: [PATCH] wagon types added UI with API integrations --- apps/edr-freight-api/nest-cli.json | 4 +- apps/edr-freight-api/package.json | 5 +- .../src/config/database.config.ts | 4 +- .../wagon-types/dto/create-wagon-type.dto.ts | 75 +++++++++++++ .../wagon-types/dto/update-wagon-type.dto.ts | 5 + .../wagon-types/wagon-types.controller.ts | 54 ++++++++- .../wagon-types/wagon-types.service.ts | 81 ++++++++++++- apps/edr-freight-web/backoffice/src/App.tsx | 31 +++-- .../src/components/layout/route-meta.ts | 7 ++ .../backoffice/src/hooks/use-wagon-types.ts | 30 ++++- .../src/pages/fleet/FleetCrudPages.tsx | 106 +++++++++++++++++- .../src/services/wagon-types.service.ts | 18 ++- 12 files changed, 382 insertions(+), 38 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts diff --git a/apps/edr-freight-api/nest-cli.json b/apps/edr-freight-api/nest-cli.json index 4f4164d16..4df6a9aef 100644 --- a/apps/edr-freight-api/nest-cli.json +++ b/apps/edr-freight-api/nest-cli.json @@ -3,7 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true, + "deleteOutDir": false, "assets": [ { "include": "migrations/**/*", @@ -20,4 +20,4 @@ ], "watchAssets": true } -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index e5e829acb..eec917c7e 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -4,7 +4,10 @@ "private": true, "description": "EDR Freight Management API", "scripts": { + "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", + "predev": "pnpm run clean", "dev": "nest start --watch", + "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", "lint": "eslint src", @@ -80,4 +83,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index a511c1654..bbac17e7a 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -17,7 +17,7 @@ import { PositionType, Position, Project, - UnitSetting, + UnitConfiguration, GlobalUnitConfiguration, Unit, EmployeeSignature, @@ -64,7 +64,7 @@ const iamEntities = [ PositionType, Position, Project, - UnitSetting, + UnitConfiguration, GlobalUnitConfiguration, Unit, EmployeeSignature, diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts new file mode 100644 index 000000000..686336b6d --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -0,0 +1,75 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}; + +const toStringArray = ({ value }: { value: unknown }) => { + if (Array.isArray(value)) return value; + if (typeof value !== 'string') return []; + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +}; + +export class CreateWagonTypeDto { + @ApiProperty({ maxLength: 32, example: 'FLAT' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiProperty({ maxLength: 100, example: 'Flat wagon' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiProperty({ example: 60 }) + @Transform(toNumber) + @IsNumber() + @Min(0) + capacityTons!: number; + + @ApiProperty({ example: 14.2 }) + @Transform(toNumber) + @IsNumber() + @Min(0) + lengthMeters!: number; + + @ApiPropertyOptional({ example: 45 }) + @IsOptional() + @Transform(toNumber) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ type: [String], example: ['container', 'break-bulk'] }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @IsString({ each: true }) + supportedLoadTypes?: string[]; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts new file mode 100644 index 000000000..846987556 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateWagonTypeDto } from './create-wagon-type.dto'; + +export class UpdateWagonTypeDto extends PartialType(CreateWagonTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts index 0c0d65bd7..76b52a0aa 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -1,5 +1,19 @@ -import { Controller, Get } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { WagonTypesService } from './wagon-types.service'; import { WagonType } from './entities/wagon-type.entity'; @@ -8,9 +22,37 @@ import { WagonType } from './entities/wagon-type.entity'; export class WagonTypesController { constructor(private readonly wagonTypesService: WagonTypesService) {} - @Get() - @ApiOperation({ summary: 'Get all active wagon types' }) - async findAll(): Promise { - return this.wagonTypesService.findAll(); + @Post() + @ApiOperation({ summary: 'Create a wagon type' }) + async create(@Body() dto: CreateWagonTypeDto): Promise { + return this.wagonTypesService.create(dto); } -} \ No newline at end of file + + @Get() + @ApiOperation({ summary: 'Get wagon types' }) + async findAll(@Query() query: Record): Promise { + return this.wagonTypesService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a wagon type by ID' }) + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.wagonTypesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a wagon type' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateWagonTypeDto, + ): Promise { + return this.wagonTypesService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Deactivate a wagon type' }) + async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.wagonTypesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index 879245783..6f9e5830e 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -1,5 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { WagonType } from './entities/wagon-type.entity'; import { WagonTypesRepository } from './wagon-types.repository'; @@ -7,13 +10,52 @@ import { WagonTypesRepository } from './wagon-types.repository'; export class WagonTypesService { constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} - async findAll(): Promise { - return this.wagonTypesRepository.findAll({ - where: { isActive: true }, - order: { code: 'ASC' }, + async create(dto: CreateWagonTypeDto): Promise { + const code = dto.code.trim().toUpperCase(); + const existing = await this.wagonTypesRepository.findAll({ where: { code } }); + if (existing.length > 0) { + throw new ConflictException(`Wagon type code "${code}" already exists`); + } + + return this.wagonTypesRepository.create({ + ...dto, + code, + name: dto.name.trim(), + supportedLoadTypes: dto.supportedLoadTypes ?? [], + isActive: dto.isActive ?? true, }); } + async findAll(query: Record = {}): Promise { + const isActive = + query.isActive === 'all' + ? undefined + : query.isActive === undefined + ? true + : query.isActive === 'true'; + const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes( + query.sortBy ?? '', + ) + ? (query.sortBy as keyof WagonType) + : 'code'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.wagonTypesRepository.findAll({ + where: isActive === undefined ? {} : { isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + }); + } + + async findById(id: string): Promise { + const wagonType = await this.wagonTypesRepository.findById(id); + + if (!wagonType) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + + return wagonType; + } + async findByCode(code: string): Promise { const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); @@ -23,4 +65,33 @@ export class WagonTypesService { return wagonType; } + + async update(id: string, dto: UpdateWagonTypeDto): Promise { + const wagonType = await this.findById(id); + const nextCode = dto.code?.trim().toUpperCase(); + + if (nextCode && nextCode !== wagonType.code) { + const existing = await this.wagonTypesRepository.findAll({ where: { code: nextCode } }); + if (existing.length > 0) { + throw new ConflictException(`Wagon type code "${nextCode}" already exists`); + } + } + + const updated = await this.wagonTypesRepository.update(id, { + ...dto, + ...(nextCode ? { code: nextCode } : {}), + ...(dto.name ? { name: dto.name.trim() } : {}), + }); + + if (!updated) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.wagonTypesRepository.update(id, { isActive: false }); + } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 554411c8d..d02e4ff0d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -36,11 +36,12 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainsPage from "./pages/trains/TrainsPage"; import { - CargoesCrudPage, - ContainersCrudPage, - TrainMasterDataPage, - WagonsCrudPage, -} from "./pages/fleet/FleetCrudPages"; + CargoesCrudPage, + ContainersCrudPage, + TrainMasterDataPage, + WagonTypesCrudPage, + WagonsCrudPage, +} from "./pages/fleet/FleetCrudPages"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -75,10 +76,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/trains", icon: , }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , + { + label: "Wagon types", + href: "/dashboard/wagon-types", + icon: , + }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , }, { label: "Containers", @@ -224,9 +230,10 @@ const App = () => { element={} /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 517ca6468..1638151cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -93,6 +93,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ }, }, ...rulesRouteMeta, + { + prefix: "/dashboard/wagon-types", + meta: { + title: "Wagon Types", + subtitle: "Manage wagon type capacity and supported load configuration", + }, + }, { prefix: "/dashboard/user1", meta: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts b/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts index 4b8019cc9..88566c776 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { wagonTypesService } from '@/services/wagon-types.service'; export const WAGON_TYPES_QUERY_KEY = ['wagon-types']; @@ -7,6 +7,30 @@ export function useWagonTypes() { return useQuery({ queryKey: WAGON_TYPES_QUERY_KEY, queryFn: () => wagonTypesService.getWagonTypes(), - staleTime: Infinity, }); -} \ No newline at end of file +} + +export function useCreateWagonType() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: wagonTypesService.create, + onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), + }); +} + +export function useUpdateWagonType() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + wagonTypesService.update(id, data), + onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), + }); +} + +export function useDeleteWagonType() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: wagonTypesService.delete, + onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 539151fec..2f03934bd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -16,7 +16,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useCargoTypes } from '@/hooks/use-cargo-types'; import { useContainerTypes } from '@/hooks/use-container-types'; -import { useWagonTypes } from '@/hooks/use-wagon-types'; +import { + useCreateWagonType, + useDeleteWagonType, + useUpdateWagonType, + useWagonTypes, +} from '@/hooks/use-wagon-types'; import { useToast } from '@/hooks/use-toast'; import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes'; import { @@ -31,8 +36,9 @@ import type { Cargo } from '@/services/cargoService'; import type { Container } from '@/services/containerService'; import type { Train } from '@/services/trains.service'; import type { Wagon } from '@/services/wagon.service'; +import type { WagonType } from '@/services/wagon-types.service'; -type FormValue = string | number; +type FormValue = string | number | boolean | string[]; type Field = { key: string; @@ -71,8 +77,20 @@ type FleetCrudPageProps = { const normalizePayload = (values: Record) => Object.fromEntries( Object.entries(values) - .map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value]) - .filter(([, value]) => value !== ''), + .map(([key, value]) => [ + key, + key === 'supportedLoadTypes' && typeof value === 'string' + ? value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + : Array.isArray(value) + ? value + : typeof value === 'string' + ? value.trim() + : value, + ]) + .filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)), ); const extractBackendErrors = (error: unknown) => { @@ -181,7 +199,10 @@ function FleetCrudPage({ setEditing(item); setForm( Object.fromEntries( - Object.keys(emptyValues).map((key) => [key, (item as Record)[key] ?? '']), + Object.keys(emptyValues).map((key) => [ + key, + (item as Record)[key] ?? '', + ]), ), ); setFieldErrors({}); @@ -349,7 +370,11 @@ function FleetCrudPage({ const value = form[field.key] ?? ''; const inputValue = field.type === 'number' && value !== '' && !Number.isFinite(Number(value)) ? '' - : value; + : Array.isArray(value) + ? value.join(', ') + : typeof value === 'boolean' + ? String(value) + : value; return (
@@ -431,6 +456,12 @@ function FleetCrudPage({ const statusBadge = (status?: string) => {status ?? '-'}; +const activeBadge = (isActive?: boolean) => ( + + {isActive === false ? 'Inactive' : 'Active'} + +); + const optionLabel = (options: { value: string; label: string }[], value?: string | null) => options.find((option) => option.value === value)?.label ?? value ?? '-'; @@ -469,6 +500,69 @@ export function TrainMasterDataPage() { ); } +export function WagonTypesCrudPage() { + const query = useWagonTypes(); + + return ( + + title="Wagon Types" + description="Manage wagon type capacities and load compatibility used by wagon master data." + addLabel="Add Wagon Type" + data={query.data} + isLoading={query.isLoading} + create={useCreateWagonType()} + update={useUpdateWagonType()} + remove={useDeleteWagonType()} + searchText={(type) => + [type.code, type.name, type.supportedLoadTypes?.join(' '), String(type.isActive)].join(' ') + } + columns={[ + { key: 'code', label: 'Code' }, + { key: 'name', label: 'Name' }, + { key: 'capacityTons', label: 'Capacity (tons)' }, + { key: 'lengthMeters', label: 'Length (m)' }, + { + key: 'supportedLoadTypes', + label: 'Load types', + render: (type) => type.supportedLoadTypes?.join(', ') || '-', + }, + { key: 'isActive', label: 'Status', render: (type) => activeBadge(type.isActive) }, + ]} + fields={[ + { key: 'code', label: 'Code', required: true }, + { key: 'name', label: 'Name', required: true }, + { key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true }, + { key: 'lengthMeters', label: 'Length (meters)', type: 'number', required: true }, + { key: 'maxWagonsPerTrain', label: 'Max wagons per train', type: 'number' }, + { + key: 'supportedLoadTypes', + label: 'Supported load types', + placeholder: 'container, break-bulk', + }, + { + key: 'isActive', + label: 'Status', + type: 'select', + options: [ + { value: 'true', label: 'Active' }, + { value: 'false', label: 'Inactive' }, + ], + onValueChange: (value) => ({ isActive: value === 'true' }), + }, + ]} + emptyValues={{ + code: '', + name: '', + capacityTons: 0, + lengthMeters: 0, + maxWagonsPerTrain: '', + supportedLoadTypes: '', + isActive: true, + }} + /> + ); +} + export function WagonsCrudPage() { const query = useWagons(); const { data: wagonTypes = [] } = useWagonTypes(); diff --git a/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts index e8990b9f5..5891f1152 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts @@ -2,12 +2,28 @@ import { api } from "../auth/http"; type ListResponse = T[] | { data: T[] }; +export interface WagonType { + id: string; + code: string; + name: string; + capacityTons: number; + lengthMeters: number; + maxWagonsPerTrain?: number | null; + supportedLoadTypes: string[]; + isActive: boolean; +} + const asList = (payload: ListResponse): T[] => Array.isArray(payload) ? payload : payload.data; export const wagonTypesService = { async getWagonTypes() { - const response = await api.get>('/wagon-types'); + const response = await api.get>('/wagon-types', { + params: { isActive: 'all' }, + }); return asList(response.data); }, + create: (data: Partial) => api.post('/wagon-types', data), + update: (id: string, data: Partial) => api.patch(`/wagon-types/${id}`, data), + delete: (id: string) => api.delete(`/wagon-types/${id}`), };