From cd73cc1549b8aa0604e68ba8fc336dd43a928729 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 25 Dec 2025 17:03:33 +0700 Subject: [PATCH] 251225:1703 On going update to 1.7.0: Refoctory drawing Module not finish --- backend/hash.txt | 1 + .../document-numbering.controller.ts | 4 +- .../dto/preview-number.dto.ts | 4 +- .../services/format.service.ts | 4 +- .../drawing/asbuilt-drawing.controller.ts | 101 + .../drawing/asbuilt-drawing.service.ts | 307 + .../drawing/drawing-master-data.controller.ts | 293 +- .../drawing/drawing-master-data.service.ts | 180 +- backend/src/modules/drawing/drawing.module.ts | 18 +- .../create-asbuilt-drawing-revision.dto.ts | 50 + .../drawing/dto/create-asbuilt-drawing.dto.ts | 72 + .../drawing/dto/search-asbuilt-drawing.dto.ts | 44 + .../asbuilt-drawing-revision.entity.ts | 28 + .../entities/asbuilt-drawing.entity.ts | 16 + .../entities/shop-drawing-revision.entity.ts | 25 + docs/20251224-document-numbering-summary.md | 399 ++ docs/{ => SQL}/8_lcbp3_v1_4_5.sql | 0 docs/{ => SQL}/8_lcbp3_v1_4_5_seed.sql | 0 docs/{ => SQL}/8_lcbp3_v1_5_1.sql | 0 docs/{ => SQL}/8_lcbp3_v1_5_1_seed.sql | 0 docs/{ => backup}/0_Requirements_V1_4_5.md | 0 docs/{ => backup}/0_Requirements_V1_5_1.md | 0 docs/{ => backup}/1_FullStackJS_V1_4_5.md | 0 docs/{ => backup}/1_FullStackJS_V1_5_1.md | 0 .../2_Backend_Plan_V1_4_4.Phase6A.md | 0 .../2_Backend_Plan_V1_4_4.Phase_Addition.md | 0 docs/{ => backup}/2_Backend_Plan_V1_4_5.md | 0 docs/{ => backup}/2_Backend_Plan_V1_5_1.md | 0 docs/{ => backup}/3_Frontend_Plan_V1_4_5.md | 0 docs/{ => backup}/3_Frontend_Plan_V1_5_1.md | 0 docs/{ => backup}/4_Data_Dictionary_V1_4_5.md | 0 docs/{ => backup}/4_Data_Dictionary_V1_5_1.md | 0 .../drawings/contract/categories/page.tsx | 282 + .../drawings/contract/sub-categories/page.tsx | 126 + .../admin/drawings/contract/volumes/page.tsx | 126 + frontend/app/(admin)/admin/drawings/page.tsx | 114 + .../drawings/shop/main-categories/page.tsx | 146 + .../drawings/shop/sub-categories/page.tsx | 146 + frontend/app/(admin)/admin/page.tsx | 7 + frontend/app/(dashboard)/drawings/page.tsx | 106 +- frontend/components/admin/sidebar.tsx | 106 +- frontend/components/drawings/list.tsx | 14 +- frontend/components/drawings/upload-form.tsx | 56 +- .../components/numbering/template-editor.tsx | 8 +- .../components/numbering/template-tester.tsx | 4 +- frontend/hooks/use-drawing.ts | 50 +- frontend/lib/api/client.ts | 19 +- frontend/lib/api/numbering.ts | 9 +- .../services/drawing-master-data.service.ts | 245 + frontend/types/drawing.ts | 6 +- .../types/dto/drawing/asbuilt-drawing.dto.ts | 2 + specs/07-database/lcbp3-v1.7.0-schema.sql | 96 +- specs/07-database/lcbp3-v1.7.0-seed-basic.sql | 8 +- .../lcbp3-v1.7.0-seed-shopdrawing.sql | 5342 ++++++++++++++--- specs/08-infrastructure/MariaDB_setting.md | 14 +- specs/08-infrastructure/lcbp3-db.md | 110 + .../2025-12-24-document-numbering-fixes.md | 71 + ...2-25-drawing-admin-panel-implementation.md | 100 + .../2025-12-25-drawing-module-refactor.md | 80 + ...25-12-25-drawing-revision-schema-update.md | 94 + 60 files changed, 8201 insertions(+), 832 deletions(-) create mode 100644 backend/hash.txt create mode 100644 backend/src/modules/drawing/asbuilt-drawing.controller.ts create mode 100644 backend/src/modules/drawing/asbuilt-drawing.service.ts create mode 100644 backend/src/modules/drawing/dto/create-asbuilt-drawing-revision.dto.ts create mode 100644 backend/src/modules/drawing/dto/create-asbuilt-drawing.dto.ts create mode 100644 backend/src/modules/drawing/dto/search-asbuilt-drawing.dto.ts create mode 100644 docs/20251224-document-numbering-summary.md rename docs/{ => SQL}/8_lcbp3_v1_4_5.sql (100%) rename docs/{ => SQL}/8_lcbp3_v1_4_5_seed.sql (100%) rename docs/{ => SQL}/8_lcbp3_v1_5_1.sql (100%) rename docs/{ => SQL}/8_lcbp3_v1_5_1_seed.sql (100%) rename docs/{ => backup}/0_Requirements_V1_4_5.md (100%) rename docs/{ => backup}/0_Requirements_V1_5_1.md (100%) rename docs/{ => backup}/1_FullStackJS_V1_4_5.md (100%) rename docs/{ => backup}/1_FullStackJS_V1_5_1.md (100%) rename docs/{ => backup}/2_Backend_Plan_V1_4_4.Phase6A.md (100%) rename docs/{ => backup}/2_Backend_Plan_V1_4_4.Phase_Addition.md (100%) rename docs/{ => backup}/2_Backend_Plan_V1_4_5.md (100%) rename docs/{ => backup}/2_Backend_Plan_V1_5_1.md (100%) rename docs/{ => backup}/3_Frontend_Plan_V1_4_5.md (100%) rename docs/{ => backup}/3_Frontend_Plan_V1_5_1.md (100%) rename docs/{ => backup}/4_Data_Dictionary_V1_4_5.md (100%) rename docs/{ => backup}/4_Data_Dictionary_V1_5_1.md (100%) create mode 100644 frontend/app/(admin)/admin/drawings/contract/categories/page.tsx create mode 100644 frontend/app/(admin)/admin/drawings/contract/sub-categories/page.tsx create mode 100644 frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx create mode 100644 frontend/app/(admin)/admin/drawings/page.tsx create mode 100644 frontend/app/(admin)/admin/drawings/shop/main-categories/page.tsx create mode 100644 frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx create mode 100644 frontend/lib/services/drawing-master-data.service.ts create mode 100644 specs/08-infrastructure/lcbp3-db.md create mode 100644 specs/09-history/2025-12-24-document-numbering-fixes.md create mode 100644 specs/09-history/2025-12-25-drawing-admin-panel-implementation.md create mode 100644 specs/09-history/2025-12-25-drawing-module-refactor.md create mode 100644 specs/09-history/2025-12-25-drawing-revision-schema-update.md diff --git a/backend/hash.txt b/backend/hash.txt new file mode 100644 index 0000000..0a3685d --- /dev/null +++ b/backend/hash.txt @@ -0,0 +1 @@ +$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose \ No newline at end of file diff --git a/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts b/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts index 133a91f..eb7a51a 100644 --- a/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts +++ b/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts @@ -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, diff --git a/backend/src/modules/document-numbering/dto/preview-number.dto.ts b/backend/src/modules/document-numbering/dto/preview-number.dto.ts index 4ee080b..9a65f49 100644 --- a/backend/src/modules/document-numbering/dto/preview-number.dto.ts +++ b/backend/src/modules/document-numbering/dto/preview-number.dto.ts @@ -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() diff --git a/backend/src/modules/document-numbering/services/format.service.ts b/backend/src/modules/document-numbering/services/format.service.ts index 276ec88..853b9c7 100644 --- a/backend/src/modules/document-numbering/services/format.service.ts +++ b/backend/src/modules/document-numbering/services/format.service.ts @@ -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', }; } diff --git a/backend/src/modules/drawing/asbuilt-drawing.controller.ts b/backend/src/modules/drawing/asbuilt-drawing.controller.ts new file mode 100644 index 0000000..be5b112 --- /dev/null +++ b/backend/src/modules/drawing/asbuilt-drawing.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/drawing/asbuilt-drawing.service.ts b/backend/src/modules/drawing/asbuilt-drawing.service.ts new file mode 100644 index 0000000..fdd6ce0 --- /dev/null +++ b/backend/src/modules/drawing/asbuilt-drawing.service.ts @@ -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, + @InjectRepository(AsBuiltDrawingRevision) + private revisionRepo: Repository, + @InjectRepository(ShopDrawingRevision) + private shopDrawingRevisionRepo: Repository, + @InjectRepository(Attachment) + private attachmentRepo: Repository, + 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); + } +} diff --git a/backend/src/modules/drawing/drawing-master-data.controller.ts b/backend/src/modules/drawing/drawing-master-data.controller.ts index 4ac7718..b523afb 100644 --- a/backend/src/modules/drawing/drawing-master-data.controller.ts +++ b/backend/src/modules/drawing/drawing-master-data.controller.ts @@ -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); } } diff --git a/backend/src/modules/drawing/drawing-master-data.service.ts b/backend/src/modules/drawing/drawing-master-data.service.ts index 7b41536..4a7e488 100644 --- a/backend/src/modules/drawing/drawing-master-data.service.ts +++ b/backend/src/modules/drawing/drawing-master-data.service.ts @@ -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, + @InjectRepository(ContractDrawingCategory) + private cdCatRepo: Repository, @InjectRepository(ContractDrawingSubCategory) private cdSubCatRepo: Repository, @InjectRepository(ShopDrawingMainCategory) private sdMainCatRepo: Repository, @InjectRepository(ShopDrawingSubCategory) private sdSubCatRepo: Repository, + @InjectRepository(ContractDrawingSubcatCatMap) + private cdMapRepo: Repository ) {} - // --- 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) { + 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) { + const cat = this.cdCatRepo.create(data); + return this.cdCatRepo.save(cat); + } + + async updateCategory(id: number, data: Partial) { + 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 + ) { + 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 = { 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) { + const cat = this.sdMainCatRepo.create(data); + return this.sdMainCatRepo.save(cat); + } + + async updateShopMainCat(id: number, data: Partial) { + 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 = { - isActive: true, + projectId, ...(mainCategoryId ? { mainCategoryId } : {}), }; return this.sdSubCatRepo.find({ where, order: { sortOrder: 'ASC' }, - relations: ['mainCategory'], // Load Parent Info }); } + + async createShopSubCat(data: Partial) { + const subCat = this.sdSubCatRepo.create(data); + return this.sdSubCatRepo.save(subCat); + } + + async updateShopSubCat(id: number, data: Partial) { + 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 }; + } } diff --git a/backend/src/modules/drawing/drawing.module.ts b/backend/src/modules/drawing/drawing.module.ts index 4567a48..287c915 100644 --- a/backend/src/modules/drawing/drawing.module.ts +++ b/backend/src/modules/drawing/drawing.module.ts @@ -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 {} diff --git a/backend/src/modules/drawing/dto/create-asbuilt-drawing-revision.dto.ts b/backend/src/modules/drawing/dto/create-asbuilt-drawing-revision.dto.ts new file mode 100644 index 0000000..4fe0490 --- /dev/null +++ b/backend/src/modules/drawing/dto/create-asbuilt-drawing-revision.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/drawing/dto/create-asbuilt-drawing.dto.ts b/backend/src/modules/drawing/dto/create-asbuilt-drawing.dto.ts new file mode 100644 index 0000000..8c26ae5 --- /dev/null +++ b/backend/src/modules/drawing/dto/create-asbuilt-drawing.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/drawing/dto/search-asbuilt-drawing.dto.ts b/backend/src/modules/drawing/dto/search-asbuilt-drawing.dto.ts new file mode 100644 index 0000000..61cc18a --- /dev/null +++ b/backend/src/modules/drawing/dto/search-asbuilt-drawing.dto.ts @@ -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; +} diff --git a/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts b/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts index 2f824b6..96831ee 100644 --- a/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts +++ b/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts @@ -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({ diff --git a/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts b/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts index 63f4985..fb0b823 100644 --- a/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts +++ b/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts @@ -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; diff --git a/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts b/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts index b673973..f9c9972 100644 --- a/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts +++ b/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts @@ -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({ diff --git a/docs/20251224-document-numbering-summary.md b/docs/20251224-document-numbering-summary.md new file mode 100644 index 0000000..b6fe249 --- /dev/null +++ b/docs/20251224-document-numbering-summary.md @@ -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** diff --git a/docs/8_lcbp3_v1_4_5.sql b/docs/SQL/8_lcbp3_v1_4_5.sql similarity index 100% rename from docs/8_lcbp3_v1_4_5.sql rename to docs/SQL/8_lcbp3_v1_4_5.sql diff --git a/docs/8_lcbp3_v1_4_5_seed.sql b/docs/SQL/8_lcbp3_v1_4_5_seed.sql similarity index 100% rename from docs/8_lcbp3_v1_4_5_seed.sql rename to docs/SQL/8_lcbp3_v1_4_5_seed.sql diff --git a/docs/8_lcbp3_v1_5_1.sql b/docs/SQL/8_lcbp3_v1_5_1.sql similarity index 100% rename from docs/8_lcbp3_v1_5_1.sql rename to docs/SQL/8_lcbp3_v1_5_1.sql diff --git a/docs/8_lcbp3_v1_5_1_seed.sql b/docs/SQL/8_lcbp3_v1_5_1_seed.sql similarity index 100% rename from docs/8_lcbp3_v1_5_1_seed.sql rename to docs/SQL/8_lcbp3_v1_5_1_seed.sql diff --git a/docs/0_Requirements_V1_4_5.md b/docs/backup/0_Requirements_V1_4_5.md similarity index 100% rename from docs/0_Requirements_V1_4_5.md rename to docs/backup/0_Requirements_V1_4_5.md diff --git a/docs/0_Requirements_V1_5_1.md b/docs/backup/0_Requirements_V1_5_1.md similarity index 100% rename from docs/0_Requirements_V1_5_1.md rename to docs/backup/0_Requirements_V1_5_1.md diff --git a/docs/1_FullStackJS_V1_4_5.md b/docs/backup/1_FullStackJS_V1_4_5.md similarity index 100% rename from docs/1_FullStackJS_V1_4_5.md rename to docs/backup/1_FullStackJS_V1_4_5.md diff --git a/docs/1_FullStackJS_V1_5_1.md b/docs/backup/1_FullStackJS_V1_5_1.md similarity index 100% rename from docs/1_FullStackJS_V1_5_1.md rename to docs/backup/1_FullStackJS_V1_5_1.md diff --git a/docs/2_Backend_Plan_V1_4_4.Phase6A.md b/docs/backup/2_Backend_Plan_V1_4_4.Phase6A.md similarity index 100% rename from docs/2_Backend_Plan_V1_4_4.Phase6A.md rename to docs/backup/2_Backend_Plan_V1_4_4.Phase6A.md diff --git a/docs/2_Backend_Plan_V1_4_4.Phase_Addition.md b/docs/backup/2_Backend_Plan_V1_4_4.Phase_Addition.md similarity index 100% rename from docs/2_Backend_Plan_V1_4_4.Phase_Addition.md rename to docs/backup/2_Backend_Plan_V1_4_4.Phase_Addition.md diff --git a/docs/2_Backend_Plan_V1_4_5.md b/docs/backup/2_Backend_Plan_V1_4_5.md similarity index 100% rename from docs/2_Backend_Plan_V1_4_5.md rename to docs/backup/2_Backend_Plan_V1_4_5.md diff --git a/docs/2_Backend_Plan_V1_5_1.md b/docs/backup/2_Backend_Plan_V1_5_1.md similarity index 100% rename from docs/2_Backend_Plan_V1_5_1.md rename to docs/backup/2_Backend_Plan_V1_5_1.md diff --git a/docs/3_Frontend_Plan_V1_4_5.md b/docs/backup/3_Frontend_Plan_V1_4_5.md similarity index 100% rename from docs/3_Frontend_Plan_V1_4_5.md rename to docs/backup/3_Frontend_Plan_V1_4_5.md diff --git a/docs/3_Frontend_Plan_V1_5_1.md b/docs/backup/3_Frontend_Plan_V1_5_1.md similarity index 100% rename from docs/3_Frontend_Plan_V1_5_1.md rename to docs/backup/3_Frontend_Plan_V1_5_1.md diff --git a/docs/4_Data_Dictionary_V1_4_5.md b/docs/backup/4_Data_Dictionary_V1_4_5.md similarity index 100% rename from docs/4_Data_Dictionary_V1_4_5.md rename to docs/backup/4_Data_Dictionary_V1_4_5.md diff --git a/docs/4_Data_Dictionary_V1_5_1.md b/docs/backup/4_Data_Dictionary_V1_5_1.md similarity index 100% rename from docs/4_Data_Dictionary_V1_5_1.md rename to docs/backup/4_Data_Dictionary_V1_5_1.md diff --git a/frontend/app/(admin)/admin/drawings/contract/categories/page.tsx b/frontend/app/(admin)/admin/drawings/contract/categories/page.tsx new file mode 100644 index 0000000..8f55fea --- /dev/null +++ b/frontend/app/(admin)/admin/drawings/contract/categories/page.tsx @@ -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(undefined); + const { data: projects = [], isLoading: isLoadingProjects } = useProjects(); + + const columns: ColumnDef[] = [ + { + accessorKey: "catCode", + header: "Code", + cell: ({ row }) => ( + + {row.getValue("catCode")} + + ), + }, + { + accessorKey: "catName", + header: "Category Name", + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => ( + + {row.getValue("description") || "-"} + + ), + }, + { + accessorKey: "sortOrder", + header: "Order", + cell: ({ row }) => ( + {row.getValue("sortOrder")} + ), + }, + ]; + + const projectFilter = ( +
+ Project: + +
+ ); + + if (!selectedProjectId) { + return ( +
+
+

Contract Drawing Categories

+

+ Manage main categories (หมวดหมู่หลัก) for contract drawings +

+
+ {projectFilter} +
+ Please select a project to manage categories. +
+
+ ); + } + + return ( +
+ 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. + */} +
+ +
+
+ ); +} + +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 ( +
+

Category Mappings (Map Sub-categories to Categories)

+
+

Select a category to view and manage its sub-categories.

+ {/* + 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. + */} + +
+
+ ) +} + +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(""); + const [selectedSubCat, setSelectedSubCat] = useState(""); + + 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 ( +
+
+ + +
+ + {selectedCat && ( +
+
+
+ + +
+ +
+ +
+
+ Mapped Sub-Categories + Action +
+ {mappings.length === 0 ? ( +
No sub-categories mapped yet.
+ ) : ( +
+ {mappings.map((m: { id: number; subCategory: ContractSubCategory }) => ( +
+ {m.subCategory.subCatCode} - {m.subCategory.subCatName} + +
+ ))} +
+ )} +
+
+ )} +
+ ) +} diff --git a/frontend/app/(admin)/admin/drawings/contract/sub-categories/page.tsx b/frontend/app/(admin)/admin/drawings/contract/sub-categories/page.tsx new file mode 100644 index 0000000..07a4a53 --- /dev/null +++ b/frontend/app/(admin)/admin/drawings/contract/sub-categories/page.tsx @@ -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(undefined); + const { data: projects = [], isLoading: isLoadingProjects } = useProjects(); + + const columns: ColumnDef[] = [ + { + accessorKey: "subCatCode", + header: "Code", + cell: ({ row }) => ( + + {row.getValue("subCatCode")} + + ), + }, + { + accessorKey: "subCatName", + header: "Sub-category Name", + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => ( + + {row.getValue("description") || "-"} + + ), + }, + { + accessorKey: "sortOrder", + header: "Order", + cell: ({ row }) => ( + {row.getValue("sortOrder")} + ), + }, + ]; + + const projectFilter = ( +
+ Project: + +
+ ); + + if (!selectedProjectId) { + return ( +
+
+

Contract Drawing Sub-categories

+

+ Manage sub-categories (หมวดหมู่ย่อย) for contract drawings +

+
+ {projectFilter} +
+ Please select a project to manage sub-categories. +
+
+ ); + } + + return ( +
+ 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} + /> +
+ ); +} diff --git a/frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx b/frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx new file mode 100644 index 0000000..3fb0b6a --- /dev/null +++ b/frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx @@ -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(undefined); + const { data: projects = [], isLoading: isLoadingProjects } = useProjects(); + + const columns: ColumnDef[] = [ + { + accessorKey: "volumeCode", + header: "Code", + cell: ({ row }) => ( + + {row.getValue("volumeCode")} + + ), + }, + { + accessorKey: "volumeName", + header: "Volume Name", + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => ( + + {row.getValue("description") || "-"} + + ), + }, + { + accessorKey: "sortOrder", + header: "Order", + cell: ({ row }) => ( + {row.getValue("sortOrder")} + ), + }, + ]; + + const projectFilter = ( +
+ Project: + +
+ ); + + if (!selectedProjectId) { + return ( +
+
+

Contract Drawing Volumes

+

+ Manage drawing volumes (เล่ม) for contract drawings +

+
+ {projectFilter} +
+ Please select a project to manage volumes. +
+
+ ); + } + + return ( +
+ 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} + /> +
+ ); +} diff --git a/frontend/app/(admin)/admin/drawings/page.tsx b/frontend/app/(admin)/admin/drawings/page.tsx new file mode 100644 index 0000000..bc4d93b --- /dev/null +++ b/frontend/app/(admin)/admin/drawings/page.tsx @@ -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 ( +
+
+

Drawing Master Data

+

+ Manage categories and volumes for Contract and Shop Drawings +

+
+ + {/* Contract Drawings Section */} +
+
+ +

Contract Drawings

+
+
+ {contractDrawingMenu.map((item) => ( + + + + + {item.title} + + + + +

+ {item.description} +

+
+
+ + ))} +
+
+ + {/* Shop Drawings Section */} +
+
+ +

Shop Drawings / As Built

+
+
+ {shopDrawingMenu.map((item) => ( + + + + + {item.title} + + + + +

+ {item.description} +

+
+
+ + ))} +
+
+
+ ); +} diff --git a/frontend/app/(admin)/admin/drawings/shop/main-categories/page.tsx b/frontend/app/(admin)/admin/drawings/shop/main-categories/page.tsx new file mode 100644 index 0000000..7dafdf0 --- /dev/null +++ b/frontend/app/(admin)/admin/drawings/shop/main-categories/page.tsx @@ -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(undefined); + const { data: projects = [], isLoading: isLoadingProjects } = useProjects(); + + const columns: ColumnDef[] = [ + { + accessorKey: "mainCategoryCode", + header: "Code", + cell: ({ row }) => ( + + {row.getValue("mainCategoryCode")} + + ), + }, + { + accessorKey: "mainCategoryName", + header: "Category Name", + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => ( + + {row.getValue("description") || "-"} + + ), + }, + { + accessorKey: "isActive", + header: "Active", + cell: ({ row }) => ( + row.getValue("isActive") ? ( + + ) : ( + + ) + ), + }, + { + accessorKey: "sortOrder", + header: "Order", + cell: ({ row }) => ( + {row.getValue("sortOrder")} + ), + }, + ]; + + const projectFilter = ( +
+ Project: + +
+ ); + + if (!selectedProjectId) { + return ( +
+
+

Shop Drawing Main Categories

+

+ Manage main categories (หมวดหมู่หลัก) for shop drawings +

+
+ {projectFilter} +
+ Please select a project to manage main categories. +
+
+ ); + } + + return ( +
+ 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} + /> +
+ ); +} diff --git a/frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx b/frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx new file mode 100644 index 0000000..9f4b1d6 --- /dev/null +++ b/frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx @@ -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(undefined); + const { data: projects = [], isLoading: isLoadingProjects } = useProjects(); + + const columns: ColumnDef[] = [ + { + accessorKey: "subCategoryCode", + header: "Code", + cell: ({ row }) => ( + + {row.getValue("subCategoryCode")} + + ), + }, + { + accessorKey: "subCategoryName", + header: "Sub-category Name", + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => ( + + {row.getValue("description") || "-"} + + ), + }, + { + accessorKey: "isActive", + header: "Active", + cell: ({ row }) => ( + row.getValue("isActive") ? ( + + ) : ( + + ) + ), + }, + { + accessorKey: "sortOrder", + header: "Order", + cell: ({ row }) => ( + {row.getValue("sortOrder")} + ), + }, + ]; + + const projectFilter = ( +
+ Project: + +
+ ); + + if (!selectedProjectId) { + return ( +
+
+

Shop Drawing Sub-categories

+

+ Manage sub-categories (หมวดหมู่ย่อย) for shop drawings +

+
+ {projectFilter} +
+ Please select a project to manage sub-categories. +
+
+ ); + } + + return ( +
+ 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} + /> +
+ ); +} diff --git a/frontend/app/(admin)/admin/page.tsx b/frontend/app/(admin)/admin/page.tsx index d75acfd..fcc17b5 100644 --- a/frontend/app/(admin)/admin/page.tsx +++ b/frontend/app/(admin)/admin/page.tsx @@ -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 ( diff --git a/frontend/app/(dashboard)/drawings/page.tsx b/frontend/app/(dashboard)/drawings/page.tsx index 512b2ff..6f5ed1a 100644 --- a/frontend/app/(dashboard)/drawings/page.tsx +++ b/frontend/app/(dashboard)/drawings/page.tsx @@ -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(undefined); + const { data: projects = [], isLoading: isLoadingProjects } = useProjects(); + return (

Drawings

- Manage contract and shop drawings + Manage contract, shop, and as-built drawings

@@ -24,25 +36,79 @@ export default function DrawingsPage() {
- - - Contract Drawings - Shop Drawings - As Built Drawings - + {/* Project Selector */} +
+ Project: + +
- - - - - - - - - - - -
+ {!selectedProjectId ? ( +
+ Please select a project to view drawings. +
+ ) : ( + + )}
); } + +function DrawingTabs({ projectId }: { projectId: number }) { + const [search, setSearch] = useState(""); + // We can add more specific filters here (e.g. category) later + + return ( + +
+ + Contract + Shop + As Built + + +
+
+ setSearch(e.target.value)} + /> +
+
+
+ + + + + + + + + + + + +
+ ) +} + diff --git a/frontend/components/admin/sidebar.tsx b/frontend/components/admin/sidebar.tsx index 75dcbcc..ef1dcfb 100644 --- a/frontend/components/admin/sidebar.tsx +++ b/frontend/components/admin/sidebar.tsx @@ -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( + // 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 (