From f13861f02e177a8def54394d753910588a030ff7 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 16 Mar 2026 13:47:35 +0700 Subject: [PATCH] 260316:1347 Refactor to NestJS 11 #2 --- .../src/modules/contract/contract.service.ts | 50 ++- .../contract/entities/contract.entity.ts | 3 +- .../drawing/drawing-master-data.controller.ts | 158 ++----- .../drawing/drawing-master-data.service.ts | 96 ++-- .../src/modules/master/master.controller.ts | 170 ++----- backend/src/modules/master/master.service.ts | 119 +++-- .../project/entities/project.entity.ts | 3 +- .../admin/doc-control/contracts/page.tsx | 13 +- .../drawings/contract/categories/page.tsx | 4 +- .../drawings/contract/sub-categories/page.tsx | 4 +- .../drawings/contract/volumes/page.tsx | 4 +- .../drawings/shop/main-categories/page.tsx | 4 +- .../drawings/shop/sub-categories/page.tsx | 4 +- .../admin/doc-control/numbering/page.tsx | 36 +- .../reference/disciplines/page.tsx | 12 +- .../doc-control/reference/rfa-types/page.tsx | 12 +- .../admin/reference/generic-crud-table.tsx | 415 ++++++++++-------- 17 files changed, 544 insertions(+), 563 deletions(-) diff --git a/backend/src/modules/contract/contract.service.ts b/backend/src/modules/contract/contract.service.ts index 8637ff3..11d3dc8 100644 --- a/backend/src/modules/contract/contract.service.ts +++ b/backend/src/modules/contract/contract.service.ts @@ -3,20 +3,45 @@ import { NotFoundException, ConflictException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Like } from 'typeorm'; +import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm'; +import { Repository, Like, EntityManager } from 'typeorm'; import { Contract } from './entities/contract.entity'; import { CreateContractDto } from './dto/create-contract.dto.js'; import { UpdateContractDto } from './dto/update-contract.dto.js'; +import { Project } from '../project/entities/project.entity'; @Injectable() export class ContractService { constructor( @InjectRepository(Contract) - private readonly contractRepo: Repository + private readonly contractRepo: Repository, + @InjectEntityManager() + private readonly entityManager: EntityManager ) {} + /** + * Helper to resolve projectId (ID or UUID) to internal INT ID + */ + async resolveProjectId(projectId: number | string): Promise { + if (typeof projectId === 'number') return projectId; + const num = Number(projectId); + if (!isNaN(num)) return num; + + const project = await this.entityManager.findOne(Project, { + where: { uuid: projectId as string }, + select: ['id'], + }); + + if (!project) { + throw new NotFoundException(`Project with UUID ${projectId} not found`); + } + + return project.id; + } + async create(dto: CreateContractDto) { + const internalProjectId = await this.resolveProjectId(dto.projectId); + const existing = await this.contractRepo.findOne({ where: { contractCode: dto.contractCode }, }); @@ -25,7 +50,7 @@ export class ContractService { `Contract Code "${dto.contractCode}" already exists` ); } - const contract = this.contractRepo.create(dto); + const contract = this.contractRepo.create({ ...dto, projectId: internalProjectId }); return this.contractRepo.save(contract); } @@ -33,6 +58,11 @@ export class ContractService { const { search, projectId, page = 1, limit = 100 } = params || {}; const skip = (page - 1) * limit; + let internalProjectId = undefined; + if (projectId) { + internalProjectId = await this.resolveProjectId(projectId); + } + const findOptions: any = { relations: ['project'], order: { contractCode: 'ASC' }, @@ -47,21 +77,20 @@ export class ContractService { searchConditions.push({ contractName: Like(`%${search}%`) }); } - if (projectId) { - // Combine project filter with search if exists + if (internalProjectId) { if (searchConditions.length > 0) { findOptions.where = searchConditions.map((cond) => ({ ...cond, - projectId, + projectId: internalProjectId, })); } else { - findOptions.where = { projectId }; + findOptions.where = { projectId: internalProjectId }; } } else { if (searchConditions.length > 0) { findOptions.where = searchConditions; } else { - delete findOptions.where; // No filters + delete findOptions.where; } } @@ -99,6 +128,9 @@ export class ContractService { async update(uuid: string, dto: UpdateContractDto) { const contract = await this.findOneByUuid(uuid); + if (dto.projectId) { + dto.projectId = await this.resolveProjectId(dto.projectId); + } Object.assign(contract, dto); return this.contractRepo.save(contract); } diff --git a/backend/src/modules/contract/entities/contract.entity.ts b/backend/src/modules/contract/entities/contract.entity.ts index 9fcb162..dcc9d9d 100644 --- a/backend/src/modules/contract/entities/contract.entity.ts +++ b/backend/src/modules/contract/entities/contract.entity.ts @@ -7,7 +7,7 @@ import { BeforeInsert, } from 'typeorm'; import { v7 as uuidv7 } from 'uuid'; -import { Exclude } from 'class-transformer'; +import { Exclude, Expose } from 'class-transformer'; import { BaseEntity } from '../../../common/entities/base.entity'; import { Project } from '../../project/entities/project.entity'; @@ -17,6 +17,7 @@ export class Contract extends BaseEntity { @Exclude() id!: number; + @Expose({ name: 'id' }) @Column({ type: 'uuid', unique: true, diff --git a/backend/src/modules/drawing/drawing-master-data.controller.ts b/backend/src/modules/drawing/drawing-master-data.controller.ts index 1869d19..d6e7f0f 100644 --- a/backend/src/modules/drawing/drawing-master-data.controller.ts +++ b/backend/src/modules/drawing/drawing-master-data.controller.ts @@ -38,28 +38,16 @@ export class DrawingMasterDataController { @Get('contract/volumes') @ApiOperation({ summary: 'List Contract Drawing Volumes' }) - @ApiQuery({ name: 'projectId', required: true, type: Number }) + @ApiQuery({ name: 'projectId', required: true, type: String }) @RequirePermission('document.view') - getVolumes(@Query('projectId', ParseIntPipe) projectId: number) { - this.logger.log(`Fetching Contract Volumes for Project ID: ${projectId}`); + getVolumes(@Query('projectId') projectId: string | number) { return this.masterDataService.findAllVolumes(projectId); } - // ... (Create/Update/Delete methods remain unchanged) ... - @Post('contract/volumes') @ApiOperation({ summary: 'Create Volume' }) @RequirePermission('master_data.drawing_category.manage') - createVolume( - @Body() - body: { - projectId: number; - volumeCode: string; - volumeName: string; - description?: string; - sortOrder: number; - } - ) { + createVolume(@Body() body: any) { return this.masterDataService.createVolume(body); } @@ -68,13 +56,7 @@ export class DrawingMasterDataController { @RequirePermission('master_data.drawing_category.manage') updateVolume( @Param('id', ParseIntPipe) id: number, - @Body() - body: { - volumeCode?: string; - volumeName?: string; - description?: string; - sortOrder?: number; - } + @Body() body: any ) { return this.masterDataService.updateVolume(id, body); } @@ -92,30 +74,16 @@ export class DrawingMasterDataController { @Get('contract/categories') @ApiOperation({ summary: 'List Contract Drawing Categories' }) - @ApiQuery({ name: 'projectId', required: true, type: Number }) + @ApiQuery({ name: 'projectId', required: true, type: String }) @RequirePermission('document.view') - getCategories(@Query('projectId', ParseIntPipe) projectId: number) { - this.logger.log( - `Fetching Contract Categories for Project ID: ${projectId}` - ); + getCategories(@Query('projectId') projectId: string | number) { return this.masterDataService.findAllCategories(projectId); } - // ... (Create/Update/Delete methods remain unchanged) ... - @Post('contract/categories') @ApiOperation({ summary: 'Create Category' }) @RequirePermission('master_data.drawing_category.manage') - createCategory( - @Body() - body: { - projectId: number; - catCode: string; - catName: string; - description?: string; - sortOrder: number; - } - ) { + createCategory(@Body() body: any) { return this.masterDataService.createCategory(body); } @@ -124,13 +92,7 @@ export class DrawingMasterDataController { @RequirePermission('master_data.drawing_category.manage') updateCategory( @Param('id', ParseIntPipe) id: number, - @Body() - body: { - catCode?: string; - catName?: string; - description?: string; - sortOrder?: number; - } + @Body() body: any ) { return this.masterDataService.updateCategory(id, body); } @@ -148,30 +110,16 @@ export class DrawingMasterDataController { @Get('contract/sub-categories') @ApiOperation({ summary: 'List Contract Drawing Sub-Categories' }) - @ApiQuery({ name: 'projectId', required: true, type: Number }) + @ApiQuery({ name: 'projectId', required: true, type: String }) @RequirePermission('document.view') - getContractSubCats(@Query('projectId', ParseIntPipe) projectId: number) { - this.logger.log( - `Fetching Contract Sub-Categories for Project ID: ${projectId}` - ); + getContractSubCats(@Query('projectId') projectId: string | number) { return this.masterDataService.findAllContractSubCats(projectId); } - // ... (Create/Update/Delete methods remain unchanged) ... - @Post('contract/sub-categories') @ApiOperation({ summary: 'Create Contract Sub-Category' }) @RequirePermission('master_data.drawing_category.manage') - createContractSubCat( - @Body() - body: { - projectId: number; - subCatCode: string; - subCatName: string; - description?: string; - sortOrder: number; - } - ) { + createContractSubCat(@Body() body: any) { return this.masterDataService.createContractSubCat(body); } @@ -180,18 +128,15 @@ export class DrawingMasterDataController { @RequirePermission('master_data.drawing_category.manage') updateContractSubCat( @Param('id', ParseIntPipe) id: number, - @Body() - body: { - subCatCode?: string; - subCatName?: string; - description?: string; - sortOrder?: number; - } + @Body() body: any ) { return this.masterDataService.updateContractSubCat(id, body); } - async deleteContractSubCat(@Param('id', ParseIntPipe) id: number) { + @Delete('contract/sub-categories/:id') + @ApiOperation({ summary: 'Delete Contract Sub-Category' }) + @RequirePermission('master_data.drawing_category.manage') + deleteContractSubCat(@Param('id', ParseIntPipe) id: number) { return this.masterDataService.deleteContractSubCat(id); } @@ -201,11 +146,11 @@ export class DrawingMasterDataController { @Get('contract/mappings') @ApiOperation({ summary: 'List Contract Drawing Mappings' }) - @ApiQuery({ name: 'projectId', required: true, type: Number }) + @ApiQuery({ name: 'projectId', required: true, type: String }) @ApiQuery({ name: 'categoryId', required: false, type: Number }) @RequirePermission('document.view') getContractMappings( - @Query('projectId', ParseIntPipe) projectId: number, + @Query('projectId') projectId: string | number, @Query('categoryId') categoryId?: number ) { return this.masterDataService.findContractMappings( @@ -217,14 +162,7 @@ export class DrawingMasterDataController { @Post('contract/mappings') @ApiOperation({ summary: 'Create Contract Drawing Mapping' }) @RequirePermission('master_data.drawing_category.manage') - createContractMapping( - @Body() - body: { - projectId: number; - categoryId: number; - subCategoryId: number; - } - ) { + createContractMapping(@Body() body: any) { return this.masterDataService.createContractMapping(body); } @@ -241,31 +179,16 @@ export class DrawingMasterDataController { @Get('shop/main-categories') @ApiOperation({ summary: 'List Shop Drawing Main Categories' }) - @ApiQuery({ name: 'projectId', required: true, type: Number }) + @ApiQuery({ name: 'projectId', required: true, type: String }) @RequirePermission('document.view') - getShopMainCats(@Query('projectId', ParseIntPipe) projectId: number) { - this.logger.log( - `Fetching Shop Main Categories for Project ID: ${projectId}` - ); + getShopMainCats(@Query('projectId') projectId: string | number) { return this.masterDataService.findAllShopMainCats(projectId); } - // ... (Create/Update/Delete methods remain unchanged) ... - @Post('shop/main-categories') @ApiOperation({ summary: 'Create Shop Main Category' }) @RequirePermission('master_data.drawing_category.manage') - createShopMainCat( - @Body() - body: { - projectId: number; - mainCategoryCode: string; - mainCategoryName: string; - description?: string; - isActive?: boolean; - sortOrder: number; - } - ) { + createShopMainCat(@Body() body: any) { return this.masterDataService.createShopMainCat(body); } @@ -274,14 +197,7 @@ export class DrawingMasterDataController { @RequirePermission('master_data.drawing_category.manage') updateShopMainCat( @Param('id', ParseIntPipe) id: number, - @Body() - body: { - mainCategoryCode?: string; - mainCategoryName?: string; - description?: string; - isActive?: boolean; - sortOrder?: number; - } + @Body() body: any ) { return this.masterDataService.updateShopMainCat(id, body); } @@ -299,16 +215,13 @@ export class DrawingMasterDataController { @Get('shop/sub-categories') @ApiOperation({ summary: 'List Shop Drawing Sub-Categories' }) - @ApiQuery({ name: 'projectId', required: true, type: Number }) + @ApiQuery({ name: 'projectId', required: true, type: String }) @ApiQuery({ name: 'mainCategoryId', required: false, type: Number }) @RequirePermission('document.view') getShopSubCats( - @Query('projectId', ParseIntPipe) projectId: number, + @Query('projectId') projectId: string | number, @Query('mainCategoryId') mainCategoryId?: number ) { - this.logger.log( - `Fetching Shop Sub-Categories for Project ID: ${projectId}, MainCategory: ${mainCategoryId}` - ); return this.masterDataService.findAllShopSubCats( projectId, mainCategoryId ? Number(mainCategoryId) : undefined @@ -318,17 +231,7 @@ export class DrawingMasterDataController { @Post('shop/sub-categories') @ApiOperation({ summary: 'Create Shop Sub-Category' }) @RequirePermission('master_data.drawing_category.manage') - createShopSubCat( - @Body() - body: { - projectId: number; - subCategoryCode: string; - subCategoryName: string; - description?: string; - isActive?: boolean; - sortOrder: number; - } - ) { + createShopSubCat(@Body() body: any) { return this.masterDataService.createShopSubCat(body); } @@ -337,14 +240,7 @@ export class DrawingMasterDataController { @RequirePermission('master_data.drawing_category.manage') updateShopSubCat( @Param('id', ParseIntPipe) id: number, - @Body() - body: { - subCategoryCode?: string; - subCategoryName?: string; - description?: string; - isActive?: boolean; - sortOrder?: number; - } + @Body() body: any ) { return this.masterDataService.updateShopSubCat(id, body); } diff --git a/backend/src/modules/drawing/drawing-master-data.service.ts b/backend/src/modules/drawing/drawing-master-data.service.ts index 4a7e488..cb8a9e0 100644 --- a/backend/src/modules/drawing/drawing-master-data.service.ts +++ b/backend/src/modules/drawing/drawing-master-data.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, FindOptionsWhere } from 'typeorm'; +import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere, EntityManager } from 'typeorm'; // Entities import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity'; @@ -9,6 +9,7 @@ import { ContractDrawingSubCategory } from './entities/contract-drawing-sub-cate import { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity'; import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity'; import { ContractDrawingSubcatCatMap } from './entities/contract-drawing-subcat-cat-map.entity'; +import { Project } from '../project/entities/project.entity'; @Injectable() export class DrawingMasterDataService { @@ -24,22 +25,47 @@ export class DrawingMasterDataService { @InjectRepository(ShopDrawingSubCategory) private sdSubCatRepo: Repository, @InjectRepository(ContractDrawingSubcatCatMap) - private cdMapRepo: Repository + private cdMapRepo: Repository, + @InjectEntityManager() + private entityManager: EntityManager ) {} + /** + * Helper to resolve projectId (ID or UUID) to internal INT ID + */ + async resolveProjectId(projectId: number | string): Promise { + if (typeof projectId === 'number') return projectId; + const num = Number(projectId); + if (!isNaN(num)) return num; + + // If it's a string and not a number, it's a UUID (ADR-019) + const project = await this.entityManager.findOne(Project, { + where: { uuid: projectId as string }, + select: ['id'], + }); + + if (!project) { + throw new NotFoundException(`Project with UUID ${projectId} not found`); + } + + return project.id; + } + // ===================================================== // Contract Drawing Volumes // ===================================================== - async findAllVolumes(projectId: number) { + async findAllVolumes(projectId: number | string) { + const internalId = await this.resolveProjectId(projectId); return this.cdVolumeRepo.find({ - where: { projectId }, + where: { projectId: internalId }, order: { sortOrder: 'ASC' }, }); } - async createVolume(data: Partial) { - const volume = this.cdVolumeRepo.create(data); + async createVolume(data: any) { + const internalId = await this.resolveProjectId(data.projectId); + const volume = this.cdVolumeRepo.create({ ...data, projectId: internalId }); return this.cdVolumeRepo.save(volume); } @@ -61,15 +87,17 @@ export class DrawingMasterDataService { // Contract Drawing Categories // ===================================================== - async findAllCategories(projectId: number) { + async findAllCategories(projectId: number | string) { + const internalId = await this.resolveProjectId(projectId); return this.cdCatRepo.find({ - where: { projectId }, + where: { projectId: internalId }, order: { sortOrder: 'ASC' }, }); } - async createCategory(data: Partial) { - const cat = this.cdCatRepo.create(data); + async createCategory(data: any) { + const internalId = await this.resolveProjectId(data.projectId); + const cat = this.cdCatRepo.create({ ...data, projectId: internalId }); return this.cdCatRepo.save(cat); } @@ -91,15 +119,17 @@ export class DrawingMasterDataService { // Contract Drawing Sub-Categories // ===================================================== - async findAllContractSubCats(projectId: number) { + async findAllContractSubCats(projectId: number | string) { + const internalId = await this.resolveProjectId(projectId); return this.cdSubCatRepo.find({ - where: { projectId }, + where: { projectId: internalId }, order: { sortOrder: 'ASC' }, }); } - async createContractSubCat(data: Partial) { - const subCat = this.cdSubCatRepo.create(data); + async createContractSubCat(data: any) { + const internalId = await this.resolveProjectId(data.projectId); + const subCat = this.cdSubCatRepo.create({ ...data, projectId: internalId }); return this.cdSubCatRepo.save(subCat); } @@ -124,8 +154,9 @@ export class DrawingMasterDataService { // Contract Drawing Mappings (Category <-> Sub-Category) // ===================================================== - async findContractMappings(projectId: number, categoryId?: number) { - const where: FindOptionsWhere = { projectId }; + async findContractMappings(projectId: number | string, categoryId?: number) { + const internalId = await this.resolveProjectId(projectId); + const where: FindOptionsWhere = { projectId: internalId }; if (categoryId) { where.categoryId = categoryId; } @@ -136,15 +167,12 @@ export class DrawingMasterDataService { }); } - async createContractMapping(data: { - projectId: number; - categoryId: number; - subCategoryId: number; - }) { + async createContractMapping(data: any) { + const internalId = await this.resolveProjectId(data.projectId); // Check if mapping already exists to prevent duplicates (though DB has UNIQUE constraint) const existing = await this.cdMapRepo.findOne({ where: { - projectId: data.projectId, + projectId: internalId, categoryId: data.categoryId, subCategoryId: data.subCategoryId, }, @@ -152,7 +180,7 @@ export class DrawingMasterDataService { if (existing) return existing; - const map = this.cdMapRepo.create(data); + const map = this.cdMapRepo.create({ ...data, projectId: internalId }); return this.cdMapRepo.save(map); } @@ -167,15 +195,17 @@ export class DrawingMasterDataService { // Shop Drawing Main Categories // ===================================================== - async findAllShopMainCats(projectId: number) { + async findAllShopMainCats(projectId: number | string) { + const internalId = await this.resolveProjectId(projectId); return this.sdMainCatRepo.find({ - where: { projectId }, + where: { projectId: internalId }, order: { sortOrder: 'ASC' }, }); } - async createShopMainCat(data: Partial) { - const cat = this.sdMainCatRepo.create(data); + async createShopMainCat(data: any) { + const internalId = await this.resolveProjectId(data.projectId); + const cat = this.sdMainCatRepo.create({ ...data, projectId: internalId }); return this.sdMainCatRepo.save(cat); } @@ -197,9 +227,10 @@ export class DrawingMasterDataService { // Shop Drawing Sub-Categories // ===================================================== - async findAllShopSubCats(projectId: number, mainCategoryId?: number) { + async findAllShopSubCats(projectId: number | string, mainCategoryId?: number) { + const internalId = await this.resolveProjectId(projectId); const where: FindOptionsWhere = { - projectId, + projectId: internalId, ...(mainCategoryId ? { mainCategoryId } : {}), }; @@ -209,8 +240,9 @@ export class DrawingMasterDataService { }); } - async createShopSubCat(data: Partial) { - const subCat = this.sdSubCatRepo.create(data); + async createShopSubCat(data: any) { + const internalId = await this.resolveProjectId(data.projectId); + const subCat = this.sdSubCatRepo.create({ ...data, projectId: internalId }); return this.sdSubCatRepo.save(subCat); } diff --git a/backend/src/modules/master/master.controller.ts b/backend/src/modules/master/master.controller.ts index f582343..15b03ee 100644 --- a/backend/src/modules/master/master.controller.ts +++ b/backend/src/modules/master/master.controller.ts @@ -13,151 +13,78 @@ import { UseGuards, ParseIntPipe, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { MasterService } from './master.service'; - -// DTOs (สมมติว่ามีการสร้างไฟล์เหล่านี้แล้วตามแผนงาน) -import { CreateTagDto } from './dto/create-tag.dto'; -import { UpdateTagDto } from './dto/update-tag.dto'; -import { SearchTagDto } from './dto/search-tag.dto'; -import { CreateDisciplineDto } from './dto/create-discipline.dto'; // [New] -import { CreateSubTypeDto } from './dto/create-sub-type.dto'; // [New] -import { SaveNumberFormatDto } from './dto/save-number-format.dto'; // [New] - import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { RbacGuard } from '../../common/guards/rbac.guard'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +// Import DTOs +import { CreateTagDto } from './dto/create-tag.dto'; +import { UpdateTagDto } from './dto/update-tag.dto'; +import { SearchTagDto } from './dto/search-tag.dto'; + @ApiTags('Master Data') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RbacGuard) @Controller('master') -@UseGuards(JwtAuthGuard) export class MasterController { constructor(private readonly masterService: MasterService) {} - // ================================================================= - // 📦 Common Dropdowns (Read-Only) - // ================================================================= - + // --- Correspondence Types --- @Get('correspondence-types') - @ApiOperation({ summary: 'Get all active correspondence types' }) - getCorrespondenceTypes() { + @ApiOperation({ summary: 'Get all correspondence types' }) + findAllCorrespondenceTypes() { return this.masterService.findAllCorrespondenceTypes(); } @Post('correspondence-types') @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Create Correspondence Type' }) createCorrespondenceType(@Body() dto: any) { return this.masterService.createCorrespondenceType(dto); } - @Patch('correspondence-types/:id') - @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Update Correspondence Type' }) - updateCorrespondenceType( - @Param('id', ParseIntPipe) id: number, - @Body() dto: any - ) { - return this.masterService.updateCorrespondenceType(id, dto); - } - - @Delete('correspondence-types/:id') - @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Delete Correspondence Type' }) - deleteCorrespondenceType(@Param('id', ParseIntPipe) id: number) { - return this.masterService.deleteCorrespondenceType(id); - } - - @Get('correspondence-statuses') - @ApiOperation({ summary: 'Get all active correspondence statuses' }) - getCorrespondenceStatuses() { - return this.masterService.findAllCorrespondenceStatuses(); - } - + // --- RFA Types --- @Get('rfa-types') - @ApiOperation({ summary: 'Get all active RFA types' }) - @ApiQuery({ name: 'contractId', required: false, type: Number }) - getRfaTypes(@Query('contractId') contractId?: number) { + @ApiOperation({ summary: 'Get all RFA types' }) + @ApiQuery({ name: 'contractId', required: false, type: String }) + findAllRfaTypes(@Query('contractId') contractId?: string | number) { return this.masterService.findAllRfaTypes(contractId); } @Post('rfa-types') @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Create RFA Type' }) createRfaType(@Body() dto: any) { - // Note: Should use proper DTO. Delegating to service. - // Need to add createRfaType to MasterService or RfaService? - // Given the context, MasterService seems appropriate for "Reference Data". return this.masterService.createRfaType(dto); } - @Patch('rfa-types/:id') - @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Update RFA Type' }) - updateRfaType(@Param('id', ParseIntPipe) id: number, @Body() dto: any) { - return this.masterService.updateRfaType(id, dto); - } - - @Delete('rfa-types/:id') - @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Delete RFA Type' }) - deleteRfaType(@Param('id', ParseIntPipe) id: number) { - return this.masterService.deleteRfaType(id); - } - - @Get('rfa-statuses') - @ApiOperation({ summary: 'Get all active RFA status codes' }) - getRfaStatuses() { - return this.masterService.findAllRfaStatuses(); - } - - @Get('rfa-approve-codes') - @ApiOperation({ summary: 'Get all active RFA approve codes' }) - getRfaApproveCodes() { - return this.masterService.findAllRfaApproveCodes(); - } - - @Get('circulation-statuses') - @ApiOperation({ summary: 'Get all active circulation statuses' }) - getCirculationStatuses() { - return this.masterService.findAllCirculationStatuses(); - } - - // ================================================================= - // 🏗️ Disciplines Management (Req 6B) - // ================================================================= - + // --- Disciplines --- @Get('disciplines') - @ApiOperation({ summary: 'Get disciplines (filter by contract optional)' }) - @ApiQuery({ name: 'contractId', required: false, type: Number }) - getDisciplines(@Query('contractId') contractId?: number) { + @ApiOperation({ summary: 'Get all disciplines' }) + @ApiQuery({ name: 'contractId', required: false, type: String }) + findAllDisciplines(@Query('contractId') contractId?: string | number) { return this.masterService.findAllDisciplines(contractId); } @Post('disciplines') - @RequirePermission('master_data.manage') // สิทธิ์ Admin - @ApiOperation({ summary: 'Create a new discipline' }) - createDiscipline(@Body() dto: CreateDisciplineDto) { + @RequirePermission('master_data.manage') + createDiscipline(@Body() dto: any) { return this.masterService.createDiscipline(dto); } @Delete('disciplines/:id') @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Delete a discipline' }) deleteDiscipline(@Param('id', ParseIntPipe) id: number) { return this.masterService.deleteDiscipline(id); } - // ================================================================= - // 📑 Correspondence Sub-Types (Req 6B) - // ================================================================= - + // --- Sub Types --- @Get('sub-types') - @ApiOperation({ summary: 'Get sub-types (filter by contract/type optional)' }) - @ApiQuery({ name: 'contractId', required: false, type: Number }) - @ApiQuery({ name: 'typeId', required: false, type: Number }) - getSubTypes( - @Query('contractId') contractId?: number, + @ApiOperation({ summary: 'Get all sub-types' }) + @ApiQuery({ name: 'contractId', required: false, type: String }) + findAllSubTypes( + @Query('contractId') contractId?: string | number, @Query('typeId') typeId?: number ) { return this.masterService.findAllSubTypes(contractId, typeId); @@ -165,62 +92,43 @@ export class MasterController { @Post('sub-types') @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Create/Map a new sub-type' }) - createSubType(@Body() dto: CreateSubTypeDto) { + createSubType(@Body() dto: any) { return this.masterService.createSubType(dto); } - @Delete('sub-types/:id') - @RequirePermission('master_data.manage') - @ApiOperation({ summary: 'Delete a sub-type' }) - deleteSubType(@Param('id', ParseIntPipe) id: number) { - return this.masterService.deleteSubType(id); - } - - // ================================================================= - // 🔢 Numbering Formats (Admin Config) - // ================================================================= - + // --- Numbering Formats --- @Get('numbering-formats') - @RequirePermission('master_data.manage') // ข้อมูล config ควรสงวนสิทธิ์ - @ApiOperation({ summary: 'Get numbering format for specific project/type' }) - getNumberFormat( - @Query('projectId', ParseIntPipe) projectId: number, + @ApiOperation({ summary: 'Get numbering format for project/type' }) + findNumberFormat( + @Query('projectId') projectId: string | number, @Query('typeId', ParseIntPipe) typeId: number ) { return this.masterService.findNumberFormat(projectId, typeId); } @Post('numbering-formats') - @RequirePermission('system.manage_all') // เฉพาะ Superadmin/System Admin - @ApiOperation({ summary: 'Save or Update numbering format template' }) - saveNumberFormat(@Body() dto: SaveNumberFormatDto) { + @RequirePermission('master_data.manage') + saveNumberFormat(@Body() dto: any) { return this.masterService.saveNumberFormat(dto); } - // ================================================================= - // 🏷️ Tag Management - // ================================================================= - + // --- Tags --- @Get('tags') - @ApiOperation({ summary: 'Get all tags (supports search & pagination)' }) - getTags(@Query() query: SearchTagDto) { + @ApiOperation({ summary: 'Get all tags' }) + findAllTags(@Query() query: SearchTagDto) { return this.masterService.findAllTags(query); } @Get('tags/:id') @ApiOperation({ summary: 'Get a tag by ID' }) - getTagById(@Param('id', ParseIntPipe) id: number) { + findOneTag(@Param('id', ParseIntPipe) id: number) { return this.masterService.findOneTag(id); } @Post('tags') @RequirePermission('master_data.tag.manage') @ApiOperation({ summary: 'Create a new tag' }) - createTag( - @CurrentUser() user: { userId: number }, - @Body() dto: CreateTagDto - ) { + createTag(@Body() dto: CreateTagDto, @CurrentUser() user: any) { return this.masterService.createTag(dto, user.userId); } diff --git a/backend/src/modules/master/master.service.ts b/backend/src/modules/master/master.service.ts index 45890d4..d42b2a1 100644 --- a/backend/src/modules/master/master.service.ts +++ b/backend/src/modules/master/master.service.ts @@ -5,8 +5,8 @@ import { NotFoundException, ConflictException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm'; +import { Repository, EntityManager } from 'typeorm'; // Import Entities import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; @@ -21,6 +21,8 @@ import { Tag } from './entities/tag.entity'; import { Discipline } from './entities/discipline.entity'; import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity'; import { DocumentNumberFormat } from '../document-numbering/entities/document-number-format.entity'; +import { Project } from '../project/entities/project.entity'; +import { Contract } from '../contract/entities/contract.entity'; // Import DTOs import { CreateTagDto } from './dto/create-tag.dto'; @@ -54,12 +56,41 @@ export class MasterService { @InjectRepository(CorrespondenceSubType) private readonly subTypeRepo: Repository, @InjectRepository(DocumentNumberFormat) - private readonly formatRepo: Repository + private readonly formatRepo: Repository, + + @InjectEntityManager() + private readonly entityManager: EntityManager ) {} - // ... (Method เดิม: findAllCorrespondenceTypes, findAllCorrespondenceStatuses, ฯลฯ เก็บไว้เหมือนเดิม) ... - // หมายเหตุ: ตรวจสอบว่า Entity ใช้ชื่อ property ว่า isActive หรือ is_active (ใน SQL เป็น is_active แต่ใน Entity มักเป็น isActive) - // โค้ดเดิมใช้ `where: { isActive: true }` ซึ่งถูกต้องถ้า Entity map column name แล้ว + /** + * Helper to resolve projectId (ID or UUID) to internal INT ID + */ + async resolveProjectId(projectId: number | string): Promise { + if (typeof projectId === 'number') return projectId; + const num = Number(projectId); + if (!isNaN(num)) return num; + const project = await this.entityManager.findOne(Project, { + where: { uuid: projectId as string }, + select: ['id'], + }); + if (!project) throw new NotFoundException(`Project with UUID ${projectId} not found`); + return project.id; + } + + /** + * Helper to resolve contractId (ID or UUID) to internal INT ID + */ + async resolveContractId(contractId: number | string): Promise { + if (typeof contractId === 'number') return contractId; + const num = Number(contractId); + if (!isNaN(num)) return num; + const contract = await this.entityManager.findOne(Contract, { + where: { uuid: contractId as string }, + select: ['id'], + }); + if (!contract) throw new NotFoundException(`Contract with UUID ${contractId} not found`); + return contract.id; + } async findAllCorrespondenceTypes() { return this.corrTypeRepo.find({ @@ -92,27 +123,29 @@ export class MasterService { order: { sortOrder: 'ASC' }, }); } - async findAllRfaTypes(contractId?: number) { + async findAllRfaTypes(contractId?: number | string) { const where: any = { isActive: true }; if (contractId) { - where.contractId = contractId; + where.contractId = await this.resolveContractId(contractId); } return this.rfaTypeRepo.find({ where, order: { typeCode: 'ASC' }, - relations: contractId ? [] : [], // Add relations if needed later }); } async createRfaType(dto: any) { - // Validate unique code if needed - const rfaType = this.rfaTypeRepo.create(dto); + const internalContractId = await this.resolveContractId(dto.contractId); + const rfaType = this.rfaTypeRepo.create({ ...dto, contractId: internalContractId }); return this.rfaTypeRepo.save(rfaType); } async updateRfaType(id: number, dto: any) { const rfaType = await this.rfaTypeRepo.findOne({ where: { id } }); if (!rfaType) throw new NotFoundException('RFA Type not found'); + if (dto.contractId) { + dto.contractId = await this.resolveContractId(dto.contractId); + } Object.assign(rfaType, dto); return this.rfaTypeRepo.save(rfaType); } @@ -146,31 +179,32 @@ export class MasterService { // 🏗️ Disciplines Logic // ================================================================= - async findAllDisciplines(contractId?: number) { + async findAllDisciplines(contractId?: number | string) { const query = this.disciplineRepo .createQueryBuilder('d') .leftJoinAndSelect('d.contract', 'c') .orderBy('d.disciplineCode', 'ASC'); if (contractId) { - query.where('d.contractId = :contractId', { contractId }); + const internalId = await this.resolveContractId(contractId); + query.where('d.contractId = :contractId', { contractId: internalId }); } - // เพิ่มเงื่อนไข Active หากต้องการ query.andWhere('d.isActive = :isActive', { isActive: true }); return query.getMany(); } - async createDiscipline(dto: CreateDisciplineDto) { + async createDiscipline(dto: any) { + const internalContractId = await this.resolveContractId(dto.contractId); const exists = await this.disciplineRepo.findOne({ - where: { contractId: dto.contractId, disciplineCode: dto.disciplineCode }, + where: { contractId: internalContractId, disciplineCode: dto.disciplineCode }, }); if (exists) throw new ConflictException( 'Discipline code already exists in this contract' ); - const discipline = this.disciplineRepo.create(dto); + const discipline = this.disciplineRepo.create({ ...dto, contractId: internalContractId }); return this.disciplineRepo.save(discipline); } @@ -185,23 +219,25 @@ export class MasterService { // 📑 Sub-Types Logic // ================================================================= - async findAllSubTypes(contractId?: number, typeId?: number) { + async findAllSubTypes(contractId?: number | string, typeId?: number) { const query = this.subTypeRepo .createQueryBuilder('st') .leftJoinAndSelect('st.contract', 'c') .leftJoinAndSelect('st.correspondenceType', 'ct') .orderBy('st.subTypeCode', 'ASC'); - if (contractId) - query.andWhere('st.contractId = :contractId', { contractId }); + if (contractId) { + const internalId = await this.resolveContractId(contractId); + query.andWhere('st.contractId = :contractId', { contractId: internalId }); + } if (typeId) query.andWhere('st.correspondenceTypeId = :typeId', { typeId }); return query.getMany(); } - async createSubType(dto: CreateSubTypeDto) { - // อาจจะเช็ค Duplicate code ด้วย logic คล้าย discipline - const subType = this.subTypeRepo.create(dto); + async createSubType(dto: any) { + const internalContractId = await this.resolveContractId(dto.contractId); + const subType = this.subTypeRepo.create({ ...dto, contractId: internalContractId }); return this.subTypeRepo.save(subType); } @@ -216,47 +252,43 @@ export class MasterService { // 🔢 Numbering Formats Logic // ================================================================= - async findNumberFormat(projectId: number, typeId: number) { + async findNumberFormat(projectId: number | string, typeId: number) { + const internalId = await this.resolveProjectId(projectId); const format = await this.formatRepo.findOne({ - where: { projectId, correspondenceTypeId: typeId }, + where: { projectId: internalId, correspondenceTypeId: typeId }, }); - if (!format) { - // Optional: Return default format structure or null - return null; - } - return format; + return format || null; } - async saveNumberFormat(dto: SaveNumberFormatDto) { - // Check if exists (Upsert) + async saveNumberFormat(dto: any) { + const internalProjectId = await this.resolveProjectId(dto.projectId); let format = await this.formatRepo.findOne({ where: { - projectId: dto.projectId, + projectId: internalProjectId, correspondenceTypeId: dto.correspondenceTypeId, }, }); if (format) { format.formatTemplate = dto.formatTemplate; - // format.updatedBy = ... (ถ้ามี) } else { format = this.formatRepo.create({ - projectId: dto.projectId, - correspondenceTypeId: dto.correspondenceTypeId, - formatTemplate: dto.formatTemplate, + ...dto, + projectId: internalProjectId, }); } return this.formatRepo.save(format); } - // ... (Tag Logic เดิม คงไว้ตามปกติ) ... async findAllTags(query?: SearchTagDto) { const qb = this.tagRepo.createQueryBuilder('tag'); if (query?.project_id) { + // In Tags, we use project_id (INT) directly or resolve if UUID passed via query + const internalId = await this.resolveProjectId(query.project_id); qb.andWhere('tag.project_id = :projectId', { - projectId: query.project_id, + projectId: internalId, }); } @@ -288,16 +320,21 @@ export class MasterService { return tag; } - async createTag(dto: CreateTagDto, userId: number) { + async createTag(dto: any, userId: number) { + const internalProjectId = dto.project_id ? await this.resolveProjectId(dto.project_id) : null; const tag = this.tagRepo.create({ ...dto, + project_id: internalProjectId, created_by: userId, }); return this.tagRepo.save(tag); } - async updateTag(id: number, dto: UpdateTagDto) { + async updateTag(id: number, dto: any) { const tag = await this.findOneTag(id); + if (dto.project_id) { + dto.project_id = await this.resolveProjectId(dto.project_id); + } Object.assign(tag, dto); return this.tagRepo.save(tag); } diff --git a/backend/src/modules/project/entities/project.entity.ts b/backend/src/modules/project/entities/project.entity.ts index 7a9beb6..216cde4 100644 --- a/backend/src/modules/project/entities/project.entity.ts +++ b/backend/src/modules/project/entities/project.entity.ts @@ -6,7 +6,7 @@ import { BeforeInsert, } from 'typeorm'; import { v7 as uuidv7 } from 'uuid'; -import { Exclude } from 'class-transformer'; +import { Exclude, Expose } from 'class-transformer'; import { BaseEntity } from '../../../common/entities/base.entity'; import { Contract } from '../../contract/entities/contract.entity'; @@ -16,6 +16,7 @@ export class Project extends BaseEntity { @Exclude() id!: number; + @Expose({ name: 'id' }) @Column({ type: 'uuid', unique: true, diff --git a/frontend/app/(admin)/admin/doc-control/contracts/page.tsx b/frontend/app/(admin)/admin/doc-control/contracts/page.tsx index 01c453e..1c4192a 100644 --- a/frontend/app/(admin)/admin/doc-control/contracts/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/contracts/page.tsx @@ -214,10 +214,12 @@ export default function ContractsPage() { const handleEdit = (contract: Contract) => { setEditingUuid(contract.uuid); + // ADR-019: projectId might be a number or a UUID string from the entity response + const pId = String((contract as any).id || (contract as any).projectId || ""); reset({ contractCode: contract.contractCode, contractName: contract.contractName, - projectId: contract.projectId?.toString() || "", + projectId: pId, description: contract.description || "", startDate: contract.startDate ? new Date(contract.startDate).toISOString().split('T')[0] : "", endDate: contract.endDate ? new Date(contract.endDate).toISOString().split('T')[0] : "", @@ -239,10 +241,11 @@ export default function ContractsPage() { }; const onSubmit = (data: ContractFormData) => { + // ADR-019: Resolve projectId (ID or UUID) const submitData = { ...data, - projectId: parseInt(data.projectId), - }; + projectId: isNaN(Number(data.projectId)) ? data.projectId : Number(data.projectId), + } as any; if (editingUuid) { updateContract.mutate({ uuid: editingUuid, data: submitData }); @@ -304,8 +307,8 @@ export default function ContractsPage() { - {(projects as Project[])?.map((p) => ( - + {(projects as any[])?.map((p) => ( + {p.projectCode} - {p.projectName} ))} diff --git a/frontend/app/(admin)/admin/doc-control/drawings/contract/categories/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/contract/categories/page.tsx index c8dd397..7a4126a 100644 --- a/frontend/app/(admin)/admin/doc-control/drawings/contract/categories/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/drawings/contract/categories/page.tsx @@ -70,8 +70,8 @@ export default function ContractCategoriesPage() { )} - {projects.map((project: { id: number; projectName: string; projectCode: string }) => ( - + {(projects as any[]).map((project) => ( + {project.projectCode} - {project.projectName} ))} diff --git a/frontend/app/(admin)/admin/doc-control/drawings/contract/sub-categories/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/contract/sub-categories/page.tsx index 03101ff..f5e7bfc 100644 --- a/frontend/app/(admin)/admin/doc-control/drawings/contract/sub-categories/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/drawings/contract/sub-categories/page.tsx @@ -62,8 +62,8 @@ export default function ContractSubCategoriesPage() { )} - {projects.map((project: { id: number; projectName: string; projectCode: string }) => ( - + {(projects as any[]).map((project) => ( + {project.projectCode} - {project.projectName} ))} diff --git a/frontend/app/(admin)/admin/doc-control/drawings/contract/volumes/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/contract/volumes/page.tsx index f8327a4..7d827c4 100644 --- a/frontend/app/(admin)/admin/doc-control/drawings/contract/volumes/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/drawings/contract/volumes/page.tsx @@ -74,8 +74,8 @@ export default function ContractVolumesPage() { )} - {projects.map((project: { id: number; projectName: string; projectCode: string }) => ( - + {(projects as any[]).map((project) => ( + {project.projectCode} - {project.projectName} ))} diff --git a/frontend/app/(admin)/admin/doc-control/drawings/shop/main-categories/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/shop/main-categories/page.tsx index 0066883..7dc30d3 100644 --- a/frontend/app/(admin)/admin/doc-control/drawings/shop/main-categories/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/drawings/shop/main-categories/page.tsx @@ -73,8 +73,8 @@ export default function ShopMainCategoriesPage() { )} - {projects.map((project: { id: number; projectName: string; projectCode: string }) => ( - + {(projects as any[]).map((project) => ( + {project.projectCode} - {project.projectName} ))} diff --git a/frontend/app/(admin)/admin/doc-control/drawings/shop/sub-categories/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/shop/sub-categories/page.tsx index 8f93a16..f082ce6 100644 --- a/frontend/app/(admin)/admin/doc-control/drawings/shop/sub-categories/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/drawings/shop/sub-categories/page.tsx @@ -75,8 +75,8 @@ export default function ShopSubCategoriesPage() { )} - {projects.map((project: { id: number; projectName: string; projectCode: string }) => ( - + {(projects as any[]).map((project) => ( + {project.projectCode} - {project.projectName} ))} diff --git a/frontend/app/(admin)/admin/doc-control/numbering/page.tsx b/frontend/app/(admin)/admin/doc-control/numbering/page.tsx index 8f7d8be..a1fb090 100644 --- a/frontend/app/(admin)/admin/doc-control/numbering/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/numbering/page.tsx @@ -24,29 +24,37 @@ import { BulkImportForm } from '@/components/numbering/bulk-import-form'; export default function NumberingPage() { const { data: projects = [] } = useProjects(); - const [selectedProjectId, setSelectedProjectId] = useState('1'); + // Initialize with empty string or first project if available + const [selectedProjectId, setSelectedProjectId] = useState(''); const [activeTab, setActiveTab] = useState('templates'); + useEffect(() => { + if (projects.length > 0 && !selectedProjectId) { + const first = projects[0] as any; + setSelectedProjectId(String(first.id || first.uuid)); + } + }, [projects, selectedProjectId]); + // View states const [isEditing, setIsEditing] = useState(false); const [activeTemplate, setActiveTemplate] = useState(undefined); const [isTesting, setIsTesting] = useState(false); const [testTemplate, setTestTemplate] = useState(null); - const selectedProjectName = - projects.find((p: { id: number; projectName: string }) => p.id.toString() === selectedProjectId)?.projectName || - 'Unknown Project'; + const selectedProject = projects.find((p: any) => String(p.id || p.uuid) === selectedProjectId) as any; + const selectedProjectName = selectedProject?.projectName || 'Unknown Project'; // Master Data const { data: correspondenceTypes = [] } = useCorrespondenceTypes(); - const { data: contracts = [] } = useContracts(Number(selectedProjectId)); - const contractId = contracts[0]?.id; + const { data: contracts = [] } = useContracts(selectedProjectId as any); // Passing UUID/ID string + const firstContract = contracts[0] as any; + const contractId = firstContract?.id || firstContract?.uuid; const { data: disciplines = [] } = useDisciplines(contractId); const { data: templateResponse, isLoading: isLoadingTemplates } = useTemplates(); const saveTemplateMutation = useSaveTemplate(); - // Extract templates array from response (handles both direct array and { data: array } formats) + // Extract templates array from response const templates: NumberingTemplate[] = Array.isArray(templateResponse) ? templateResponse : ((templateResponse as any)?.data ?? []); @@ -76,7 +84,7 @@ export default function NumberingPage() {
- {projects.map((project: { id: number; projectCode: string; projectName: string }) => ( - + {(projects as any[]).map((project) => ( + {project.projectCode} - {project.projectName} ))} @@ -129,7 +137,7 @@ export default function NumberingPage() {
{templates - .filter((t) => !t.projectId || t.projectId === Number(selectedProjectId)) + .filter((t: any) => !t.projectId || String(t.projectId) === selectedProjectId || t.project?.uuid === selectedProjectId) .map((template) => (
@@ -194,11 +202,11 @@ export default function NumberingPage() {
- - + +
- +
diff --git a/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx index 3a27bb8..270b35b 100644 --- a/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx @@ -43,9 +43,9 @@ export default function DisciplinesPage() { }, ]; - const contractOptions = contracts.map((c) => ({ + const contractOptions = contracts.map((c: any) => ({ label: `${c.contractName} (${c.contractCode})`, - value: c.id, + value: String(c.id || c.uuid), })); return ( @@ -55,8 +55,8 @@ export default function DisciplinesPage() { title="Disciplines Management" description="Manage system disciplines (e.g., ARCH, STR, MEC)" queryKey={['disciplines', selectedContractId ?? 'all']} - fetchFn={() => masterDataService.getDisciplines(selectedContractId ? parseInt(selectedContractId) : undefined)} - createFn={(data: Record) => masterDataService.createDiscipline(data as unknown as Parameters[0])} + fetchFn={() => masterDataService.getDisciplines(selectedContractId ? selectedContractId : undefined)} + createFn={(data: Record) => masterDataService.createDiscipline(data as any)} updateFn={(id, data) => Promise.reject('Not implemented yet')} deleteFn={(id) => masterDataService.deleteDiscipline(id)} columns={columns} @@ -71,8 +71,8 @@ export default function DisciplinesPage() { All Contracts - {contracts.map((c) => ( - + {contracts.map((c: any) => ( + {c.contractName} ({c.contractCode}) ))} diff --git a/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx index 2370bd4..5ec70da 100644 --- a/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx @@ -47,9 +47,9 @@ export default function RfaTypesPage() { }, ]; - const contractOptions = contracts.map((c) => ({ + const contractOptions = contracts.map((c: any) => ({ label: `${c.contractName} (${c.contractCode})`, - value: c.id, + value: String(c.id || c.uuid), })); return ( @@ -58,8 +58,8 @@ export default function RfaTypesPage() { entityName="RFA Type" title="RFA Types Management" queryKey={['rfa-types', selectedContractId ?? 'all']} - fetchFn={() => masterDataService.getRfaTypes(selectedContractId ? parseInt(selectedContractId) : undefined)} - createFn={(data: Record) => masterDataService.createRfaType(data as unknown as any)} + fetchFn={() => masterDataService.getRfaTypes(selectedContractId ? selectedContractId : undefined)} + createFn={(data: Record) => masterDataService.createRfaType(data as any)} updateFn={(id, data) => masterDataService.updateRfaType(id, data)} deleteFn={(id) => masterDataService.deleteRfaType(id)} columns={columns} @@ -74,8 +74,8 @@ export default function RfaTypesPage() { All Contracts - {contracts.map((c) => ( - + {contracts.map((c: any) => ( + {c.contractName} ({c.contractCode}) ))} diff --git a/frontend/components/admin/reference/generic-crud-table.tsx b/frontend/components/admin/reference/generic-crud-table.tsx index 58750c2..aa89471 100644 --- a/frontend/components/admin/reference/generic-crud-table.tsx +++ b/frontend/components/admin/reference/generic-crud-table.tsx @@ -1,9 +1,22 @@ "use client"; import { useState } from "react"; -import { DataTable } from "@/components/common/data-table"; -import { ColumnDef } from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + flexRender, + getCoreRowModel, + useReactTable, + ColumnDef, +} from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; +import { Plus, Pencil, Trash2, Loader2 } from "lucide-react"; import { Dialog, DialogContent, @@ -11,20 +24,11 @@ import { DialogTitle, DialogFooter, } from "@/components/ui/dialog"; +import { useForm } from "react-hook-form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Plus, Pencil, Trash2, RefreshCw } from "lucide-react"; -import { toast } from "sonner"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, @@ -35,31 +39,41 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Skeleton } from "@/components/ui/skeleton"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; -interface FieldConfig { +interface Field { name: string; label: string; - type: "text" | "textarea" | "checkbox" | "select"; + type: "text" | "number" | "checkbox" | "select" | "textarea"; required?: boolean; - options?: { label: string; value: string | number | boolean }[]; + options?: { label: string; value: string | number }[]; } -interface GenericCrudTableProps { - entityName: string; - queryKey: string[]; - fetchFn: () => Promise; - createFn: (data: Record) => Promise; - updateFn: (id: number, data: Record) => Promise; - deleteFn: (id: number) => Promise; - columns: ColumnDef[]; - fields: FieldConfig[]; - title?: string; +interface GenericCrudTableProps { + title: string; description?: string; + entityName: string; + queryKey: any[]; + fetchFn: () => Promise; + createFn: (data: any) => Promise; + updateFn: (id: number, data: any) => Promise; + deleteFn: (id: number) => Promise; + columns: ColumnDef[]; + fields: Field[]; filters?: React.ReactNode; } -export function GenericCrudTable({ +export function GenericCrudTable({ + title, + description, entityName, queryKey, fetchFn, @@ -68,254 +82,303 @@ export function GenericCrudTable({ deleteFn, columns, fields, - title, - description, filters, -}: GenericCrudTableProps) { +}: GenericCrudTableProps) { const queryClient = useQueryClient(); - const [isOpen, setIsOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [formData, setFormData] = useState>({}); - - // Delete Dialog State - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingItem, setEditingId] = useState(null); const [itemToDelete, setItemToDelete] = useState(null); - const { data, isLoading, refetch } = useQuery({ + const { data: rawData, isLoading, refetch } = useQuery({ queryKey, queryFn: fetchFn, }); + // ADR-019: Support both direct array or wrapped data object + const data: T[] = Array.isArray(rawData) ? rawData : (rawData as any)?.data || []; + const createMutation = useMutation({ mutationFn: createFn, onSuccess: () => { - toast.success(`${entityName} created successfully`); queryClient.invalidateQueries({ queryKey }); - handleClose(); + toast.success(`${entityName} created successfully`); + setIsDialogOpen(false); + reset(); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || `Failed to create ${entityName}`); }, - onError: () => toast.error(`Failed to create ${entityName}`), }); const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: Record }) => updateFn(id, data), + mutationFn: ({ id, data }: { id: number; data: any }) => updateFn(id, data), onSuccess: () => { - toast.success(`${entityName} updated successfully`); queryClient.invalidateQueries({ queryKey }); - handleClose(); + toast.success(`${entityName} updated successfully`); + setIsDialogOpen(false); + setEditingId(null); + reset(); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || `Failed to update ${entityName}`); }, - onError: () => toast.error(`Failed to update ${entityName}`), }); const deleteMutation = useMutation({ mutationFn: deleteFn, onSuccess: () => { - toast.success(`${entityName} deleted successfully`); queryClient.invalidateQueries({ queryKey }); - setDeleteDialogOpen(false); + toast.success(`${entityName} deleted successfully`); setItemToDelete(null); }, - onError: () => toast.error(`Failed to delete ${entityName}`), + onError: (error: any) => { + toast.error(error.response?.data?.message || `Failed to delete ${entityName}`); + }, }); - const handleCreate = () => { - setEditingItem(null); - setFormData({}); - setIsOpen(true); + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { errors }, + } = useForm(); + + const table = useReactTable({ + data, + columns: [ + ...columns, + { + id: "actions", + cell: ({ row }) => ( +
+ + +
+ ), + }, + ], + getCoreRowModel: getCoreRowModel(), + }); + + const handleAdd = () => { + setEditingId(null); + reset(); + fields.forEach((f) => { + if (f.type === "checkbox") setValue(f.name, true); + }); + setIsDialogOpen(true); }; - const handleEdit = (item: TEntity) => { - setEditingItem(item); - setFormData({ ...item }); - setIsOpen(true); + const handleEdit = (item: any) => { + setEditingId(item.id); + reset(item); + // Ensure select values are strings for Shadcn Select + fields.forEach(f => { + if (f.type === 'select' && item[f.name]) { + setValue(f.name, String(item[f.name])); + } + }); + setIsDialogOpen(true); }; - const handleDeleteClick = (id: number) => { - setItemToDelete(id); - setDeleteDialogOpen(true); - }; - - const confirmDelete = () => { - if (itemToDelete) { - deleteMutation.mutate(itemToDelete); - } - }; - - const handleClose = () => { - setIsOpen(false); - setEditingItem(null); - setFormData({}); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = (formData: any) => { if (editingItem) { - updateMutation.mutate({ id: editingItem.id, data: formData }); + updateMutation.mutate({ id: editingItem, data: formData }); } else { createMutation.mutate(formData); } }; - const handleChange = (field: string, value: unknown) => { - setFormData((prev: Record) => ({ ...prev, [field]: value })); - }; - - // Add default Actions column if not present - const tableColumns = [ - ...columns, - { - id: "actions", - header: "Actions", - cell: ({ row }: { row: { original: TEntity } }) => ( -
- - -
- ), - }, - ]; - return (
-
+
- {title &&

{title}

} +

{title}

{description && ( -

{description}

+

{description}

)}
-
- {filters} - - -
+
- {isLoading ? ( -
- {[1, 2, 3, 4, 5].map((i) => ( -
- -
- ))} -
- ) : ( - - )} + {filters &&
{filters}
} - - +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {isLoading ? ( + + +
+ + Loading... +
+
+
+ ) : data.length === 0 ? ( + + + No data found. + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + )} +
+
+
+ + + - {editingItem ? `Edit ${entityName}` : `New ${entityName}`} + {editingItem ? `Edit ${entityName}` : `Add New ${entityName}`} -
+ {fields.map((field) => (
- - {field.type === "textarea" ? ( -