251225:1703 On going update to 1.7.0: Refoctory drawing Module not finish
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-25 17:03:33 +07:00
parent 7db6a003db
commit cd73cc1549
60 changed files with 8201 additions and 832 deletions

1
backend/hash.txt Normal file
View File

@@ -0,0 +1 @@
$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose

View File

@@ -90,8 +90,8 @@ export class DocumentNumberingController {
async previewNumber(@Body() dto: PreviewNumberDto) {
return this.numberingService.previewNumber({
projectId: dto.projectId,
originatorOrganizationId: dto.originatorId,
typeId: dto.typeId,
originatorOrganizationId: dto.originatorOrganizationId,
typeId: dto.correspondenceTypeId,
subTypeId: dto.subTypeId,
rfaTypeId: dto.rfaTypeId,
disciplineId: dto.disciplineId,

View File

@@ -12,12 +12,12 @@ export class PreviewNumberDto {
@ApiProperty({ description: 'Originator organization ID' })
@IsInt()
@Type(() => Number)
originatorId!: number;
originatorOrganizationId!: number;
@ApiProperty({ description: 'Correspondence type ID' })
@IsInt()
@Type(() => Number)
typeId!: number;
correspondenceTypeId!: number;
@ApiPropertyOptional({ description: 'Sub type ID (for TRANSMITTAL)' })
@IsOptional()

View File

@@ -108,8 +108,8 @@ export class FormatService {
'{ORG}': orgCode,
'{RECIPIENT}': recipientCode,
'{DISCIPLINE}': disciplineCode,
'{YEAR}': year.toString().substring(2),
'{YEAR:BE}': (year + 543).toString().substring(2),
'{YEAR}': year.toString(),
'{YEAR:BE}': (year + 543).toString(),
'{REV}': '0',
};
}

View File

@@ -0,0 +1,101 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
ParseIntPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
// Guards
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
// Services
import { AsBuiltDrawingService } from './asbuilt-drawing.service';
// DTOs
import { CreateAsBuiltDrawingDto } from './dto/create-asbuilt-drawing.dto';
import { CreateAsBuiltDrawingRevisionDto } from './dto/create-asbuilt-drawing-revision.dto';
import { SearchAsBuiltDrawingDto } from './dto/search-asbuilt-drawing.dto';
// Decorators
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Audit } from '../../common/decorators/audit.decorator';
import { User } from '../user/entities/user.entity';
@ApiTags('Drawings - AS Built')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('drawings/asbuilt')
export class AsBuiltDrawingController {
constructor(private readonly asBuiltDrawingService: AsBuiltDrawingService) {}
@Post()
@ApiOperation({ summary: 'Create new AS Built Drawing' })
@ApiResponse({ status: 201, description: 'AS Built Drawing created' })
@ApiResponse({ status: 409, description: 'Drawing number already exists' })
@RequirePermission('drawing.create')
@Audit('drawing.create', 'asbuilt_drawing')
async create(
@Body() createDto: CreateAsBuiltDrawingDto,
@CurrentUser() user: User
) {
return this.asBuiltDrawingService.create(createDto, user);
}
@Post(':id/revisions')
@ApiOperation({ summary: 'Create new revision for AS Built Drawing' })
@ApiResponse({ status: 201, description: 'Revision created' })
@ApiResponse({ status: 404, description: 'AS Built Drawing not found' })
@ApiResponse({ status: 409, description: 'Revision label already exists' })
async createRevision(
@Param('id', ParseIntPipe) id: number,
@Body() createDto: CreateAsBuiltDrawingRevisionDto
) {
return this.asBuiltDrawingService.createRevision(id, createDto);
}
@Get()
@ApiOperation({ summary: 'List AS Built Drawings with search/pagination' })
@ApiResponse({ status: 200, description: 'List of AS Built Drawings' })
@RequirePermission('drawing.view')
async findAll(@Query() searchDto: SearchAsBuiltDrawingDto) {
return this.asBuiltDrawingService.findAll(searchDto);
}
@Get(':id')
@ApiOperation({ summary: 'Get AS Built Drawing by ID' })
@ApiResponse({ status: 200, description: 'AS Built Drawing details' })
@ApiResponse({ status: 404, description: 'AS Built Drawing not found' })
@RequirePermission('drawing.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.asBuiltDrawingService.findOne(id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft delete AS Built Drawing' })
@ApiResponse({ status: 204, description: 'AS Built Drawing deleted' })
@ApiResponse({ status: 404, description: 'AS Built Drawing not found' })
@RequirePermission('drawing.delete')
@Audit('drawing.delete', 'asbuilt_drawing')
async remove(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
) {
return this.asBuiltDrawingService.remove(id, user);
}
}

View File

@@ -0,0 +1,307 @@
import {
Injectable,
NotFoundException,
ConflictException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm';
// Entities
import { AsBuiltDrawing } from './entities/asbuilt-drawing.entity';
import { AsBuiltDrawingRevision } from './entities/asbuilt-drawing-revision.entity';
import { ShopDrawingRevision } from './entities/shop-drawing-revision.entity';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { User } from '../user/entities/user.entity';
// DTOs
import { CreateAsBuiltDrawingDto } from './dto/create-asbuilt-drawing.dto';
import { CreateAsBuiltDrawingRevisionDto } from './dto/create-asbuilt-drawing-revision.dto';
import { SearchAsBuiltDrawingDto } from './dto/search-asbuilt-drawing.dto';
// Services
import { FileStorageService } from '../../common/file-storage/file-storage.service';
@Injectable()
export class AsBuiltDrawingService {
private readonly logger = new Logger(AsBuiltDrawingService.name);
constructor(
@InjectRepository(AsBuiltDrawing)
private asBuiltDrawingRepo: Repository<AsBuiltDrawing>,
@InjectRepository(AsBuiltDrawingRevision)
private revisionRepo: Repository<AsBuiltDrawingRevision>,
@InjectRepository(ShopDrawingRevision)
private shopDrawingRevisionRepo: Repository<ShopDrawingRevision>,
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>,
private fileStorageService: FileStorageService,
private dataSource: DataSource
) {}
/**
* สร้าง AS Built Drawing ใหม่ พร้อม Revision แรก (Rev 0)
*/
async create(createDto: CreateAsBuiltDrawingDto, user: User) {
// 1. Check Duplicate
const exists = await this.asBuiltDrawingRepo.findOne({
where: { drawingNumber: createDto.drawingNumber },
});
if (exists) {
throw new ConflictException(
`Drawing number "${createDto.drawingNumber}" already exists.`
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 2. Prepare Relations
let shopDrawingRevisions: ShopDrawingRevision[] = [];
if (createDto.shopDrawingRevisionIds?.length) {
shopDrawingRevisions = await this.shopDrawingRevisionRepo.findBy({
id: In(createDto.shopDrawingRevisionIds),
});
}
let attachments: Attachment[] = [];
if (createDto.attachmentIds?.length) {
attachments = await this.attachmentRepo.findBy({
id: In(createDto.attachmentIds),
});
}
// 3. Create Master AS Built Drawing
const asBuiltDrawing = queryRunner.manager.create(AsBuiltDrawing, {
projectId: createDto.projectId,
drawingNumber: createDto.drawingNumber,
mainCategoryId: createDto.mainCategoryId,
subCategoryId: createDto.subCategoryId,
updatedBy: user.user_id,
});
const savedDrawing = await queryRunner.manager.save(asBuiltDrawing);
// 4. Create First Revision (Rev 0)
const revision = queryRunner.manager.create(AsBuiltDrawingRevision, {
asBuiltDrawingId: savedDrawing.id,
revisionNumber: 0,
revisionLabel: createDto.revisionLabel || '0',
title: createDto.title,
legacyDrawingNumber: createDto.legacyDrawingNumber,
revisionDate: createDto.revisionDate
? new Date(createDto.revisionDate)
: new Date(),
description: createDto.description,
shopDrawingRevisions: shopDrawingRevisions,
attachments: attachments,
});
await queryRunner.manager.save(revision);
// 5. Commit Files
if (createDto.attachmentIds?.length) {
await this.fileStorageService.commit(
createDto.attachmentIds.map(String)
);
}
await queryRunner.commitTransaction();
return {
...savedDrawing,
currentRevision: revision,
};
} catch (err) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Failed to create AS Built drawing: ${(err as Error).message}`
);
throw err;
} finally {
await queryRunner.release();
}
}
/**
* เพิ่ม Revision ใหม่ (Add Revision)
*/
async createRevision(
asBuiltDrawingId: number,
createDto: CreateAsBuiltDrawingRevisionDto
) {
const asBuiltDrawing = await this.asBuiltDrawingRepo.findOneBy({
id: asBuiltDrawingId,
});
if (!asBuiltDrawing) {
throw new NotFoundException('AS Built Drawing not found');
}
const exists = await this.revisionRepo.findOne({
where: { asBuiltDrawingId, revisionLabel: createDto.revisionLabel },
});
if (exists) {
throw new ConflictException(
`Revision label "${createDto.revisionLabel}" already exists for this drawing.`
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
let shopDrawingRevisions: ShopDrawingRevision[] = [];
if (createDto.shopDrawingRevisionIds?.length) {
shopDrawingRevisions = await this.shopDrawingRevisionRepo.findBy({
id: In(createDto.shopDrawingRevisionIds),
});
}
let attachments: Attachment[] = [];
if (createDto.attachmentIds?.length) {
attachments = await this.attachmentRepo.findBy({
id: In(createDto.attachmentIds),
});
}
const latestRev = await this.revisionRepo.findOne({
where: { asBuiltDrawingId },
order: { revisionNumber: 'DESC' },
});
const nextRevNum = (latestRev?.revisionNumber ?? -1) + 1;
const revision = queryRunner.manager.create(AsBuiltDrawingRevision, {
asBuiltDrawingId,
revisionNumber: nextRevNum,
revisionLabel: createDto.revisionLabel,
title: createDto.title,
legacyDrawingNumber: createDto.legacyDrawingNumber,
revisionDate: createDto.revisionDate
? new Date(createDto.revisionDate)
: new Date(),
description: createDto.description,
shopDrawingRevisions: shopDrawingRevisions,
attachments: attachments,
});
await queryRunner.manager.save(revision);
if (createDto.attachmentIds?.length) {
await this.fileStorageService.commit(
createDto.attachmentIds.map(String)
);
}
await queryRunner.commitTransaction();
return revision;
} catch (err) {
await queryRunner.rollbackTransaction();
this.logger.error(`Failed to create revision: ${(err as Error).message}`);
throw err;
} finally {
await queryRunner.release();
}
}
/**
* ค้นหา AS Built Drawing
*/
async findAll(searchDto: SearchAsBuiltDrawingDto) {
const {
projectId,
mainCategoryId,
subCategoryId,
search,
page = 1,
limit = 20,
} = searchDto;
const query = this.asBuiltDrawingRepo
.createQueryBuilder('abd')
.leftJoinAndSelect('abd.mainCategory', 'mainCat')
.leftJoinAndSelect('abd.subCategory', 'subCat')
.leftJoinAndSelect('abd.revisions', 'rev')
.where('abd.projectId = :projectId', { projectId });
if (mainCategoryId) {
query.andWhere('abd.mainCategoryId = :mainCategoryId', {
mainCategoryId,
});
}
if (subCategoryId) {
query.andWhere('abd.subCategoryId = :subCategoryId', { subCategoryId });
}
if (search) {
query.andWhere('abd.drawingNumber LIKE :search', {
search: `%${search}%`,
});
}
query.orderBy('abd.updatedAt', 'DESC');
const skip = (page - 1) * limit;
query.skip(skip).take(limit);
const [items, total] = await query.getManyAndCount();
// Transform Data
const transformedItems = items.map((item) => {
item.revisions.sort((a, b) => b.revisionNumber - a.revisionNumber);
const currentRevision = item.revisions[0];
return {
...item,
currentRevision,
revisions: undefined,
};
});
return {
data: transformedItems,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* ดูรายละเอียด AS Built Drawing
*/
async findOne(id: number) {
const asBuiltDrawing = await this.asBuiltDrawingRepo.findOne({
where: { id },
relations: [
'mainCategory',
'subCategory',
'revisions',
'revisions.attachments',
'revisions.shopDrawingRevisions',
],
order: {
revisions: { revisionNumber: 'DESC' },
},
});
if (!asBuiltDrawing) {
throw new NotFoundException(`AS Built Drawing ID ${id} not found`);
}
return asBuiltDrawing;
}
/**
* ลบ AS Built Drawing
*/
async remove(id: number, user: User) {
const asBuiltDrawing = await this.findOne(id);
asBuiltDrawing.updatedBy = user.user_id;
await this.asBuiltDrawingRepo.save(asBuiltDrawing);
return this.asBuiltDrawingRepo.softRemove(asBuiltDrawing);
}
}

View File

@@ -2,12 +2,20 @@ import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { DrawingMasterDataService } from './drawing-master-data.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -17,55 +25,310 @@ import { RequirePermission } from '../../common/decorators/require-permission.de
@ApiTags('Drawing Master Data')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('drawings/master')
@Controller('drawings/master-data')
export class DrawingMasterDataController {
// ✅ ต้องมี export ตรงนี้
constructor(private readonly masterDataService: DrawingMasterDataService) {}
// --- Contract Drawing Endpoints ---
// =====================================================
// Contract Drawing Volumes
// =====================================================
@Get('contract/volumes')
@ApiOperation({ summary: 'List Contract Drawing Volumes' })
@ApiQuery({ name: 'projectId', required: true, type: Number })
@RequirePermission('document.view')
getVolumes(@Query('projectId', ParseIntPipe) projectId: number) {
return this.masterDataService.findAllVolumes(projectId);
}
@Post('contract/volumes')
@ApiOperation({ summary: 'Create Volume (Admin/PM)' })
@RequirePermission('master_data.drawing_category.manage') // สิทธิ์ ID 16
createVolume(@Body() body: any) {
// ควรใช้ DTO จริงในการผลิต
@ApiOperation({ summary: 'Create Volume' })
@RequirePermission('master_data.drawing_category.manage')
createVolume(
@Body()
body: {
projectId: number;
volumeCode: string;
volumeName: string;
description?: string;
sortOrder: number;
}
) {
return this.masterDataService.createVolume(body);
}
@Patch('contract/volumes/:id')
@ApiOperation({ summary: 'Update Volume' })
@RequirePermission('master_data.drawing_category.manage')
updateVolume(
@Param('id', ParseIntPipe) id: number,
@Body()
body: {
volumeCode?: string;
volumeName?: string;
description?: string;
sortOrder?: number;
}
) {
return this.masterDataService.updateVolume(id, body);
}
@Delete('contract/volumes/:id')
@ApiOperation({ summary: 'Delete Volume' })
@RequirePermission('master_data.drawing_category.manage')
deleteVolume(@Param('id', ParseIntPipe) id: number) {
return this.masterDataService.deleteVolume(id);
}
// =====================================================
// Contract Drawing Categories
// =====================================================
@Get('contract/categories')
@ApiOperation({ summary: 'List Contract Drawing Categories' })
@ApiQuery({ name: 'projectId', required: true, type: Number })
@RequirePermission('document.view')
getCategories(@Query('projectId', ParseIntPipe) projectId: number) {
return this.masterDataService.findAllCategories(projectId);
}
@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;
}
) {
return this.masterDataService.createCategory(body);
}
@Patch('contract/categories/:id')
@ApiOperation({ summary: 'Update Category' })
@RequirePermission('master_data.drawing_category.manage')
updateCategory(
@Param('id', ParseIntPipe) id: number,
@Body()
body: {
catCode?: string;
catName?: string;
description?: string;
sortOrder?: number;
}
) {
return this.masterDataService.updateCategory(id, body);
}
@Delete('contract/categories/:id')
@ApiOperation({ summary: 'Delete Category' })
@RequirePermission('master_data.drawing_category.manage')
deleteCategory(@Param('id', ParseIntPipe) id: number) {
return this.masterDataService.deleteCategory(id);
}
// =====================================================
// Contract Drawing Sub-Categories
// =====================================================
@Get('contract/sub-categories')
@ApiOperation({ summary: 'List Contract Drawing Sub-Categories' })
@ApiQuery({ name: 'projectId', required: true, type: Number })
@RequirePermission('document.view')
getContractSubCats(@Query('projectId', ParseIntPipe) projectId: number) {
return this.masterDataService.findAllContractSubCats(projectId);
}
@Post('contract/sub-categories')
@ApiOperation({ summary: 'Create Contract Sub-Category (Admin/PM)' })
@ApiOperation({ summary: 'Create Contract Sub-Category' })
@RequirePermission('master_data.drawing_category.manage')
createContractSubCat(@Body() body: any) {
createContractSubCat(
@Body()
body: {
projectId: number;
subCatCode: string;
subCatName: string;
description?: string;
sortOrder: number;
}
) {
return this.masterDataService.createContractSubCat(body);
}
// --- Shop Drawing Endpoints ---
@Patch('contract/sub-categories/:id')
@ApiOperation({ summary: 'Update Contract Sub-Category' })
@RequirePermission('master_data.drawing_category.manage')
updateContractSubCat(
@Param('id', ParseIntPipe) id: number,
@Body()
body: {
subCatCode?: string;
subCatName?: string;
description?: string;
sortOrder?: number;
}
) {
return this.masterDataService.updateContractSubCat(id, body);
}
async deleteContractSubCat(@Param('id', ParseIntPipe) id: number) {
return this.masterDataService.deleteContractSubCat(id);
}
// =====================================================
// Contract Drawing Mappings
// =====================================================
@Get('contract/mappings')
@ApiOperation({ summary: 'List Contract Drawing Mappings' })
@ApiQuery({ name: 'projectId', required: true, type: Number })
@ApiQuery({ name: 'categoryId', required: false, type: Number })
@RequirePermission('document.view')
getContractMappings(
@Query('projectId', ParseIntPipe) projectId: number,
@Query('categoryId') categoryId?: number
) {
return this.masterDataService.findContractMappings(
projectId,
categoryId ? Number(categoryId) : undefined
);
}
@Post('contract/mappings')
@ApiOperation({ summary: 'Create Contract Drawing Mapping' })
@RequirePermission('master_data.drawing_category.manage')
createContractMapping(
@Body()
body: {
projectId: number;
categoryId: number;
subCategoryId: number;
}
) {
return this.masterDataService.createContractMapping(body);
}
@Delete('contract/mappings/:id')
@ApiOperation({ summary: 'Delete Contract Drawing Mapping' })
@RequirePermission('master_data.drawing_category.manage')
deleteContractMapping(@Param('id', ParseIntPipe) id: number) {
return this.masterDataService.deleteContractMapping(id);
}
// =====================================================
// Shop Drawing Main Categories
// =====================================================
@Get('shop/main-categories')
@ApiOperation({ summary: 'List Shop Drawing Main Categories' })
@ApiQuery({ name: 'projectId', required: true, type: Number })
@RequirePermission('document.view')
getShopMainCats() {
return this.masterDataService.findAllShopMainCats();
getShopMainCats(@Query('projectId', ParseIntPipe) projectId: number) {
return this.masterDataService.findAllShopMainCats(projectId);
}
@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;
}
) {
return this.masterDataService.createShopMainCat(body);
}
@Patch('shop/main-categories/:id')
@ApiOperation({ summary: 'Update Shop Main Category' })
@RequirePermission('master_data.drawing_category.manage')
updateShopMainCat(
@Param('id', ParseIntPipe) id: number,
@Body()
body: {
mainCategoryCode?: string;
mainCategoryName?: string;
description?: string;
isActive?: boolean;
sortOrder?: number;
}
) {
return this.masterDataService.updateShopMainCat(id, body);
}
@Delete('shop/main-categories/:id')
@ApiOperation({ summary: 'Delete Shop Main Category' })
@RequirePermission('master_data.drawing_category.manage')
deleteShopMainCat(@Param('id', ParseIntPipe) id: number) {
return this.masterDataService.deleteShopMainCat(id);
}
// =====================================================
// Shop Drawing Sub-Categories
// =====================================================
@Get('shop/sub-categories')
@ApiOperation({ summary: 'List Shop Drawing Sub-Categories' })
@ApiQuery({ name: 'projectId', required: true, type: Number })
@ApiQuery({ name: 'mainCategoryId', required: false, type: Number })
@RequirePermission('document.view')
getShopSubCats(@Query('mainCategoryId') mainCategoryId?: number) {
return this.masterDataService.findAllShopSubCats(mainCategoryId);
getShopSubCats(
@Query('projectId', ParseIntPipe) projectId: number,
@Query('mainCategoryId') mainCategoryId?: number
) {
return this.masterDataService.findAllShopSubCats(
projectId,
mainCategoryId ? Number(mainCategoryId) : undefined
);
}
@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;
}
) {
return this.masterDataService.createShopSubCat(body);
}
@Patch('shop/sub-categories/:id')
@ApiOperation({ summary: 'Update Shop Sub-Category' })
@RequirePermission('master_data.drawing_category.manage')
updateShopSubCat(
@Param('id', ParseIntPipe) id: number,
@Body()
body: {
subCategoryCode?: string;
subCategoryName?: string;
description?: string;
isActive?: boolean;
sortOrder?: number;
}
) {
return this.masterDataService.updateShopSubCat(id, body);
}
@Delete('shop/sub-categories/:id')
@ApiOperation({ summary: 'Delete Shop Sub-Category' })
@RequirePermission('master_data.drawing_category.manage')
deleteShopSubCat(@Param('id', ParseIntPipe) id: number) {
return this.masterDataService.deleteShopSubCat(id);
}
}

View File

@@ -1,27 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
// Entities
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
import { ContractDrawingCategory } from './entities/contract-drawing-category.entity';
import { ContractDrawingSubCategory } from './entities/contract-drawing-sub-category.entity';
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';
@Injectable()
export class DrawingMasterDataService {
constructor(
@InjectRepository(ContractDrawingVolume)
private cdVolumeRepo: Repository<ContractDrawingVolume>,
@InjectRepository(ContractDrawingCategory)
private cdCatRepo: Repository<ContractDrawingCategory>,
@InjectRepository(ContractDrawingSubCategory)
private cdSubCatRepo: Repository<ContractDrawingSubCategory>,
@InjectRepository(ShopDrawingMainCategory)
private sdMainCatRepo: Repository<ShopDrawingMainCategory>,
@InjectRepository(ShopDrawingSubCategory)
private sdSubCatRepo: Repository<ShopDrawingSubCategory>,
@InjectRepository(ContractDrawingSubcatCatMap)
private cdMapRepo: Repository<ContractDrawingSubcatCatMap>
) {}
// --- Contract Drawing Volumes ---
// =====================================================
// Contract Drawing Volumes
// =====================================================
async findAllVolumes(projectId: number) {
return this.cdVolumeRepo.find({
where: { projectId },
@@ -34,7 +43,54 @@ export class DrawingMasterDataService {
return this.cdVolumeRepo.save(volume);
}
// --- Contract Drawing Sub-Categories ---
async updateVolume(id: number, data: Partial<ContractDrawingVolume>) {
const volume = await this.cdVolumeRepo.findOne({ where: { id } });
if (!volume) throw new NotFoundException(`Volume #${id} not found`);
Object.assign(volume, data);
return this.cdVolumeRepo.save(volume);
}
async deleteVolume(id: number) {
const result = await this.cdVolumeRepo.delete(id);
if (result.affected === 0)
throw new NotFoundException(`Volume #${id} not found`);
return { deleted: true };
}
// =====================================================
// Contract Drawing Categories
// =====================================================
async findAllCategories(projectId: number) {
return this.cdCatRepo.find({
where: { projectId },
order: { sortOrder: 'ASC' },
});
}
async createCategory(data: Partial<ContractDrawingCategory>) {
const cat = this.cdCatRepo.create(data);
return this.cdCatRepo.save(cat);
}
async updateCategory(id: number, data: Partial<ContractDrawingCategory>) {
const cat = await this.cdCatRepo.findOne({ where: { id } });
if (!cat) throw new NotFoundException(`Category #${id} not found`);
Object.assign(cat, data);
return this.cdCatRepo.save(cat);
}
async deleteCategory(id: number) {
const result = await this.cdCatRepo.delete(id);
if (result.affected === 0)
throw new NotFoundException(`Category #${id} not found`);
return { deleted: true };
}
// =====================================================
// Contract Drawing Sub-Categories
// =====================================================
async findAllContractSubCats(projectId: number) {
return this.cdSubCatRepo.find({
where: { projectId },
@@ -47,26 +103,128 @@ export class DrawingMasterDataService {
return this.cdSubCatRepo.save(subCat);
}
// --- Shop Drawing Main Categories ---
async findAllShopMainCats() {
async updateContractSubCat(
id: number,
data: Partial<ContractDrawingSubCategory>
) {
const subCat = await this.cdSubCatRepo.findOne({ where: { id } });
if (!subCat) throw new NotFoundException(`Sub-Category #${id} not found`);
Object.assign(subCat, data);
return this.cdSubCatRepo.save(subCat);
}
async deleteContractSubCat(id: number) {
const result = await this.cdSubCatRepo.delete(id);
if (result.affected === 0)
throw new NotFoundException(`Sub-Category #${id} not found`);
return { deleted: true };
}
// =====================================================
// Contract Drawing Mappings (Category <-> Sub-Category)
// =====================================================
async findContractMappings(projectId: number, categoryId?: number) {
const where: FindOptionsWhere<ContractDrawingSubcatCatMap> = { projectId };
if (categoryId) {
where.categoryId = categoryId;
}
return this.cdMapRepo.find({
where,
relations: ['subCategory', 'category'],
order: { id: 'ASC' },
});
}
async createContractMapping(data: {
projectId: number;
categoryId: number;
subCategoryId: number;
}) {
// Check if mapping already exists to prevent duplicates (though DB has UNIQUE constraint)
const existing = await this.cdMapRepo.findOne({
where: {
projectId: data.projectId,
categoryId: data.categoryId,
subCategoryId: data.subCategoryId,
},
});
if (existing) return existing;
const map = this.cdMapRepo.create(data);
return this.cdMapRepo.save(map);
}
async deleteContractMapping(id: number) {
const result = await this.cdMapRepo.delete(id);
if (result.affected === 0)
throw new NotFoundException(`Mapping #${id} not found`);
return { deleted: true };
}
// =====================================================
// Shop Drawing Main Categories
// =====================================================
async findAllShopMainCats(projectId: number) {
return this.sdMainCatRepo.find({
where: { isActive: true },
where: { projectId },
order: { sortOrder: 'ASC' },
});
}
// --- Shop Drawing Sub Categories ---
async findAllShopSubCats(mainCategoryId?: number) {
// ✅ FIX: ใช้วิธี Spread Operator เพื่อสร้าง Object เงื่อนไขที่ถูกต้องตาม Type
async createShopMainCat(data: Partial<ShopDrawingMainCategory>) {
const cat = this.sdMainCatRepo.create(data);
return this.sdMainCatRepo.save(cat);
}
async updateShopMainCat(id: number, data: Partial<ShopDrawingMainCategory>) {
const cat = await this.sdMainCatRepo.findOne({ where: { id } });
if (!cat) throw new NotFoundException(`Main Category #${id} not found`);
Object.assign(cat, data);
return this.sdMainCatRepo.save(cat);
}
async deleteShopMainCat(id: number) {
const result = await this.sdMainCatRepo.delete(id);
if (result.affected === 0)
throw new NotFoundException(`Main Category #${id} not found`);
return { deleted: true };
}
// =====================================================
// Shop Drawing Sub-Categories
// =====================================================
async findAllShopSubCats(projectId: number, mainCategoryId?: number) {
const where: FindOptionsWhere<ShopDrawingSubCategory> = {
isActive: true,
projectId,
...(mainCategoryId ? { mainCategoryId } : {}),
};
return this.sdSubCatRepo.find({
where,
order: { sortOrder: 'ASC' },
relations: ['mainCategory'], // Load Parent Info
});
}
async createShopSubCat(data: Partial<ShopDrawingSubCategory>) {
const subCat = this.sdSubCatRepo.create(data);
return this.sdSubCatRepo.save(subCat);
}
async updateShopSubCat(id: number, data: Partial<ShopDrawingSubCategory>) {
const subCat = await this.sdSubCatRepo.findOne({ where: { id } });
if (!subCat) throw new NotFoundException(`Sub-Category #${id} not found`);
Object.assign(subCat, data);
return this.sdSubCatRepo.save(subCat);
}
async deleteShopSubCat(id: number) {
const result = await this.sdSubCatRepo.delete(id);
if (result.affected === 0)
throw new NotFoundException(`Sub-Category #${id} not found`);
return { deleted: true };
}
}

View File

@@ -5,6 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractDrawing } from './entities/contract-drawing.entity';
import { ShopDrawing } from './entities/shop-drawing.entity';
import { ShopDrawingRevision } from './entities/shop-drawing-revision.entity';
import { AsBuiltDrawing } from './entities/asbuilt-drawing.entity';
import { AsBuiltDrawingRevision } from './entities/asbuilt-drawing-revision.entity';
// Entities (Master Data - Contract Drawing)
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
@@ -22,15 +24,19 @@ import { Attachment } from '../../common/file-storage/entities/attachment.entity
// Services
import { ShopDrawingService } from './shop-drawing.service';
import { ContractDrawingService } from './contract-drawing.service';
import { DrawingMasterDataService } from './drawing-master-data.service'; // ✅ New
import { AsBuiltDrawingService } from './asbuilt-drawing.service';
import { DrawingMasterDataService } from './drawing-master-data.service';
// Controllers
import { ShopDrawingController } from './shop-drawing.controller';
import { ContractDrawingController } from './contract-drawing.controller';
import { AsBuiltDrawingController } from './asbuilt-drawing.controller';
import { DrawingMasterDataController } from './drawing-master-data.controller';
// Modules
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -38,14 +44,16 @@ import { UserModule } from '../user/user.module';
ContractDrawing,
ShopDrawing,
ShopDrawingRevision,
AsBuiltDrawing,
AsBuiltDrawingRevision,
// Master Data
ContractDrawingVolume,
ContractDrawingSubCategory,
ContractDrawingSubcatCatMap,
ContractDrawingCategory,
ShopDrawingMainCategory, // ✅
ShopDrawingSubCategory, // ✅
ShopDrawingMainCategory,
ShopDrawingSubCategory,
// Common
Attachment,
@@ -56,13 +64,15 @@ import { UserModule } from '../user/user.module';
providers: [
ShopDrawingService,
ContractDrawingService,
AsBuiltDrawingService,
DrawingMasterDataService,
],
controllers: [
ShopDrawingController,
ContractDrawingController,
AsBuiltDrawingController,
DrawingMasterDataController,
],
exports: [ShopDrawingService, ContractDrawingService],
exports: [ShopDrawingService, ContractDrawingService, AsBuiltDrawingService],
})
export class DrawingModule {}

View File

@@ -0,0 +1,50 @@
import {
IsNotEmpty,
IsOptional,
IsString,
IsArray,
IsDateString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for creating a new revision to an existing AS Built Drawing
*/
export class CreateAsBuiltDrawingRevisionDto {
@ApiProperty({ description: 'Revision label (e.g., A, B, 1)' })
@IsString()
@IsNotEmpty()
revisionLabel!: string;
@ApiProperty({ description: 'Drawing title for this revision' })
@IsString()
@IsNotEmpty()
title!: string;
@ApiPropertyOptional({ description: 'Legacy/original drawing number' })
@IsString()
@IsOptional()
legacyDrawingNumber?: string;
@ApiPropertyOptional({ description: 'Revision date (ISO string)' })
@IsDateString()
@IsOptional()
revisionDate?: string;
@ApiPropertyOptional({ description: 'Description of changes' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({
description: 'Shop Drawing Revision IDs to reference',
})
@IsArray()
@IsOptional()
shopDrawingRevisionIds?: number[];
@ApiPropertyOptional({ description: 'Attachment IDs' })
@IsArray()
@IsOptional()
attachmentIds?: number[];
}

View File

@@ -0,0 +1,72 @@
import {
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsArray,
IsDateString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for creating a new AS Built Drawing with its first revision
*/
export class CreateAsBuiltDrawingDto {
@ApiProperty({ description: 'Project ID' })
@IsNumber()
@IsNotEmpty()
projectId!: number;
@ApiProperty({ description: 'AS Built Drawing Number (unique)' })
@IsString()
@IsNotEmpty()
drawingNumber!: string;
@ApiProperty({ description: 'Main Category ID' })
@IsNumber()
@IsNotEmpty()
mainCategoryId!: number;
@ApiProperty({ description: 'Sub Category ID' })
@IsNumber()
@IsNotEmpty()
subCategoryId!: number;
// First Revision Data
@ApiProperty({ description: 'Drawing title' })
@IsString()
@IsNotEmpty()
title!: string;
@ApiPropertyOptional({ description: 'Revision label (e.g., A, B, 0)' })
@IsString()
@IsOptional()
revisionLabel?: string;
@ApiPropertyOptional({ description: 'Legacy/original drawing number' })
@IsString()
@IsOptional()
legacyDrawingNumber?: string;
@ApiPropertyOptional({ description: 'Revision date (ISO string)' })
@IsDateString()
@IsOptional()
revisionDate?: string;
@ApiPropertyOptional({ description: 'Description of the revision' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({
description: 'Shop Drawing Revision IDs to reference',
})
@IsArray()
@IsOptional()
shopDrawingRevisionIds?: number[];
@ApiPropertyOptional({ description: 'Attachment IDs' })
@IsArray()
@IsOptional()
attachmentIds?: number[];
}

View File

@@ -0,0 +1,44 @@
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
/**
* DTO for searching/filtering AS Built Drawings
*/
export class SearchAsBuiltDrawingDto {
@ApiProperty({ description: 'Project ID' })
@Type(() => Number)
@IsNumber()
projectId!: number;
@ApiPropertyOptional({ description: 'Filter by Main Category ID' })
@Type(() => Number)
@IsNumber()
@IsOptional()
mainCategoryId?: number;
@ApiPropertyOptional({ description: 'Filter by Sub Category ID' })
@Type(() => Number)
@IsNumber()
@IsOptional()
subCategoryId?: number;
@ApiPropertyOptional({ description: 'Search by drawing number' })
@IsString()
@IsOptional()
search?: string;
@ApiPropertyOptional({ description: 'Page number', default: 1 })
@Type(() => Number)
@IsNumber()
@Min(1)
@IsOptional()
page?: number = 1;
@ApiPropertyOptional({ description: 'Items per page', default: 20 })
@Type(() => Number)
@IsNumber()
@Min(1)
@IsOptional()
limit?: number = 20;
}

View File

@@ -7,12 +7,15 @@ import {
JoinColumn,
ManyToMany,
JoinTable,
Unique,
} from 'typeorm';
import { AsBuiltDrawing } from './asbuilt-drawing.entity';
import { ShopDrawingRevision } from './shop-drawing-revision.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { User } from '../../user/entities/user.entity';
@Entity('asbuilt_drawing_revisions')
@Unique(['asBuiltDrawingId', 'isCurrent'])
export class AsBuiltDrawingRevision {
@PrimaryGeneratedColumn()
id!: number;
@@ -35,9 +38,26 @@ export class AsBuiltDrawingRevision {
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'legacy_drawing_number', length: 100, nullable: true })
legacyDrawingNumber?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@Column({
name: 'is_current',
type: 'boolean',
nullable: true,
default: null,
})
isCurrent?: boolean | null;
@Column({ name: 'created_by', nullable: true })
createdBy?: number;
@Column({ name: 'updated_by', nullable: true })
updatedBy?: number;
// Relations
@ManyToOne(() => AsBuiltDrawing, (drawing) => drawing.revisions, {
onDelete: 'CASCADE',
@@ -45,6 +65,14 @@ export class AsBuiltDrawingRevision {
@JoinColumn({ name: 'asbuilt_drawing_id' })
asBuiltDrawing!: AsBuiltDrawing;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
creator?: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'updated_by' })
updater?: User;
// Relation to Shop Drawing Revisions (M:N)
@ManyToMany(() => ShopDrawingRevision)
@JoinTable({

View File

@@ -12,6 +12,8 @@ import {
import { Project } from '../../project/entities/project.entity';
import { AsBuiltDrawingRevision } from './asbuilt-drawing-revision.entity';
import { User } from '../../user/entities/user.entity';
import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './shop-drawing-sub-category.entity';
@Entity('asbuilt_drawings')
export class AsBuiltDrawing {
@@ -24,6 +26,12 @@ export class AsBuiltDrawing {
@Column({ name: 'drawing_number', length: 100, unique: true })
drawingNumber!: string;
@Column({ name: 'main_category_id' })
mainCategoryId!: number;
@Column({ name: 'sub_category_id' })
subCategoryId!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -41,6 +49,14 @@ export class AsBuiltDrawing {
@JoinColumn({ name: 'project_id' })
project!: Project;
@ManyToOne(() => ShopDrawingMainCategory)
@JoinColumn({ name: 'main_category_id' })
mainCategory!: ShopDrawingMainCategory;
@ManyToOne(() => ShopDrawingSubCategory)
@JoinColumn({ name: 'sub_category_id' })
subCategory!: ShopDrawingSubCategory;
@ManyToOne(() => User)
@JoinColumn({ name: 'updated_by' })
updater?: User;

View File

@@ -7,12 +7,15 @@ import {
JoinColumn,
ManyToMany,
JoinTable,
Unique,
} from 'typeorm';
import { ShopDrawing } from './shop-drawing.entity';
import { ContractDrawing } from './contract-drawing.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { User } from '../../user/entities/user.entity';
@Entity('shop_drawing_revisions')
@Unique(['shopDrawingId', 'isCurrent'])
export class ShopDrawingRevision {
@PrimaryGeneratedColumn()
id!: number; // เติม !
@@ -41,6 +44,20 @@ export class ShopDrawingRevision {
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // เติม !
@Column({
name: 'is_current',
type: 'boolean',
nullable: true,
default: null,
})
isCurrent?: boolean | null;
@Column({ name: 'created_by', nullable: true })
createdBy?: number;
@Column({ name: 'updated_by', nullable: true })
updatedBy?: number;
// Relations
@ManyToOne(() => ShopDrawing, (shopDrawing) => shopDrawing.revisions, {
onDelete: 'CASCADE',
@@ -48,6 +65,14 @@ export class ShopDrawingRevision {
@JoinColumn({ name: 'shop_drawing_id' })
shopDrawing!: ShopDrawing; // เติม !
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
creator?: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'updated_by' })
updater?: User;
// References to Contract Drawings (M:N)
@ManyToMany(() => ContractDrawing)
@JoinTable({

View File

@@ -0,0 +1,399 @@
# 📋 Document Numbering System Summary
> **Version:** v1.7.0
> **Last Updated:** 2025-12-24
> **Status:** Implemented (with known build issues)
---
## 📊 Architecture Overview
ระบบ Document Numbering ใช้สำหรับสร้างเลขที่เอกสารอัตโนมัติ โดยมี **Reserve-Confirm Pattern** และ **Two-Phase Locking** (Redis + DB Optimistic Lock) เพื่อป้องกัน Race Conditions
```mermaid
flowchart TB
subgraph Frontend["Frontend (Next.js)"]
A[lib/api/numbering.ts]
B[components/numbering/*]
C[types/dto/numbering.dto.ts]
end
subgraph Backend["Backend (NestJS)"]
D[Controllers]
E[DocumentNumberingService]
F[Sub-Services]
G[Entities]
end
A --> D
B --> A
E --> F
E --> G
```
---
## 📁 Backend Structure
### Module Location
`backend/src/modules/document-numbering/`
| Directory | Files | Description |
| -------------- | ----- | ----------------------------------------------------------------------------------- |
| `controllers/` | 3 | Public, Admin, Metrics Controllers |
| `services/` | 8 | Main + Counter, Reservation, Format, Lock, Template, Audit, Metrics, ManualOverride |
| `entities/` | 5 | Format, Counter, Reservation, Audit, Error |
| `dto/` | 5 | Preview, Reserve, ConfirmReservation, CounterKey, ManualOverride |
| `interfaces/` | 1 | GenerateNumberContext |
### Key Services
| Service | Responsibility |
| ------------------------------ | -------------------------------------------------------------------- |
| `DocumentNumberingService` | Main orchestrator (generateNextNumber, reserveNumber, previewNumber) |
| `CounterService` | Increment counter with Optimistic Lock |
| `ReservationService` | Reserve-Confirm pattern handling |
| `FormatService` | Token replacement & format resolution |
| `DocumentNumberingLockService` | Redis distributed lock (Redlock) |
| `ManualOverrideService` | Admin counter override |
| `AuditService` | Audit logging |
| `MetricsService` | Prometheus metrics |
---
## 📁 Frontend Structure
### Files
| Path | Description |
| -------------------------------------------- | ------------------------------ |
| `lib/api/numbering.ts` | API client + Types (335 lines) |
| `lib/services/document-numbering.service.ts` | Service wrapper |
| `types/dto/numbering.dto.ts` | DTOs for forms |
| `types/numbering.ts` | Type re-exports |
### Components (`components/numbering/`)
| Component | Description |
| -------------------------- | --------------------------- |
| `template-editor.tsx` | Editor for format templates |
| `template-tester.tsx` | Test number generation |
| `sequence-viewer.tsx` | View counter sequences |
| `metrics-dashboard.tsx` | Audit/Error logs dashboard |
| `manual-override-form.tsx` | Admin counter override |
| `void-replace-form.tsx` | Void & Replace number |
| `cancel-number-form.tsx` | Cancel/Skip a number |
| `bulk-import-form.tsx` | Bulk import counters |
| `audit-logs-table.tsx` | Audit logs table |
### Admin Pages
- `app/(admin)/admin/numbering/` - Template management
- `app/(admin)/admin/system-logs/numbering/` - System logs
---
## 💾 Database Schema (v1.7.0)
### 5 Tables
| Table | Purpose | Key Feature |
| ------------------------------ | ------------------------- | ------------------------------------------- |
| `document_number_formats` | Template รูปแบบเลขที่เอกสาร | Unique per (project, correspondence_type) |
| `document_number_counters` | Running Number Counter | **8-Column Composite PK** + Optimistic Lock |
| `document_number_audit` | Audit Trail สำหรับทุกการสร้าง | เก็บ ≥ 7 ปี |
| `document_number_errors` | Error Log | 5 Error Types |
| `document_number_reservations` | **Two-Phase Commit** | Reserve → Confirm Pattern |
---
## 🔑 Counter Composite Primary Key (8 Columns)
```sql
PRIMARY KEY (
project_id,
originator_organization_id,
recipient_organization_id, -- 0 = no recipient (RFA)
correspondence_type_id,
sub_type_id, -- 0 = ไม่ระบุ (for TRANSMITTAL)
rfa_type_id, -- 0 = ไม่ใช่ RFA
discipline_id, -- 0 = ไม่ระบุ
reset_scope -- 'YEAR_2024', 'NONE', etc.
)
```
### Reset Scope Values
| Value | Description |
| --------------- | -------------------------------- |
| `YEAR_XXXX` | Reset ทุกปี เช่น `YEAR_2024` |
| `MONTH_XXXX_XX` | Reset ทุกเดือน เช่น `MONTH_2024_01` |
| `CONTRACT_XXXX` | Reset ต่อสัญญา |
| `NONE` | ไม่ Reset |
### Constraints
```sql
CONSTRAINT chk_last_number_positive CHECK (last_number >= 0)
CONSTRAINT chk_reset_scope_format CHECK (
reset_scope IN ('NONE')
OR reset_scope LIKE 'YEAR_%'
OR reset_scope LIKE 'MONTH_%'
OR reset_scope LIKE 'CONTRACT_%'
)
```
---
## 📜 Business Rules
### 1⃣ Number Generation Rules
| Rule | Description |
| ----------------------------- | ---------------------------------------------------- |
| **Uniqueness** | เลขที่เอกสารห้ามซ้ำกันภายใน Project |
| **Sequence Reset** | Reset ตาม `reset_scope` (ปกติ Reset ต่อปี) |
| **Idempotency** | ใช้ `Idempotency-Key` header ป้องกันการสร้างซ้ำ |
| **Race Condition Prevention** | Redis Lock (Primary) + DB Optimistic Lock (Fallback) |
| **Format Fallback** | ใช้ Default Format ถ้าไม่มี Specific Format |
### 2⃣ Two-Phase Commit (Reserve → Confirm)
```mermaid
stateDiagram-v2
[*] --> RESERVED: Reserve Number
RESERVED --> CONFIRMED: Confirm (Save Document)
RESERVED --> CANCELLED: Cancel / Timeout (15 min)
CONFIRMED --> VOID: Admin Void
VOID --> [*]
CANCELLED --> [*]
```
| Status | Description |
| ----------- | ----------------------------------- |
| `RESERVED` | จองแล้ว รอ Confirm (หมดอายุใน 15 นาที) |
| `CONFIRMED` | ยืนยันแล้ว ใช้งานจริง |
| `CANCELLED` | ยกเลิก (User/System/Timeout) |
| `VOID` | Admin Void (ยกเลิกเลขที่หลัง Confirm) |
### 3⃣ Format Template Tokens
| Token | Example Value | Description |
| -------------- | ------------- | ---------------------------- |
| `{PROJECT}` | `LCBP3` | Project Code |
| `{ORG}` | `NAP` | Originator Organization Code |
| `{RECIPIENT}` | `PAT` | Recipient Organization Code |
| `{TYPE}` | `LET` | Correspondence Type Code |
| `{DISCIPLINE}` | `STR` | Discipline Code |
| `{SEQ:N}` | `0001` | Sequence padded to N digits |
| `{YEAR}` | `2025` | 4-digit CE Year |
| `{YEAR:BE}` | `2568` | 4-digit Buddhist Era Year |
| `{REV}` | `0` | Revision Number |
### Example Format
```
Template: {ORG}-{RECIPIENT}-{TYPE}-{YEAR:BE}-{SEQ:4}
Result: NAP-PAT-LET-67-0001
```
### 4⃣ Format Resolution Priority
1. **Specific Format**: project_id + correspondence_type_id
2. **Default Format**: project_id + correspondence_type_id = NULL
3. **Fallback**: `{ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE}`
---
## 🛡️ Concurrency Control Strategy
```mermaid
sequenceDiagram
participant API
participant Redis
participant DB
API->>Redis: Acquire Lock (TTL: 5s)
alt Redis Lock Success
API->>DB: SELECT counter + UPDATE (increment)
API->>Redis: Release Lock
else Redis Lock Failed/Timeout
Note over API,DB: Fallback to DB Optimistic Lock
API->>DB: SELECT FOR UPDATE + INCREMENT
API->>DB: Check version (Optimistic Lock)
end
```
| Strategy | Use Case |
| ---------------------- | ------------------------------------------- |
| **Redis Redlock** | Primary - Distributed Lock across instances |
| **DB Optimistic Lock** | Fallback - When Redis down/timeout |
| **Version Column** | Prevent concurrent updates |
---
## 🔄 Generation Flow
```mermaid
sequenceDiagram
participant Client
participant Controller
participant Service
participant RedisLock
participant CounterService
participant FormatService
participant DB
Client->>Controller: POST /preview or Reserve
Controller->>Service: generateNextNumber(ctx)
Service->>RedisLock: acquireLock(counterKey)
Service->>CounterService: incrementCounter(key)
CounterService->>DB: SELECT FOR UPDATE + INCREMENT
DB-->>CounterService: sequence
CounterService-->>Service: sequence
Service->>FormatService: format(options)
FormatService->>DB: Lookup (Project, Type, Org, Discipline)
FormatService-->>Service: formattedNumber
Service->>DB: save AuditLog
Service->>RedisLock: releaseLock()
Service-->>Controller: { number, auditId }
Controller-->>Client: Response
```
---
## 🔌 API Endpoints
### Public (`/document-numbering`)
| Method | Endpoint | Permission | Description |
| ------ | --------------- | ------------------------ | --------------------------- |
| POST | `/preview` | `correspondence.read` | Preview เลขที่ (ไม่ increment) |
| GET | `/sequences` | `correspondence.read` | ดู Counter ทั้งหมด |
| GET | `/logs/audit` | `system.view_logs` | Audit Logs |
| GET | `/logs/errors` | `system.view_logs` | Error Logs |
| PATCH | `/counters/:id` | `system.manage_settings` | Update Counter (Deprecated) |
### Admin (`/admin/document-numbering`)
| Method | Endpoint | Description |
| ------ | ------------------------ | --------------------------- |
| GET | `/templates` | ดู Templates ทั้งหมด |
| GET | `/templates?projectId=X` | ดู Templates ตาม Project |
| POST | `/templates` | สร้าง/แก้ไข Template |
| DELETE | `/templates/:id` | ลบ Template |
| GET | `/metrics` | Audit + Error Logs combined |
| POST | `/manual-override` | Override Counter Value |
| POST | `/void-and-replace` | Void + สร้างเลขใหม่ |
| POST | `/cancel` | ยกเลิกเลขที่ |
| POST | `/bulk-import` | Import Counters จาก Legacy |
---
## 📈 Audit & Monitoring
### Audit Log Operations
| Operation | Description |
| ----------------- | ------------------ |
| `RESERVE` | จองเลขที่ |
| `CONFIRM` | ยืนยันการใช้เลขที่ |
| `MANUAL_OVERRIDE` | Admin แก้ไข Counter |
| `VOID_REPLACE` | Void และสร้างใหม่ |
| `CANCEL` | ยกเลิกเลขที่ |
### Audit Log Fields
| Field | Description |
| ------------------- | ----------------------------- |
| `counter_key` | JSON 8 fields (Composite Key) |
| `reservation_token` | UUID v4 สำหรับ Reserve-Confirm |
| `idempotency_key` | Request Idempotency Key |
| `template_used` | Format Template ที่ใช้ |
| `retry_count` | จำนวนครั้งที่ retry |
| `lock_wait_ms` | เวลารอ Redis lock (ms) |
| `total_duration_ms` | เวลารวมทั้งหมด (ms) |
| `fallback_used` | NONE / DB_LOCK / RETRY |
### Error Types
| Type | Description |
| ------------------ | ------------------------------- |
| `LOCK_TIMEOUT` | Redis lock หมดเวลา |
| `VERSION_CONFLICT` | Optimistic lock fail |
| `DB_ERROR` | Database error |
| `REDIS_ERROR` | Redis connection error |
| `VALIDATION_ERROR` | Template/Input validation error |
### Prometheus Metrics
| Metric | Type | Description |
| -------------------------------- | --------- | ----------------------------- |
| `numbering_sequences_total` | Counter | Total sequences generated |
| `numbering_sequence_utilization` | Gauge | Utilization of sequence space |
| `numbering_lock_wait_seconds` | Histogram | Time waiting for locks |
| `numbering_lock_failures_total` | Counter | Lock acquisition failures |
---
## 🔐 Permissions
| Permission | Description |
| ------------------------ | --------------------------------------------- |
| `correspondence.read` | Preview, View Sequences |
| `system.view_logs` | View Audit/Error Logs |
| `system.manage_settings` | Template CRUD, Override, Void, Cancel, Import |
---
## ⚠️ Known Issues (Current Build)
### TypeScript Errors
1. **DTO Field Mismatch**
- `PreviewNumberDto.originatorId` vs Service expects `originatorOrganizationId`
2. **Missing Properties in PreviewNumberDto**
- `correspondenceTypeId` (used as `typeId`)
- `customTokens`
3. **TypeScript Initializers**
- DTOs need `!` or default values for strict mode
### Files Needing Fix
- `dto/preview-number.dto.ts`
- `dto/reserve-number.dto.ts`
- `dto/confirm-reservation.dto.ts`
- `dto/counter-key.dto.ts`
- `entities/document-number-format.entity.ts`
- `entities/document-number-error.entity.ts`
- `services/document-numbering.service.ts`
---
## 📚 Related Documentation
- [specs/01-requirements/03.11-document-numbering.md](../specs/01-requirements/03.11-document-numbering.md)
- [specs/03-implementation/document-numbering.md](../specs/03-implementation/document-numbering.md)
- [specs/07-database/data-dictionary-v1.7.0.md](../specs/07-database/data-dictionary-v1.7.0.md)
- [specs/07-database/lcbp3-v1.7.0-schema.sql](../specs/07-database/lcbp3-v1.7.0-schema.sql)
---
## 📝 Changelog
### v1.7.0
- Changed `document_number_counters` PK from 5 to **8 columns**
- Added `document_number_reservations` table for Two-Phase Commit
- Added `reset_scope` field (replaces `current_year`)
- Enhanced `document_number_audit` with operation tracking
- Added `idempotency_key` support
### v1.5.1
- Initial implementation
- Basic format templating
- Counter management
---
**End of Document**

View File

@@ -0,0 +1,282 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService, ContractCategory, ContractSubCategory } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface Category {
id: number;
catCode: string;
catName: string;
description?: string;
sortOrder: number;
}
export default function ContractCategoriesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<Category>[] = [
{
accessorKey: "catCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("catCode")}
</Badge>
),
},
{
accessorKey: "catName",
header: "Category Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Contract Drawing Categories</h1>
<p className="text-muted-foreground mt-1">
Manage main categories () for contract drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage categories.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Category"
title="Contract Drawing Categories"
description="Manage main categories (หมวดหมู่หลัก) for contract drawings"
queryKey={["contract-drawing-categories", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getContractCategories(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createContractCategory({ ...data, projectId: selectedProjectId })}
updateFn={(id, data) => drawingMasterDataService.updateContractCategory(id, data)}
deleteFn={(id) => drawingMasterDataService.deleteContractCategory(id)}
columns={columns}
fields={[
{ name: "catCode", label: "Category Code", type: "text", required: true },
{ name: "catName", label: "Category Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
{/*
Note: For mapping, we should ideally have a separate "Mappings" column or action button.
Since GenericCrudTable might not support custom action columns easily without modification,
we are currently just listing categories. To add mapping functionality, we might need
to either extend GenericCrudTable or create a dedicated page for mappings.
Given the constraints, I will add a "Mapped Sub-categories" management section
that opens when clicking a category ROW or adding a custom action if GenericCrudTable supports it.
For now, let's assume we need to extend GenericCrudTable or replace it to support this specific requirement.
However, to keep it simple and consistent:
Let's add a separate section below the table or a dialog triggered by a custom cell.
*/}
<div className="mt-8 border-t pt-8">
<CategoryMappingSection projectId={selectedProjectId} />
</div>
</div>
);
}
function CategoryMappingSection({ projectId }: { projectId: number }) {
// ... logic to manage mappings would go here ...
// But to properly implement this, we need a full mapping UI.
// Let's defer this implementation pattern to a separate component to keep this file clean
// and just mount it here.
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Category Mappings (Map Sub-categories to Categories)</h2>
<div className="bg-muted/30 p-4 rounded-lg border-dashed border">
<p className="text-sm text-muted-foreground">Select a category to view and manage its sub-categories.</p>
{/*
Real implementation would be complex here.
Better approach: Add a "Manage Sub-categories" button to the Categories table if possible.
Or simpler: A separate "Mapping" page.
*/}
<ManageMappings projectId={projectId} />
</div>
</div>
)
}
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; // Use sonner instead of use-toast
function ManageMappings({ projectId }: { projectId: number }) {
const queryClient = useQueryClient();
const [selectedCat, setSelectedCat] = useState<string>("");
const [selectedSubCat, setSelectedSubCat] = useState<string>("");
const { data: categories = [] } = useQuery({
queryKey: ["contract-categories", String(projectId)],
queryFn: () => drawingMasterDataService.getContractCategories(projectId),
});
const { data: subCategories = [] } = useQuery({
queryKey: ["contract-sub-categories", String(projectId)],
queryFn: () => drawingMasterDataService.getContractSubCategories(projectId),
});
const { data: mappings = [] } = useQuery({
queryKey: ["contract-mappings", String(projectId), selectedCat],
queryFn: () => drawingMasterDataService.getContractMappings(projectId, selectedCat ? parseInt(selectedCat) : undefined),
enabled: !!selectedCat,
});
const createMutation = useMutation({
mutationFn: drawingMasterDataService.createContractMapping,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["contract-mappings"] });
toast.success("Mapping created");
setSelectedSubCat("");
},
});
const deleteMutation = useMutation({
mutationFn: drawingMasterDataService.deleteContractMapping,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["contract-mappings"] });
toast.success("Mapping removed");
}
});
const handleAdd = () => {
if (!selectedCat || !selectedSubCat) return;
createMutation.mutate({
projectId,
categoryId: parseInt(selectedCat),
subCategoryId: parseInt(selectedSubCat),
});
};
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Select Category</label>
<Select value={selectedCat} onValueChange={setSelectedCat}>
<SelectTrigger>
<SelectValue placeholder="Select Category..." />
</SelectTrigger>
<SelectContent>
{categories.map((c: ContractCategory) => (
<SelectItem key={c.id} value={String(c.id)}>{c.catCode} - {c.catName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedCat && (
<div className="space-y-4">
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Add Sub-Category</label>
<Select value={selectedSubCat} onValueChange={setSelectedSubCat}>
<SelectTrigger>
<SelectValue placeholder="Select Sub-Category to add..." />
</SelectTrigger>
<SelectContent>
{subCategories
.filter((s: ContractSubCategory) => !mappings.find((m: { subCategory: { id: number } }) => m.subCategory.id === s.id))
.map((s: ContractSubCategory) => (
<SelectItem key={s.id} value={String(s.id)}>{s.subCatCode} - {s.subCatName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleAdd} disabled={!selectedSubCat || createMutation.isPending}>
<Plus className="h-4 w-4 mr-2" /> Add
</Button>
</div>
<div className="border rounded-md">
<div className="p-2 bg-muted/50 font-medium text-sm grid grid-cols-[1fr,auto] gap-2">
<span>Mapped Sub-Categories</span>
<span>Action</span>
</div>
{mappings.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">No sub-categories mapped yet.</div>
) : (
<div className="divide-y">
{mappings.map((m: { id: number; subCategory: ContractSubCategory }) => (
<div key={m.id} className="p-2 grid grid-cols-[1fr,auto] gap-2 items-center">
<span className="text-sm">{m.subCategory.subCatCode} - {m.subCategory.subCatName}</span>
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(m.id)} disabled={deleteMutation.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface SubCategory {
id: number;
subCatCode: string;
subCatName: string;
description?: string;
sortOrder: number;
}
export default function ContractSubCategoriesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<SubCategory>[] = [
{
accessorKey: "subCatCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("subCatCode")}
</Badge>
),
},
{
accessorKey: "subCatName",
header: "Sub-category Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Contract Drawing Sub-categories</h1>
<p className="text-muted-foreground mt-1">
Manage sub-categories () for contract drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage sub-categories.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Sub-category"
title="Contract Drawing Sub-categories"
description="Manage sub-categories (หมวดหมู่ย่อย) for contract drawings"
queryKey={["contract-drawing-sub-categories", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getContractSubCategories(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createContractSubCategory({ ...data, projectId: selectedProjectId })}
updateFn={(id, data) => drawingMasterDataService.updateContractSubCategory(id, data)}
deleteFn={(id) => drawingMasterDataService.deleteContractSubCategory(id)}
columns={columns}
fields={[
{ name: "subCatCode", label: "Sub-category Code", type: "text", required: true },
{ name: "subCatName", label: "Sub-category Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface Volume {
id: number;
volumeCode: string;
volumeName: string;
description?: string;
sortOrder: number;
}
export default function ContractVolumesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<Volume>[] = [
{
accessorKey: "volumeCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("volumeCode")}
</Badge>
),
},
{
accessorKey: "volumeName",
header: "Volume Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Contract Drawing Volumes</h1>
<p className="text-muted-foreground mt-1">
Manage drawing volumes () for contract drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage volumes.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Volume"
title="Contract Drawing Volumes"
description="Manage drawing volumes (เล่ม) for contract drawings"
queryKey={["contract-drawing-volumes", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getContractVolumes(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createContractVolume({ ...data, projectId: selectedProjectId })}
updateFn={(id, data) => drawingMasterDataService.updateContractVolume(id, data)}
deleteFn={(id) => drawingMasterDataService.deleteContractVolume(id)}
columns={columns}
fields={[
{ name: "volumeCode", label: "Volume Code", type: "text", required: true },
{ name: "volumeName", label: "Volume Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
FileStack,
FolderTree,
Layers,
BookOpen,
FileBox
} from "lucide-react";
import Link from "next/link";
const contractDrawingMenu = [
{
title: "Volumes",
description: "Manage contract drawing volumes (เล่ม)",
href: "/admin/drawings/contract/volumes",
icon: BookOpen,
},
{
title: "Categories",
description: "Manage main categories (หมวดหมู่หลัก)",
href: "/admin/drawings/contract/categories",
icon: FolderTree,
},
{
title: "Sub-categories",
description: "Manage sub-categories (หมวดหมู่ย่อย)",
href: "/admin/drawings/contract/sub-categories",
icon: Layers,
},
];
const shopDrawingMenu = [
{
title: "Main Categories",
description: "Manage main categories (หมวดหมู่หลัก)",
href: "/admin/drawings/shop/main-categories",
icon: FolderTree,
},
{
title: "Sub-categories",
description: "Manage sub-categories (หมวดหมู่ย่อย)",
href: "/admin/drawings/shop/sub-categories",
icon: Layers,
},
];
export default function DrawingsAdminPage() {
return (
<div className="p-6 space-y-8">
<div>
<h1 className="text-2xl font-bold">Drawing Master Data</h1>
<p className="text-muted-foreground mt-1">
Manage categories and volumes for Contract and Shop Drawings
</p>
</div>
{/* Contract Drawings Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileStack className="h-5 w-5 text-blue-600" />
<h2 className="text-lg font-semibold">Contract Drawings</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{contractDrawingMenu.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full border-blue-200 hover:border-blue-400">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{item.title}
</CardTitle>
<item.icon className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
{/* Shop Drawings Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileBox className="h-5 w-5 text-green-600" />
<h2 className="text-lg font-semibold">Shop Drawings / As Built</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shopDrawingMenu.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full border-green-200 hover:border-green-400">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{item.title}
</CardTitle>
<item.icon className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, CheckCircle, XCircle } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface MainCategory {
id: number;
mainCategoryCode: string;
mainCategoryName: string;
description?: string;
isActive: boolean;
sortOrder: number;
}
export default function ShopMainCategoriesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<MainCategory>[] = [
{
accessorKey: "mainCategoryCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("mainCategoryCode")}
</Badge>
),
},
{
accessorKey: "mainCategoryName",
header: "Category Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "isActive",
header: "Active",
cell: ({ row }) => (
row.getValue("isActive") ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Shop Drawing Main Categories</h1>
<p className="text-muted-foreground mt-1">
Manage main categories () for shop drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage main categories.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Main Category"
title="Shop Drawing Main Categories"
description="Manage main categories (หมวดหมู่หลัก) for shop drawings"
queryKey={["shop-drawing-main-categories", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getShopMainCategories(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createShopMainCategory({
...data,
projectId: selectedProjectId,
isActive: data.isActive === "true" || data.isActive === true
})}
updateFn={(id, data) => drawingMasterDataService.updateShopMainCategory(id, {
...data,
isActive: data.isActive === "true" || data.isActive === true
})}
deleteFn={(id) => drawingMasterDataService.deleteShopMainCategory(id)}
columns={columns}
fields={[
{ name: "mainCategoryCode", label: "Category Code", type: "text", required: true },
{ name: "mainCategoryName", label: "Category Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "isActive", label: "Active", type: "checkbox" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
import { ColumnDef } from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, CheckCircle, XCircle } from "lucide-react";
import { useProjects } from "@/hooks/use-master-data";
import { drawingMasterDataService } from "@/lib/services/drawing-master-data.service";
import { Badge } from "@/components/ui/badge";
interface SubCategory {
id: number;
subCategoryCode: string;
subCategoryName: string;
description?: string;
isActive: boolean;
sortOrder: number;
}
export default function ShopSubCategoriesPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
const columns: ColumnDef<SubCategory>[] = [
{
accessorKey: "subCategoryCode",
header: "Code",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
{row.getValue("subCategoryCode")}
</Badge>
),
},
{
accessorKey: "subCategoryName",
header: "Sub-category Name",
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("description") || "-"}
</span>
),
},
{
accessorKey: "isActive",
header: "Active",
cell: ({ row }) => (
row.getValue("isActive") ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)
),
},
{
accessorKey: "sortOrder",
header: "Order",
cell: ({ row }) => (
<span className="font-mono">{row.getValue("sortOrder")}</span>
),
},
];
const projectFilter = (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (!selectedProjectId) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Shop Drawing Sub-categories</h1>
<p className="text-muted-foreground mt-1">
Manage sub-categories () for shop drawings
</p>
</div>
{projectFilter}
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to manage sub-categories.
</div>
</div>
);
}
return (
<div className="p-6">
<GenericCrudTable
entityName="Sub-category"
title="Shop Drawing Sub-categories"
description="Manage sub-categories (หมวดหมู่ย่อย) for shop drawings"
queryKey={["shop-drawing-sub-categories", String(selectedProjectId)]}
fetchFn={() => drawingMasterDataService.getShopSubCategories(selectedProjectId)}
createFn={(data) => drawingMasterDataService.createShopSubCategory({
...data,
projectId: selectedProjectId,
isActive: data.isActive === "true" || data.isActive === true
})}
updateFn={(id, data) => drawingMasterDataService.updateShopSubCategory(id, {
...data,
isActive: data.isActive === "true" || data.isActive === true
})}
deleteFn={(id) => drawingMasterDataService.deleteShopSubCategory(id)}
columns={columns}
fields={[
{ name: "subCategoryCode", label: "Sub-category Code", type: "text", required: true },
{ name: "subCategoryName", label: "Sub-category Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "isActive", label: "Active", type: "checkbox" },
{ name: "sortOrder", label: "Sort Order", type: "text", required: true },
]}
filters={projectFilter}
/>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
Shield,
Activity,
ArrowRight,
FileStack,
} from "lucide-react";
import Link from "next/link";
import { Skeleton } from "@/components/ui/skeleton";
@@ -78,6 +79,12 @@ export default function AdminPage() {
href: "/admin/numbering",
icon: Settings,
},
{
title: "Drawing Master Data",
description: "Manage drawing categories, volumes, and classifications",
href: "/admin/drawings",
icon: FileStack,
},
];
return (

View File

@@ -1,19 +1,31 @@
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DrawingList } from "@/components/drawings/list";
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Upload, Loader2 } from "lucide-react";
import Link from "next/link";
import { useProjects } from "@/hooks/use-master-data";
export default function DrawingsPage() {
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Drawings</h1>
<p className="text-muted-foreground mt-1">
Manage contract and shop drawings
Manage contract, shop, and as-built drawings
</p>
</div>
<Link href="/drawings/upload">
@@ -24,25 +36,79 @@ export default function DrawingsPage() {
</Link>
</div>
<Tabs defaultValue="contract" className="w-full">
<TabsList className="grid w-full grid-cols-3 max-w-[600px]">
<TabsTrigger value="contract">Contract Drawings</TabsTrigger>
<TabsTrigger value="shop">Shop Drawings</TabsTrigger>
<TabsTrigger value="asbuilt">As Built Drawings</TabsTrigger>
</TabsList>
{/* Project Selector */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectId?.toString() ?? ""}
onValueChange={(v) => setSelectedProjectId(v ? parseInt(v) : undefined)}
>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SelectValue placeholder="Select Project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project: { id: number; projectName: string; projectCode: string }) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.projectCode} - {project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<TabsContent value="contract" className="mt-6">
<DrawingList type="CONTRACT" />
</TabsContent>
<TabsContent value="shop" className="mt-6">
<DrawingList type="SHOP" />
</TabsContent>
<TabsContent value="asbuilt" className="mt-6">
<DrawingList type="AS_BUILT" />
</TabsContent>
</Tabs>
{!selectedProjectId ? (
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
Please select a project to view drawings.
</div>
) : (
<DrawingTabs projectId={selectedProjectId} />
)}
</div>
);
}
function DrawingTabs({ projectId }: { projectId: number }) {
const [search, setSearch] = useState("");
// We can add more specific filters here (e.g. category) later
return (
<Tabs defaultValue="contract" className="w-full">
<div className="flex justify-between items-center mb-6">
<TabsList className="grid w-full grid-cols-3 max-w-[400px]">
<TabsTrigger value="contract">Contract</TabsTrigger>
<TabsTrigger value="shop">Shop</TabsTrigger>
<TabsTrigger value="asbuilt">As Built</TabsTrigger>
</TabsList>
<div className="flex gap-2">
<div className="relative">
<input
type="text"
placeholder="Search drawings..."
className="h-10 w-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
</div>
<TabsContent value="contract" className="mt-0">
<DrawingList type="CONTRACT" projectId={projectId} filters={{ search }} />
</TabsContent>
<TabsContent value="shop" className="mt-0">
<DrawingList type="SHOP" projectId={projectId} filters={{ search }} />
</TabsContent>
<TabsContent value="asbuilt" className="mt-0">
<DrawingList type="AS_BUILT" projectId={projectId} filters={{ search }} />
</TabsContent>
</Tabs>
)
}

View File

@@ -2,15 +2,46 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Users, Building2, Settings, FileText, Activity, GitGraph, Shield, BookOpen } from "lucide-react";
import {
Users,
Building2,
Settings,
FileText,
Activity,
GitGraph,
Shield,
BookOpen,
FileStack,
ChevronDown,
ChevronRight,
} from "lucide-react";
const menuItems = [
interface MenuItem {
href?: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
children?: { href: string; label: string }[];
}
const menuItems: MenuItem[] = [
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
{ href: "/admin/projects", label: "Projects", icon: FileText },
{ href: "/admin/contracts", label: "Contracts", icon: FileText },
{ href: "/admin/reference", label: "Reference Data", icon: BookOpen },
{
label: "Drawing Master Data",
icon: FileStack,
children: [
{ href: "/admin/drawings/contract/volumes", label: "Contract: Volumes" },
{ href: "/admin/drawings/contract/categories", label: "Contract: Categories" },
{ href: "/admin/drawings/contract/sub-categories", label: "Contract: Sub-categories" },
{ href: "/admin/drawings/shop/main-categories", label: "Shop: Main Categories" },
{ href: "/admin/drawings/shop/sub-categories", label: "Shop: Sub-categories" },
]
},
{ href: "/admin/numbering", label: "Numbering", icon: FileText },
{ href: "/admin/workflows", label: "Workflows", icon: GitGraph },
{ href: "/admin/security/roles", label: "Security Roles", icon: Shield },
@@ -22,6 +53,20 @@ const menuItems = [
export function AdminSidebar() {
const pathname = usePathname();
const [expandedMenus, setExpandedMenus] = useState<string[]>(
// Auto-expand if current path matches a child
menuItems
.filter(item => item.children?.some(child => pathname.startsWith(child.href)))
.map(item => item.label)
);
const toggleMenu = (label: string) => {
setExpandedMenus(prev =>
prev.includes(label)
? prev.filter(l => l !== label)
: [...prev, label]
);
};
return (
<aside className="w-64 border-r bg-card p-4 hidden md:block">
@@ -33,12 +78,65 @@ export function AdminSidebar() {
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
// Has children - collapsible menu
if (item.children) {
const isExpanded = expandedMenus.includes(item.label);
const hasActiveChild = item.children.some(child => pathname.startsWith(child.href));
return (
<div key={item.label}>
<button
onClick={() => toggleMenu(item.label)}
className={cn(
"w-full flex items-center justify-between gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium",
hasActiveChild
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<span className="flex items-center gap-3">
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{isExpanded && (
<div className="ml-4 mt-1 space-y-1 border-l pl-4">
{item.children.map((child) => {
const isActive = pathname === child.href;
return (
<Link
key={child.href}
href={child.href}
className={cn(
"block px-3 py-1.5 rounded-lg transition-colors text-sm",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{child.label}
</Link>
);
})}
</div>
)}
</div>
);
}
// Simple menu item
const isActive = pathname.startsWith(item.href!);
return (
<Link
key={item.href}
href={item.href}
href={item.href!}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium",
isActive

View File

@@ -5,13 +5,21 @@ import { useDrawings } from "@/hooks/use-drawing";
import { Drawing } from "@/types/drawing";
import { Loader2 } from "lucide-react";
import { SearchContractDrawingDto } from "@/types/dto/drawing/contract-drawing.dto";
import { SearchShopDrawingDto } from "@/types/dto/drawing/shop-drawing.dto";
import { SearchAsBuiltDrawingDto } from "@/types/dto/drawing/asbuilt-drawing.dto";
type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | SearchAsBuiltDrawingDto;
interface DrawingListProps {
type: "CONTRACT" | "SHOP" | "AS_BUILT";
projectId?: number;
projectId: number;
filters?: Partial<DrawingSearchParams>;
}
export function DrawingList({ type, projectId }: DrawingListProps) {
const { data: drawings, isLoading, isError } = useDrawings(type, { projectId: projectId ?? 1 });
export function DrawingList({ type, projectId, filters }: DrawingListProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: drawings, isLoading, isError } = useDrawings(type, { projectId, ...filters } as any);
// Note: The hook handles switching services based on type.
// The params { type } might be redundant if getAll doesn't use it, but safe to pass.

View File

@@ -55,9 +55,11 @@ const shopSchema = baseSchema.extend({
const asBuiltSchema = baseSchema.extend({
drawingType: z.literal("AS_BUILT"),
drawingNumber: z.string().min(1, "Drawing Number is required"),
mainCategoryId: z.string().min(1, "Main Category is required"),
subCategoryId: z.string().min(1, "Sub Category is required"),
// Revision Fields
revisionLabel: z.string().default("0"),
title: z.string().optional(),
title: z.string().min(1, "Title is required"),
legacyDrawingNumber: z.string().optional(),
description: z.string().optional(),
});
@@ -130,8 +132,10 @@ export function DrawingUploadForm({ projectId = 1 }: DrawingUploadFormProps) {
// Date default to now
} else if (data.drawingType === 'AS_BUILT') {
formData.append('drawingNumber', data.drawingNumber);
formData.append('mainCategoryId', data.mainCategoryId);
formData.append('subCategoryId', data.subCategoryId);
formData.append('revisionLabel', data.revisionLabel || '0');
if (data.title) formData.append('title', data.title);
formData.append('title', data.title);
if (data.legacyDrawingNumber) formData.append('legacyDrawingNumber', data.legacyDrawingNumber);
if (data.description) formData.append('description', data.description);
}
@@ -306,9 +310,51 @@ export function DrawingUploadForm({ projectId = 1 }: DrawingUploadFormProps) {
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Title</Label>
<Input {...register("title")} placeholder="Title" />
<Label>Main Category *</Label>
<Select onValueChange={(v) => {
setValue("mainCategoryId", v);
setSelectedShopMainCat(v ? parseInt(v) : undefined);
}}>
<SelectTrigger>
<SelectValue placeholder="Select Main Category" />
</SelectTrigger>
<SelectContent>
{shopMainCats?.map((c: any) => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
{(errors as any).mainCategoryId && (
<p className="text-sm text-destructive">{(errors as any).mainCategoryId.message}</p>
)}
</div>
<div>
<Label>Sub Category *</Label>
<Select onValueChange={(v) => setValue("subCategoryId", v)}>
<SelectTrigger>
<SelectValue placeholder="Select Sub Category" />
</SelectTrigger>
<SelectContent>
{shopSubCats?.map((c: any) => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
{(errors as any).subCategoryId && (
<p className="text-sm text-destructive">{(errors as any).subCategoryId.message}</p>
)}
</div>
</div>
<div>
<Label>Title *</Label>
<Input {...register("title")} placeholder="Drawing Title" />
{(errors as any).title && (
<p className="text-sm text-destructive">{(errors as any).title.message}</p>
)}
</div>
<div>
<Label>Description</Label>

View File

@@ -30,8 +30,8 @@ const VARIABLES = [
{ key: '{DISCIPLINE}', name: 'Discipline Code', example: 'STR' },
{ key: '{SUBTYPE}', name: 'Sub-Type Code', example: 'GEN' },
{ key: '{SUBTYPE_NUM}', name: 'Sub-Type Number', example: '01' },
{ key: '{YEAR}', name: 'Year (B.E.)', example: '2568' },
{ key: '{YEAR_SHORT}', name: 'Year Short (68)', example: '68' },
{ key: '{YEAR:BE}', name: 'Year (B.E.)', example: '2568' },
{ key: '{YEAR:CE}', name: 'Year (C.E.)', example: '2025' },
{ key: '{SEQ:4}', name: 'Sequence (4-digit)', example: '0001' },
];
@@ -69,8 +69,8 @@ export function TemplateEditor({
VARIABLES.forEach((v) => {
// Simple mock replacement for preview
let replacement = v.example;
if (v.key === '{YEAR}') replacement = (new Date().getFullYear() + 543).toString();
if (v.key === '{YEAR_SHORT}') replacement = (new Date().getFullYear() + 543).toString().slice(-2);
if (v.key === '{YEAR:BE}') replacement = (new Date().getFullYear() + 543).toString();
if (v.key === '{YEAR:CE}') replacement = new Date().getFullYear().toString();
// Dynamic context based on selection (optional visual enhancement)
if (v.key === '{TYPE}' && typeId) {

View File

@@ -68,9 +68,9 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
try {
const result = await numberingApi.previewNumber({
projectId: projectId,
originatorId: parseInt(testData.originatorId || "0"),
originatorOrganizationId: parseInt(testData.originatorId || "0"),
recipientOrganizationId: parseInt(testData.recipientId || "0"),
typeId: parseInt(testData.correspondenceTypeId || "0"),
correspondenceTypeId: parseInt(testData.correspondenceTypeId || "0"),
disciplineId: parseInt(testData.disciplineId || "0"),
});
setGeneratedNumber(result.previewNumber);

View File

@@ -1,13 +1,14 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
import { asBuiltDrawingService } from '@/lib/services/asbuilt-drawing.service'; // Added
import { asBuiltDrawingService } from '@/lib/services/asbuilt-drawing.service';
import { SearchContractDrawingDto, CreateContractDrawingDto } from '@/types/dto/drawing/contract-drawing.dto';
import { SearchShopDrawingDto, CreateShopDrawingDto } from '@/types/dto/drawing/shop-drawing.dto';
import { SearchAsBuiltDrawingDto, CreateAsBuiltDrawingDto } from '@/types/dto/drawing/asbuilt-drawing.dto'; // Added
import { SearchAsBuiltDrawingDto, CreateAsBuiltDrawingDto } from '@/types/dto/drawing/asbuilt-drawing.dto';
import { toast } from 'sonner';
import { ContractDrawing, ShopDrawing, AsBuiltDrawing } from "@/types/drawing";
type DrawingType = 'CONTRACT' | 'SHOP' | 'AS_BUILT'; // Added AS_BUILT
type DrawingType = 'CONTRACT' | 'SHOP' | 'AS_BUILT';
type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | SearchAsBuiltDrawingDto;
type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto | CreateAsBuiltDrawingDto;
@@ -25,13 +26,45 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
return useQuery({
queryKey: drawingKeys.list(type, params),
queryFn: async () => {
let response;
if (type === 'CONTRACT') {
return contractDrawingService.getAll(params as SearchContractDrawingDto);
} else if (type === 'SHOP') {
return shopDrawingService.getAll(params as SearchShopDrawingDto);
} else {
return asBuiltDrawingService.getAll(params as SearchAsBuiltDrawingDto);
response = await contractDrawingService.getAll(params as SearchContractDrawingDto);
// Map ContractDrawing to Drawing
if (response && response.data) {
response.data = response.data.map((d: ContractDrawing) => ({
...d,
drawingId: d.id,
drawingNumber: d.contractDrawingNo,
type: 'CONTRACT',
}));
}
} else if (type === 'SHOP') {
response = await shopDrawingService.getAll(params as SearchShopDrawingDto);
// Map ShopDrawing to Drawing
if (response && response.data) {
response.data = response.data.map((d: ShopDrawing) => ({
...d,
drawingId: d.id,
type: 'SHOP',
title: d.currentRevision?.title || "Untitled",
revision: d.currentRevision?.revisionNumber,
legacyDrawingNumber: d.currentRevision?.legacyDrawingNumber,
}));
}
} else {
response = await asBuiltDrawingService.getAll(params as SearchAsBuiltDrawingDto);
// Map AsBuiltDrawing to Drawing
if (response && response.data) {
response.data = response.data.map((d: AsBuiltDrawing) => ({
...d,
drawingId: d.id,
type: 'AS_BUILT',
title: d.currentRevision?.title || "Untitled",
revision: d.currentRevision?.revisionNumber,
}));
}
}
return response;
},
placeholderData: (previousData) => previousData,
});
@@ -69,7 +102,8 @@ export function useCreateDrawing(type: DrawingType) {
}
},
onSuccess: () => {
toast.success(`${type === 'CONTRACT' ? 'Contract' : 'Shop'} Drawing uploaded successfully`);
const typeName = type === 'CONTRACT' ? 'Contract' : type === 'SHOP' ? 'Shop' : 'As Built';
toast.success(`${typeName} Drawing uploaded successfully`);
queryClient.invalidateQueries({ queryKey: drawingKeys.lists() });
},
onError: (error: Error & { response?: { data?: { message?: string } } }) => {
@@ -79,5 +113,3 @@ export function useCreateDrawing(type: DrawingType) {
},
});
}
// You can add useCreateShopDrawingRevision logic here if needed separate

View File

@@ -1,7 +1,6 @@
// File: lib/api/client.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosError } from "axios";
import { v4 as uuidv4 } from "uuid";
import { getSession } from "next-auth/react";
// อ่านค่า Base URL จาก Environment Variable
const baseURL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
@@ -29,18 +28,20 @@ apiClient.interceptors.request.use(
}
// 2. Authentication Token Injection
// ดึง Session จาก NextAuth (ทำงานเฉพาะฝั่ง Client)
// ดึง Token จาก Zustand persist store (localStorage)
if (typeof window !== "undefined") {
try {
const session = await getSession();
// @ts-ignore: Session type extended in types/next-auth.d.ts
const token = session?.accessToken;
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const parsed = JSON.parse(authStorage);
const token = parsed?.state?.token;
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
}
} catch (error) {
console.warn("Failed to retrieve session token:", error);
console.warn("Failed to retrieve auth token:", error);
}
}

View File

@@ -274,18 +274,19 @@ export const numberingApi = {
*/
previewNumber: async (ctx: {
projectId: number;
originatorId: number;
typeId: number;
originatorOrganizationId: number;
correspondenceTypeId: number;
disciplineId?: number;
subTypeId?: number;
rfaTypeId?: number;
recipientOrganizationId?: number;
}): Promise<{ previewNumber: string; nextSequence: number }> => {
const res = await apiClient.post<{ previewNumber: string; nextSequence: number }>(
const res = await apiClient.post<{ data: { previewNumber: string; nextSequence: number } }>(
'/document-numbering/preview',
ctx
);
return res.data;
// Backend wraps response in { data: { ... }, message: "Success" }
return res.data.data || res.data;
},
/**

View File

@@ -0,0 +1,245 @@
// File: lib/services/drawing-master-data.service.ts
import apiClient from "@/lib/api/client";
// ===========================
// Contract Drawing Volumes
// ===========================
export interface ContractVolume {
id: number;
projectId: number;
volumeCode: string;
volumeName: string;
description?: string;
sortOrder: number;
}
export interface CreateContractVolumeDto {
projectId: number;
volumeCode: string;
volumeName: string;
description?: string;
sortOrder: number;
}
// ===========================
// Contract Drawing Categories
// ===========================
export interface ContractCategory {
id: number;
projectId: number;
catCode: string;
catName: string;
description?: string;
sortOrder: number;
}
export interface CreateContractCategoryDto {
projectId: number;
catCode: string;
catName: string;
description?: string;
sortOrder: number;
}
// ===========================
// Contract Drawing Sub-categories
// ===========================
export interface ContractSubCategory {
id: number;
projectId: number;
subCatCode: string;
subCatName: string;
description?: string;
sortOrder: number;
}
export interface CreateContractSubCategoryDto {
projectId: number;
subCatCode: string;
subCatName: string;
description?: string;
sortOrder: number;
}
// ===========================
// Shop Drawing Main Categories
// ===========================
export interface ShopMainCategory {
id: number;
projectId: number;
mainCategoryCode: string;
mainCategoryName: string;
description?: string;
isActive: boolean;
sortOrder: number;
}
export interface CreateShopMainCategoryDto {
projectId: number;
mainCategoryCode: string;
mainCategoryName: string;
description?: string;
isActive?: boolean;
sortOrder: number;
}
// ===========================
// Shop Drawing Sub-categories
// ===========================
export interface ShopSubCategory {
id: number;
projectId: number;
subCategoryCode: string;
subCategoryName: string;
description?: string;
isActive: boolean;
sortOrder: number;
}
export interface CreateShopSubCategoryDto {
projectId: number;
subCategoryCode: string;
subCategoryName: string;
description?: string;
isActive?: boolean;
sortOrder: number;
}
// ===========================
// Service
// ===========================
export const drawingMasterDataService = {
// --- Contract Volumes ---
async getContractVolumes(projectId: number): Promise<ContractVolume[]> {
const response = await apiClient.get(`/drawings/master-data/contract/volumes`, {
params: { projectId },
});
return response.data;
},
async createContractVolume(data: CreateContractVolumeDto): Promise<ContractVolume> {
const response = await apiClient.post(`/drawings/master-data/contract/volumes`, data);
return response.data;
},
async updateContractVolume(id: number, data: Partial<CreateContractVolumeDto>): Promise<ContractVolume> {
const response = await apiClient.patch(`/drawings/master-data/contract/volumes/${id}`, data);
return response.data;
},
async deleteContractVolume(id: number): Promise<void> {
await apiClient.delete(`/drawings/master-data/contract/volumes/${id}`);
},
// --- Contract Categories ---
async getContractCategories(projectId: number): Promise<ContractCategory[]> {
const response = await apiClient.get(`/drawings/master-data/contract/categories`, {
params: { projectId },
});
return response.data;
},
async createContractCategory(data: CreateContractCategoryDto): Promise<ContractCategory> {
const response = await apiClient.post(`/drawings/master-data/contract/categories`, data);
return response.data;
},
async updateContractCategory(id: number, data: Partial<CreateContractCategoryDto>): Promise<ContractCategory> {
const response = await apiClient.patch(`/drawings/master-data/contract/categories/${id}`, data);
return response.data;
},
async deleteContractCategory(id: number): Promise<void> {
await apiClient.delete(`/drawings/master-data/contract/categories/${id}`);
},
// --- Contract Sub-categories ---
async getContractSubCategories(projectId: number): Promise<ContractSubCategory[]> {
const response = await apiClient.get(`/drawings/master-data/contract/sub-categories`, {
params: { projectId },
});
return response.data;
},
async createContractSubCategory(data: CreateContractSubCategoryDto): Promise<ContractSubCategory> {
const response = await apiClient.post(`/drawings/master-data/contract/sub-categories`, data);
return response.data;
},
async updateContractSubCategory(id: number, data: Partial<CreateContractSubCategoryDto>): Promise<ContractSubCategory> {
const response = await apiClient.patch(`/drawings/master-data/contract/sub-categories/${id}`, data);
return response.data;
},
async deleteContractSubCategory(id: number): Promise<void> {
await apiClient.delete(`/drawings/master-data/contract/sub-categories/${id}`);
},
// --- Contract Category Mappings ---
async getContractMappings(
projectId: number,
categoryId?: number
): Promise<{ id: number; subCategory: ContractSubCategory; category: ContractCategory }[]> {
const response = await apiClient.get(`/drawings/master-data/contract/mappings`, {
params: { projectId, categoryId },
});
return response.data;
},
async createContractMapping(data: {
projectId: number;
categoryId: number;
subCategoryId: number;
}): Promise<{ id: number }> {
const response = await apiClient.post(`/drawings/master-data/contract/mappings`, data);
return response.data;
},
async deleteContractMapping(id: number): Promise<void> {
await apiClient.delete(`/drawings/master-data/contract/mappings/${id}`);
},
// --- Shop Main Categories ---
async getShopMainCategories(projectId: number): Promise<ShopMainCategory[]> {
const response = await apiClient.get(`/drawings/master-data/shop/main-categories`, {
params: { projectId },
});
return response.data;
},
async createShopMainCategory(data: CreateShopMainCategoryDto): Promise<ShopMainCategory> {
const response = await apiClient.post(`/drawings/master-data/shop/main-categories`, data);
return response.data;
},
async updateShopMainCategory(id: number, data: Partial<CreateShopMainCategoryDto>): Promise<ShopMainCategory> {
const response = await apiClient.patch(`/drawings/master-data/shop/main-categories/${id}`, data);
return response.data;
},
async deleteShopMainCategory(id: number): Promise<void> {
await apiClient.delete(`/drawings/master-data/shop/main-categories/${id}`);
},
// --- Shop Sub-categories ---
async getShopSubCategories(projectId: number, mainCategoryId?: number): Promise<ShopSubCategory[]> {
const response = await apiClient.get(`/drawings/master-data/shop/sub-categories`, {
params: { projectId, mainCategoryId },
});
return response.data;
},
async createShopSubCategory(data: CreateShopSubCategoryDto): Promise<ShopSubCategory> {
const response = await apiClient.post(`/drawings/master-data/shop/sub-categories`, data);
return response.data;
},
async updateShopSubCategory(id: number, data: Partial<CreateShopSubCategoryDto>): Promise<ShopSubCategory> {
const response = await apiClient.patch(`/drawings/master-data/shop/sub-categories/${id}`, data);
return response.data;
},
async deleteShopSubCategory(id: number): Promise<void> {
await apiClient.delete(`/drawings/master-data/shop/sub-categories/${id}`);
},
};

View File

@@ -8,7 +8,9 @@ export interface DrawingRevision {
revisionDescription?: string;
revisedByName: string;
fileUrl: string;
isCurrent: boolean;
isCurrent: boolean | null; // Updated: null = not current (MariaDB UNIQUE pattern)
createdBy?: number; // Added v1.7.0
updatedBy?: number; // Added v1.7.0
}
export interface ContractDrawing {
@@ -39,6 +41,8 @@ export interface AsBuiltDrawing {
id: number;
drawingNumber: string;
projectId: number;
mainCategoryId: number;
subCategoryId: number;
currentRevision?: DrawingRevision;
createdAt: string;
updatedAt: string;

View File

@@ -4,6 +4,8 @@
export interface CreateAsBuiltDrawingDto {
projectId: number;
drawingNumber: string;
mainCategoryId: number;
subCategoryId: number;
// First Revision Data
revisionLabel?: string;

View File

@@ -796,7 +796,7 @@ CREATE TABLE shop_drawing_sub_categories (
CREATE TABLE shop_drawings (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
project_id INT NOT NULL COMMENT 'โครงการ',
drawing_number VARCHAR(100) NOT NULL UNIQUE COMMENT 'เลขที่ Shop Drawing',
drawing_number VARCHAR(100) NOT NULL COMMENT 'เลขที่ Shop Drawing',
main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก',
sub_category_id INT NOT NULL COMMENT 'หมวดหมู่ย่อย',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
@@ -805,7 +805,8 @@ CREATE TABLE shop_drawings (
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
FOREIGN KEY (project_id) REFERENCES projects (id),
FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (id),
FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id)
FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id),
UNIQUE KEY ux_shop_dwg_no_project (project_id, drawing_number)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"';
-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)
@@ -814,14 +815,22 @@ CREATE TABLE shop_drawing_revisions (
shop_drawing_id INT NOT NULL COMMENT 'Master ID',
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
is_current BOOLEAN DEFAULT NULL COMMENT '(TRUE = Revision ปัจจุบัน, NULL = ไม่ใช่ปัจจุบัน)',
revision_date DATE COMMENT 'วันที่ของ Revision',
title VARCHAR(500) NOT NULL COMMENT 'ชื่อแบบ',
description TEXT COMMENT 'คำอธิบายการแก้ไข',
legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ Shop Drawing',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
created_by INT COMMENT 'ผู้สร้าง',
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings (id) ON DELETE CASCADE,
UNIQUE KEY ux_sd_rev_drawing_revision (shop_drawing_id, revision_number)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1 :N)';
FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE
SET NULL,
FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE
SET NULL,
UNIQUE KEY ux_sd_rev_drawing_revision (shop_drawing_id, revision_number),
UNIQUE KEY uq_sd_current (shop_drawing_id, is_current)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)';
-- ตารางเชื่อมระหว่าง shop_drawing_revisions กับ contract_drawings (M:N)
CREATE TABLE shop_drawing_revision_contract_refs (
@@ -839,7 +848,7 @@ CREATE TABLE shop_drawing_revision_contract_refs (
CREATE TABLE asbuilt_drawings (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
project_id INT NOT NULL COMMENT 'โครงการ',
drawing_number VARCHAR(100) NOT NULL UNIQUE COMMENT 'เลขที่ AS Built Drawing',
drawing_number VARCHAR(100) NOT NULL COMMENT 'เลขที่ AS Built Drawing',
main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก',
sub_category_id INT NOT NULL COMMENT 'หมวดหมู่ย่อย',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
@@ -848,25 +857,34 @@ CREATE TABLE asbuilt_drawings (
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
FOREIGN KEY (project_id) REFERENCES projects (id),
FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (id),
FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"';
FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id),
UNIQUE KEY ux_asbuilt_no_project (project_id, drawing_number)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบ AS Built"';
-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)
-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ AS Built (1:N)
CREATE TABLE asbuilt_drawing_revisions (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision',
asbuilt_drawing_id INT NOT NULL COMMENT 'Master ID',
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
is_current BOOLEAN DEFAULT NULL COMMENT '(TRUE = Revision ปัจจุบัน, NULL = ไม่ใช่ปัจจุบัน)',
revision_date DATE COMMENT 'วันที่ของ Revision',
title VARCHAR(500) NOT NULL COMMENT 'ชื่อแบบ',
description TEXT COMMENT 'คำอธิบายการแก้ไข',
legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ AS Built Drawing',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
created_by INT COMMENT 'ผู้สร้าง',
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings (id) ON DELETE CASCADE,
UNIQUE KEY ux_sd_rev_drawing_revision (asbuilt_drawing_id, revision_number)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ asbuilt_drawings (1 :N)';
FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE
SET NULL,
FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE
SET NULL,
UNIQUE KEY ux_asbuilt_rev_drawing_revision (asbuilt_drawing_id, revision_number),
UNIQUE KEY uq_asbuilt_current (asbuilt_drawing_id, is_current)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ asbuilt_drawings (1:N)';
-- ตารางเชื่อมระหว่าง asbuilt_drawing_revisions กับ shop_drawings (M:N)
-- ตารางเชื่อมระหว่าง asbuilt_drawing_revisions กับ shop_drawing_revisions (M:N)
CREATE TABLE asbuilt_revision_shop_revisions_refs (
asbuilt_drawing_revision_id INT COMMENT 'ID ของ AS Built Drawing Revision',
shop_drawing_revision_id INT COMMENT 'ID ของ Shop Drawing Revision',
@@ -878,6 +896,58 @@ CREATE TABLE asbuilt_revision_shop_revisions_refs (
FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions (id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมระหว่าง asbuilt_drawing_revisions กับ shop_drawing_revisions (M :N)';
-- =====================================================
-- View: Shop Drawing พร้อม Current Revision
-- =====================================================
CREATE OR REPLACE VIEW vw_shop_drawing_current AS
SELECT sd.id,
sd.project_id,
sd.drawing_number,
sd.main_category_id,
sd.sub_category_id,
sd.created_at,
sd.updated_at,
sd.deleted_at,
sd.updated_by,
sdr.id AS revision_id,
sdr.revision_number,
sdr.revision_label,
sdr.revision_date,
sdr.title AS revision_title,
sdr.description AS revision_description,
sdr.legacy_drawing_number,
sdr.created_by AS revision_created_by,
sdr.updated_by AS revision_updated_by
FROM shop_drawings sd
LEFT JOIN shop_drawing_revisions sdr ON sd.id = sdr.shop_drawing_id
AND sdr.is_current = TRUE;
-- =====================================================
-- View: As Built Drawing พร้อม Current Revision
-- =====================================================
CREATE OR REPLACE VIEW vw_asbuilt_drawing_current AS
SELECT ad.id,
ad.project_id,
ad.drawing_number,
ad.main_category_id,
ad.sub_category_id,
ad.created_at,
ad.updated_at,
ad.deleted_at,
ad.updated_by,
adr.id AS revision_id,
adr.revision_number,
adr.revision_label,
adr.revision_date,
adr.title AS revision_title,
adr.description AS revision_description,
adr.legacy_drawing_number,
adr.created_by AS revision_created_by,
adr.updated_by AS revision_updated_by
FROM asbuilt_drawings ad
LEFT JOIN asbuilt_drawing_revisions adr ON ad.id = adr.asbuilt_drawing_id
AND adr.is_current = TRUE;
-- =====================================================
-- 6. 🔄 Circulations (ใบเวียนภายใน)
-- =====================================================
@@ -1978,7 +2048,9 @@ CREATE INDEX idx_correspondences_project_type ON correspondences (project_id, co
CREATE INDEX idx_corr_revisions_status_current ON correspondence_revisions (correspondence_status_id, is_current);
CREATE INDEX IDX_AUDIT_DOC_ID ON document_number_audit (document_id);
CREATE INDEX IDX_AUDIT_STATUS ON document_number_audit (status);
CREATE INDEX IDX_AUDIT_STATUS ON document_number_audit (STATUS);
CREATE INDEX IDX_AUDIT_OPERATION ON document_number_audit (operation);
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -202,7 +202,7 @@ INSERT INTO users (
VALUES (
1,
'superadmin',
'$2b$10$E6d5k.f46jr.POGWKHhiQ.X1ZsFrMpZox//sCxeOiLUULGuAHO0NW',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose',
'Super',
'Admin',
'superadmin @example.com',
@@ -212,7 +212,7 @@ VALUES (
(
2,
'admin',
'$2b$10$E6d5k.f46jr.POGWKHhiQ.X1ZsFrMpZox//sCxeOiLUULGuAHO0NW',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose',
'Admin',
'คคง.',
'admin@example.com',
@@ -222,7 +222,7 @@ VALUES (
(
3,
'editor01',
'$2b$10$E6d5k.f46jr.POGWKHhiQ.X1ZsFrMpZox//sCxeOiLUULGuAHO0NW',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose',
'DC',
'C1',
'editor01 @example.com',
@@ -232,7 +232,7 @@ VALUES (
(
4,
'viewer01',
'$2b$10$E6d5k.f46jr.POGWKHhiQ.X1ZsFrMpZox//sCxeOiLUULGuAHO0NW',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose',
'Viewer',
'สคฉ.03',
'viewer01 @example.com',

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,10 @@
## กำหนดสิทธิ
```bash
chown -R 999:999 /share/Container/mariadb/init
chmod 755 /share/Container/mariadb/init
setfacl -R -m u:999:r-x /share/Container/mariadb/init
setfacl -R -d -m u:999:r-x /share/Container/mariadb/init
chown -R 999:999 /share/nap-dms/mariadb/init
chmod 755 /share/nap-dms/mariadb/init
setfacl -R -m u:999:r-x /share/nap-dms/mariadb/init
setfacl -R -d -m u:999:r-x /share/nap-dms/mariadb/init
chown -R 33:33 /share/Container/pma/tmp
chmod 755 /share/Container/pma/tmp
@@ -86,9 +86,9 @@ services:
ports:
- "3306:3306"
volumes:
- "/share/Container/mariadb/data:/var/lib/mysql"
- "/share/Container/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro"
- "/share/Container/mariadb/init:/docker-entrypoint-initdb.d:ro"
- "/share/nap-dms/mariadb/data:/var/lib/mysql"
- "/share/nap-dms/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro"
- "/share/nap-dms/mariadb/init:/docker-entrypoint-initdb.d:ro"
- "/share/dms-data/mariadb/backup:/backup"
healthcheck:
test:

View File

@@ -0,0 +1,110 @@
# File: mariadb/docker-compose-db.yml
# DMS Container v1_7_0 Servive: mariadb, pma
x-restart: &restart_policy
restart: unless-stopped
x-logging: &default_logging
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
services:
mariadb:
<<: [*restart_policy, *default_logging]
image: mariadb:11.8
container_name: mariadb
stdin_open: true
tty: true
deploy:
resources:
limits:
cpus: "2.0"
memory: 4G
reservations:
cpus: "0.5"
memory: 1G
environment:
MYSQL_ROOT_PASSWORD: "Center#2025"
MYSQL_DATABASE: "lcbp3"
MYSQL_USER: "center"
MYSQL_PASSWORD: "Center#2025"
TZ: "Asia/Bangkok"
ports:
- "3306:3306"
volumes:
- "/share/nap-dms/mariadb/data:/var/lib/mysql"
- "/share/nap-dms/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro"
- "/share/nap-dms/mariadb/init:/docker-entrypoint-initdb.d:ro"
- "/share/dms-data/mariadb/backup:/backup"
healthcheck:
test:
["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -pCenter#2025 || exit 1"]
interval: 10s
timeout: 5s
retries: 15
networks:
lcbp3: {}
pma:
<<: [*restart_policy, *default_logging]
image: phpmyadmin:5-apache
container_name: pma
stdin_open: true
tty: true
deploy:
resources:
limits:
cpus: "0.25"
memory: 256M
environment:
TZ: "Asia/Bangkok"
PMA_HOST: "mariadb"
PMA_PORT: "3306"
PMA_ABSOLUTE_URI: "https://pma.np-dms.work/"
UPLOAD_LIMIT: "1G"
MEMORY_LIMIT: "512M"
ports:
- "89:80"
# expose:
# - "80"
volumes:
- "/share/Container/pma/config.user.inc.php:/etc/phpmyadmin/config.user.inc.php:ro"
- "/share/Container/pma/zzz-custom.ini:/usr/local/etc/php/conf.d/zzz-custom.ini:ro"
- "/share/Container/pma/tmp:/var/lib/phpmyadmin/tmp:rw"
- "/share/dms-data/logs/pma:/var/log/apache2"
depends_on:
mariadb:
condition: service_healthy
networks:
lcbp3: {}
networks:
lcbp3:
external: true
# chown -R 999:999 /share/nap-dms/mariadb/init
# chmod 755 /share/nap-dms/mariadb/init
# setfacl -R -m u:999:r-x /share/nap-dms/mariadb/init
# setfacl -R -d -m u:999:r-x /share/nap-dms/mariadb/init
# chown -R 33:33 /share/Container/pma/tmp
# chmod 755 /share/Container/pma/tmp
# setfacl -R -m u:33:rwx /share/Container/pma/tmp
# setfacl -R -d -m u:33:rwx /share/Container/pma/tmp
# chown -R 33:33 /share/dms-data/logs/pma
# chmod 755 /share/dms-data/logs/pma
# setfacl -R -m u:33:rwx /share/dms-data/logs/pma
# setfacl -R -d -m u:33:rwx /share/dms-data/logs/pma
# setfacl -R -m u:1000:rwx /share/Container/gitea
# setfacl -R -m u:1000:rwx /share/dms-data/gitea_repos
# setfacl -R -m u:1000:rwx /share/dms-data/gitea_registry
# docker exec -it mariadb mysql -u root -p
# CREATE DATABASE npm;
# CREATE USER 'npm'@'%' IDENTIFIED BY 'npm';
# GRANT ALL PRIVILEGES ON npm.* TO 'npm'@'%';
# FLUSH PRIVILEGES;

View File

@@ -0,0 +1,71 @@
# Session History: 2025-12-24 - Document Numbering Fixes
## Overview
- **Date:** 2025-12-24
- **Duration:** ~2 hours
- **Focus:** Document Numbering System - Bug Fixes & Improvements
---
## Changes Made
### 1. Year Token Format (4-digit)
**Files:**
- `backend/src/modules/document-numbering/services/format.service.ts`
**Changes:**
```typescript
// Before
'{YEAR}': year.toString().substring(2), // "25"
'{YEAR:BE}': (year + 543).toString().substring(2), // "68"
// After
'{YEAR}': year.toString(), // "2025"
'{YEAR:BE}': (year + 543).toString(), // "2568"
```
---
### 2. TypeScript Field Name Fixes
**Files:**
- `backend/src/modules/document-numbering/dto/preview-number.dto.ts`
- `backend/src/modules/document-numbering/controllers/document-numbering.controller.ts`
- `frontend/lib/api/numbering.ts`
- `frontend/components/numbering/template-tester.tsx`
**Changes:**
- `originatorId``originatorOrganizationId`
- `typeId``correspondenceTypeId`
---
### 3. Generate Test Number Bug Fix
**Root Cause:**
1. API client ใช้ NextAuth `getSession()` แต่ token อยู่ใน Zustand localStorage (`auth-storage`)
2. Response wrapper mismatch: backend ส่ง `{ data: {...} }` แต่ frontend อ่าน `res.data` โดยตรง
**Files:**
- `frontend/lib/api/client.ts` - ดึง token จาก `localStorage['auth-storage']`
- `frontend/lib/api/numbering.ts` - แก้ response unwrapping: `res.data.data || res.data`
---
### 4. Documentation
**Files Created/Updated:**
- `docs/document-numbering-summary.md` - Comprehensive system summary
---
## Verification Results
| Test | Result |
| -------------------- | --------- |
| Backend Build | ✅ Pass |
| Frontend Build | ✅ Pass |
| Generate Test Number | ✅ Working |
---
## Notes
- Template ต้องใช้ `{YEAR:BE}` เพื่อแสดงปี พ.ศ. (ไม่ใช่ `{YEAR}`)
- สามารถแก้ไข Template ผ่าน Admin > Numbering > Edit Template

View File

@@ -0,0 +1,100 @@
# Drawing Module Frontend/Backend Implementation
**วันที่:** 25 ธันวาคม 2568 (2025-12-25)
**Session:** Drawing Dashboard & Admin Panel UX/UI Implementation
---
## 🎯 วัตถุประสงค์
1. Update Backend entities และ Frontend types ตาม v1.7.0 schema (Drawing Revision)
2. สร้าง Admin Panel สำหรับจัดการ Drawing Master Data
3. สร้าง Backend APIs สำหรับ CRUD operations
---
## ✅ สิ่งที่ทำเสร็จ
### 1. Backend Entity Updates (Drawing Revision Schema)
| File | Changes |
| ------------------------------------ | --------------------------------------------------------- |
| `shop-drawing-revision.entity.ts` | เพิ่ม `isCurrent`, `createdBy`, `updatedBy`, User relations |
| `asbuilt-drawing-revision.entity.ts` | เพิ่ม `isCurrent`, `createdBy`, `updatedBy`, User relations |
### 2. Frontend Type Updates
| File | Changes |
| --------------------------- | ------------------------------------------------------------------------- |
| `frontend/types/drawing.ts` | `DrawingRevision` - เพิ่ม `createdBy`, `updatedBy`, update `isCurrent` type |
### 3. Admin Panel Frontend (6 pages)
| Route | Description |
| ----------------------------------------- | ------------------ |
| `/admin/drawings` | Navigation hub |
| `/admin/drawings/contract/volumes` | Volume CRUD |
| `/admin/drawings/contract/categories` | Category CRUD |
| `/admin/drawings/contract/sub-categories` | Sub-category CRUD |
| `/admin/drawings/shop/main-categories` | Main Category CRUD |
| `/admin/drawings/shop/sub-categories` | Sub-category CRUD |
**Service:** `frontend/lib/services/drawing-master-data.service.ts`
### 4. Backend APIs (Full CRUD)
**Controller:** `backend/src/modules/drawing/drawing-master-data.controller.ts`
**Service:** `backend/src/modules/drawing/drawing-master-data.service.ts`
| Endpoint | Methods |
| --------------------------------------------------- | ------------------------ |
| `/api/drawings/master-data/contract/volumes` | GET, POST, PATCH, DELETE |
| `/api/drawings/master-data/contract/categories` | GET, POST, PATCH, DELETE |
| `/api/drawings/master-data/contract/sub-categories` | GET, POST, PATCH, DELETE |
| `/api/drawings/master-data/shop/main-categories` | GET, POST, PATCH, DELETE |
| `/api/drawings/master-data/shop/sub-categories` | GET, POST, PATCH, DELETE |
### 5. Admin Dashboard Update
เพิ่ม "Drawing Master Data" link ใน Admin Dashboard (`frontend/app/(admin)/admin/page.tsx`)
---
## 📁 ไฟล์ที่แก้ไข/สร้างใหม่
### Backend
- `backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts` - Modified
- `backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts` - Modified
- `backend/src/modules/drawing/drawing-master-data.controller.ts` - Rewritten
- `backend/src/modules/drawing/drawing-master-data.service.ts` - Rewritten
### Frontend
- `frontend/types/drawing.ts` - Modified
- `frontend/lib/services/drawing-master-data.service.ts` - **NEW**
- `frontend/app/(admin)/admin/drawings/page.tsx` - **NEW**
- `frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx` - **NEW**
- `frontend/app/(admin)/admin/drawings/contract/categories/page.tsx` - **NEW**
- `frontend/app/(admin)/admin/drawings/contract/sub-categories/page.tsx` - **NEW**
- `frontend/app/(admin)/admin/drawings/shop/main-categories/page.tsx` - **NEW**
- `frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx` - **NEW**
- `frontend/app/(admin)/admin/page.tsx` - Modified
### Specs
- `specs/09-history/2025-12-25-drawing-revision-schema-update.md` - Updated (marked complete)
---
## 🔧 Build Status
| Component | Status |
| --------- | -------- |
| Backend | ✅ Passed |
| Frontend | ✅ Passed |
---
## 📋 TODO (Phase 2+)
- [ ] Dashboard Drawing UX Enhancements (filters)
- [ ] Contract Drawing: Category-SubCategory mapping UI
- [ ] Shop Drawing: MainCategory-SubCategory linking

View File

@@ -0,0 +1,80 @@
# Session History: Drawing Module Refactor v1.7.0
**Date:** 2025-12-25
**Session ID:** cdbb2d6b-1fab-459e-8ec9-e864bd30b308
---
## Objective
Refactor Drawing module (backend & frontend) to align with `lcbp3-v1.7.0-schema.sql`, specifically for AS Built Drawings.
---
## Changes Made
### Backend
#### Entities Updated
| File | Changes |
| ------------------------------------ | --------------------------------------------------- |
| `asbuilt-drawing.entity.ts` | Added `mainCategoryId`, `subCategoryId` + relations |
| `asbuilt-drawing-revision.entity.ts` | Added `legacyDrawingNumber` |
#### New Files Created
| File | Description |
| -------------------------------------------- | ----------------------------------- |
| `dto/create-asbuilt-drawing.dto.ts` | Create AS Built with first revision |
| `dto/create-asbuilt-drawing-revision.dto.ts` | Add revision to existing AS Built |
| `dto/search-asbuilt-drawing.dto.ts` | Search with pagination |
| `asbuilt-drawing.service.ts` | CRUD service |
| `asbuilt-drawing.controller.ts` | REST controller |
#### Module Updated
- `drawing.module.ts` - Registered new entities, service, controller
#### New API Endpoints
| Method | Path | Description |
| ------ | --------------------------------- | ------------ |
| POST | `/drawings/asbuilt` | Create |
| POST | `/drawings/asbuilt/:id/revisions` | Add revision |
| GET | `/drawings/asbuilt` | List |
| GET | `/drawings/asbuilt/:id` | Get by ID |
| DELETE | `/drawings/asbuilt/:id` | Delete |
---
### Frontend
#### Types Updated
| File | Changes |
| ------------------------------------------ | ------------------------------------------------------------------- |
| `types/drawing.ts` | `AsBuiltDrawing` interface: added `mainCategoryId`, `subCategoryId` |
| `types/dto/drawing/asbuilt-drawing.dto.ts` | Added category IDs |
#### Components Updated
| File | Changes |
| ------------------------------------- | ------------------------------------------------------- |
| `components/drawings/upload-form.tsx` | AS_BUILT form: added category selectors, title required |
| `components/drawings/list.tsx` | `projectId` now required prop |
| `app/(dashboard)/drawings/page.tsx` | Added project selector dropdown |
#### Hooks Updated
| File | Changes |
| ---------------------- | -------------------------------- |
| `hooks/use-drawing.ts` | Fixed toast message for AS_BUILT |
---
## Verification Results
| Component | Command | Result |
| --------- | ------------ | --------- |
| Backend | `pnpm build` | ✅ Success |
| Frontend | `pnpm build` | ✅ Success |
---
## Notes
- AS Built Drawings use same category structure as Shop Drawings (`shop_drawing_main_categories`, `shop_drawing_sub_categories`)
- No existing data in `asbuilt_drawings` table, no migration needed
- Pre-existing lint warnings (`any` types) in `upload-form.tsx` not addressed in this session

View File

@@ -0,0 +1,94 @@
# Drawing Revision Schema Update
**วันที่:** 25 ธันวาคม 2568 (2025-12-25)
**Session:** Drawing Revision Schema Consistency Update
---
## 🎯 วัตถุประสงค์
ปรับปรุง schema ของตาราง Drawing (Shop Drawing และ As Built Drawing) ให้สอดคล้องกับ pattern ของตาราง revision อื่นๆ ในระบบ (เช่น `correspondence_revisions`, `rfa_revisions`)
---
## 📝 การเปลี่ยนแปลง
### 1. Schema Updates (`lcbp3-v1.7.0-schema.sql`)
#### 1.1 เพิ่ม Columns ใน `shop_drawing_revisions`
```sql
is_current BOOLEAN DEFAULT NULL COMMENT '(TRUE = Revision ปัจจุบัน, NULL = ไม่ใช่ปัจจุบัน)'
created_by INT COMMENT 'ผู้สร้าง'
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด'
```
- เพิ่ม Foreign Keys สำหรับ `created_by` และ `updated_by` ไปยัง `users` table
- เพิ่ม `UNIQUE KEY uq_sd_current (shop_drawing_id, is_current)` เพื่อ enforce ว่ามี `is_current = TRUE` ได้แค่ 1 row ต่อ drawing
#### 1.2 เพิ่ม Columns ใน `asbuilt_drawing_revisions`
- เหมือนกับ `shop_drawing_revisions`
#### 1.3 เปลี่ยน Unique Constraint ของ `drawing_number`
- **เดิม:** `UNIQUE (drawing_number)` - Global uniqueness
- **ใหม่:** `UNIQUE (project_id, drawing_number)` - Project-scoped uniqueness
### 2. Views เพิ่มใหม่
```sql
-- View สำหรับ Shop Drawing พร้อม Current Revision
CREATE OR REPLACE VIEW vw_shop_drawing_current AS ...
-- View สำหรับ As Built Drawing พร้อม Current Revision
CREATE OR REPLACE VIEW vw_asbuilt_drawing_current AS ...
```
**ประโยชน์:**
- Query ง่ายขึ้นโดยไม่ต้อง JOIN ทุกครั้ง
- ตัวอย่าง: `SELECT * FROM vw_shop_drawing_current WHERE project_id = 3`
### 3. Seed Data Updates (`lcbp3-v1.7.0-seed-shopdrawing.sql`)
เพิ่ม UPDATE statement ท้ายไฟล์เพื่อ set `is_current = TRUE` สำหรับ revision ล่าสุดของแต่ละ drawing:
```sql
UPDATE shop_drawing_revisions sdr
JOIN (
SELECT shop_drawing_id, MAX(revision_number) AS max_rev
FROM shop_drawing_revisions
GROUP BY shop_drawing_id
) latest ON sdr.shop_drawing_id = latest.shop_drawing_id
AND sdr.revision_number = latest.max_rev
SET sdr.is_current = TRUE;
```
---
## 🔧 เหตุผลทางเทคนิค
### ทำไมใช้ `DEFAULT NULL` แทน `DEFAULT FALSE`?
MariaDB/MySQL ไม่อนุญาตให้มี duplicate values ใน UNIQUE constraint รวมถึง `FALSE` หลายตัว:
| `is_current` | ความหมาย | อนุญาตหลายแถว? |
| ------------ | -------------- | ----------------------------- |
| `TRUE` | Revision ปัจจุบัน | ❌ ไม่ได้ (UNIQUE) |
| `NULL` | Revision เก่า | ✅ ได้ (NULL ignored in UNIQUE) |
| `FALSE` | Revision เก่า | ❌ ไม่ได้ (จะซ้ำกัน) |
---
## 📁 ไฟล์ที่แก้ไข
| ไฟล์ | การเปลี่ยนแปลง |
| ----------------------------------------------------- | ------------------------------------ |
| `specs/07-database/lcbp3-v1.7.0-schema.sql` | เพิ่ม columns, views, และ constraints |
| `specs/07-database/lcbp3-v1.7.0-seed-shopdrawing.sql` | เพิ่ม UPDATE statement สำหรับ is_current |
---
## ✅ สถานะ
- [x] Schema updated
- [x] Seed data updated
- [x] Views created
- [x] Backend entities/DTOs update
- [x] Frontend types update