251225:1703 On going update to 1.7.0: Refoctory drawing Module not finish
This commit is contained in:
1
backend/hash.txt
Normal file
1
backend/hash.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose
|
||||||
@@ -90,8 +90,8 @@ export class DocumentNumberingController {
|
|||||||
async previewNumber(@Body() dto: PreviewNumberDto) {
|
async previewNumber(@Body() dto: PreviewNumberDto) {
|
||||||
return this.numberingService.previewNumber({
|
return this.numberingService.previewNumber({
|
||||||
projectId: dto.projectId,
|
projectId: dto.projectId,
|
||||||
originatorOrganizationId: dto.originatorId,
|
originatorOrganizationId: dto.originatorOrganizationId,
|
||||||
typeId: dto.typeId,
|
typeId: dto.correspondenceTypeId,
|
||||||
subTypeId: dto.subTypeId,
|
subTypeId: dto.subTypeId,
|
||||||
rfaTypeId: dto.rfaTypeId,
|
rfaTypeId: dto.rfaTypeId,
|
||||||
disciplineId: dto.disciplineId,
|
disciplineId: dto.disciplineId,
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export class PreviewNumberDto {
|
|||||||
@ApiProperty({ description: 'Originator organization ID' })
|
@ApiProperty({ description: 'Originator organization ID' })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
originatorId!: number;
|
originatorOrganizationId!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Correspondence type ID' })
|
@ApiProperty({ description: 'Correspondence type ID' })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
typeId!: number;
|
correspondenceTypeId!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sub type ID (for TRANSMITTAL)' })
|
@ApiPropertyOptional({ description: 'Sub type ID (for TRANSMITTAL)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ export class FormatService {
|
|||||||
'{ORG}': orgCode,
|
'{ORG}': orgCode,
|
||||||
'{RECIPIENT}': recipientCode,
|
'{RECIPIENT}': recipientCode,
|
||||||
'{DISCIPLINE}': disciplineCode,
|
'{DISCIPLINE}': disciplineCode,
|
||||||
'{YEAR}': year.toString().substring(2),
|
'{YEAR}': year.toString(),
|
||||||
'{YEAR:BE}': (year + 543).toString().substring(2),
|
'{YEAR:BE}': (year + 543).toString(),
|
||||||
'{REV}': '0',
|
'{REV}': '0',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
101
backend/src/modules/drawing/asbuilt-drawing.controller.ts
Normal file
101
backend/src/modules/drawing/asbuilt-drawing.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
307
backend/src/modules/drawing/asbuilt-drawing.service.ts
Normal file
307
backend/src/modules/drawing/asbuilt-drawing.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,20 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
Body,
|
Body,
|
||||||
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} 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 { DrawingMasterDataService } from './drawing-master-data.service';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
@@ -17,55 +25,310 @@ import { RequirePermission } from '../../common/decorators/require-permission.de
|
|||||||
@ApiTags('Drawing Master Data')
|
@ApiTags('Drawing Master Data')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||||
@Controller('drawings/master')
|
@Controller('drawings/master-data')
|
||||||
export class DrawingMasterDataController {
|
export class DrawingMasterDataController {
|
||||||
// ✅ ต้องมี export ตรงนี้
|
|
||||||
constructor(private readonly masterDataService: DrawingMasterDataService) {}
|
constructor(private readonly masterDataService: DrawingMasterDataService) {}
|
||||||
|
|
||||||
// --- Contract Drawing Endpoints ---
|
// =====================================================
|
||||||
|
// Contract Drawing Volumes
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
@Get('contract/volumes')
|
@Get('contract/volumes')
|
||||||
@ApiOperation({ summary: 'List Contract Drawing Volumes' })
|
@ApiOperation({ summary: 'List Contract Drawing Volumes' })
|
||||||
|
@ApiQuery({ name: 'projectId', required: true, type: Number })
|
||||||
@RequirePermission('document.view')
|
@RequirePermission('document.view')
|
||||||
getVolumes(@Query('projectId', ParseIntPipe) projectId: number) {
|
getVolumes(@Query('projectId', ParseIntPipe) projectId: number) {
|
||||||
return this.masterDataService.findAllVolumes(projectId);
|
return this.masterDataService.findAllVolumes(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('contract/volumes')
|
@Post('contract/volumes')
|
||||||
@ApiOperation({ summary: 'Create Volume (Admin/PM)' })
|
@ApiOperation({ summary: 'Create Volume' })
|
||||||
@RequirePermission('master_data.drawing_category.manage') // สิทธิ์ ID 16
|
@RequirePermission('master_data.drawing_category.manage')
|
||||||
createVolume(@Body() body: any) {
|
createVolume(
|
||||||
// ควรใช้ DTO จริงในการผลิต
|
@Body()
|
||||||
|
body: {
|
||||||
|
projectId: number;
|
||||||
|
volumeCode: string;
|
||||||
|
volumeName: string;
|
||||||
|
description?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
return this.masterDataService.createVolume(body);
|
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')
|
@Get('contract/sub-categories')
|
||||||
@ApiOperation({ summary: 'List Contract Drawing Sub-Categories' })
|
@ApiOperation({ summary: 'List Contract Drawing Sub-Categories' })
|
||||||
|
@ApiQuery({ name: 'projectId', required: true, type: Number })
|
||||||
@RequirePermission('document.view')
|
@RequirePermission('document.view')
|
||||||
getContractSubCats(@Query('projectId', ParseIntPipe) projectId: number) {
|
getContractSubCats(@Query('projectId', ParseIntPipe) projectId: number) {
|
||||||
return this.masterDataService.findAllContractSubCats(projectId);
|
return this.masterDataService.findAllContractSubCats(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('contract/sub-categories')
|
@Post('contract/sub-categories')
|
||||||
@ApiOperation({ summary: 'Create Contract Sub-Category (Admin/PM)' })
|
@ApiOperation({ summary: 'Create Contract Sub-Category' })
|
||||||
@RequirePermission('master_data.drawing_category.manage')
|
@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);
|
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')
|
@Get('shop/main-categories')
|
||||||
@ApiOperation({ summary: 'List Shop Drawing Main Categories' })
|
@ApiOperation({ summary: 'List Shop Drawing Main Categories' })
|
||||||
|
@ApiQuery({ name: 'projectId', required: true, type: Number })
|
||||||
@RequirePermission('document.view')
|
@RequirePermission('document.view')
|
||||||
getShopMainCats() {
|
getShopMainCats(@Query('projectId', ParseIntPipe) projectId: number) {
|
||||||
return this.masterDataService.findAllShopMainCats();
|
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')
|
@Get('shop/sub-categories')
|
||||||
@ApiOperation({ summary: 'List Shop Drawing Sub-Categories' })
|
@ApiOperation({ summary: 'List Shop Drawing Sub-Categories' })
|
||||||
|
@ApiQuery({ name: 'projectId', required: true, type: Number })
|
||||||
|
@ApiQuery({ name: 'mainCategoryId', required: false, type: Number })
|
||||||
@RequirePermission('document.view')
|
@RequirePermission('document.view')
|
||||||
getShopSubCats(@Query('mainCategoryId') mainCategoryId?: number) {
|
getShopSubCats(
|
||||||
return this.masterDataService.findAllShopSubCats(mainCategoryId);
|
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
|
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 { ContractDrawingSubCategory } from './entities/contract-drawing-sub-category.entity';
|
||||||
import { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity';
|
import { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity';
|
||||||
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
|
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
|
||||||
|
import { ContractDrawingSubcatCatMap } from './entities/contract-drawing-subcat-cat-map.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DrawingMasterDataService {
|
export class DrawingMasterDataService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ContractDrawingVolume)
|
@InjectRepository(ContractDrawingVolume)
|
||||||
private cdVolumeRepo: Repository<ContractDrawingVolume>,
|
private cdVolumeRepo: Repository<ContractDrawingVolume>,
|
||||||
|
@InjectRepository(ContractDrawingCategory)
|
||||||
|
private cdCatRepo: Repository<ContractDrawingCategory>,
|
||||||
@InjectRepository(ContractDrawingSubCategory)
|
@InjectRepository(ContractDrawingSubCategory)
|
||||||
private cdSubCatRepo: Repository<ContractDrawingSubCategory>,
|
private cdSubCatRepo: Repository<ContractDrawingSubCategory>,
|
||||||
@InjectRepository(ShopDrawingMainCategory)
|
@InjectRepository(ShopDrawingMainCategory)
|
||||||
private sdMainCatRepo: Repository<ShopDrawingMainCategory>,
|
private sdMainCatRepo: Repository<ShopDrawingMainCategory>,
|
||||||
@InjectRepository(ShopDrawingSubCategory)
|
@InjectRepository(ShopDrawingSubCategory)
|
||||||
private sdSubCatRepo: Repository<ShopDrawingSubCategory>,
|
private sdSubCatRepo: Repository<ShopDrawingSubCategory>,
|
||||||
|
@InjectRepository(ContractDrawingSubcatCatMap)
|
||||||
|
private cdMapRepo: Repository<ContractDrawingSubcatCatMap>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// --- Contract Drawing Volumes ---
|
// =====================================================
|
||||||
|
// Contract Drawing Volumes
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
async findAllVolumes(projectId: number) {
|
async findAllVolumes(projectId: number) {
|
||||||
return this.cdVolumeRepo.find({
|
return this.cdVolumeRepo.find({
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
@@ -34,7 +43,54 @@ export class DrawingMasterDataService {
|
|||||||
return this.cdVolumeRepo.save(volume);
|
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) {
|
async findAllContractSubCats(projectId: number) {
|
||||||
return this.cdSubCatRepo.find({
|
return this.cdSubCatRepo.find({
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
@@ -47,26 +103,128 @@ export class DrawingMasterDataService {
|
|||||||
return this.cdSubCatRepo.save(subCat);
|
return this.cdSubCatRepo.save(subCat);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Shop Drawing Main Categories ---
|
async updateContractSubCat(
|
||||||
async findAllShopMainCats() {
|
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({
|
return this.sdMainCatRepo.find({
|
||||||
where: { isActive: true },
|
where: { projectId },
|
||||||
order: { sortOrder: 'ASC' },
|
order: { sortOrder: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Shop Drawing Sub Categories ---
|
async createShopMainCat(data: Partial<ShopDrawingMainCategory>) {
|
||||||
async findAllShopSubCats(mainCategoryId?: number) {
|
const cat = this.sdMainCatRepo.create(data);
|
||||||
// ✅ FIX: ใช้วิธี Spread Operator เพื่อสร้าง Object เงื่อนไขที่ถูกต้องตาม Type
|
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> = {
|
const where: FindOptionsWhere<ShopDrawingSubCategory> = {
|
||||||
isActive: true,
|
projectId,
|
||||||
...(mainCategoryId ? { mainCategoryId } : {}),
|
...(mainCategoryId ? { mainCategoryId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.sdSubCatRepo.find({
|
return this.sdSubCatRepo.find({
|
||||||
where,
|
where,
|
||||||
order: { sortOrder: 'ASC' },
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { ContractDrawing } from './entities/contract-drawing.entity';
|
import { ContractDrawing } from './entities/contract-drawing.entity';
|
||||||
import { ShopDrawing } from './entities/shop-drawing.entity';
|
import { ShopDrawing } from './entities/shop-drawing.entity';
|
||||||
import { ShopDrawingRevision } from './entities/shop-drawing-revision.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)
|
// Entities (Master Data - Contract Drawing)
|
||||||
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
|
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
|
||||||
@@ -22,15 +24,19 @@ import { Attachment } from '../../common/file-storage/entities/attachment.entity
|
|||||||
// Services
|
// Services
|
||||||
import { ShopDrawingService } from './shop-drawing.service';
|
import { ShopDrawingService } from './shop-drawing.service';
|
||||||
import { ContractDrawingService } from './contract-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
|
// Controllers
|
||||||
import { ShopDrawingController } from './shop-drawing.controller';
|
import { ShopDrawingController } from './shop-drawing.controller';
|
||||||
import { ContractDrawingController } from './contract-drawing.controller';
|
import { ContractDrawingController } from './contract-drawing.controller';
|
||||||
|
import { AsBuiltDrawingController } from './asbuilt-drawing.controller';
|
||||||
import { DrawingMasterDataController } from './drawing-master-data.controller';
|
import { DrawingMasterDataController } from './drawing-master-data.controller';
|
||||||
|
|
||||||
// Modules
|
// Modules
|
||||||
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
@@ -38,14 +44,16 @@ import { UserModule } from '../user/user.module';
|
|||||||
ContractDrawing,
|
ContractDrawing,
|
||||||
ShopDrawing,
|
ShopDrawing,
|
||||||
ShopDrawingRevision,
|
ShopDrawingRevision,
|
||||||
|
AsBuiltDrawing,
|
||||||
|
AsBuiltDrawingRevision,
|
||||||
|
|
||||||
// Master Data
|
// Master Data
|
||||||
ContractDrawingVolume,
|
ContractDrawingVolume,
|
||||||
ContractDrawingSubCategory,
|
ContractDrawingSubCategory,
|
||||||
ContractDrawingSubcatCatMap,
|
ContractDrawingSubcatCatMap,
|
||||||
ContractDrawingCategory,
|
ContractDrawingCategory,
|
||||||
ShopDrawingMainCategory, // ✅
|
ShopDrawingMainCategory,
|
||||||
ShopDrawingSubCategory, // ✅
|
ShopDrawingSubCategory,
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -56,13 +64,15 @@ import { UserModule } from '../user/user.module';
|
|||||||
providers: [
|
providers: [
|
||||||
ShopDrawingService,
|
ShopDrawingService,
|
||||||
ContractDrawingService,
|
ContractDrawingService,
|
||||||
|
AsBuiltDrawingService,
|
||||||
DrawingMasterDataService,
|
DrawingMasterDataService,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
ShopDrawingController,
|
ShopDrawingController,
|
||||||
ContractDrawingController,
|
ContractDrawingController,
|
||||||
|
AsBuiltDrawingController,
|
||||||
DrawingMasterDataController,
|
DrawingMasterDataController,
|
||||||
],
|
],
|
||||||
exports: [ShopDrawingService, ContractDrawingService],
|
exports: [ShopDrawingService, ContractDrawingService, AsBuiltDrawingService],
|
||||||
})
|
})
|
||||||
export class DrawingModule {}
|
export class DrawingModule {}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ import {
|
|||||||
JoinColumn,
|
JoinColumn,
|
||||||
ManyToMany,
|
ManyToMany,
|
||||||
JoinTable,
|
JoinTable,
|
||||||
|
Unique,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AsBuiltDrawing } from './asbuilt-drawing.entity';
|
import { AsBuiltDrawing } from './asbuilt-drawing.entity';
|
||||||
import { ShopDrawingRevision } from './shop-drawing-revision.entity';
|
import { ShopDrawingRevision } from './shop-drawing-revision.entity';
|
||||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
|
||||||
@Entity('asbuilt_drawing_revisions')
|
@Entity('asbuilt_drawing_revisions')
|
||||||
|
@Unique(['asBuiltDrawingId', 'isCurrent'])
|
||||||
export class AsBuiltDrawingRevision {
|
export class AsBuiltDrawingRevision {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
@@ -35,9 +38,26 @@ export class AsBuiltDrawingRevision {
|
|||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'legacy_drawing_number', length: 100, nullable: true })
|
||||||
|
legacyDrawingNumber?: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date;
|
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
|
// Relations
|
||||||
@ManyToOne(() => AsBuiltDrawing, (drawing) => drawing.revisions, {
|
@ManyToOne(() => AsBuiltDrawing, (drawing) => drawing.revisions, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
@@ -45,6 +65,14 @@ export class AsBuiltDrawingRevision {
|
|||||||
@JoinColumn({ name: 'asbuilt_drawing_id' })
|
@JoinColumn({ name: 'asbuilt_drawing_id' })
|
||||||
asBuiltDrawing!: AsBuiltDrawing;
|
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)
|
// Relation to Shop Drawing Revisions (M:N)
|
||||||
@ManyToMany(() => ShopDrawingRevision)
|
@ManyToMany(() => ShopDrawingRevision)
|
||||||
@JoinTable({
|
@JoinTable({
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { Project } from '../../project/entities/project.entity';
|
import { Project } from '../../project/entities/project.entity';
|
||||||
import { AsBuiltDrawingRevision } from './asbuilt-drawing-revision.entity';
|
import { AsBuiltDrawingRevision } from './asbuilt-drawing-revision.entity';
|
||||||
import { User } from '../../user/entities/user.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')
|
@Entity('asbuilt_drawings')
|
||||||
export class AsBuiltDrawing {
|
export class AsBuiltDrawing {
|
||||||
@@ -24,6 +26,12 @@ export class AsBuiltDrawing {
|
|||||||
@Column({ name: 'drawing_number', length: 100, unique: true })
|
@Column({ name: 'drawing_number', length: 100, unique: true })
|
||||||
drawingNumber!: string;
|
drawingNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'main_category_id' })
|
||||||
|
mainCategoryId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'sub_category_id' })
|
||||||
|
subCategoryId!: number;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@@ -41,6 +49,14 @@ export class AsBuiltDrawing {
|
|||||||
@JoinColumn({ name: 'project_id' })
|
@JoinColumn({ name: 'project_id' })
|
||||||
project!: Project;
|
project!: Project;
|
||||||
|
|
||||||
|
@ManyToOne(() => ShopDrawingMainCategory)
|
||||||
|
@JoinColumn({ name: 'main_category_id' })
|
||||||
|
mainCategory!: ShopDrawingMainCategory;
|
||||||
|
|
||||||
|
@ManyToOne(() => ShopDrawingSubCategory)
|
||||||
|
@JoinColumn({ name: 'sub_category_id' })
|
||||||
|
subCategory!: ShopDrawingSubCategory;
|
||||||
|
|
||||||
@ManyToOne(() => User)
|
@ManyToOne(() => User)
|
||||||
@JoinColumn({ name: 'updated_by' })
|
@JoinColumn({ name: 'updated_by' })
|
||||||
updater?: User;
|
updater?: User;
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import {
|
|||||||
JoinColumn,
|
JoinColumn,
|
||||||
ManyToMany,
|
ManyToMany,
|
||||||
JoinTable,
|
JoinTable,
|
||||||
|
Unique,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ShopDrawing } from './shop-drawing.entity';
|
import { ShopDrawing } from './shop-drawing.entity';
|
||||||
import { ContractDrawing } from './contract-drawing.entity';
|
import { ContractDrawing } from './contract-drawing.entity';
|
||||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
|
||||||
@Entity('shop_drawing_revisions')
|
@Entity('shop_drawing_revisions')
|
||||||
|
@Unique(['shopDrawingId', 'isCurrent'])
|
||||||
export class ShopDrawingRevision {
|
export class ShopDrawingRevision {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number; // เติม !
|
id!: number; // เติม !
|
||||||
@@ -41,6 +44,20 @@ export class ShopDrawingRevision {
|
|||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date; // เติม !
|
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
|
// Relations
|
||||||
@ManyToOne(() => ShopDrawing, (shopDrawing) => shopDrawing.revisions, {
|
@ManyToOne(() => ShopDrawing, (shopDrawing) => shopDrawing.revisions, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
@@ -48,6 +65,14 @@ export class ShopDrawingRevision {
|
|||||||
@JoinColumn({ name: 'shop_drawing_id' })
|
@JoinColumn({ name: 'shop_drawing_id' })
|
||||||
shopDrawing!: ShopDrawing; // เติม !
|
shopDrawing!: ShopDrawing; // เติม !
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'created_by' })
|
||||||
|
creator?: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updater?: User;
|
||||||
|
|
||||||
// References to Contract Drawings (M:N)
|
// References to Contract Drawings (M:N)
|
||||||
@ManyToMany(() => ContractDrawing)
|
@ManyToMany(() => ContractDrawing)
|
||||||
@JoinTable({
|
@JoinTable({
|
||||||
|
|||||||
399
docs/20251224-document-numbering-summary.md
Normal file
399
docs/20251224-document-numbering-summary.md
Normal 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**
|
||||||
282
frontend/app/(admin)/admin/drawings/contract/categories/page.tsx
Normal file
282
frontend/app/(admin)/admin/drawings/contract/categories/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx
Normal file
126
frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/app/(admin)/admin/drawings/page.tsx
Normal file
114
frontend/app/(admin)/admin/drawings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx
Normal file
146
frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Activity,
|
Activity,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
FileStack,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -78,6 +79,12 @@ export default function AdminPage() {
|
|||||||
href: "/admin/numbering",
|
href: "/admin/numbering",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Drawing Master Data",
|
||||||
|
description: "Manage drawing categories, volumes, and classifications",
|
||||||
|
href: "/admin/drawings",
|
||||||
|
icon: FileStack,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { DrawingList } from "@/components/drawings/list";
|
import { DrawingList } from "@/components/drawings/list";
|
||||||
import { Button } from "@/components/ui/button";
|
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 Link from "next/link";
|
||||||
|
import { useProjects } from "@/hooks/use-master-data";
|
||||||
|
|
||||||
export default function DrawingsPage() {
|
export default function DrawingsPage() {
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined);
|
||||||
|
const { data: projects = [], isLoading: isLoadingProjects } = useProjects();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Drawings</h1>
|
<h1 className="text-3xl font-bold">Drawings</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Manage contract and shop drawings
|
Manage contract, shop, and as-built drawings
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/drawings/upload">
|
<Link href="/drawings/upload">
|
||||||
@@ -24,25 +36,79 @@ export default function DrawingsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="contract" className="w-full">
|
{/* Project Selector */}
|
||||||
<TabsList className="grid w-full grid-cols-3 max-w-[600px]">
|
<div className="flex items-center gap-4">
|
||||||
<TabsTrigger value="contract">Contract Drawings</TabsTrigger>
|
<span className="text-sm font-medium">Project:</span>
|
||||||
<TabsTrigger value="shop">Shop Drawings</TabsTrigger>
|
<Select
|
||||||
<TabsTrigger value="asbuilt">As Built Drawings</TabsTrigger>
|
value={selectedProjectId?.toString() ?? ""}
|
||||||
</TabsList>
|
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">
|
{!selectedProjectId ? (
|
||||||
<DrawingList type="CONTRACT" />
|
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
|
||||||
</TabsContent>
|
Please select a project to view drawings.
|
||||||
|
</div>
|
||||||
<TabsContent value="shop" className="mt-6">
|
) : (
|
||||||
<DrawingList type="SHOP" />
|
<DrawingTabs projectId={selectedProjectId} />
|
||||||
</TabsContent>
|
)}
|
||||||
|
|
||||||
<TabsContent value="asbuilt" className="mt-6">
|
|
||||||
<DrawingList type="AS_BUILT" />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,46 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
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/users", label: "Users", icon: Users },
|
||||||
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
|
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
|
||||||
{ href: "/admin/projects", label: "Projects", icon: FileText },
|
{ href: "/admin/projects", label: "Projects", icon: FileText },
|
||||||
{ href: "/admin/contracts", label: "Contracts", icon: FileText },
|
{ href: "/admin/contracts", label: "Contracts", icon: FileText },
|
||||||
{ href: "/admin/reference", label: "Reference Data", icon: BookOpen },
|
{ 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/numbering", label: "Numbering", icon: FileText },
|
||||||
{ href: "/admin/workflows", label: "Workflows", icon: GitGraph },
|
{ href: "/admin/workflows", label: "Workflows", icon: GitGraph },
|
||||||
{ href: "/admin/security/roles", label: "Security Roles", icon: Shield },
|
{ href: "/admin/security/roles", label: "Security Roles", icon: Shield },
|
||||||
@@ -22,6 +53,20 @@ const menuItems = [
|
|||||||
|
|
||||||
export function AdminSidebar() {
|
export function AdminSidebar() {
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<aside className="w-64 border-r bg-card p-4 hidden md:block">
|
<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">
|
<nav className="space-y-1">
|
||||||
{menuItems.map((item) => {
|
{menuItems.map((item) => {
|
||||||
const Icon = item.icon;
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href!}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium",
|
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium",
|
||||||
isActive
|
isActive
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ import { useDrawings } from "@/hooks/use-drawing";
|
|||||||
import { Drawing } from "@/types/drawing";
|
import { Drawing } from "@/types/drawing";
|
||||||
import { Loader2 } from "lucide-react";
|
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 {
|
interface DrawingListProps {
|
||||||
type: "CONTRACT" | "SHOP" | "AS_BUILT";
|
type: "CONTRACT" | "SHOP" | "AS_BUILT";
|
||||||
projectId?: number;
|
projectId: number;
|
||||||
|
filters?: Partial<DrawingSearchParams>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawingList({ type, projectId }: DrawingListProps) {
|
export function DrawingList({ type, projectId, filters }: DrawingListProps) {
|
||||||
const { data: drawings, isLoading, isError } = useDrawings(type, { projectId: projectId ?? 1 });
|
// 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.
|
// 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.
|
// The params { type } might be redundant if getAll doesn't use it, but safe to pass.
|
||||||
|
|||||||
@@ -55,9 +55,11 @@ const shopSchema = baseSchema.extend({
|
|||||||
const asBuiltSchema = baseSchema.extend({
|
const asBuiltSchema = baseSchema.extend({
|
||||||
drawingType: z.literal("AS_BUILT"),
|
drawingType: z.literal("AS_BUILT"),
|
||||||
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
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
|
// Revision Fields
|
||||||
revisionLabel: z.string().default("0"),
|
revisionLabel: z.string().default("0"),
|
||||||
title: z.string().optional(),
|
title: z.string().min(1, "Title is required"),
|
||||||
legacyDrawingNumber: z.string().optional(),
|
legacyDrawingNumber: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -130,8 +132,10 @@ export function DrawingUploadForm({ projectId = 1 }: DrawingUploadFormProps) {
|
|||||||
// Date default to now
|
// Date default to now
|
||||||
} else if (data.drawingType === 'AS_BUILT') {
|
} else if (data.drawingType === 'AS_BUILT') {
|
||||||
formData.append('drawingNumber', data.drawingNumber);
|
formData.append('drawingNumber', data.drawingNumber);
|
||||||
|
formData.append('mainCategoryId', data.mainCategoryId);
|
||||||
|
formData.append('subCategoryId', data.subCategoryId);
|
||||||
formData.append('revisionLabel', data.revisionLabel || '0');
|
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.legacyDrawingNumber) formData.append('legacyDrawingNumber', data.legacyDrawingNumber);
|
||||||
if (data.description) formData.append('description', data.description);
|
if (data.description) formData.append('description', data.description);
|
||||||
}
|
}
|
||||||
@@ -293,7 +297,7 @@ export function DrawingUploadForm({ projectId = 1 }: DrawingUploadFormProps) {
|
|||||||
{/* AS BUILT FIELDS */}
|
{/* AS BUILT FIELDS */}
|
||||||
{drawingType === 'AS_BUILT' && (
|
{drawingType === 'AS_BUILT' && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Drawing No *</Label>
|
<Label>Drawing No *</Label>
|
||||||
<Input {...register("drawingNumber")} placeholder="e.g. AB-101" />
|
<Input {...register("drawingNumber")} placeholder="e.g. AB-101" />
|
||||||
@@ -306,9 +310,51 @@ export function DrawingUploadForm({ projectId = 1 }: DrawingUploadFormProps) {
|
|||||||
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
|
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<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>
|
<div>
|
||||||
<Label>Title</Label>
|
<Label>Title *</Label>
|
||||||
<Input {...register("title")} placeholder="Title" />
|
<Input {...register("title")} placeholder="Drawing Title" />
|
||||||
|
{(errors as any).title && (
|
||||||
|
<p className="text-sm text-destructive">{(errors as any).title.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Description</Label>
|
<Label>Description</Label>
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ const VARIABLES = [
|
|||||||
{ key: '{DISCIPLINE}', name: 'Discipline Code', example: 'STR' },
|
{ key: '{DISCIPLINE}', name: 'Discipline Code', example: 'STR' },
|
||||||
{ key: '{SUBTYPE}', name: 'Sub-Type Code', example: 'GEN' },
|
{ key: '{SUBTYPE}', name: 'Sub-Type Code', example: 'GEN' },
|
||||||
{ key: '{SUBTYPE_NUM}', name: 'Sub-Type Number', example: '01' },
|
{ key: '{SUBTYPE_NUM}', name: 'Sub-Type Number', example: '01' },
|
||||||
{ key: '{YEAR}', name: 'Year (B.E.)', example: '2568' },
|
{ key: '{YEAR:BE}', name: 'Year (B.E.)', example: '2568' },
|
||||||
{ key: '{YEAR_SHORT}', name: 'Year Short (68)', example: '68' },
|
{ key: '{YEAR:CE}', name: 'Year (C.E.)', example: '2025' },
|
||||||
{ key: '{SEQ:4}', name: 'Sequence (4-digit)', example: '0001' },
|
{ key: '{SEQ:4}', name: 'Sequence (4-digit)', example: '0001' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ export function TemplateEditor({
|
|||||||
VARIABLES.forEach((v) => {
|
VARIABLES.forEach((v) => {
|
||||||
// Simple mock replacement for preview
|
// Simple mock replacement for preview
|
||||||
let replacement = v.example;
|
let replacement = v.example;
|
||||||
if (v.key === '{YEAR}') replacement = (new Date().getFullYear() + 543).toString();
|
if (v.key === '{YEAR:BE}') replacement = (new Date().getFullYear() + 543).toString();
|
||||||
if (v.key === '{YEAR_SHORT}') replacement = (new Date().getFullYear() + 543).toString().slice(-2);
|
if (v.key === '{YEAR:CE}') replacement = new Date().getFullYear().toString();
|
||||||
|
|
||||||
// Dynamic context based on selection (optional visual enhancement)
|
// Dynamic context based on selection (optional visual enhancement)
|
||||||
if (v.key === '{TYPE}' && typeId) {
|
if (v.key === '{TYPE}' && typeId) {
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
|||||||
try {
|
try {
|
||||||
const result = await numberingApi.previewNumber({
|
const result = await numberingApi.previewNumber({
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
originatorId: parseInt(testData.originatorId || "0"),
|
originatorOrganizationId: parseInt(testData.originatorId || "0"),
|
||||||
recipientOrganizationId: parseInt(testData.recipientId || "0"),
|
recipientOrganizationId: parseInt(testData.recipientId || "0"),
|
||||||
typeId: parseInt(testData.correspondenceTypeId || "0"),
|
correspondenceTypeId: parseInt(testData.correspondenceTypeId || "0"),
|
||||||
disciplineId: parseInt(testData.disciplineId || "0"),
|
disciplineId: parseInt(testData.disciplineId || "0"),
|
||||||
});
|
});
|
||||||
setGeneratedNumber(result.previewNumber);
|
setGeneratedNumber(result.previewNumber);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
|
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
|
||||||
import { shopDrawingService } from '@/lib/services/shop-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 { SearchContractDrawingDto, CreateContractDrawingDto } from '@/types/dto/drawing/contract-drawing.dto';
|
||||||
import { SearchShopDrawingDto, CreateShopDrawingDto } from '@/types/dto/drawing/shop-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 { 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 DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | SearchAsBuiltDrawingDto;
|
||||||
type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto | CreateAsBuiltDrawingDto;
|
type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto | CreateAsBuiltDrawingDto;
|
||||||
|
|
||||||
@@ -25,13 +26,45 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: drawingKeys.list(type, params),
|
queryKey: drawingKeys.list(type, params),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
let response;
|
||||||
if (type === 'CONTRACT') {
|
if (type === 'CONTRACT') {
|
||||||
return contractDrawingService.getAll(params as SearchContractDrawingDto);
|
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') {
|
} else if (type === 'SHOP') {
|
||||||
return shopDrawingService.getAll(params as SearchShopDrawingDto);
|
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 {
|
} else {
|
||||||
return asBuiltDrawingService.getAll(params as SearchAsBuiltDrawingDto);
|
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,
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
@@ -69,7 +102,8 @@ export function useCreateDrawing(type: DrawingType) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
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() });
|
queryClient.invalidateQueries({ queryKey: drawingKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: (error: Error & { response?: { data?: { message?: string } } }) => {
|
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
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// File: lib/api/client.ts
|
// File: lib/api/client.ts
|
||||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosError } from "axios";
|
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosError } from "axios";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { getSession } from "next-auth/react";
|
|
||||||
|
|
||||||
// อ่านค่า Base URL จาก Environment Variable
|
// อ่านค่า Base URL จาก Environment Variable
|
||||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
|
const baseURL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
|
||||||
@@ -29,18 +28,20 @@ apiClient.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Authentication Token Injection
|
// 2. Authentication Token Injection
|
||||||
// ดึง Session จาก NextAuth (ทำงานเฉพาะฝั่ง Client)
|
// ดึง Token จาก Zustand persist store (localStorage)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
try {
|
try {
|
||||||
const session = await getSession();
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
// @ts-ignore: Session type extended in types/next-auth.d.ts
|
if (authStorage) {
|
||||||
const token = session?.accessToken;
|
const parsed = JSON.parse(authStorage);
|
||||||
|
const token = parsed?.state?.token;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers["Authorization"] = `Bearer ${token}`;
|
config.headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to retrieve session token:", error);
|
console.warn("Failed to retrieve auth token:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,4 +74,4 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
@@ -274,18 +274,19 @@ export const numberingApi = {
|
|||||||
*/
|
*/
|
||||||
previewNumber: async (ctx: {
|
previewNumber: async (ctx: {
|
||||||
projectId: number;
|
projectId: number;
|
||||||
originatorId: number;
|
originatorOrganizationId: number;
|
||||||
typeId: number;
|
correspondenceTypeId: number;
|
||||||
disciplineId?: number;
|
disciplineId?: number;
|
||||||
subTypeId?: number;
|
subTypeId?: number;
|
||||||
rfaTypeId?: number;
|
rfaTypeId?: number;
|
||||||
recipientOrganizationId?: number;
|
recipientOrganizationId?: number;
|
||||||
}): Promise<{ previewNumber: string; nextSequence: 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',
|
'/document-numbering/preview',
|
||||||
ctx
|
ctx
|
||||||
);
|
);
|
||||||
return res.data;
|
// Backend wraps response in { data: { ... }, message: "Success" }
|
||||||
|
return res.data.data || res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
245
frontend/lib/services/drawing-master-data.service.ts
Normal file
245
frontend/lib/services/drawing-master-data.service.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,7 +8,9 @@ export interface DrawingRevision {
|
|||||||
revisionDescription?: string;
|
revisionDescription?: string;
|
||||||
revisedByName: string;
|
revisedByName: string;
|
||||||
fileUrl: 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 {
|
export interface ContractDrawing {
|
||||||
@@ -39,6 +41,8 @@ export interface AsBuiltDrawing {
|
|||||||
id: number;
|
id: number;
|
||||||
drawingNumber: string;
|
drawingNumber: string;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
|
mainCategoryId: number;
|
||||||
|
subCategoryId: number;
|
||||||
currentRevision?: DrawingRevision;
|
currentRevision?: DrawingRevision;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
export interface CreateAsBuiltDrawingDto {
|
export interface CreateAsBuiltDrawingDto {
|
||||||
projectId: number;
|
projectId: number;
|
||||||
drawingNumber: string;
|
drawingNumber: string;
|
||||||
|
mainCategoryId: number;
|
||||||
|
subCategoryId: number;
|
||||||
|
|
||||||
// First Revision Data
|
// First Revision Data
|
||||||
revisionLabel?: string;
|
revisionLabel?: string;
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ CREATE TABLE shop_drawing_sub_categories (
|
|||||||
CREATE TABLE shop_drawings (
|
CREATE TABLE shop_drawings (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||||
project_id INT NOT NULL COMMENT 'โครงการ',
|
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 'หมวดหมู่หลัก',
|
main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก',
|
||||||
sub_category_id INT NOT NULL COMMENT 'หมวดหมู่ย่อย',
|
sub_category_id INT NOT NULL COMMENT 'หมวดหมู่ย่อย',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
||||||
@@ -805,7 +805,8 @@ CREATE TABLE shop_drawings (
|
|||||||
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
||||||
FOREIGN KEY (project_id) REFERENCES projects (id),
|
FOREIGN KEY (project_id) REFERENCES projects (id),
|
||||||
FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (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 เก็บข้อมูล "แบบก่อสร้าง"';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"';
|
||||||
|
|
||||||
-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)
|
-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)
|
||||||
@@ -814,14 +815,22 @@ CREATE TABLE shop_drawing_revisions (
|
|||||||
shop_drawing_id INT NOT NULL COMMENT 'Master ID',
|
shop_drawing_id INT NOT NULL COMMENT 'Master ID',
|
||||||
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
|
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
|
||||||
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
|
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
|
||||||
|
is_current BOOLEAN DEFAULT NULL COMMENT '(TRUE = Revision ปัจจุบัน, NULL = ไม่ใช่ปัจจุบัน)',
|
||||||
revision_date DATE COMMENT 'วันที่ของ Revision',
|
revision_date DATE COMMENT 'วันที่ของ Revision',
|
||||||
title VARCHAR(500) NOT NULL COMMENT 'ชื่อแบบ',
|
title VARCHAR(500) NOT NULL COMMENT 'ชื่อแบบ',
|
||||||
description TEXT COMMENT 'คำอธิบายการแก้ไข',
|
description TEXT COMMENT 'คำอธิบายการแก้ไข',
|
||||||
legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ Shop Drawing',
|
legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ Shop Drawing',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
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,
|
FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings (id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY ux_sd_rev_drawing_revision (shop_drawing_id, revision_number)
|
FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1 :N)';
|
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)
|
-- ตารางเชื่อมระหว่าง shop_drawing_revisions กับ contract_drawings (M:N)
|
||||||
CREATE TABLE shop_drawing_revision_contract_refs (
|
CREATE TABLE shop_drawing_revision_contract_refs (
|
||||||
@@ -839,7 +848,7 @@ CREATE TABLE shop_drawing_revision_contract_refs (
|
|||||||
CREATE TABLE asbuilt_drawings (
|
CREATE TABLE asbuilt_drawings (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||||
project_id INT NOT NULL COMMENT 'โครงการ',
|
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 'หมวดหมู่หลัก',
|
main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก',
|
||||||
sub_category_id INT NOT NULL COMMENT 'หมวดหมู่ย่อย',
|
sub_category_id INT NOT NULL COMMENT 'หมวดหมู่ย่อย',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
||||||
@@ -848,25 +857,34 @@ CREATE TABLE asbuilt_drawings (
|
|||||||
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
||||||
FOREIGN KEY (project_id) REFERENCES projects (id),
|
FOREIGN KEY (project_id) REFERENCES projects (id),
|
||||||
FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (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),
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"';
|
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 (
|
CREATE TABLE asbuilt_drawing_revisions (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision',
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision',
|
||||||
asbuilt_drawing_id INT NOT NULL COMMENT 'Master ID',
|
asbuilt_drawing_id INT NOT NULL COMMENT 'Master ID',
|
||||||
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
|
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
|
||||||
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
|
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
|
||||||
|
is_current BOOLEAN DEFAULT NULL COMMENT '(TRUE = Revision ปัจจุบัน, NULL = ไม่ใช่ปัจจุบัน)',
|
||||||
revision_date DATE COMMENT 'วันที่ของ Revision',
|
revision_date DATE COMMENT 'วันที่ของ Revision',
|
||||||
title VARCHAR(500) NOT NULL COMMENT 'ชื่อแบบ',
|
title VARCHAR(500) NOT NULL COMMENT 'ชื่อแบบ',
|
||||||
description TEXT COMMENT 'คำอธิบายการแก้ไข',
|
description TEXT COMMENT 'คำอธิบายการแก้ไข',
|
||||||
legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ AS Built Drawing',
|
legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ AS Built Drawing',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
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,
|
FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings (id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY ux_sd_rev_drawing_revision (asbuilt_drawing_id, revision_number)
|
FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ asbuilt_drawings (1 :N)';
|
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 (
|
CREATE TABLE asbuilt_revision_shop_revisions_refs (
|
||||||
asbuilt_drawing_revision_id INT COMMENT 'ID ของ AS Built Drawing Revision',
|
asbuilt_drawing_revision_id INT COMMENT 'ID ของ AS Built Drawing Revision',
|
||||||
shop_drawing_revision_id INT COMMENT 'ID ของ Shop 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
|
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)';
|
) 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 (ใบเวียนภายใน)
|
-- 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_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_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);
|
CREATE INDEX IDX_AUDIT_OPERATION ON document_number_audit (operation);
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ INSERT INTO users (
|
|||||||
VALUES (
|
VALUES (
|
||||||
1,
|
1,
|
||||||
'superadmin',
|
'superadmin',
|
||||||
'$2b$10$E6d5k.f46jr.POGWKHhiQ.X1ZsFrMpZox//sCxeOiLUULGuAHO0NW',
|
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose',
|
||||||
'Super',
|
'Super',
|
||||||
'Admin',
|
'Admin',
|
||||||
'superadmin @example.com',
|
'superadmin @example.com',
|
||||||
@@ -212,7 +212,7 @@ VALUES (
|
|||||||
(
|
(
|
||||||
2,
|
2,
|
||||||
'admin',
|
'admin',
|
||||||
'$2b$10$E6d5k.f46jr.POGWKHhiQ.X1ZsFrMpZox//sCxeOiLUULGuAHO0NW',
|
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose',
|
||||||
'Admin',
|
'Admin',
|
||||||
'คคง.',
|
'คคง.',
|
||||||
'admin@example.com',
|
'admin@example.com',
|
||||||
@@ -222,7 +222,7 @@ VALUES (
|
|||||||
(
|
(
|
||||||
3,
|
3,
|
||||||
'editor01',
|
'editor01',
|
||||||
'$2b$10$E6d5k.f46jr.POGWKHhiQ.X1ZsFrMpZox//sCxeOiLUULGuAHO0NW',
|
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose',
|
||||||
'DC',
|
'DC',
|
||||||
'C1',
|
'C1',
|
||||||
'editor01 @example.com',
|
'editor01 @example.com',
|
||||||
@@ -232,7 +232,7 @@ VALUES (
|
|||||||
(
|
(
|
||||||
4,
|
4,
|
||||||
'viewer01',
|
'viewer01',
|
||||||
'$2b$10$E6d5k.f46jr.POGWKHhiQ.X1ZsFrMpZox//sCxeOiLUULGuAHO0NW',
|
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose',
|
||||||
'Viewer',
|
'Viewer',
|
||||||
'สคฉ.03',
|
'สคฉ.03',
|
||||||
'viewer01 @example.com',
|
'viewer01 @example.com',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,10 @@
|
|||||||
## กำหนดสิทธิ
|
## กำหนดสิทธิ
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chown -R 999:999 /share/Container/mariadb/init
|
chown -R 999:999 /share/nap-dms/mariadb/init
|
||||||
chmod 755 /share/Container/mariadb/init
|
chmod 755 /share/nap-dms/mariadb/init
|
||||||
setfacl -R -m u:999:r-x /share/Container/mariadb/init
|
setfacl -R -m u:999:r-x /share/nap-dms/mariadb/init
|
||||||
setfacl -R -d -m u:999:r-x /share/Container/mariadb/init
|
setfacl -R -d -m u:999:r-x /share/nap-dms/mariadb/init
|
||||||
|
|
||||||
chown -R 33:33 /share/Container/pma/tmp
|
chown -R 33:33 /share/Container/pma/tmp
|
||||||
chmod 755 /share/Container/pma/tmp
|
chmod 755 /share/Container/pma/tmp
|
||||||
@@ -86,9 +86,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- "/share/Container/mariadb/data:/var/lib/mysql"
|
- "/share/nap-dms/mariadb/data:/var/lib/mysql"
|
||||||
- "/share/Container/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro"
|
- "/share/nap-dms/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro"
|
||||||
- "/share/Container/mariadb/init:/docker-entrypoint-initdb.d:ro"
|
- "/share/nap-dms/mariadb/init:/docker-entrypoint-initdb.d:ro"
|
||||||
- "/share/dms-data/mariadb/backup:/backup"
|
- "/share/dms-data/mariadb/backup:/backup"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
|
|||||||
110
specs/08-infrastructure/lcbp3-db.md
Normal file
110
specs/08-infrastructure/lcbp3-db.md
Normal 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;
|
||||||
71
specs/09-history/2025-12-24-document-numbering-fixes.md
Normal file
71
specs/09-history/2025-12-24-document-numbering-fixes.md
Normal 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
|
||||||
@@ -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
|
||||||
80
specs/09-history/2025-12-25-drawing-module-refactor.md
Normal file
80
specs/09-history/2025-12-25-drawing-module-refactor.md
Normal 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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user