260316:1347 Refactor to NestJS 11 #2
Build and Deploy / deploy (push) Failing after 2m19s

This commit is contained in:
admin
2026-03-16 13:47:35 +07:00
parent a75ba3105f
commit f13861f02e
17 changed files with 544 additions and 563 deletions
@@ -3,20 +3,45 @@ import {
NotFoundException, NotFoundException,
ConflictException, ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm'; import { Repository, Like, EntityManager } from 'typeorm';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { CreateContractDto } from './dto/create-contract.dto.js'; import { CreateContractDto } from './dto/create-contract.dto.js';
import { UpdateContractDto } from './dto/update-contract.dto.js'; import { UpdateContractDto } from './dto/update-contract.dto.js';
import { Project } from '../project/entities/project.entity';
@Injectable() @Injectable()
export class ContractService { export class ContractService {
constructor( constructor(
@InjectRepository(Contract) @InjectRepository(Contract)
private readonly contractRepo: Repository<Contract> private readonly contractRepo: Repository<Contract>,
@InjectEntityManager()
private readonly entityManager: EntityManager
) {} ) {}
/**
* Helper to resolve projectId (ID or UUID) to internal INT ID
*/
async resolveProjectId(projectId: number | string): Promise<number> {
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) { async create(dto: CreateContractDto) {
const internalProjectId = await this.resolveProjectId(dto.projectId);
const existing = await this.contractRepo.findOne({ const existing = await this.contractRepo.findOne({
where: { contractCode: dto.contractCode }, where: { contractCode: dto.contractCode },
}); });
@@ -25,7 +50,7 @@ export class ContractService {
`Contract Code "${dto.contractCode}" already exists` `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); return this.contractRepo.save(contract);
} }
@@ -33,6 +58,11 @@ export class ContractService {
const { search, projectId, page = 1, limit = 100 } = params || {}; const { search, projectId, page = 1, limit = 100 } = params || {};
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
let internalProjectId = undefined;
if (projectId) {
internalProjectId = await this.resolveProjectId(projectId);
}
const findOptions: any = { const findOptions: any = {
relations: ['project'], relations: ['project'],
order: { contractCode: 'ASC' }, order: { contractCode: 'ASC' },
@@ -47,21 +77,20 @@ export class ContractService {
searchConditions.push({ contractName: Like(`%${search}%`) }); searchConditions.push({ contractName: Like(`%${search}%`) });
} }
if (projectId) { if (internalProjectId) {
// Combine project filter with search if exists
if (searchConditions.length > 0) { if (searchConditions.length > 0) {
findOptions.where = searchConditions.map((cond) => ({ findOptions.where = searchConditions.map((cond) => ({
...cond, ...cond,
projectId, projectId: internalProjectId,
})); }));
} else { } else {
findOptions.where = { projectId }; findOptions.where = { projectId: internalProjectId };
} }
} else { } else {
if (searchConditions.length > 0) { if (searchConditions.length > 0) {
findOptions.where = searchConditions; findOptions.where = searchConditions;
} else { } else {
delete findOptions.where; // No filters delete findOptions.where;
} }
} }
@@ -99,6 +128,9 @@ export class ContractService {
async update(uuid: string, dto: UpdateContractDto) { async update(uuid: string, dto: UpdateContractDto) {
const contract = await this.findOneByUuid(uuid); const contract = await this.findOneByUuid(uuid);
if (dto.projectId) {
dto.projectId = await this.resolveProjectId(dto.projectId);
}
Object.assign(contract, dto); Object.assign(contract, dto);
return this.contractRepo.save(contract); return this.contractRepo.save(contract);
} }
@@ -7,7 +7,7 @@ import {
BeforeInsert, BeforeInsert,
} from 'typeorm'; } from 'typeorm';
import { v7 as uuidv7 } from 'uuid'; 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 { BaseEntity } from '../../../common/entities/base.entity';
import { Project } from '../../project/entities/project.entity'; import { Project } from '../../project/entities/project.entity';
@@ -17,6 +17,7 @@ export class Contract extends BaseEntity {
@Exclude() @Exclude()
id!: number; id!: number;
@Expose({ name: 'id' })
@Column({ @Column({
type: 'uuid', type: 'uuid',
unique: true, unique: true,
@@ -38,28 +38,16 @@ export class DrawingMasterDataController {
@Get('contract/volumes') @Get('contract/volumes')
@ApiOperation({ summary: 'List Contract Drawing Volumes' }) @ApiOperation({ summary: 'List Contract Drawing Volumes' })
@ApiQuery({ name: 'projectId', required: true, type: Number }) @ApiQuery({ name: 'projectId', required: true, type: String })
@RequirePermission('document.view') @RequirePermission('document.view')
getVolumes(@Query('projectId', ParseIntPipe) projectId: number) { getVolumes(@Query('projectId') projectId: string | number) {
this.logger.log(`Fetching Contract Volumes for Project ID: ${projectId}`);
return this.masterDataService.findAllVolumes(projectId); return this.masterDataService.findAllVolumes(projectId);
} }
// ... (Create/Update/Delete methods remain unchanged) ...
@Post('contract/volumes') @Post('contract/volumes')
@ApiOperation({ summary: 'Create Volume' }) @ApiOperation({ summary: 'Create Volume' })
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
createVolume( createVolume(@Body() body: any) {
@Body()
body: {
projectId: number;
volumeCode: string;
volumeName: string;
description?: string;
sortOrder: number;
}
) {
return this.masterDataService.createVolume(body); return this.masterDataService.createVolume(body);
} }
@@ -68,13 +56,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
updateVolume( updateVolume(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() @Body() body: any
body: {
volumeCode?: string;
volumeName?: string;
description?: string;
sortOrder?: number;
}
) { ) {
return this.masterDataService.updateVolume(id, body); return this.masterDataService.updateVolume(id, body);
} }
@@ -92,30 +74,16 @@ export class DrawingMasterDataController {
@Get('contract/categories') @Get('contract/categories')
@ApiOperation({ summary: 'List Contract Drawing Categories' }) @ApiOperation({ summary: 'List Contract Drawing Categories' })
@ApiQuery({ name: 'projectId', required: true, type: Number }) @ApiQuery({ name: 'projectId', required: true, type: String })
@RequirePermission('document.view') @RequirePermission('document.view')
getCategories(@Query('projectId', ParseIntPipe) projectId: number) { getCategories(@Query('projectId') projectId: string | number) {
this.logger.log(
`Fetching Contract Categories for Project ID: ${projectId}`
);
return this.masterDataService.findAllCategories(projectId); return this.masterDataService.findAllCategories(projectId);
} }
// ... (Create/Update/Delete methods remain unchanged) ...
@Post('contract/categories') @Post('contract/categories')
@ApiOperation({ summary: 'Create Category' }) @ApiOperation({ summary: 'Create Category' })
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
createCategory( createCategory(@Body() body: any) {
@Body()
body: {
projectId: number;
catCode: string;
catName: string;
description?: string;
sortOrder: number;
}
) {
return this.masterDataService.createCategory(body); return this.masterDataService.createCategory(body);
} }
@@ -124,13 +92,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
updateCategory( updateCategory(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() @Body() body: any
body: {
catCode?: string;
catName?: string;
description?: string;
sortOrder?: number;
}
) { ) {
return this.masterDataService.updateCategory(id, body); return this.masterDataService.updateCategory(id, body);
} }
@@ -148,30 +110,16 @@ export class DrawingMasterDataController {
@Get('contract/sub-categories') @Get('contract/sub-categories')
@ApiOperation({ summary: 'List Contract Drawing 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') @RequirePermission('document.view')
getContractSubCats(@Query('projectId', ParseIntPipe) projectId: number) { getContractSubCats(@Query('projectId') projectId: string | number) {
this.logger.log(
`Fetching Contract Sub-Categories for Project ID: ${projectId}`
);
return this.masterDataService.findAllContractSubCats(projectId); return this.masterDataService.findAllContractSubCats(projectId);
} }
// ... (Create/Update/Delete methods remain unchanged) ...
@Post('contract/sub-categories') @Post('contract/sub-categories')
@ApiOperation({ summary: 'Create Contract Sub-Category' }) @ApiOperation({ summary: 'Create Contract Sub-Category' })
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
createContractSubCat( createContractSubCat(@Body() body: any) {
@Body()
body: {
projectId: number;
subCatCode: string;
subCatName: string;
description?: string;
sortOrder: number;
}
) {
return this.masterDataService.createContractSubCat(body); return this.masterDataService.createContractSubCat(body);
} }
@@ -180,18 +128,15 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
updateContractSubCat( updateContractSubCat(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() @Body() body: any
body: {
subCatCode?: string;
subCatName?: string;
description?: string;
sortOrder?: number;
}
) { ) {
return this.masterDataService.updateContractSubCat(id, body); 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); return this.masterDataService.deleteContractSubCat(id);
} }
@@ -201,11 +146,11 @@ export class DrawingMasterDataController {
@Get('contract/mappings') @Get('contract/mappings')
@ApiOperation({ summary: 'List Contract Drawing 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 }) @ApiQuery({ name: 'categoryId', required: false, type: Number })
@RequirePermission('document.view') @RequirePermission('document.view')
getContractMappings( getContractMappings(
@Query('projectId', ParseIntPipe) projectId: number, @Query('projectId') projectId: string | number,
@Query('categoryId') categoryId?: number @Query('categoryId') categoryId?: number
) { ) {
return this.masterDataService.findContractMappings( return this.masterDataService.findContractMappings(
@@ -217,14 +162,7 @@ export class DrawingMasterDataController {
@Post('contract/mappings') @Post('contract/mappings')
@ApiOperation({ summary: 'Create Contract Drawing Mapping' }) @ApiOperation({ summary: 'Create Contract Drawing Mapping' })
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
createContractMapping( createContractMapping(@Body() body: any) {
@Body()
body: {
projectId: number;
categoryId: number;
subCategoryId: number;
}
) {
return this.masterDataService.createContractMapping(body); return this.masterDataService.createContractMapping(body);
} }
@@ -241,31 +179,16 @@ export class DrawingMasterDataController {
@Get('shop/main-categories') @Get('shop/main-categories')
@ApiOperation({ summary: 'List Shop Drawing 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') @RequirePermission('document.view')
getShopMainCats(@Query('projectId', ParseIntPipe) projectId: number) { getShopMainCats(@Query('projectId') projectId: string | number) {
this.logger.log(
`Fetching Shop Main Categories for Project ID: ${projectId}`
);
return this.masterDataService.findAllShopMainCats(projectId); return this.masterDataService.findAllShopMainCats(projectId);
} }
// ... (Create/Update/Delete methods remain unchanged) ...
@Post('shop/main-categories') @Post('shop/main-categories')
@ApiOperation({ summary: 'Create Shop Main Category' }) @ApiOperation({ summary: 'Create Shop Main Category' })
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
createShopMainCat( createShopMainCat(@Body() body: any) {
@Body()
body: {
projectId: number;
mainCategoryCode: string;
mainCategoryName: string;
description?: string;
isActive?: boolean;
sortOrder: number;
}
) {
return this.masterDataService.createShopMainCat(body); return this.masterDataService.createShopMainCat(body);
} }
@@ -274,14 +197,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
updateShopMainCat( updateShopMainCat(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() @Body() body: any
body: {
mainCategoryCode?: string;
mainCategoryName?: string;
description?: string;
isActive?: boolean;
sortOrder?: number;
}
) { ) {
return this.masterDataService.updateShopMainCat(id, body); return this.masterDataService.updateShopMainCat(id, body);
} }
@@ -299,16 +215,13 @@ export class DrawingMasterDataController {
@Get('shop/sub-categories') @Get('shop/sub-categories')
@ApiOperation({ summary: 'List Shop Drawing 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 }) @ApiQuery({ name: 'mainCategoryId', required: false, type: Number })
@RequirePermission('document.view') @RequirePermission('document.view')
getShopSubCats( getShopSubCats(
@Query('projectId', ParseIntPipe) projectId: number, @Query('projectId') projectId: string | number,
@Query('mainCategoryId') mainCategoryId?: number @Query('mainCategoryId') mainCategoryId?: number
) { ) {
this.logger.log(
`Fetching Shop Sub-Categories for Project ID: ${projectId}, MainCategory: ${mainCategoryId}`
);
return this.masterDataService.findAllShopSubCats( return this.masterDataService.findAllShopSubCats(
projectId, projectId,
mainCategoryId ? Number(mainCategoryId) : undefined mainCategoryId ? Number(mainCategoryId) : undefined
@@ -318,17 +231,7 @@ export class DrawingMasterDataController {
@Post('shop/sub-categories') @Post('shop/sub-categories')
@ApiOperation({ summary: 'Create Shop Sub-Category' }) @ApiOperation({ summary: 'Create Shop Sub-Category' })
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
createShopSubCat( createShopSubCat(@Body() body: any) {
@Body()
body: {
projectId: number;
subCategoryCode: string;
subCategoryName: string;
description?: string;
isActive?: boolean;
sortOrder: number;
}
) {
return this.masterDataService.createShopSubCat(body); return this.masterDataService.createShopSubCat(body);
} }
@@ -337,14 +240,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage') @RequirePermission('master_data.drawing_category.manage')
updateShopSubCat( updateShopSubCat(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() @Body() body: any
body: {
subCategoryCode?: string;
subCategoryName?: string;
description?: string;
isActive?: boolean;
sortOrder?: number;
}
) { ) {
return this.masterDataService.updateShopSubCat(id, body); return this.masterDataService.updateShopSubCat(id, body);
} }
@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm'; import { Repository, FindOptionsWhere, EntityManager } from 'typeorm';
// Entities // Entities
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity'; 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 { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity'; import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
import { ContractDrawingSubcatCatMap } from './entities/contract-drawing-subcat-cat-map.entity'; import { ContractDrawingSubcatCatMap } from './entities/contract-drawing-subcat-cat-map.entity';
import { Project } from '../project/entities/project.entity';
@Injectable() @Injectable()
export class DrawingMasterDataService { export class DrawingMasterDataService {
@@ -24,22 +25,47 @@ export class DrawingMasterDataService {
@InjectRepository(ShopDrawingSubCategory) @InjectRepository(ShopDrawingSubCategory)
private sdSubCatRepo: Repository<ShopDrawingSubCategory>, private sdSubCatRepo: Repository<ShopDrawingSubCategory>,
@InjectRepository(ContractDrawingSubcatCatMap) @InjectRepository(ContractDrawingSubcatCatMap)
private cdMapRepo: Repository<ContractDrawingSubcatCatMap> private cdMapRepo: Repository<ContractDrawingSubcatCatMap>,
@InjectEntityManager()
private entityManager: EntityManager
) {} ) {}
/**
* Helper to resolve projectId (ID or UUID) to internal INT ID
*/
async resolveProjectId(projectId: number | string): Promise<number> {
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 // Contract Drawing Volumes
// ===================================================== // =====================================================
async findAllVolumes(projectId: number) { async findAllVolumes(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
return this.cdVolumeRepo.find({ return this.cdVolumeRepo.find({
where: { projectId }, where: { projectId: internalId },
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
}); });
} }
async createVolume(data: Partial<ContractDrawingVolume>) { async createVolume(data: any) {
const volume = this.cdVolumeRepo.create(data); const internalId = await this.resolveProjectId(data.projectId);
const volume = this.cdVolumeRepo.create({ ...data, projectId: internalId });
return this.cdVolumeRepo.save(volume); return this.cdVolumeRepo.save(volume);
} }
@@ -61,15 +87,17 @@ export class DrawingMasterDataService {
// Contract Drawing Categories // Contract Drawing Categories
// ===================================================== // =====================================================
async findAllCategories(projectId: number) { async findAllCategories(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
return this.cdCatRepo.find({ return this.cdCatRepo.find({
where: { projectId }, where: { projectId: internalId },
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
}); });
} }
async createCategory(data: Partial<ContractDrawingCategory>) { async createCategory(data: any) {
const cat = this.cdCatRepo.create(data); const internalId = await this.resolveProjectId(data.projectId);
const cat = this.cdCatRepo.create({ ...data, projectId: internalId });
return this.cdCatRepo.save(cat); return this.cdCatRepo.save(cat);
} }
@@ -91,15 +119,17 @@ export class DrawingMasterDataService {
// Contract Drawing Sub-Categories // Contract Drawing Sub-Categories
// ===================================================== // =====================================================
async findAllContractSubCats(projectId: number) { async findAllContractSubCats(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
return this.cdSubCatRepo.find({ return this.cdSubCatRepo.find({
where: { projectId }, where: { projectId: internalId },
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
}); });
} }
async createContractSubCat(data: Partial<ContractDrawingSubCategory>) { async createContractSubCat(data: any) {
const subCat = this.cdSubCatRepo.create(data); const internalId = await this.resolveProjectId(data.projectId);
const subCat = this.cdSubCatRepo.create({ ...data, projectId: internalId });
return this.cdSubCatRepo.save(subCat); return this.cdSubCatRepo.save(subCat);
} }
@@ -124,8 +154,9 @@ export class DrawingMasterDataService {
// Contract Drawing Mappings (Category <-> Sub-Category) // Contract Drawing Mappings (Category <-> Sub-Category)
// ===================================================== // =====================================================
async findContractMappings(projectId: number, categoryId?: number) { async findContractMappings(projectId: number | string, categoryId?: number) {
const where: FindOptionsWhere<ContractDrawingSubcatCatMap> = { projectId }; const internalId = await this.resolveProjectId(projectId);
const where: FindOptionsWhere<ContractDrawingSubcatCatMap> = { projectId: internalId };
if (categoryId) { if (categoryId) {
where.categoryId = categoryId; where.categoryId = categoryId;
} }
@@ -136,15 +167,12 @@ export class DrawingMasterDataService {
}); });
} }
async createContractMapping(data: { async createContractMapping(data: any) {
projectId: number; const internalId = await this.resolveProjectId(data.projectId);
categoryId: number;
subCategoryId: number;
}) {
// Check if mapping already exists to prevent duplicates (though DB has UNIQUE constraint) // Check if mapping already exists to prevent duplicates (though DB has UNIQUE constraint)
const existing = await this.cdMapRepo.findOne({ const existing = await this.cdMapRepo.findOne({
where: { where: {
projectId: data.projectId, projectId: internalId,
categoryId: data.categoryId, categoryId: data.categoryId,
subCategoryId: data.subCategoryId, subCategoryId: data.subCategoryId,
}, },
@@ -152,7 +180,7 @@ export class DrawingMasterDataService {
if (existing) return existing; if (existing) return existing;
const map = this.cdMapRepo.create(data); const map = this.cdMapRepo.create({ ...data, projectId: internalId });
return this.cdMapRepo.save(map); return this.cdMapRepo.save(map);
} }
@@ -167,15 +195,17 @@ export class DrawingMasterDataService {
// Shop Drawing Main Categories // Shop Drawing Main Categories
// ===================================================== // =====================================================
async findAllShopMainCats(projectId: number) { async findAllShopMainCats(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
return this.sdMainCatRepo.find({ return this.sdMainCatRepo.find({
where: { projectId }, where: { projectId: internalId },
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
}); });
} }
async createShopMainCat(data: Partial<ShopDrawingMainCategory>) { async createShopMainCat(data: any) {
const cat = this.sdMainCatRepo.create(data); const internalId = await this.resolveProjectId(data.projectId);
const cat = this.sdMainCatRepo.create({ ...data, projectId: internalId });
return this.sdMainCatRepo.save(cat); return this.sdMainCatRepo.save(cat);
} }
@@ -197,9 +227,10 @@ export class DrawingMasterDataService {
// Shop Drawing Sub-Categories // 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<ShopDrawingSubCategory> = { const where: FindOptionsWhere<ShopDrawingSubCategory> = {
projectId, projectId: internalId,
...(mainCategoryId ? { mainCategoryId } : {}), ...(mainCategoryId ? { mainCategoryId } : {}),
}; };
@@ -209,8 +240,9 @@ export class DrawingMasterDataService {
}); });
} }
async createShopSubCat(data: Partial<ShopDrawingSubCategory>) { async createShopSubCat(data: any) {
const subCat = this.sdSubCatRepo.create(data); const internalId = await this.resolveProjectId(data.projectId);
const subCat = this.sdSubCatRepo.create({ ...data, projectId: internalId });
return this.sdSubCatRepo.save(subCat); return this.sdSubCatRepo.save(subCat);
} }
+39 -131
View File
@@ -13,151 +13,78 @@ import {
UseGuards, UseGuards,
ParseIntPipe, ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { MasterService } from './master.service'; 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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.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') @ApiTags('Master Data')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('master') @Controller('master')
@UseGuards(JwtAuthGuard)
export class MasterController { export class MasterController {
constructor(private readonly masterService: MasterService) {} constructor(private readonly masterService: MasterService) {}
// ================================================================= // --- Correspondence Types ---
// 📦 Common Dropdowns (Read-Only)
// =================================================================
@Get('correspondence-types') @Get('correspondence-types')
@ApiOperation({ summary: 'Get all active correspondence types' }) @ApiOperation({ summary: 'Get all correspondence types' })
getCorrespondenceTypes() { findAllCorrespondenceTypes() {
return this.masterService.findAllCorrespondenceTypes(); return this.masterService.findAllCorrespondenceTypes();
} }
@Post('correspondence-types') @Post('correspondence-types')
@RequirePermission('master_data.manage') @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create Correspondence Type' })
createCorrespondenceType(@Body() dto: any) { createCorrespondenceType(@Body() dto: any) {
return this.masterService.createCorrespondenceType(dto); return this.masterService.createCorrespondenceType(dto);
} }
@Patch('correspondence-types/:id') // --- RFA Types ---
@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();
}
@Get('rfa-types') @Get('rfa-types')
@ApiOperation({ summary: 'Get all active RFA types' }) @ApiOperation({ summary: 'Get all RFA types' })
@ApiQuery({ name: 'contractId', required: false, type: Number }) @ApiQuery({ name: 'contractId', required: false, type: String })
getRfaTypes(@Query('contractId') contractId?: number) { findAllRfaTypes(@Query('contractId') contractId?: string | number) {
return this.masterService.findAllRfaTypes(contractId); return this.masterService.findAllRfaTypes(contractId);
} }
@Post('rfa-types') @Post('rfa-types')
@RequirePermission('master_data.manage') @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create RFA Type' })
createRfaType(@Body() dto: any) { 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); return this.masterService.createRfaType(dto);
} }
@Patch('rfa-types/:id') // --- Disciplines ---
@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)
// =================================================================
@Get('disciplines') @Get('disciplines')
@ApiOperation({ summary: 'Get disciplines (filter by contract optional)' }) @ApiOperation({ summary: 'Get all disciplines' })
@ApiQuery({ name: 'contractId', required: false, type: Number }) @ApiQuery({ name: 'contractId', required: false, type: String })
getDisciplines(@Query('contractId') contractId?: number) { findAllDisciplines(@Query('contractId') contractId?: string | number) {
return this.masterService.findAllDisciplines(contractId); return this.masterService.findAllDisciplines(contractId);
} }
@Post('disciplines') @Post('disciplines')
@RequirePermission('master_data.manage') // สิทธิ์ Admin @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create a new discipline' }) createDiscipline(@Body() dto: any) {
createDiscipline(@Body() dto: CreateDisciplineDto) {
return this.masterService.createDiscipline(dto); return this.masterService.createDiscipline(dto);
} }
@Delete('disciplines/:id') @Delete('disciplines/:id')
@RequirePermission('master_data.manage') @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete a discipline' })
deleteDiscipline(@Param('id', ParseIntPipe) id: number) { deleteDiscipline(@Param('id', ParseIntPipe) id: number) {
return this.masterService.deleteDiscipline(id); return this.masterService.deleteDiscipline(id);
} }
// ================================================================= // --- Sub Types ---
// 📑 Correspondence Sub-Types (Req 6B)
// =================================================================
@Get('sub-types') @Get('sub-types')
@ApiOperation({ summary: 'Get sub-types (filter by contract/type optional)' }) @ApiOperation({ summary: 'Get all sub-types' })
@ApiQuery({ name: 'contractId', required: false, type: Number }) @ApiQuery({ name: 'contractId', required: false, type: String })
@ApiQuery({ name: 'typeId', required: false, type: Number }) findAllSubTypes(
getSubTypes( @Query('contractId') contractId?: string | number,
@Query('contractId') contractId?: number,
@Query('typeId') typeId?: number @Query('typeId') typeId?: number
) { ) {
return this.masterService.findAllSubTypes(contractId, typeId); return this.masterService.findAllSubTypes(contractId, typeId);
@@ -165,62 +92,43 @@ export class MasterController {
@Post('sub-types') @Post('sub-types')
@RequirePermission('master_data.manage') @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create/Map a new sub-type' }) createSubType(@Body() dto: any) {
createSubType(@Body() dto: CreateSubTypeDto) {
return this.masterService.createSubType(dto); return this.masterService.createSubType(dto);
} }
@Delete('sub-types/:id') // --- Numbering Formats ---
@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)
// =================================================================
@Get('numbering-formats') @Get('numbering-formats')
@RequirePermission('master_data.manage') // ข้อมูล config ควรสงวนสิทธิ์ @ApiOperation({ summary: 'Get numbering format for project/type' })
@ApiOperation({ summary: 'Get numbering format for specific project/type' }) findNumberFormat(
getNumberFormat( @Query('projectId') projectId: string | number,
@Query('projectId', ParseIntPipe) projectId: number,
@Query('typeId', ParseIntPipe) typeId: number @Query('typeId', ParseIntPipe) typeId: number
) { ) {
return this.masterService.findNumberFormat(projectId, typeId); return this.masterService.findNumberFormat(projectId, typeId);
} }
@Post('numbering-formats') @Post('numbering-formats')
@RequirePermission('system.manage_all') // เฉพาะ Superadmin/System Admin @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Save or Update numbering format template' }) saveNumberFormat(@Body() dto: any) {
saveNumberFormat(@Body() dto: SaveNumberFormatDto) {
return this.masterService.saveNumberFormat(dto); return this.masterService.saveNumberFormat(dto);
} }
// ================================================================= // --- Tags ---
// 🏷️ Tag Management
// =================================================================
@Get('tags') @Get('tags')
@ApiOperation({ summary: 'Get all tags (supports search & pagination)' }) @ApiOperation({ summary: 'Get all tags' })
getTags(@Query() query: SearchTagDto) { findAllTags(@Query() query: SearchTagDto) {
return this.masterService.findAllTags(query); return this.masterService.findAllTags(query);
} }
@Get('tags/:id') @Get('tags/:id')
@ApiOperation({ summary: 'Get a tag by 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); return this.masterService.findOneTag(id);
} }
@Post('tags') @Post('tags')
@RequirePermission('master_data.tag.manage') @RequirePermission('master_data.tag.manage')
@ApiOperation({ summary: 'Create a new tag' }) @ApiOperation({ summary: 'Create a new tag' })
createTag( createTag(@Body() dto: CreateTagDto, @CurrentUser() user: any) {
@CurrentUser() user: { userId: number },
@Body() dto: CreateTagDto
) {
return this.masterService.createTag(dto, user.userId); return this.masterService.createTag(dto, user.userId);
} }
+78 -41
View File
@@ -5,8 +5,8 @@ import {
NotFoundException, NotFoundException,
ConflictException, ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository, EntityManager } from 'typeorm';
// Import Entities // Import Entities
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; 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 { Discipline } from './entities/discipline.entity';
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity'; import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
import { DocumentNumberFormat } from '../document-numbering/entities/document-number-format.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 DTOs
import { CreateTagDto } from './dto/create-tag.dto'; import { CreateTagDto } from './dto/create-tag.dto';
@@ -54,12 +56,41 @@ export class MasterService {
@InjectRepository(CorrespondenceSubType) @InjectRepository(CorrespondenceSubType)
private readonly subTypeRepo: Repository<CorrespondenceSubType>, private readonly subTypeRepo: Repository<CorrespondenceSubType>,
@InjectRepository(DocumentNumberFormat) @InjectRepository(DocumentNumberFormat)
private readonly formatRepo: Repository<DocumentNumberFormat> private readonly formatRepo: Repository<DocumentNumberFormat>,
@InjectEntityManager()
private readonly entityManager: EntityManager
) {} ) {}
// ... (Method เดิม: findAllCorrespondenceTypes, findAllCorrespondenceStatuses, ฯลฯ เก็บไว้เหมือนเดิม) ... /**
// หมายเหตุ: ตรวจสอบว่า Entity ใช้ชื่อ property ว่า isActive หรือ is_active (ใน SQL เป็น is_active แต่ใน Entity มักเป็น isActive) * Helper to resolve projectId (ID or UUID) to internal INT ID
// โค้ดเดิมใช้ `where: { isActive: true }` ซึ่งถูกต้องถ้า Entity map column name แล้ว */
async resolveProjectId(projectId: number | string): Promise<number> {
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<number> {
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() { async findAllCorrespondenceTypes() {
return this.corrTypeRepo.find({ return this.corrTypeRepo.find({
@@ -92,27 +123,29 @@ export class MasterService {
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
}); });
} }
async findAllRfaTypes(contractId?: number) { async findAllRfaTypes(contractId?: number | string) {
const where: any = { isActive: true }; const where: any = { isActive: true };
if (contractId) { if (contractId) {
where.contractId = contractId; where.contractId = await this.resolveContractId(contractId);
} }
return this.rfaTypeRepo.find({ return this.rfaTypeRepo.find({
where, where,
order: { typeCode: 'ASC' }, order: { typeCode: 'ASC' },
relations: contractId ? [] : [], // Add relations if needed later
}); });
} }
async createRfaType(dto: any) { async createRfaType(dto: any) {
// Validate unique code if needed const internalContractId = await this.resolveContractId(dto.contractId);
const rfaType = this.rfaTypeRepo.create(dto); const rfaType = this.rfaTypeRepo.create({ ...dto, contractId: internalContractId });
return this.rfaTypeRepo.save(rfaType); return this.rfaTypeRepo.save(rfaType);
} }
async updateRfaType(id: number, dto: any) { async updateRfaType(id: number, dto: any) {
const rfaType = await this.rfaTypeRepo.findOne({ where: { id } }); const rfaType = await this.rfaTypeRepo.findOne({ where: { id } });
if (!rfaType) throw new NotFoundException('RFA Type not found'); if (!rfaType) throw new NotFoundException('RFA Type not found');
if (dto.contractId) {
dto.contractId = await this.resolveContractId(dto.contractId);
}
Object.assign(rfaType, dto); Object.assign(rfaType, dto);
return this.rfaTypeRepo.save(rfaType); return this.rfaTypeRepo.save(rfaType);
} }
@@ -146,31 +179,32 @@ export class MasterService {
// 🏗️ Disciplines Logic // 🏗️ Disciplines Logic
// ================================================================= // =================================================================
async findAllDisciplines(contractId?: number) { async findAllDisciplines(contractId?: number | string) {
const query = this.disciplineRepo const query = this.disciplineRepo
.createQueryBuilder('d') .createQueryBuilder('d')
.leftJoinAndSelect('d.contract', 'c') .leftJoinAndSelect('d.contract', 'c')
.orderBy('d.disciplineCode', 'ASC'); .orderBy('d.disciplineCode', 'ASC');
if (contractId) { 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 }); query.andWhere('d.isActive = :isActive', { isActive: true });
return query.getMany(); return query.getMany();
} }
async createDiscipline(dto: CreateDisciplineDto) { async createDiscipline(dto: any) {
const internalContractId = await this.resolveContractId(dto.contractId);
const exists = await this.disciplineRepo.findOne({ const exists = await this.disciplineRepo.findOne({
where: { contractId: dto.contractId, disciplineCode: dto.disciplineCode }, where: { contractId: internalContractId, disciplineCode: dto.disciplineCode },
}); });
if (exists) if (exists)
throw new ConflictException( throw new ConflictException(
'Discipline code already exists in this contract' '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); return this.disciplineRepo.save(discipline);
} }
@@ -185,23 +219,25 @@ export class MasterService {
// 📑 Sub-Types Logic // 📑 Sub-Types Logic
// ================================================================= // =================================================================
async findAllSubTypes(contractId?: number, typeId?: number) { async findAllSubTypes(contractId?: number | string, typeId?: number) {
const query = this.subTypeRepo const query = this.subTypeRepo
.createQueryBuilder('st') .createQueryBuilder('st')
.leftJoinAndSelect('st.contract', 'c') .leftJoinAndSelect('st.contract', 'c')
.leftJoinAndSelect('st.correspondenceType', 'ct') .leftJoinAndSelect('st.correspondenceType', 'ct')
.orderBy('st.subTypeCode', 'ASC'); .orderBy('st.subTypeCode', 'ASC');
if (contractId) if (contractId) {
query.andWhere('st.contractId = :contractId', { contractId }); const internalId = await this.resolveContractId(contractId);
query.andWhere('st.contractId = :contractId', { contractId: internalId });
}
if (typeId) query.andWhere('st.correspondenceTypeId = :typeId', { typeId }); if (typeId) query.andWhere('st.correspondenceTypeId = :typeId', { typeId });
return query.getMany(); return query.getMany();
} }
async createSubType(dto: CreateSubTypeDto) { async createSubType(dto: any) {
// อาจจะเช็ค Duplicate code ด้วย logic คล้าย discipline const internalContractId = await this.resolveContractId(dto.contractId);
const subType = this.subTypeRepo.create(dto); const subType = this.subTypeRepo.create({ ...dto, contractId: internalContractId });
return this.subTypeRepo.save(subType); return this.subTypeRepo.save(subType);
} }
@@ -216,47 +252,43 @@ export class MasterService {
// 🔢 Numbering Formats Logic // 🔢 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({ const format = await this.formatRepo.findOne({
where: { projectId, correspondenceTypeId: typeId }, where: { projectId: internalId, correspondenceTypeId: typeId },
}); });
if (!format) { return format || null;
// Optional: Return default format structure or null
return null;
}
return format;
} }
async saveNumberFormat(dto: SaveNumberFormatDto) { async saveNumberFormat(dto: any) {
// Check if exists (Upsert) const internalProjectId = await this.resolveProjectId(dto.projectId);
let format = await this.formatRepo.findOne({ let format = await this.formatRepo.findOne({
where: { where: {
projectId: dto.projectId, projectId: internalProjectId,
correspondenceTypeId: dto.correspondenceTypeId, correspondenceTypeId: dto.correspondenceTypeId,
}, },
}); });
if (format) { if (format) {
format.formatTemplate = dto.formatTemplate; format.formatTemplate = dto.formatTemplate;
// format.updatedBy = ... (ถ้ามี)
} else { } else {
format = this.formatRepo.create({ format = this.formatRepo.create({
projectId: dto.projectId, ...dto,
correspondenceTypeId: dto.correspondenceTypeId, projectId: internalProjectId,
formatTemplate: dto.formatTemplate,
}); });
} }
return this.formatRepo.save(format); return this.formatRepo.save(format);
} }
// ... (Tag Logic เดิม คงไว้ตามปกติ) ...
async findAllTags(query?: SearchTagDto) { async findAllTags(query?: SearchTagDto) {
const qb = this.tagRepo.createQueryBuilder('tag'); const qb = this.tagRepo.createQueryBuilder('tag');
if (query?.project_id) { 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', { qb.andWhere('tag.project_id = :projectId', {
projectId: query.project_id, projectId: internalId,
}); });
} }
@@ -288,16 +320,21 @@ export class MasterService {
return tag; 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({ const tag = this.tagRepo.create({
...dto, ...dto,
project_id: internalProjectId,
created_by: userId, created_by: userId,
}); });
return this.tagRepo.save(tag); return this.tagRepo.save(tag);
} }
async updateTag(id: number, dto: UpdateTagDto) { async updateTag(id: number, dto: any) {
const tag = await this.findOneTag(id); const tag = await this.findOneTag(id);
if (dto.project_id) {
dto.project_id = await this.resolveProjectId(dto.project_id);
}
Object.assign(tag, dto); Object.assign(tag, dto);
return this.tagRepo.save(tag); return this.tagRepo.save(tag);
} }
@@ -6,7 +6,7 @@ import {
BeforeInsert, BeforeInsert,
} from 'typeorm'; } from 'typeorm';
import { v7 as uuidv7 } from 'uuid'; 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 { BaseEntity } from '../../../common/entities/base.entity';
import { Contract } from '../../contract/entities/contract.entity'; import { Contract } from '../../contract/entities/contract.entity';
@@ -16,6 +16,7 @@ export class Project extends BaseEntity {
@Exclude() @Exclude()
id!: number; id!: number;
@Expose({ name: 'id' })
@Column({ @Column({
type: 'uuid', type: 'uuid',
unique: true, unique: true,
@@ -214,10 +214,12 @@ export default function ContractsPage() {
const handleEdit = (contract: Contract) => { const handleEdit = (contract: Contract) => {
setEditingUuid(contract.uuid); 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({ reset({
contractCode: contract.contractCode, contractCode: contract.contractCode,
contractName: contract.contractName, contractName: contract.contractName,
projectId: contract.projectId?.toString() || "", projectId: pId,
description: contract.description || "", description: contract.description || "",
startDate: contract.startDate ? new Date(contract.startDate).toISOString().split('T')[0] : "", startDate: contract.startDate ? new Date(contract.startDate).toISOString().split('T')[0] : "",
endDate: contract.endDate ? new Date(contract.endDate).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) => { const onSubmit = (data: ContractFormData) => {
// ADR-019: Resolve projectId (ID or UUID)
const submitData = { const submitData = {
...data, ...data,
projectId: parseInt(data.projectId), projectId: isNaN(Number(data.projectId)) ? data.projectId : Number(data.projectId),
}; } as any;
if (editingUuid) { if (editingUuid) {
updateContract.mutate({ uuid: editingUuid, data: submitData }); updateContract.mutate({ uuid: editingUuid, data: submitData });
@@ -304,8 +307,8 @@ export default function ContractsPage() {
<SelectValue placeholder="Select Project" /> <SelectValue placeholder="Select Project" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(projects as Project[])?.map((p) => ( {(projects as any[])?.map((p) => (
<SelectItem key={p.id} value={p.id.toString()}> <SelectItem key={p.uuid || p.id} value={String(p.id || p.uuid)}>
{p.projectCode} - {p.projectName} {p.projectCode} - {p.projectName}
</SelectItem> </SelectItem>
))} ))}
@@ -70,8 +70,8 @@ export default function ContractCategoriesPage() {
)} )}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => ( {(projects as any[]).map((project) => (
<SelectItem key={project.id} value={String(project.id)}> <SelectItem key={project.uuid || project.id} value={String(project.id || project.uuid)}>
{project.projectCode} - {project.projectName} {project.projectCode} - {project.projectName}
</SelectItem> </SelectItem>
))} ))}
@@ -62,8 +62,8 @@ export default function ContractSubCategoriesPage() {
)} )}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => ( {(projects as any[]).map((project) => (
<SelectItem key={project.id} value={String(project.id)}> <SelectItem key={project.uuid || project.id} value={String(project.id || project.uuid)}>
{project.projectCode} - {project.projectName} {project.projectCode} - {project.projectName}
</SelectItem> </SelectItem>
))} ))}
@@ -74,8 +74,8 @@ export default function ContractVolumesPage() {
)} )}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => ( {(projects as any[]).map((project) => (
<SelectItem key={project.id} value={String(project.id)}> <SelectItem key={project.uuid || project.id} value={String(project.id || project.uuid)}>
{project.projectCode} - {project.projectName} {project.projectCode} - {project.projectName}
</SelectItem> </SelectItem>
))} ))}
@@ -73,8 +73,8 @@ export default function ShopMainCategoriesPage() {
)} )}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => ( {(projects as any[]).map((project) => (
<SelectItem key={project.id} value={String(project.id)}> <SelectItem key={project.uuid || project.id} value={String(project.id || project.uuid)}>
{project.projectCode} - {project.projectName} {project.projectCode} - {project.projectName}
</SelectItem> </SelectItem>
))} ))}
@@ -75,8 +75,8 @@ export default function ShopSubCategoriesPage() {
)} )}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => ( {(projects as any[]).map((project) => (
<SelectItem key={project.id} value={String(project.id)}> <SelectItem key={project.uuid || project.id} value={String(project.id || project.uuid)}>
{project.projectCode} - {project.projectName} {project.projectCode} - {project.projectName}
</SelectItem> </SelectItem>
))} ))}
@@ -24,29 +24,37 @@ import { BulkImportForm } from '@/components/numbering/bulk-import-form';
export default function NumberingPage() { export default function NumberingPage() {
const { data: projects = [] } = useProjects(); const { data: projects = [] } = useProjects();
const [selectedProjectId, setSelectedProjectId] = useState('1'); // Initialize with empty string or first project if available
const [selectedProjectId, setSelectedProjectId] = useState<string>('');
const [activeTab, setActiveTab] = useState('templates'); 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 // View states
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [activeTemplate, setActiveTemplate] = useState<NumberingTemplate | undefined>(undefined); const [activeTemplate, setActiveTemplate] = useState<NumberingTemplate | undefined>(undefined);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null); const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
const selectedProjectName = const selectedProject = projects.find((p: any) => String(p.id || p.uuid) === selectedProjectId) as any;
projects.find((p: { id: number; projectName: string }) => p.id.toString() === selectedProjectId)?.projectName || const selectedProjectName = selectedProject?.projectName || 'Unknown Project';
'Unknown Project';
// Master Data // Master Data
const { data: correspondenceTypes = [] } = useCorrespondenceTypes(); const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
const { data: contracts = [] } = useContracts(Number(selectedProjectId)); const { data: contracts = [] } = useContracts(selectedProjectId as any); // Passing UUID/ID string
const contractId = contracts[0]?.id; const firstContract = contracts[0] as any;
const contractId = firstContract?.id || firstContract?.uuid;
const { data: disciplines = [] } = useDisciplines(contractId); const { data: disciplines = [] } = useDisciplines(contractId);
const { data: templateResponse, isLoading: isLoadingTemplates } = useTemplates(); const { data: templateResponse, isLoading: isLoadingTemplates } = useTemplates();
const saveTemplateMutation = useSaveTemplate(); 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) const templates: NumberingTemplate[] = Array.isArray(templateResponse)
? templateResponse ? templateResponse
: ((templateResponse as any)?.data ?? []); : ((templateResponse as any)?.data ?? []);
@@ -76,7 +84,7 @@ export default function NumberingPage() {
<div className="p-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4"> <div className="p-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4">
<TemplateEditor <TemplateEditor
template={activeTemplate} template={activeTemplate}
projectId={Number(selectedProjectId)} projectId={selectedProjectId as any}
projectName={selectedProjectName} projectName={selectedProjectName}
correspondenceTypes={correspondenceTypes} correspondenceTypes={correspondenceTypes}
disciplines={disciplines} disciplines={disciplines}
@@ -100,8 +108,8 @@ export default function NumberingPage() {
<SelectValue placeholder="Select Project" /> <SelectValue placeholder="Select Project" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projects.map((project: { id: number; projectCode: string; projectName: string }) => ( {(projects as any[]).map((project) => (
<SelectItem key={project.id} value={project.id.toString()}> <SelectItem key={project.uuid || project.id} value={String(project.id || project.uuid)}>
{project.projectCode} - {project.projectName} {project.projectCode} - {project.projectName}
</SelectItem> </SelectItem>
))} ))}
@@ -129,7 +137,7 @@ export default function NumberingPage() {
<div className="lg:col-span-2 space-y-4"> <div className="lg:col-span-2 space-y-4">
<div className="grid gap-4"> <div className="grid gap-4">
{templates {templates
.filter((t) => !t.projectId || t.projectId === Number(selectedProjectId)) .filter((t: any) => !t.projectId || String(t.projectId) === selectedProjectId || t.project?.uuid === selectedProjectId)
.map((template) => ( .map((template) => (
<Card key={template.id} className="p-6 hover:shadow-md transition-shadow"> <Card key={template.id} className="p-6 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
@@ -194,11 +202,11 @@ export default function NumberingPage() {
<TabsContent value="tools" className="space-y-4"> <TabsContent value="tools" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<ManualOverrideForm projectId={Number(selectedProjectId)} /> <ManualOverrideForm projectId={selectedProjectId as any} />
<VoidReplaceForm projectId={Number(selectedProjectId)} /> <VoidReplaceForm projectId={selectedProjectId as any} />
<CancelNumberForm /> <CancelNumberForm />
<div className="md:col-span-2"> <div className="md:col-span-2">
<BulkImportForm projectId={Number(selectedProjectId)} /> <BulkImportForm projectId={selectedProjectId as any} />
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
@@ -43,9 +43,9 @@ export default function DisciplinesPage() {
}, },
]; ];
const contractOptions = contracts.map((c) => ({ const contractOptions = contracts.map((c: any) => ({
label: `${c.contractName} (${c.contractCode})`, label: `${c.contractName} (${c.contractCode})`,
value: c.id, value: String(c.id || c.uuid),
})); }));
return ( return (
@@ -55,8 +55,8 @@ export default function DisciplinesPage() {
title="Disciplines Management" title="Disciplines Management"
description="Manage system disciplines (e.g., ARCH, STR, MEC)" description="Manage system disciplines (e.g., ARCH, STR, MEC)"
queryKey={['disciplines', selectedContractId ?? 'all']} queryKey={['disciplines', selectedContractId ?? 'all']}
fetchFn={() => masterDataService.getDisciplines(selectedContractId ? parseInt(selectedContractId) : undefined)} fetchFn={() => masterDataService.getDisciplines(selectedContractId ? selectedContractId : undefined)}
createFn={(data: Record<string, unknown>) => masterDataService.createDiscipline(data as unknown as Parameters<typeof masterDataService.createDiscipline>[0])} createFn={(data: Record<string, unknown>) => masterDataService.createDiscipline(data as any)}
updateFn={(id, data) => Promise.reject('Not implemented yet')} updateFn={(id, data) => Promise.reject('Not implemented yet')}
deleteFn={(id) => masterDataService.deleteDiscipline(id)} deleteFn={(id) => masterDataService.deleteDiscipline(id)}
columns={columns} columns={columns}
@@ -71,8 +71,8 @@ export default function DisciplinesPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Contracts</SelectItem> <SelectItem value="all">All Contracts</SelectItem>
{contracts.map((c) => ( {contracts.map((c: any) => (
<SelectItem key={c.id} value={c.id.toString()}> <SelectItem key={c.uuid || c.id} value={String(c.id || c.uuid)}>
{c.contractName} ({c.contractCode}) {c.contractName} ({c.contractCode})
</SelectItem> </SelectItem>
))} ))}
@@ -47,9 +47,9 @@ export default function RfaTypesPage() {
}, },
]; ];
const contractOptions = contracts.map((c) => ({ const contractOptions = contracts.map((c: any) => ({
label: `${c.contractName} (${c.contractCode})`, label: `${c.contractName} (${c.contractCode})`,
value: c.id, value: String(c.id || c.uuid),
})); }));
return ( return (
@@ -58,8 +58,8 @@ export default function RfaTypesPage() {
entityName="RFA Type" entityName="RFA Type"
title="RFA Types Management" title="RFA Types Management"
queryKey={['rfa-types', selectedContractId ?? 'all']} queryKey={['rfa-types', selectedContractId ?? 'all']}
fetchFn={() => masterDataService.getRfaTypes(selectedContractId ? parseInt(selectedContractId) : undefined)} fetchFn={() => masterDataService.getRfaTypes(selectedContractId ? selectedContractId : undefined)}
createFn={(data: Record<string, unknown>) => masterDataService.createRfaType(data as unknown as any)} createFn={(data: Record<string, unknown>) => masterDataService.createRfaType(data as any)}
updateFn={(id, data) => masterDataService.updateRfaType(id, data)} updateFn={(id, data) => masterDataService.updateRfaType(id, data)}
deleteFn={(id) => masterDataService.deleteRfaType(id)} deleteFn={(id) => masterDataService.deleteRfaType(id)}
columns={columns} columns={columns}
@@ -74,8 +74,8 @@ export default function RfaTypesPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Contracts</SelectItem> <SelectItem value="all">All Contracts</SelectItem>
{contracts.map((c) => ( {contracts.map((c: any) => (
<SelectItem key={c.id} value={c.id.toString()}> <SelectItem key={c.uuid || c.id} value={String(c.id || c.uuid)}>
{c.contractName} ({c.contractCode}) {c.contractName} ({c.contractCode})
</SelectItem> </SelectItem>
))} ))}
@@ -1,9 +1,22 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { DataTable } from "@/components/common/data-table"; import {
import { ColumnDef } from "@tanstack/react-table"; 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 { Button } from "@/components/ui/button";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,20 +24,11 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -35,31 +39,41 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } 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; name: string;
label: string; label: string;
type: "text" | "textarea" | "checkbox" | "select"; type: "text" | "number" | "checkbox" | "select" | "textarea";
required?: boolean; required?: boolean;
options?: { label: string; value: string | number | boolean }[]; options?: { label: string; value: string | number }[];
} }
interface GenericCrudTableProps<TEntity extends { id: number }> { interface GenericCrudTableProps<T> {
entityName: string; title: string;
queryKey: string[];
fetchFn: () => Promise<TEntity[]>;
createFn: (data: Record<string, unknown>) => Promise<TEntity>;
updateFn: (id: number, data: Record<string, unknown>) => Promise<TEntity>;
deleteFn: (id: number) => Promise<unknown>;
columns: ColumnDef<TEntity>[];
fields: FieldConfig[];
title?: string;
description?: string; description?: string;
entityName: string;
queryKey: any[];
fetchFn: () => Promise<T[] | { data: T[] }>;
createFn: (data: any) => Promise<any>;
updateFn: (id: number, data: any) => Promise<any>;
deleteFn: (id: number) => Promise<any>;
columns: ColumnDef<T>[];
fields: Field[];
filters?: React.ReactNode; filters?: React.ReactNode;
} }
export function GenericCrudTable<TEntity extends { id: number }>({ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
title,
description,
entityName, entityName,
queryKey, queryKey,
fetchFn, fetchFn,
@@ -68,254 +82,303 @@ export function GenericCrudTable<TEntity extends { id: number }>({
deleteFn, deleteFn,
columns, columns,
fields, fields,
title,
description,
filters, filters,
}: GenericCrudTableProps<TEntity>) { }: GenericCrudTableProps<T>) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<TEntity | null>(null); const [editingItem, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<Record<string, unknown>>({});
// Delete Dialog State
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<number | null>(null); const [itemToDelete, setItemToDelete] = useState<number | null>(null);
const { data, isLoading, refetch } = useQuery({ const { data: rawData, isLoading, refetch } = useQuery({
queryKey, queryKey,
queryFn: fetchFn, 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({ const createMutation = useMutation({
mutationFn: createFn, mutationFn: createFn,
onSuccess: () => { onSuccess: () => {
toast.success(`${entityName} created successfully`);
queryClient.invalidateQueries({ queryKey }); 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({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => updateFn(id, data), mutationFn: ({ id, data }: { id: number; data: any }) => updateFn(id, data),
onSuccess: () => { onSuccess: () => {
toast.success(`${entityName} updated successfully`);
queryClient.invalidateQueries({ queryKey }); 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({ const deleteMutation = useMutation({
mutationFn: deleteFn, mutationFn: deleteFn,
onSuccess: () => { onSuccess: () => {
toast.success(`${entityName} deleted successfully`);
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
setDeleteDialogOpen(false); toast.success(`${entityName} deleted successfully`);
setItemToDelete(null); setItemToDelete(null);
}, },
onError: () => toast.error(`Failed to delete ${entityName}`), onError: (error: any) => {
toast.error(error.response?.data?.message || `Failed to delete ${entityName}`);
},
}); });
const handleCreate = () => { const {
setEditingItem(null); register,
setFormData({}); handleSubmit,
setIsOpen(true); reset,
setValue,
watch,
formState: { errors },
} = useForm();
const table = useReactTable({
data,
columns: [
...columns,
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(row.original)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => setItemToDelete(row.original.id as number)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
),
},
],
getCoreRowModel: getCoreRowModel(),
});
const handleAdd = () => {
setEditingId(null);
reset();
fields.forEach((f) => {
if (f.type === "checkbox") setValue(f.name, true);
});
setIsDialogOpen(true);
}; };
const handleEdit = (item: TEntity) => { const handleEdit = (item: any) => {
setEditingItem(item); setEditingId(item.id);
setFormData({ ...item }); reset(item);
setIsOpen(true); // 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) => { const onSubmit = (formData: any) => {
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();
if (editingItem) { if (editingItem) {
updateMutation.mutate({ id: editingItem.id, data: formData }); updateMutation.mutate({ id: editingItem, data: formData });
} else { } else {
createMutation.mutate(formData); createMutation.mutate(formData);
} }
}; };
const handleChange = (field: string, value: unknown) => {
setFormData((prev: Record<string, unknown>) => ({ ...prev, [field]: value }));
};
// Add default Actions column if not present
const tableColumns = [
...columns,
{
id: "actions",
header: "Actions",
cell: ({ row }: { row: { original: TEntity } }) => (
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(row.original)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => handleDeleteClick(row.original.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
),
},
];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex justify-between items-center">
<div> <div>
{title && <h2 className="text-xl font-bold">{title}</h2>} <h2 className="text-2xl font-bold tracking-tight">{title}</h2>
{description && ( {description && (
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-muted-foreground">{description}</p>
)} )}
</div> </div>
<div className="flex gap-2 items-center"> <Button onClick={handleAdd}>
{filters} <Plus className="h-4 w-4 mr-2" /> Add {entityName}
<Button </Button>
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
<Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
Add {entityName}
</Button>
</div>
</div> </div>
{isLoading ? ( {filters && <div className="py-2">{filters}</div>}
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center space-x-4">
<Skeleton className="h-12 w-full" />
</div>
))}
</div>
) : (
<DataTable columns={tableColumns} data={data || []} />
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}> <div className="rounded-md border bg-card">
<DialogContent> <Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length + 1}
className="h-24 text-center"
>
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
</TableCell>
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length + 1}
className="h-24 text-center text-muted-foreground"
>
No data found.
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editingItem ? `Edit ${entityName}` : `New ${entityName}`} {editingItem ? `Edit ${entityName}` : `Add New ${entityName}`}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 py-4">
{fields.map((field) => ( {fields.map((field) => (
<div key={field.name} className="space-y-2"> <div key={field.name} className="space-y-2">
<Label htmlFor={field.name}>{field.label}</Label> <Label htmlFor={field.name}>
{field.type === "textarea" ? ( {field.label} {field.required && "*"}
<Textarea </Label>
id={field.name} {field.type === "checkbox" ? (
value={(formData[field.name] as string) || ""}
onChange={(e) => handleChange(field.name, e.target.value)}
required={field.required}
/>
) : field.type === "checkbox" ? (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id={field.name} id={field.name}
checked={!!formData[field.name]} checked={watch(field.name)}
onCheckedChange={(checked) => onCheckedChange={(checked) => setValue(field.name, checked)}
handleChange(field.name, checked)
}
/> />
<label htmlFor={field.name} className="text-sm"> <label
Enabled htmlFor={field.name}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Active
</label> </label>
</div> </div>
) : field.type === "select" ? ( ) : field.type === "select" ? (
<Select <Select
value={formData[field.name]?.toString() || ""} value={String(watch(field.name) || "")}
onValueChange={(value) => handleChange(field.name, value)} onValueChange={(val) => setValue(field.name, val)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={`Select ${field.label}`} /> <SelectValue placeholder={`Select ${field.label}...`} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{field.options?.map((opt) => ( {field.options?.map((opt) => (
<SelectItem key={(opt.value as string | number)} value={opt.value.toString()}> <SelectItem key={opt.value} value={String(opt.value)}>
{opt.label} {opt.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) : field.type === "textarea" ? (
<Textarea
id={field.name}
{...register(field.name, { required: field.required })}
/>
) : ( ) : (
<Input <Input
id={field.name} id={field.name}
type="text" type={field.type}
value={(formData[field.name] as string) || ""} {...register(field.name, {
onChange={(e) => handleChange(field.name, e.target.value)} required: field.required,
required={field.required} valueAsNumber: field.type === "number",
})}
/> />
)} )}
{errors[field.name] && (
<p className="text-xs text-red-500 font-medium">
{field.label} is required
</p>
)}
</div> </div>
))} ))}
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={handleClose} onClick={() => setIsDialogOpen(false)}
disabled={createMutation.isPending || updateMutation.isPending}
> >
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
type="submit" {(createMutation.isPending || updateMutation.isPending) && (
disabled={createMutation.isPending || updateMutation.isPending} <Loader2 className="mr-2 h-4 w-4 animate-spin" />
> )}
{editingItem ? "Update" : "Create"} {editingItem ? "Save Changes" : `Add ${entityName}`}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog
open={itemToDelete !== null}
onOpenChange={(open) => !open && setItemToDelete(null)}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete the {entityName.toLowerCase()} and remove it from the system. This action cannot be undone. This will permanently delete this{" "}
{entityName.toLowerCase()} and remove its data from our servers.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDelete} onClick={() => itemToDelete && deleteMutation.mutate(itemToDelete)}
className="bg-red-600 hover:bg-red-700" className="bg-red-600 hover:bg-red-700"
> >
{deleteMutation.isPending ? "Deleting..." : "Delete"} {deleteMutation.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction> </AlertDialogAction>