Compare commits

..

2 Commits

Author SHA1 Message Date
admin 36d078ae24 260322:0849 Correct Coresspondence / Doing RFA
Build and Deploy / deploy (push) Failing after 52s
2026-03-22 08:49:50 +07:00
admin 03d16cfd64 260321:1700 Correct Coresspondence / Doing RFA 2026-03-21 17:00:41 +07:00
72 changed files with 2061 additions and 925 deletions
+8 -8
View File
@@ -12,10 +12,10 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
### 📊 Project Status: UAT Ready, Security Hardened (2026-03-19) ### 📊 Project Status: UAT Ready, Security Hardened (2026-03-19)
| Area | Status | Notes | | Area | Status | Notes |
| ------------- | ------------------------ | ---------------------------------------- | | ------------- | ----------------------- | ---------------------------------------- |
| Backend | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities | | Backend | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities |
| Frontend | ✅ Quality Hardened | Next.js 16.2.0, 0 `any`, 0 console.log | | Frontend | ✅ Quality Hardened | Next.js 16.2.0, 0 `any`, 0 console.log |
| Database | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration (ADR-009) | | Database | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration (ADR-009) |
| Documentation | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy | | Documentation | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy |
| AI Migration | 🔄 Pre-migration Setup | n8n + Ollama (ADR-017/018) | | AI Migration | 🔄 Pre-migration Setup | n8n + Ollama (ADR-017/018) |
@@ -60,15 +60,15 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
### 📁 Key Spec Documents (Quick Reference) ### 📁 Key Spec Documents (Quick Reference)
| เอกสาร | Path | ใช้เมื่อ | | เอกสาร | Path | ใช้เมื่อ |
| -------------------- | ----------------------------------------------------------- | ----------------------------------- | | -------------------- | ----------------------------------------------------------- | ----------------------------------- |
| **Schema Tables** | `03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query ทุกครั้ง | | **Schema Tables** | `03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query ทุกครั้ง |
| **Data Dictionary** | `03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Field Meaning + Business Rules | | **Data Dictionary** | `03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Field Meaning + Business Rules |
| **Seed Permissions** | `03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` | ตรวจ CASL Permission Matrix | | **Seed Permissions** | `03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` | ตรวจ CASL Permission Matrix |
| **Edge Cases** | `01-Requirements/01-06-edge-cases-and-rules.md` | 37 Rules ป้องกัน Bug | | **Edge Cases** | `01-Requirements/01-06-edge-cases-and-rules.md` | 37 Rules ป้องกัน Bug |
| **Migration Scope** | `03-Data-and-Storage/03-06-migration-business-scope.md` | งาน Migration Bot | | **Migration Scope** | `03-Data-and-Storage/03-06-migration-business-scope.md` | งาน Migration Bot |
| **Release Policy** | `04-Infrastructure-OPS/04-08-release-management-policy.md` | ก่อน Deploy / Hotfix | | **Release Policy** | `04-Infrastructure-OPS/04-08-release-management-policy.md` | ก่อน Deploy / Hotfix |
| **UAT Criteria** | `01-Requirements/01-05-acceptance-criteria.md` | ตรวจความสมบูรณ์ Feature | | **UAT Criteria** | `01-Requirements/01-05-acceptance-criteria.md` | ตรวจความสมบูรณ์ Feature |
| **ADR-009** | `06-Decision-Records/ADR-009-db-strategy.md` | Schema Change Process | | **ADR-009** | `06-Decision-Records/ADR-009-db-strategy.md` | Schema Change Process |
| **ADR-018** | `06-Decision-Records/ADR-018-ai-boundary.md` | AI/Ollama Integration Rules | | **ADR-018** | `06-Decision-Records/ADR-018-ai-boundary.md` | AI/Ollama Integration Rules |
| **ADR-019** | `06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | Hybrid ID Strategy (INT + UUIDv7) | | **ADR-019** | `06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | Hybrid ID Strategy (INT + UUIDv7) |
+10
View File
@@ -2,6 +2,16 @@
## [Unreleased] ## [Unreleased]
### Document Numbering System Fixes (2026-03-21)
#### 🔢 **Template Management Hardening**
- **Issue**: Save/Edit functionality failing due to missing fields and data complexity.
- **Fix (Backend)**: Added `disciplineId` and `isActive` to `DocumentNumberFormat` entity.
- **Fix (Backend)**: Implemented automated "Upsert" logic in `DocumentNumberingService` to handle business keys (Project + Type + Discipline).
- **Fix (Frontend)**: Refactored `numberingApi.saveTemplate` to sanitize DTOs and avoid sending nested relation objects.
- **Feature**: Added **Discipline** selection to the Template Editor UI for granular numbering rules.
- **Result**: Administrators can now successfully create, update, and manage numbering templates globally or per discipline.
### Build & Deployment Fixes (2026-03-20) ### Build & Deployment Fixes (2026-03-20)
#### 🔧 **Backend Dependency Resolution** #### 🔧 **Backend Dependency Resolution**
+3 -3
View File
@@ -180,7 +180,7 @@ POST /api/correspondences
--- ---
**Last Updated**: 2026-03-19 **Last Updated**: 2026-03-21
**Version**: 1.8.1 **Version**: 1.8.1
**Status**: Draft | Review | Approved **Status**: Draft | Review | Approved
``` ```
@@ -541,11 +541,11 @@ graph LR
| 1.0.0 | 2025-01-15 | John Doe | Initial version | | 1.0.0 | 2025-01-15 | John Doe | Initial version |
| 1.1.0 | 2025-02-20 | Jane Smith | Add CC support | | 1.1.0 | 2025-02-20 | Jane Smith | Add CC support |
| 1.2.0 | 2025-03-10 | John Doe | Update workflow | | 1.2.0 | 2025-03-10 | John Doe | Update workflow |
| 1.8.1 | 2026-03-19 | Tech Lead | Security hardening, dependency updates | | 1.8.1 | 2026-03-21 | Tech Lead | Security hardening, numbering fixes, dependency updates |
**Current Version**: 1.8.1 **Current Version**: 1.8.1
**Status**: Approved **Status**: Approved
**Last Updated**: 2026-03-19 **Last Updated**: 2026-03-21
**Security**: 0 vulnerabilities (backend) **Security**: 0 vulnerabilities (backend)
``` ```
+1 -1
View File
@@ -10,7 +10,7 @@
--- ---
## 📈 Current Status (As of 2026-03-19) ## 📈 Current Status (As of 2026-03-21)
**Version 1.8.1 (Patch) — UAT Ready, Security Hardened** **Version 1.8.1 (Patch) — UAT Ready, Security Hardened**
+4 -4
View File
@@ -4,10 +4,10 @@ services:
container_name: mariadb-local container_name: mariadb-local
restart: always restart: always
environment: environment:
MYSQL_ROOT_PASSWORD: Center#2025 MYSQL_ROOT_PASSWORD: Center2025
MYSQL_DATABASE: lcbp3_dev MYSQL_DATABASE: lcbp3_dev
MYSQL_USER: admin MYSQL_USER: admin
MYSQL_PASSWORD: Center#2025 MYSQL_PASSWORD: Center2025
ports: ports:
- '3306:3306' - '3306:3306'
volumes: volumes:
@@ -47,9 +47,9 @@ services:
environment: environment:
- discovery.type=single-node - discovery.type=single-node
- xpack.security.enabled=false # ปิด security เพื่อความง่ายใน Dev (Prod ต้องเปิด) - xpack.security.enabled=false # ปิด security เพื่อความง่ายใน Dev (Prod ต้องเปิด)
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" - 'ES_JAVA_OPTS=-Xms512m -Xmx512m'
ports: ports:
- "9200:9200" - '9200:9200'
volumes: volumes:
- esdata:/usr/share/elasticsearch/data - esdata:/usr/share/elasticsearch/data
networks: networks:
+3 -3
View File
@@ -25,7 +25,7 @@
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.5", "@casl/ability": "^6.7.5",
"@elastic/elasticsearch": "^9.3.4", "@elastic/elasticsearch": "^8.13.0",
"@nestjs-modules/ioredis": "^2.0.2", "@nestjs-modules/ioredis": "^2.0.2",
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
@@ -61,9 +61,9 @@
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"joi": "^18.0.1", "joi": "^18.0.1",
"ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
"ms": "^2.1.3",
"nest-winston": "^1.10.2", "nest-winston": "^1.10.2",
"nodemailer": "^8.0.3", "nodemailer": "^8.0.3",
"opossum": "^9.0.0", "opossum": "^9.0.0",
@@ -92,8 +92,8 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/ms": "^2.1.0", "@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/opossum": "^8.1.9", "@types/opossum": "^8.1.9",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
@@ -102,4 +102,32 @@ export class UuidResolverService {
async resolveContractId(contractId: number | string): Promise<number> { async resolveContractId(contractId: number | string): Promise<number> {
return this.resolve('Contract', 'contracts', 'id', contractId); return this.resolve('Contract', 'contracts', 'id', contractId);
} }
/**
* Resolve shopDrawingRevisionId (INT or UUID string) to internal INT ID.
*/
async resolveShopDrawingRevisionId(
shopDrawingRevisionId: number | string
): Promise<number> {
return this.resolve(
'Shop Drawing Revision',
'shop_drawing_revisions',
'id',
shopDrawingRevisionId
);
}
/**
* Resolve asBuiltDrawingRevisionId (INT or UUID string) to internal INT ID.
*/
async resolveAsBuiltDrawingRevisionId(
asBuiltDrawingRevisionId: number | string
): Promise<number> {
return this.resolve(
'As-Built Drawing Revision',
'asbuilt_drawing_revisions',
'id',
asBuiltDrawingRevisionId
);
}
} }
@@ -207,6 +207,9 @@ export class CorrespondenceService {
issuedDate: createDto.issuedDate issuedDate: createDto.issuedDate
? new Date(createDto.issuedDate) ? new Date(createDto.issuedDate)
: undefined, : undefined,
receivedDate: createDto.receivedDate
? new Date(createDto.receivedDate)
: undefined,
description: createDto.description, description: createDto.description,
details: createDto.details, details: createDto.details,
createdBy: user.user_id, createdBy: user.user_id,
@@ -521,6 +524,8 @@ export class CorrespondenceService {
revisionUpdate.documentDate = new Date(updateDto.documentDate); revisionUpdate.documentDate = new Date(updateDto.documentDate);
if (updateDto.issuedDate) if (updateDto.issuedDate)
revisionUpdate.issuedDate = new Date(updateDto.issuedDate); revisionUpdate.issuedDate = new Date(updateDto.issuedDate);
if (updateDto.receivedDate)
revisionUpdate.receivedDate = new Date(updateDto.receivedDate);
if (updateDto.description) if (updateDto.description)
revisionUpdate.description = updateDto.description; revisionUpdate.description = updateDto.description;
if (updateDto.details) revisionUpdate.details = updateDto.details; if (updateDto.details) revisionUpdate.details = updateDto.details;
@@ -99,6 +99,14 @@ export class CreateCorrespondenceDto {
@IsOptional() @IsOptional()
issuedDate?: string; issuedDate?: string;
@ApiPropertyOptional({
description: 'Received Date (วันที่รับเอกสาร)',
example: '2025-12-06T00:00:00Z',
})
@IsDateString()
@IsOptional()
receivedDate?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Attachment temp IDs from upload phase (Two-Phase Storage)', description: 'Attachment temp IDs from upload phase (Two-Phase Storage)',
example: ['uuid-temp-1', 'uuid-temp-2'], example: ['uuid-temp-1', 'uuid-temp-2'],
@@ -32,7 +32,7 @@ export class DocumentNumberingAdminController {
@Get('templates') @Get('templates')
@ApiOperation({ summary: 'Get all document numbering templates' }) @ApiOperation({ summary: 'Get all document numbering templates' })
@RequirePermission('system.manage_settings') @RequirePermission('numbering.view_formats')
async getTemplates(@Query('projectId') projectId?: number) { async getTemplates(@Query('projectId') projectId?: number) {
if (projectId) { if (projectId) {
return this.service.getTemplatesByProject(projectId); return this.service.getTemplatesByProject(projectId);
@@ -42,7 +42,7 @@ export class DocumentNumberingAdminController {
@Post('templates') @Post('templates')
@ApiOperation({ summary: 'Create or Update a numbering template' }) @ApiOperation({ summary: 'Create or Update a numbering template' })
@RequirePermission('system.manage_settings') @RequirePermission('numbering.manage_formats')
async saveTemplate( async saveTemplate(
@Body() dto: Partial<DocumentNumberFormat> & { projectId?: number | string } @Body() dto: Partial<DocumentNumberFormat> & { projectId?: number | string }
) { ) {
@@ -51,7 +51,7 @@ export class DocumentNumberingAdminController {
@Delete('templates/:id') @Delete('templates/:id')
@ApiOperation({ summary: 'Delete a numbering template' }) @ApiOperation({ summary: 'Delete a numbering template' })
@RequirePermission('system.manage_settings') @RequirePermission('numbering.manage_formats')
async deleteTemplate(@Param('id', ParseIntPipe) id: number) { async deleteTemplate(@Param('id', ParseIntPipe) id: number) {
await this.service.deleteTemplate(id); await this.service.deleteTemplate(id);
return { success: true }; return { success: true };
@@ -78,7 +78,7 @@ export class DocumentNumberingAdminController {
@ApiOperation({ @ApiOperation({
summary: 'Manually override or set a document number counter', summary: 'Manually override or set a document number counter',
}) })
@RequirePermission('system.manage_settings') @RequirePermission('numbering.manage_formats')
async manualOverride( async manualOverride(
@Body() dto: ManualOverrideDto, @Body() dto: ManualOverrideDto,
@CurrentUser() user: User @CurrentUser() user: User
@@ -88,7 +88,7 @@ export class DocumentNumberingAdminController {
@Post('void-and-replace') @Post('void-and-replace')
@ApiOperation({ summary: 'Void a number and replace with a new generation' }) @ApiOperation({ summary: 'Void a number and replace with a new generation' })
@RequirePermission('system.manage_settings') @RequirePermission('numbering.manage_formats')
async voidAndReplace( async voidAndReplace(
@Body() @Body()
dto: { dto: {
@@ -104,7 +104,7 @@ export class DocumentNumberingAdminController {
@Post('cancel') @Post('cancel')
@ApiOperation({ summary: 'Cancel/Skip a specific document number' }) @ApiOperation({ summary: 'Cancel/Skip a specific document number' })
@RequirePermission('system.manage_settings') @RequirePermission('numbering.manage_formats')
async cancelNumber( async cancelNumber(
@Body() @Body()
dto: { dto: {
@@ -119,7 +119,7 @@ export class DocumentNumberingAdminController {
@Post('bulk-import') @Post('bulk-import')
@ApiOperation({ summary: 'Bulk import/set document number counters' }) @ApiOperation({ summary: 'Bulk import/set document number counters' })
@RequirePermission('system.manage_settings') @RequirePermission('numbering.manage_formats')
async bulkImport(@Body() items: ManualOverrideDto[]) { async bulkImport(@Body() items: ManualOverrideDto[]) {
return this.service.bulkImport(items); return this.service.bulkImport(items);
} }
@@ -68,7 +68,7 @@ export class DocumentNumberingController {
@Patch('counters/:id') @Patch('counters/:id')
@ApiOperation({ summary: 'Update counter sequence value (Admin only)' }) @ApiOperation({ summary: 'Update counter sequence value (Admin only)' })
@RequirePermission('system.manage_settings') @RequirePermission('numbering.manage_formats')
async updateCounter( async updateCounter(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body('sequence') sequence: number @Body('sequence') sequence: number
@@ -105,7 +105,7 @@ export class DocumentNumberingController {
) )
: undefined; : undefined;
return this.numberingService.previewNumber({ const result = await this.numberingService.previewNumber({
projectId: resolvedProjectId, projectId: resolvedProjectId,
originatorOrganizationId: resolvedOriginatorId, originatorOrganizationId: resolvedOriginatorId,
typeId: dto.correspondenceTypeId, typeId: dto.correspondenceTypeId,
@@ -116,5 +116,7 @@ export class DocumentNumberingController {
year: dto.year, year: dto.year,
customTokens: dto.customTokens, customTokens: dto.customTokens,
}); });
console.log('[DocumentNumberingController] Preview result:', JSON.stringify(result));
return result;
} }
} }
@@ -14,7 +14,7 @@ import { Project } from '../../project/entities/project.entity';
import { CorrespondenceType } from '../../correspondence/entities/correspondence-type.entity'; import { CorrespondenceType } from '../../correspondence/entities/correspondence-type.entity';
@Entity('document_number_formats') @Entity('document_number_formats')
@Unique(['projectId', 'correspondenceTypeId']) @Unique(['projectId', 'correspondenceTypeId', 'disciplineId'])
export class DocumentNumberFormat { export class DocumentNumberFormat {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -25,6 +25,9 @@ export class DocumentNumberFormat {
@Column({ name: 'correspondence_type_id', nullable: true }) @Column({ name: 'correspondence_type_id', nullable: true })
correspondenceTypeId?: number; correspondenceTypeId?: number;
@Column({ name: 'discipline_id', default: 0 })
disciplineId!: number;
@Column({ name: 'format_string', length: 100 }) @Column({ name: 'format_string', length: 100 })
formatTemplate!: string; formatTemplate!: string;
@@ -35,6 +38,9 @@ export class DocumentNumberFormat {
@Column({ name: 'reset_annually', default: true }) @Column({ name: 'reset_annually', default: true })
resetSequenceYearly!: boolean; resetSequenceYearly!: boolean;
@Column({ name: 'is_active', default: 1 })
isActive!: number;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt!: Date; createdAt!: Date;
@@ -5,7 +5,13 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm'; import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository, EntityManager } from 'typeorm'; import {
Repository,
EntityManager,
In,
IsNull,
Equal,
} from 'typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DocumentNumberFormat } from '../entities/document-number-format.entity'; import { DocumentNumberFormat } from '../entities/document-number-format.entity';
@@ -121,7 +127,7 @@ export class DocumentNumberingService {
const sequence = await this.counterService.incrementCounter(key); const sequence = await this.counterService.incrementCounter(key);
// 4. Format Number // 4. Format Number
const documentNumber = await this.formatService.format({ const { previewNumber: documentNumber } = await this.formatService.format({
projectId: ctx.projectId, projectId: ctx.projectId,
correspondenceTypeId: ctx.typeId, correspondenceTypeId: ctx.typeId,
subTypeId: ctx.subTypeId, subTypeId: ctx.subTypeId,
@@ -193,7 +199,7 @@ export class DocumentNumberingService {
async previewNumber( async previewNumber(
ctx: GenerateNumberContext ctx: GenerateNumberContext
): Promise<{ previewNumber: string; nextSequence: number }> { ): Promise<{ previewNumber: string; nextSequence: number; isDefault: boolean }> {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const resetScope = `YEAR_${currentYear}`; const resetScope = `YEAR_${currentYear}`;
@@ -211,7 +217,7 @@ export class DocumentNumberingService {
const currentSeq = await this.counterService.getCurrentCounter(key); const currentSeq = await this.counterService.getCurrentCounter(key);
const nextSequence = currentSeq + 1; const nextSequence = currentSeq + 1;
const previewNumber = await this.formatService.format({ const { previewNumber, isDefault } = await this.formatService.format({
projectId: ctx.projectId, projectId: ctx.projectId,
correspondenceTypeId: ctx.typeId, correspondenceTypeId: ctx.typeId,
subTypeId: ctx.subTypeId, subTypeId: ctx.subTypeId,
@@ -224,7 +230,7 @@ export class DocumentNumberingService {
recipientOrganizationId: ctx.recipientOrganizationId, recipientOrganizationId: ctx.recipientOrganizationId,
}); });
return { previewNumber, nextSequence }; return { previewNumber, nextSequence, isDefault };
} }
/** /**
@@ -258,10 +264,36 @@ export class DocumentNumberingService {
async saveTemplate( async saveTemplate(
dto: Partial<DocumentNumberFormat> & { projectId?: number | string } dto: Partial<DocumentNumberFormat> & { projectId?: number | string }
) { ) {
if (dto.projectId) { try {
dto.projectId = await this.uuidResolver.resolveProjectId(dto.projectId); this.logger.log(`Saving numbering template: ${JSON.stringify(dto)}`);
// Resolve project ID if it's a UUID/String
if (dto.projectId && typeof dto.projectId === 'string') {
dto.projectId = await this.uuidResolver.resolveProjectId(dto.projectId);
}
// Upsert logic: If no ID provided, check for existing template with same business key
if (!dto.id) {
const existing = await this.formatRepo.findOne({
where: {
projectId: Number(dto.projectId),
correspondenceTypeId: dto.correspondenceTypeId ? Equal(dto.correspondenceTypeId) : IsNull(),
disciplineId: dto.disciplineId || 0,
},
});
if (existing) {
this.logger.log(`Found existing template ID: ${existing.id} for business key, updating instead of creating.`);
dto.id = existing.id;
}
}
const result = await this.formatRepo.save(dto);
this.logger.log(`Successfully saved template ID: ${result.id}`);
return result;
} catch (e: any) {
this.logger.error(`Failed to save numbering template: ${e.message}`, e.stack);
throw e;
} }
return this.formatRepo.save(dto);
} }
async deleteTemplate(id: number) { async deleteTemplate(id: number) {
@@ -39,12 +39,14 @@ export class FormatService {
private disciplineRepo: Repository<Discipline> private disciplineRepo: Repository<Discipline>
) {} ) {}
async format(options: FormatOptions): Promise<string> { async format(options: FormatOptions): Promise<{ previewNumber: string; isDefault: boolean }> {
const { template } = await this.resolveFormatAndScope(options); const { template, isDefault } = await this.resolveFormatAndScope(options);
const currentYear = options.year || new Date().getFullYear(); const currentYear = options.year || new Date().getFullYear();
const tokens = await this.resolveTokens(options, currentYear); const tokens = await this.resolveTokens(options, currentYear);
return this.replaceTokens(template, tokens, options.sequence); const previewNumber = this.replaceTokens(template, tokens, options.sequence);
console.log(`[FormatService] Generated: "${previewNumber}" | Template: "${template}" | isDefault: ${isDefault}`);
return { previewNumber, isDefault };
} }
// --- Helpers --- // --- Helpers ---
@@ -52,6 +54,7 @@ export class FormatService {
private async resolveFormatAndScope(options: FormatOptions): Promise<{ private async resolveFormatAndScope(options: FormatOptions): Promise<{
template: string; template: string;
resetSequenceYearly: boolean; resetSequenceYearly: boolean;
isDefault: boolean;
}> { }> {
// 1. Specific Format // 1. Specific Format
const specificFormat = await this.formatRepo.findOne({ const specificFormat = await this.formatRepo.findOne({
@@ -64,6 +67,7 @@ export class FormatService {
return { return {
template: specificFormat.formatTemplate, template: specificFormat.formatTemplate,
resetSequenceYearly: specificFormat.resetSequenceYearly, resetSequenceYearly: specificFormat.resetSequenceYearly,
isDefault: false,
}; };
// 2. Default Format // 2. Default Format
@@ -74,12 +78,14 @@ export class FormatService {
return { return {
template: defaultFormat.formatTemplate, template: defaultFormat.formatTemplate,
resetSequenceYearly: defaultFormat.resetSequenceYearly, resetSequenceYearly: defaultFormat.resetSequenceYearly,
isDefault: true,
}; };
// 3. Fallback // 3. Fallback
return { return {
template: '{ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE}', template: '{ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE}',
resetSequenceYearly: true, resetSequenceYearly: true,
isDefault: true,
}; };
} }
@@ -61,7 +61,7 @@ export class ReservationService {
const sequence = await this.counterService.incrementCounter(counterKey); const sequence = await this.counterService.incrementCounter(counterKey);
// Format document number // Format document number
const documentNumber = await this.formatService.format({ const { previewNumber: documentNumber } = await this.formatService.format({
...dto, ...dto,
sequence, sequence,
resetScope: counterKey.resetScope, resetScope: counterKey.resetScope,
@@ -12,6 +12,15 @@ export class CreateTagDto {
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({
example: 'red',
description: 'รหัสสี หรือชื่อคลาสสำหรับ UI',
required: false,
})
@IsString()
@IsOptional()
color_code?: string;
@ApiProperty({ @ApiProperty({
example: 1, example: 1,
description: 'Project ID or UUID', description: 'Project ID or UUID',
@@ -86,13 +86,21 @@ export class CreateRfaRevisionDto {
}) })
@IsObject() @IsObject()
@IsOptional() @IsOptional()
details?: Record<string, any>; details?: Record<string, unknown>;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Linked Shop Drawing Revision IDs', description: 'Linked Shop Drawing Revision IDs or UUIDs',
example: [1, 2], example: ['shop-revision-uuid-1', 'shop-revision-uuid-2'],
}) })
@IsArray() @IsArray()
@IsOptional() @IsOptional()
shopDrawingRevisionIds?: number[]; // IDs of linked Shop Drawings shopDrawingRevisionIds?: Array<number | string>;
@ApiPropertyOptional({
description: 'Linked As-Built Drawing Revision IDs or UUIDs',
example: ['asbuilt-revision-uuid-1'],
})
@IsArray()
@IsOptional()
asBuiltDrawingRevisionIds?: Array<number | string>;
} }
+16 -7
View File
@@ -20,9 +20,9 @@ export class CreateRfaDto {
@IsOptional() @IsOptional()
contractId?: string; contractId?: string;
@ApiProperty({ description: 'To Organization ID or UUID', required: false }) @ApiProperty({ description: 'To Organization ID or UUID' })
@IsOptional() @IsNotEmpty()
toOrganizationId?: number | string; toOrganizationId!: number | string;
@ApiProperty({ description: 'ID ของประเภท RFA', example: 1 }) @ApiProperty({ description: 'ID ของประเภท RFA', example: 1 })
@IsInt() @IsInt()
@@ -76,14 +76,23 @@ export class CreateRfaDto {
}) })
@IsObject() @IsObject()
@IsOptional() @IsOptional()
details?: Record<string, any>; details?: Record<string, unknown>;
@ApiProperty({ @ApiProperty({
description: 'รายการ Shop Drawing Revisions ที่แนบมาด้วย', description: 'รายการ Shop Drawing Revision IDs หรือ UUIDs ที่แนบมาด้วย',
required: false, required: false,
type: [Number], type: [String],
}) })
@IsArray() @IsArray()
@IsOptional() @IsOptional()
shopDrawingRevisionIds?: number[]; shopDrawingRevisionIds?: Array<number | string>;
@ApiProperty({
description: 'รายการ As-Built Drawing Revision IDs หรือ UUIDs ที่แนบมาด้วย',
required: false,
type: [String],
})
@IsArray()
@IsOptional()
asBuiltDrawingRevisionIds?: Array<number | string>;
} }
@@ -1,14 +1,34 @@
import { Entity, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { RfaRevision } from './rfa-revision.entity'; import { RfaRevision } from './rfa-revision.entity';
import { AsBuiltDrawingRevision } from '../../drawing/entities/asbuilt-drawing-revision.entity';
import { ShopDrawingRevision } from '../../drawing/entities/shop-drawing-revision.entity'; import { ShopDrawingRevision } from '../../drawing/entities/shop-drawing-revision.entity';
@Entity('rfa_items') @Entity('rfa_items')
export class RfaItem { export class RfaItem {
@PrimaryColumn({ name: 'rfa_revision_id' }) @PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'rfa_revision_id' })
rfaRevisionId!: number; rfaRevisionId!: number;
@PrimaryColumn({ name: 'shop_drawing_revision_id' }) @Column({
shopDrawingRevisionId!: number; name: 'item_type',
type: 'enum',
enum: ['SHOP', 'AS_BUILT'],
})
itemType!: 'SHOP' | 'AS_BUILT';
@Column({ name: 'shop_drawing_revision_id', nullable: true })
shopDrawingRevisionId?: number;
@Column({ name: 'asbuilt_drawing_revision_id', nullable: true })
asBuiltDrawingRevisionId?: number;
// Relations // Relations
@ManyToOne(() => RfaRevision, (rfaRev) => rfaRev.items, { @ManyToOne(() => RfaRevision, (rfaRev) => rfaRev.items, {
@@ -19,5 +39,9 @@ export class RfaItem {
@ManyToOne(() => ShopDrawingRevision) @ManyToOne(() => ShopDrawingRevision)
@JoinColumn({ name: 'shop_drawing_revision_id' }) @JoinColumn({ name: 'shop_drawing_revision_id' })
shopDrawingRevision!: ShopDrawingRevision; shopDrawingRevision?: ShopDrawingRevision;
@ManyToOne(() => AsBuiltDrawingRevision)
@JoinColumn({ name: 'asbuilt_drawing_revision_id' })
asBuiltDrawingRevision?: AsBuiltDrawingRevision;
} }
+6
View File
@@ -7,10 +7,13 @@ import { CorrespondenceRouting } from '../correspondence/entities/correspondence
import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity'; import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity'; import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity'; import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity'; import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { Discipline } from '../master/entities/discipline.entity';
import { RfaApproveCode } from './entities/rfa-approve-code.entity'; import { RfaApproveCode } from './entities/rfa-approve-code.entity';
import { RfaItem } from './entities/rfa-item.entity'; import { RfaItem } from './entities/rfa-item.entity';
import { RfaRevision } from './entities/rfa-revision.entity'; import { RfaRevision } from './entities/rfa-revision.entity';
@@ -46,7 +49,10 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
Correspondence, Correspondence,
CorrespondenceRevision, CorrespondenceRevision,
CorrespondenceStatus, CorrespondenceStatus,
CorrespondenceType,
AsBuiltDrawingRevision,
ShopDrawingRevision, ShopDrawingRevision,
Discipline,
RfaWorkflow, RfaWorkflow,
RfaWorkflowTemplate, RfaWorkflowTemplate,
RfaWorkflowTemplateStep, RfaWorkflowTemplateStep,
+168 -15
View File
@@ -13,12 +13,16 @@ import { DataSource, In, Repository } from 'typeorm';
// Entities // Entities
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity'; import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity'; import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity'; import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity'; import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { Discipline } from '../master/entities/discipline.entity';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { RfaApproveCode } from './entities/rfa-approve-code.entity'; import { RfaApproveCode } from './entities/rfa-approve-code.entity';
import { RfaItem } from './entities/rfa-item.entity'; import { RfaItem } from './entities/rfa-item.entity';
@@ -72,10 +76,16 @@ export class RfaService {
private corrRevRepo: Repository<CorrespondenceRevision>, private corrRevRepo: Repository<CorrespondenceRevision>,
@InjectRepository(CorrespondenceStatus) @InjectRepository(CorrespondenceStatus)
private corrStatusRepo: Repository<CorrespondenceStatus>, private corrStatusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(CorrespondenceType)
private correspondenceTypeRepo: Repository<CorrespondenceType>,
@InjectRepository(Discipline)
private disciplineRepo: Repository<Discipline>,
@InjectRepository(RfaStatusCode) @InjectRepository(RfaStatusCode)
private rfaStatusRepo: Repository<RfaStatusCode>, private rfaStatusRepo: Repository<RfaStatusCode>,
@InjectRepository(RfaApproveCode) @InjectRepository(RfaApproveCode)
private rfaApproveRepo: Repository<RfaApproveCode>, private rfaApproveRepo: Repository<RfaApproveCode>,
@InjectRepository(AsBuiltDrawingRevision)
private asBuiltDrawingRevRepo: Repository<AsBuiltDrawingRevision>,
@InjectRepository(ShopDrawingRevision) @InjectRepository(ShopDrawingRevision)
private shopDrawingRevRepo: Repository<ShopDrawingRevision>, private shopDrawingRevRepo: Repository<ShopDrawingRevision>,
@InjectRepository(CorrespondenceRouting) @InjectRepository(CorrespondenceRouting)
@@ -105,6 +115,104 @@ export class RfaService {
}); });
if (!rfaType) throw new NotFoundException('RFA Type not found'); if (!rfaType) throw new NotFoundException('RFA Type not found');
const rfaTypeCode = rfaType.typeCode.toUpperCase();
const rawShopDrawingRefs = createDto.shopDrawingRevisionIds ?? [];
const rawAsBuiltDrawingRefs = createDto.asBuiltDrawingRevisionIds ?? [];
if (['DDW', 'SDW'].includes(rfaTypeCode)) {
if (rawShopDrawingRefs.length === 0) {
throw new BadRequestException(
'Selected RFA Type requires at least one Shop Drawing Revision'
);
}
if (rawAsBuiltDrawingRefs.length > 0) {
throw new BadRequestException(
'Selected RFA Type cannot reference As-Built Drawing Revisions'
);
}
} else if (rfaTypeCode === 'ADW') {
if (rawAsBuiltDrawingRefs.length === 0) {
throw new BadRequestException(
'Selected RFA Type requires at least one As-Built Drawing Revision'
);
}
if (rawShopDrawingRefs.length > 0) {
throw new BadRequestException(
'Selected RFA Type cannot reference Shop Drawing Revisions'
);
}
} else if (
rawShopDrawingRefs.length > 0 ||
rawAsBuiltDrawingRefs.length > 0
) {
throw new BadRequestException(
'Selected RFA Type does not support drawing revision items'
);
}
const shopDrawingRevisionIds = Array.from(
new Set(
await Promise.all(
rawShopDrawingRefs.map((ref) =>
this.uuidResolver.resolveShopDrawingRevisionId(ref)
)
)
)
);
const asBuiltDrawingRevisionIds = Array.from(
new Set(
await Promise.all(
rawAsBuiltDrawingRefs.map((ref) =>
this.uuidResolver.resolveAsBuiltDrawingRevisionId(ref)
)
)
)
);
const correspondenceType = await this.correspondenceTypeRepo.findOne({
where: { typeCode: 'RFA', isActive: true },
});
if (!correspondenceType) {
throw new InternalServerErrorException(
'Correspondence Type RFA not found in Master Data'
);
}
const internalContractId = createDto.contractId
? await this.uuidResolver.resolveContractId(createDto.contractId)
: rfaType.contractId;
if (rfaType.contractId !== internalContractId) {
throw new BadRequestException(
'Selected RFA Type does not belong to the selected contract'
);
}
if (createDto.disciplineId) {
const discipline = await this.disciplineRepo.findOne({
where: { id: createDto.disciplineId },
});
if (!discipline) {
throw new NotFoundException('Discipline not found');
}
if (discipline.contractId !== internalContractId) {
throw new BadRequestException(
'Selected Discipline does not belong to the selected contract'
);
}
}
const internalRecipientOrgId = createDto.toOrganizationId
? await this.uuidResolver.resolveOrganizationId(
createDto.toOrganizationId
)
: undefined;
const statusDraft = await this.rfaStatusRepo.findOne({ const statusDraft = await this.rfaStatusRepo.findOne({
where: { statusCode: 'DFT' }, where: { statusCode: 'DFT' },
}); });
@@ -135,7 +243,9 @@ export class RfaService {
const docNumber = await this.numberingService.generateNextNumber({ const docNumber = await this.numberingService.generateNextNumber({
projectId: internalProjectId, projectId: internalProjectId,
originatorOrganizationId: userOrgId, originatorOrganizationId: userOrgId,
typeId: createDto.rfaTypeId, recipientOrganizationId: internalRecipientOrgId,
typeId: correspondenceType.id,
rfaTypeId: createDto.rfaTypeId,
disciplineId: createDto.disciplineId ?? 0, // ✅ ส่ง disciplineId ไปด้วย (0 ถ้าไม่มี) disciplineId: createDto.disciplineId ?? 0, // ✅ ส่ง disciplineId ไปด้วย (0 ถ้าไม่มี)
year: new Date().getFullYear(), year: new Date().getFullYear(),
customTokens: { customTokens: {
@@ -159,7 +269,7 @@ export class RfaService {
// 1. Create Correspondence Record // 1. Create Correspondence Record
const correspondence = queryRunner.manager.create(Correspondence, { const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber.number, correspondenceNumber: docNumber.number,
correspondenceTypeId: createDto.rfaTypeId, correspondenceTypeId: correspondenceType.id,
projectId: internalProjectId, projectId: internalProjectId,
originatorId: userOrgId, originatorId: userOrgId,
isInternal: false, isInternal: false,
@@ -168,6 +278,15 @@ export class RfaService {
}); });
const savedCorr = await queryRunner.manager.save(correspondence); const savedCorr = await queryRunner.manager.save(correspondence);
if (internalRecipientOrgId) {
const recipient = queryRunner.manager.create(CorrespondenceRecipient, {
correspondenceId: savedCorr.id,
recipientOrganizationId: internalRecipientOrgId,
recipientType: 'TO',
});
await queryRunner.manager.save(recipient);
}
// 2. Create Rfa Master Record // 2. Create Rfa Master Record
const rfa = queryRunner.manager.create(Rfa, { const rfa = queryRunner.manager.create(Rfa, {
id: savedCorr.id, // ✅ CTI Key share id: savedCorr.id, // ✅ CTI Key share
@@ -205,25 +324,51 @@ export class RfaService {
}); });
const savedRevision = await queryRunner.manager.save(rfaRevision); const savedRevision = await queryRunner.manager.save(rfaRevision);
// 4. Link Shop Drawings const rfaItems: RfaItem[] = [];
if (
createDto.shopDrawingRevisionIds && if (shopDrawingRevisionIds.length > 0) {
createDto.shopDrawingRevisionIds.length > 0
) {
const shopDrawings = await this.shopDrawingRevRepo.findBy({ const shopDrawings = await this.shopDrawingRevRepo.findBy({
id: In(createDto.shopDrawingRevisionIds), id: In(shopDrawingRevisionIds),
}); });
if (shopDrawings.length !== createDto.shopDrawingRevisionIds.length) { if (shopDrawings.length !== shopDrawingRevisionIds.length) {
throw new NotFoundException('Some Shop Drawing Revisions not found'); throw new NotFoundException('Some Shop Drawing Revisions not found');
} }
const rfaItems = shopDrawings.map((sd) => rfaItems.push(
queryRunner.manager.create(RfaItem, { ...shopDrawings.map((sd) =>
rfaRevisionId: savedRevision.id, // Correctly link to RfaRevision queryRunner.manager.create(RfaItem, {
shopDrawingRevisionId: sd.id, rfaRevisionId: savedRevision.id,
}) itemType: 'SHOP',
shopDrawingRevisionId: sd.id,
})
)
); );
}
if (asBuiltDrawingRevisionIds.length > 0) {
const asBuiltDrawings = await this.asBuiltDrawingRevRepo.findBy({
id: In(asBuiltDrawingRevisionIds),
});
if (asBuiltDrawings.length !== asBuiltDrawingRevisionIds.length) {
throw new NotFoundException(
'Some As-Built Drawing Revisions not found'
);
}
rfaItems.push(
...asBuiltDrawings.map((ad) =>
queryRunner.manager.create(RfaItem, {
rfaRevisionId: savedRevision.id,
itemType: 'AS_BUILT',
asBuiltDrawingRevisionId: ad.id,
})
)
);
}
if (rfaItems.length > 0) {
await queryRunner.manager.save(rfaItems); await queryRunner.manager.save(rfaItems);
} }
@@ -303,7 +448,11 @@ export class RfaService {
.leftJoinAndSelect('rfaRev.statusCode', 'status') .leftJoinAndSelect('rfaRev.statusCode', 'status')
.leftJoinAndSelect('rfaRev.items', 'items') .leftJoinAndSelect('rfaRev.items', 'items')
.leftJoinAndSelect('items.shopDrawingRevision', 'sdRev') .leftJoinAndSelect('items.shopDrawingRevision', 'sdRev')
.leftJoinAndSelect('sdRev.attachments', 'attachments'); .leftJoinAndSelect('sdRev.shopDrawing', 'shopDrawing')
.leftJoinAndSelect('sdRev.attachments', 'shopAttachments')
.leftJoinAndSelect('items.asBuiltDrawingRevision', 'adRev')
.leftJoinAndSelect('adRev.asBuiltDrawing', 'asBuiltDrawing')
.leftJoinAndSelect('adRev.attachments', 'asBuiltAttachments');
// Filter by Revision Status (from query param 'revisionStatus') // Filter by Revision Status (from query param 'revisionStatus')
if (revisionStatus === 'CURRENT') { if (revisionStatus === 'CURRENT') {
@@ -405,6 +554,10 @@ export class RfaService {
'correspondence.revisions.rfaRevision.items', 'correspondence.revisions.rfaRevision.items',
'correspondence.revisions.rfaRevision.items.shopDrawingRevision', 'correspondence.revisions.rfaRevision.items.shopDrawingRevision',
'correspondence.revisions.rfaRevision.items.shopDrawingRevision.shopDrawing', 'correspondence.revisions.rfaRevision.items.shopDrawingRevision.shopDrawing',
'correspondence.revisions.rfaRevision.items.shopDrawingRevision.attachments',
'correspondence.revisions.rfaRevision.items.asBuiltDrawingRevision',
'correspondence.revisions.rfaRevision.items.asBuiltDrawingRevision.asBuiltDrawing',
'correspondence.revisions.rfaRevision.items.asBuiltDrawingRevision.attachments',
], ],
order: { order: {
correspondence: { revisions: { revisionNumber: 'DESC' } }, correspondence: { revisions: { revisionNumber: 'DESC' } },
+1 -5
View File
@@ -85,7 +85,6 @@ export class UserService {
.leftJoinAndSelect('user.preference', 'preference') // Optional .leftJoinAndSelect('user.preference', 'preference') // Optional
.leftJoinAndSelect('user.assignments', 'assignments') .leftJoinAndSelect('user.assignments', 'assignments')
.leftJoinAndSelect('assignments.role', 'role') .leftJoinAndSelect('assignments.role', 'role')
.leftJoin('user.organization', 'organization') // ADR-019: expose uuid, not INT
.select([ .select([
'user.user_id', 'user.user_id',
'user.uuid', 'user.uuid',
@@ -94,15 +93,13 @@ export class UserService {
'user.firstName', 'user.firstName',
'user.lastName', 'user.lastName',
'user.lineId', 'user.lineId',
'user.primaryOrganizationId',
'user.isActive', 'user.isActive',
'user.createdAt', 'user.createdAt',
'user.updatedAt', 'user.updatedAt',
'assignments.id', 'assignments.id',
'role.roleId', 'role.roleId',
'role.roleName', 'role.roleName',
'organization.uuid',
'organization.organizationCode',
'organization.organizationName',
]); ]);
// Apply Filters // Apply Filters
@@ -168,7 +165,6 @@ export class UserService {
'assignments', 'assignments',
'assignments.role', 'assignments.role',
'assignments.role.permissions', 'assignments.role.permissions',
'organization', // ADR-019: expose org.uuid, not INT primaryOrganizationId
], ],
}); });
@@ -37,17 +37,20 @@ import {
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Organization } from "@/types/organization"; import { Organization } from "@/types/organization";
import { getApiErrorMessage } from "@/types/api-error";
export default function UsersPage() { export default function UsersPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null); const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const { data: users, isLoading } = useUsers({ const { data: users, isLoading, isError, error } = useUsers({
search: search || undefined, search: search || undefined,
primaryOrganizationId: selectedOrgId ?? undefined, primaryOrganizationId: selectedOrgId ?? undefined,
}); });
const { data: organizations = [] } = useOrganizations(); const { data: organizations = [] } = useOrganizations();
const userList = Array.isArray(users) ? users : [];
const organizationList = Array.isArray(organizations) ? organizations : [];
const deleteMutation = useDeleteUser(); const deleteMutation = useDeleteUser();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -92,7 +95,15 @@ export default function UsersPage() {
{ {
id: "organization", id: "organization",
header: "Organization", header: "Organization",
cell: ({ row }) => row.original.organization?.organizationCode ?? "-", cell: ({ row }) => {
const orgId = row.original.primaryOrganizationId;
if (!orgId) {
return "All Organizations";
}
const org = organizationList.find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString());
return org ? org.organizationCode : "All Organizations";
},
}, },
{ {
id: "roles", id: "roles",
@@ -181,7 +192,7 @@ export default function UsersPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Organizations</SelectItem> <SelectItem value="all">All Organizations</SelectItem>
{Array.isArray(organizations) && (organizations as Organization[]).map((org) => ( {organizationList.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}> <SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName} {org.organizationCode} - {org.organizationName}
</SelectItem> </SelectItem>
@@ -191,6 +202,12 @@ export default function UsersPage() {
</div> </div>
</div> </div>
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{getApiErrorMessage(error, "Failed to load users")}
</div>
)}
{isLoading ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => ( {[1, 2, 3, 4, 5].map((i) => (
@@ -200,7 +217,7 @@ export default function UsersPage() {
))} ))}
</div> </div>
) : ( ) : (
<DataTable columns={columns} data={users || []} /> <DataTable columns={columns} data={userList} />
)} )}
<UserDialog <UserDialog
@@ -50,8 +50,7 @@ import { SearchContractDto, CreateContractDto, UpdateContractDto } from "@/types
import { AxiosError } from "axios"; import { AxiosError } from "axios";
interface Project { interface Project {
uuid: string; id: string; // ADR-019: uuid exposed as 'id'
id?: number;
projectCode: string; projectCode: string;
projectName: string; projectName: string;
} }
@@ -83,14 +82,14 @@ const contractSchema = z.object({
type ContractFormData = z.infer<typeof contractSchema>; type ContractFormData = z.infer<typeof contractSchema>;
const useContracts = (params?: SearchContractDto) => { const useContracts = (params?: SearchContractDto) => {
return useQuery<Contract[]>({ return useQuery({
queryKey: ['contracts', params], queryKey: ['contracts', params],
queryFn: () => contractService.getAll(params), queryFn: () => contractService.getAll(params),
}); });
}; };
const useProjectsList = () => { const useProjectsList = () => {
return useQuery<Project[]>({ return useQuery({
queryKey: ['projects-list'], queryKey: ['projects-list'],
queryFn: () => projectService.getAll(), queryFn: () => projectService.getAll(),
}); });
@@ -29,7 +29,7 @@ export default function EditTemplatePage() {
const { data: disciplines = [] } = useDisciplines(contractId); const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName = const selectedProjectName =
projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName || projects.find((p: { id?: number; uuid?: string; projectCode: string; projectName: string }) => p.id === projectId)?.projectName ||
'LCBP3'; 'LCBP3';
useEffect(() => { useEffect(() => {
@@ -10,6 +10,7 @@ import { Workflow } from '@/types/workflow';
export default function WorkflowsPage() { export default function WorkflowsPage() {
const { data: workflows = [], isLoading: loading, error } = useWorkflowDefinitions(); const { data: workflows = [], isLoading: loading, error } = useWorkflowDefinitions();
const workflowList = Array.isArray(workflows) ? workflows : [];
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -34,13 +35,13 @@ export default function WorkflowsPage() {
<div className="text-center py-12 text-destructive border rounded-lg border-dashed border-destructive/50 bg-destructive/10"> <div className="text-center py-12 text-destructive border rounded-lg border-dashed border-destructive/50 bg-destructive/10">
Failed to load workflows. Please try again later. Failed to load workflows. Please try again later.
</div> </div>
) : workflows.length === 0 ? ( ) : workflowList.length === 0 ? (
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed"> <div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
No workflow definitions found. Click &quot;New Workflow&quot; to create one. No workflow definitions found. Click &quot;New Workflow&quot; to create one.
</div> </div>
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-4">
{workflows.map((workflow: Workflow) => ( {workflowList.map((workflow: Workflow) => (
<Card key={workflow.workflowId} className="p-6"> <Card key={workflow.workflowId} className="p-6">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex-1"> <div className="flex-1">
@@ -17,10 +17,12 @@ import { format } from "date-fns";
import { ArrowLeftIcon } from "lucide-react"; import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getApiErrorMessage } from "@/types/api-error";
export default function MigrationErrorsPage() { export default function MigrationErrorsPage() {
const [items, setItems] = useState<MigrationErrorItem[]>([]); const [items, setItems] = useState<MigrationErrorItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -29,10 +31,12 @@ export default function MigrationErrorsPage() {
const fetchData = async () => { const fetchData = async () => {
try { try {
setLoading(true); setLoading(true);
setErrorMessage(null);
const res = await migrationService.getErrors({ limit: 100 }); const res = await migrationService.getErrors({ limit: 100 });
setItems(res.items); setItems(Array.isArray(res.items) ? res.items : []);
} catch (error) { } catch (error: unknown) {
// Failed to fetch errors - loading state handles display setItems([]);
setErrorMessage(getApiErrorMessage(error, "Failed to load errors"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -59,6 +63,11 @@ export default function MigrationErrorsPage() {
<CardTitle>Error Audit Log</CardTitle> <CardTitle>Error Audit Log</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{errorMessage}
</div>
)}
{loading ? ( {loading ? (
<div className="py-10 text-center">Loading errors...</div> <div className="py-10 text-center">Loading errors...</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
@@ -20,6 +20,7 @@ import { format } from "date-fns";
import { EyeIcon, FileXIcon, CheckSquareIcon } from "lucide-react"; import { EyeIcon, FileXIcon, CheckSquareIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getApiErrorMessage } from "@/types/api-error";
export default function MigrationReviewQueuePage() { export default function MigrationReviewQueuePage() {
const [items, setItems] = useState<MigrationReviewQueueItem[]>([]); const [items, setItems] = useState<MigrationReviewQueueItem[]>([]);
@@ -27,6 +28,7 @@ export default function MigrationReviewQueuePage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [statusFilter, setStatusFilter] = useState<string>("PENDING"); const [statusFilter, setStatusFilter] = useState<string>("PENDING");
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -35,14 +37,16 @@ export default function MigrationReviewQueuePage() {
const fetchData = async () => { const fetchData = async () => {
try { try {
setLoading(true); setLoading(true);
setErrorMessage(null);
const res = await migrationService.getReviewQueue({ const res = await migrationService.getReviewQueue({
status: statusFilter === "ALL" ? undefined : (statusFilter as MigrationReviewStatus), status: statusFilter === "ALL" ? undefined : (statusFilter as MigrationReviewStatus),
limit: 50, limit: 50,
}); });
setItems(res.items); setItems(Array.isArray(res.items) ? res.items : []);
setSelectedIds([]); // reset selection on fetch setSelectedIds([]); // reset selection on fetch
} catch (error) { } catch (error: unknown) {
// Failed to fetch queue - loading state handles display setItems([]);
setErrorMessage(getApiErrorMessage(error, "Failed to load queue"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -148,6 +152,11 @@ export default function MigrationReviewQueuePage() {
<CardTitle>Queue Items - {statusFilter}</CardTitle> <CardTitle>Queue Items - {statusFilter}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{errorMessage}
</div>
)}
{loading ? ( {loading ? (
<div className="py-10 text-center">Loading queue...</div> <div className="py-10 text-center">Loading queue...</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { sessionService } from '@/lib/services/session.service'; import { Session, sessionService } from '@/lib/services/session.service';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Loader2, Trash2, Monitor, Smartphone } from 'lucide-react'; import { Loader2, Trash2, Monitor } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { getApiErrorMessage } from '@/types/api-error';
export default function SessionManagementPage() { export default function SessionManagementPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -15,10 +16,11 @@ export default function SessionManagementPage() {
data: sessions, data: sessions,
isLoading, isLoading,
error, error,
} = useQuery({ } = useQuery<Session[]>({
queryKey: ['sessions'], queryKey: ['sessions'],
queryFn: sessionService.getActiveSessions, queryFn: sessionService.getActiveSessions,
}); });
const sessionList = Array.isArray(sessions) ? sessions : [];
const revokeMutation = useMutation({ const revokeMutation = useMutation({
mutationFn: sessionService.revokeSession, mutationFn: sessionService.revokeSession,
@@ -26,8 +28,10 @@ export default function SessionManagementPage() {
toast.success('Session revoked successfully'); toast.success('Session revoked successfully');
queryClient.invalidateQueries({ queryKey: ['sessions'] }); queryClient.invalidateQueries({ queryKey: ['sessions'] });
}, },
onError: (error) => { onError: (mutationError: unknown) => {
toast.error('Failed to revoke session'); toast.error('Failed to revoke session', {
description: getApiErrorMessage(mutationError, 'Unknown error'),
});
}, },
}); });
@@ -46,7 +50,7 @@ export default function SessionManagementPage() {
} }
if (error) { if (error) {
return <div className="p-8 text-center text-red-500">Failed to load sessions. Please try again.</div>; return <div className="p-8 text-center text-red-500">{getApiErrorMessage(error, 'Failed to load sessions. Please try again.')}</div>;
} }
return ( return (
@@ -67,7 +71,7 @@ export default function SessionManagementPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sessions?.map((session: any) => ( {sessionList.map((session) => (
<TableRow key={session.id}> <TableRow key={session.id}>
<TableCell> <TableCell>
<div className="flex flex-col"> <div className="flex flex-col">
@@ -94,7 +98,7 @@ export default function SessionManagementPage() {
variant="destructive" variant="destructive"
size="sm" size="sm"
className="h-8" className="h-8"
onClick={() => handleRevoke(Number(session.id))} onClick={() => handleRevoke(session.id)}
disabled={revokeMutation.isPending} disabled={revokeMutation.isPending}
> >
<Trash2 className="mr-2 h-3.5 w-3.5" /> <Trash2 className="mr-2 h-3.5 w-3.5" />
@@ -103,7 +107,7 @@ export default function SessionManagementPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{(!sessions || sessions.length === 0) && ( {sessionList.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No active sessions found. No active sessions found.
@@ -34,16 +34,32 @@ interface RbacMatrixProps {
rolePermissions: Record<number, number[]>; // roleId -> permissionIds[] rolePermissions: Record<number, number[]>; // roleId -> permissionIds[]
} }
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const securityService = { const securityService = {
getRoles: async (): Promise<Role[]> => { getRoles: async (): Promise<Role[]> => {
const response = await apiClient.get("/users/roles"); const response = await apiClient.get("/users/roles");
const data = response.data?.data || response.data; return extractArrayData<Role>(response.data);
return Array.isArray(data) ? data : [];
}, },
getPermissions: async (): Promise<Permission[]> => { getPermissions: async (): Promise<Permission[]> => {
const response = await apiClient.get("/users/permissions"); const response = await apiClient.get("/users/permissions");
const data = response.data?.data || response.data; return extractArrayData<Permission>(response.data);
return Array.isArray(data) ? data : [];
}, },
updateRolePermissions: async (roleId: number, permissionIds: number[]) => { updateRolePermissions: async (roleId: number, permissionIds: number[]) => {
// This endpoint might not exist as a bulk update, usually it's per role // This endpoint might not exist as a bulk update, usually it's per role
@@ -98,6 +114,8 @@ export function RbacMatrix() {
}; };
const hasChanges = Object.keys(pendingChanges).length > 0; const hasChanges = Object.keys(pendingChanges).length > 0;
const roleList = Array.isArray(roles) ? roles : [];
const permissionList = Array.isArray(permissions) ? permissions : [];
if (rolesLoading || permsLoading) { if (rolesLoading || permsLoading) {
return ( return (
@@ -125,7 +143,7 @@ export function RbacMatrix() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[300px]">Permission</TableHead> <TableHead className="w-[300px]">Permission</TableHead>
{roles.map((role) => ( {roleList.map((role) => (
<TableHead key={role.roleId} className="text-center min-w-[100px]"> <TableHead key={role.roleId} className="text-center min-w-[100px]">
{role.roleName} {role.roleName}
</TableHead> </TableHead>
@@ -133,13 +151,13 @@ export function RbacMatrix() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{permissions.map((perm) => ( {permissionList.map((perm) => (
<TableRow key={perm.permissionId}> <TableRow key={perm.permissionId}>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div>{perm.permissionName}</div> <div>{perm.permissionName}</div>
<div className="text-xs text-muted-foreground">{perm.description}</div> <div className="text-xs text-muted-foreground">{perm.description}</div>
</TableCell> </TableCell>
{roles.map((role) => { {roleList.map((role) => {
// Assume role.permissions is populated // Assume role.permissions is populated
const currentRolePerms = role.permissions?.map((p) => p.permissionId) || []; const currentRolePerms = role.permissions?.map((p) => p.permissionId) || [];
const activePerms = pendingChanges[role.roleId] || currentRolePerms; const activePerms = pendingChanges[role.roleId] || currentRolePerms;
+11 -5
View File
@@ -26,6 +26,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
const ALL_ORGANIZATIONS_VALUE = "all";
// Update schema to include confirmPassword // Update schema to include confirmPassword
const userSchema = z.object({ const userSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"), username: z.string().min(3, "Username must be at least 3 characters"),
@@ -92,7 +94,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
isActive: true, isActive: true,
roleIds: [], roleIds: [],
lineId: "", lineId: "",
primaryOrganizationId: undefined, primaryOrganizationId: ALL_ORGANIZATIONS_VALUE,
password: "", password: "",
confirmPassword: "" confirmPassword: ""
}, },
@@ -107,7 +109,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
lastName: user.lastName, lastName: user.lastName,
isActive: user.isActive, isActive: user.isActive,
lineId: user.lineId || "", lineId: user.lineId || "",
primaryOrganizationId: user.organization?.uuid ?? undefined, primaryOrganizationId: user.primaryOrganizationId?.toString() || ALL_ORGANIZATIONS_VALUE,
roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [], roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [],
password: "", password: "",
confirmPassword: "" confirmPassword: ""
@@ -120,7 +122,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
lastName: "", lastName: "",
isActive: true, isActive: true,
lineId: "", lineId: "",
primaryOrganizationId: undefined, primaryOrganizationId: ALL_ORGANIZATIONS_VALUE,
roleIds: [], roleIds: [],
password: "", password: "",
confirmPassword: "" confirmPassword: ""
@@ -148,6 +150,9 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
const payload = { ...data }; const payload = { ...data };
delete payload.confirmPassword; // Don't send to API delete payload.confirmPassword; // Don't send to API
if (!payload.password) delete payload.password; // Don't send empty password on edit if (!payload.password) delete payload.password; // Don't send empty password on edit
if (payload.primaryOrganizationId === ALL_ORGANIZATIONS_VALUE) {
delete payload.primaryOrganizationId;
}
if (user) { if (user) {
updateUser.mutate( updateUser.mutate(
@@ -231,7 +236,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<div> <div>
<Label>Primary Organization</Label> <Label>Primary Organization</Label>
<Select <Select
value={watch("primaryOrganizationId") ?? undefined} value={watch("primaryOrganizationId") || ALL_ORGANIZATIONS_VALUE}
onValueChange={(val) => onValueChange={(val) =>
setValue("primaryOrganizationId", val) setValue("primaryOrganizationId", val)
} }
@@ -240,7 +245,8 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<SelectValue placeholder="Select Organization" /> <SelectValue placeholder="Select Organization" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{organizations?.map((org: { uuid: string; organizationCode: string; organizationName: string }) => ( <SelectItem value={ALL_ORGANIZATIONS_VALUE}>All Organizations</SelectItem>
{Array.isArray(organizations) && organizations.map((org: { uuid: string; organizationCode: string; organizationName: string }) => (
<SelectItem <SelectItem
key={org.uuid} key={org.uuid}
value={org.uuid} value={org.uuid}
+127 -34
View File
@@ -18,12 +18,12 @@ import { FileUploadZone } from "@/components/custom/file-upload-zone";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence"; import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence";
import { Organization, Correspondence, CorrespondenceRevision } from "@/types/correspondence"; import { Organization } from "@/types/organization";
import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines } from "@/hooks/use-master-data"; import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines } from "@/hooks/use-master-data";
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto"; import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
import type { ProjectListItem } from "@/lib/services/project.service";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { correspondenceService } from "@/lib/services/correspondence.service"; import { correspondenceService } from "@/lib/services/correspondence.service";
import { numberingApi } from "@/lib/api/numbering";
// Updated Zod Schema with all required fields // Updated Zod Schema with all required fields
const correspondenceSchema = z.object({ const correspondenceSchema = z.object({
@@ -35,6 +35,9 @@ const correspondenceSchema = z.object({
body: z.string().optional(), body: z.string().optional(),
remarks: z.string().optional(), remarks: z.string().optional(),
dueDate: z.string().optional(), // ISO Date string dueDate: z.string().optional(), // ISO Date string
documentDate: z.string().optional(),
issuedDate: z.string().optional(),
receivedDate: z.string().optional(),
fromOrganizationId: z.string().min(1, "Please select From Organization"), fromOrganizationId: z.string().min(1, "Please select From Organization"),
toOrganizationId: z.string().min(1, "Please select To Organization"), toOrganizationId: z.string().min(1, "Please select To Organization"),
importance: z.enum(["NORMAL", "HIGH", "URGENT"]), importance: z.enum(["NORMAL", "HIGH", "URGENT"]),
@@ -43,31 +46,78 @@ const correspondenceSchema = z.object({
type FormData = z.infer<typeof correspondenceSchema>; type FormData = z.infer<typeof correspondenceSchema>;
export function CorrespondenceForm({ initialData, uuid }: { initialData?: Correspondence, uuid?: string }) { type ProjectOption = {
uuid?: string;
id?: number;
projectName: string;
projectCode: string;
};
type CorrespondenceTypeOption = {
id: number;
typeName: string;
typeCode: string;
};
type DisciplineOption = {
id: number;
disciplineCode: string;
codeNameEn?: string;
};
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, uuid?: string }) {
const router = useRouter(); const router = useRouter();
const createMutation = useCreateCorrespondence(); const createMutation = useCreateCorrespondence();
const updateMutation = useUpdateCorrespondence(); const updateMutation = useUpdateCorrespondence();
// Fetch master data for dropdowns // Fetch master data for dropdowns
const { data: projects, isLoading: isLoadingProjects } = useProjects(); const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
const { data: organizations, isLoading: isLoadingOrgs } = useOrganizations(); const { data: organizations, isLoading: isLoadingOrgs } = useOrganizations();
const { data: correspondenceTypes, isLoading: isLoadingTypes } = useCorrespondenceTypes(); const { data: correspondenceTypesData, isLoading: isLoadingTypes } = useCorrespondenceTypes();
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(); const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines();
const projects = extractArrayData<ProjectOption>(projectsData);
const organizationOptions = extractArrayData<Organization>(organizations);
const correspondenceTypes = extractArrayData<CorrespondenceTypeOption>(correspondenceTypesData);
const disciplines = extractArrayData<DisciplineOption>(disciplinesData);
// Extract initial values if editing — ADR-019: use nested relation UUIDs, never raw INT FK columns // Extract initial values if editing
const currentRev = initialData?.revisions?.find((r: CorrespondenceRevision) => r.isCurrent) ?? initialData?.revisions?.[0]; const currentRev = initialData?.revisions?.find((r: any) => r.isCurrent) || initialData?.revisions?.[0];
const defaultValues: Partial<FormData> = { const defaultValues: Partial<FormData> = {
projectId: initialData?.project?.uuid ?? undefined, projectId: initialData?.project?.uuid || (initialData?.projectId ? String(initialData.projectId) : undefined),
documentTypeId: initialData?.correspondenceTypeId || undefined, documentTypeId: initialData?.correspondenceTypeId || undefined,
disciplineId: initialData?.disciplineId || undefined, disciplineId: initialData?.disciplineId || undefined,
subject: currentRev?.subject || "", subject: currentRev?.subject || currentRev?.title || "",
description: currentRev?.description || "", description: currentRev?.description || "",
body: currentRev?.body || "", body: currentRev?.body || "",
remarks: currentRev?.remarks || "", remarks: currentRev?.remarks || "",
dueDate: currentRev?.dueDate ? new Date(currentRev.dueDate).toISOString().split('T')[0] : undefined, dueDate: currentRev?.dueDate ? new Date(currentRev.dueDate).toISOString().split('T')[0] : undefined,
fromOrganizationId: initialData?.originator?.uuid ?? undefined, documentDate: currentRev?.documentDate ? new Date(currentRev.documentDate).toISOString().split('T')[0] : undefined,
toOrganizationId: initialData?.recipients?.find(r => r.recipientType === 'TO')?.recipientOrganization?.uuid ?? undefined, issuedDate: currentRev?.issuedDate ? new Date(currentRev.issuedDate).toISOString().split('T')[0] : undefined,
importance: (currentRev?.details?.['importance'] as "NORMAL" | "HIGH" | "URGENT") ?? "NORMAL", receivedDate: currentRev?.receivedDate ? new Date(currentRev.receivedDate).toISOString().split('T')[0] : undefined,
fromOrganizationId: initialData?.originatorId ? String(initialData.originatorId) : undefined,
// Map initial recipient (TO) - Simplified for now
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId
? String(initialData.recipients.find((r: any) => r.recipientType === 'TO').recipientOrganizationId)
: undefined,
importance: currentRev?.details?.importance || "NORMAL",
}; };
const { const {
@@ -77,7 +127,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
watch, watch,
formState: { errors }, formState: { errors },
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(correspondenceSchema), // @ts-ignore: Zod version mismatch in monorepo
resolver: zodResolver(correspondenceSchema) as any,
defaultValues: defaultValues as FormData, defaultValues: defaultValues as FormData,
}); });
@@ -98,6 +149,9 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
body: data.body, body: data.body,
remarks: data.remarks, remarks: data.remarks,
dueDate: data.dueDate ? new Date(data.dueDate).toISOString() : undefined, dueDate: data.dueDate ? new Date(data.dueDate).toISOString() : undefined,
documentDate: data.documentDate ? new Date(data.documentDate).toISOString() : undefined,
issuedDate: data.issuedDate ? new Date(data.issuedDate).toISOString() : undefined,
receivedDate: data.receivedDate ? new Date(data.receivedDate).toISOString() : undefined,
originatorId: data.fromOrganizationId, originatorId: data.fromOrganizationId,
recipients: [ recipients: [
{ organizationId: data.toOrganizationId, type: 'TO' } { organizationId: data.toOrganizationId, type: 'TO' }
@@ -133,19 +187,14 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
const fetchPreview = async () => { const fetchPreview = async () => {
try { try {
const res = await correspondenceService.previewNumber({ const res = await numberingApi.previewNumber({
projectId, projectId,
typeId: documentTypeId, correspondenceTypeId: documentTypeId,
disciplineId, disciplineId,
originatorId: fromOrgId, originatorOrganizationId: fromOrgId,
// Map recipients structure matching backend expectation recipientOrganizationId: toOrgId
recipients: [{ organizationId: toOrgId, type: 'TO' }],
// Add date just to be safe, though service uses 'now'
dueDate: new Date().toISOString(),
// [Fix] Subject is required by DTO validation, send placeholder if empty
subject: watch('subject') || "Preview Subject"
}); });
setPreview(res); setPreview({ number: res.previewNumber, isDefaultTemplate: res.isDefault });
} catch (err) { } catch (err) {
setPreview(null); setPreview(null);
} }
@@ -217,8 +266,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} /> <SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(projects || []).map((p: ProjectListItem) => ( {projects.map((p) => (
<SelectItem key={p.uuid} value={p.uuid}> <SelectItem key={p.uuid || String(p.id)} value={p.uuid || String(p.id)}>
{p.projectName} ({p.projectCode}) {p.projectName} ({p.projectCode})
</SelectItem> </SelectItem>
))} ))}
@@ -241,7 +290,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
<SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} /> <SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(correspondenceTypes || []).map((t: { id: number; typeName: string; typeCode: string }) => ( {correspondenceTypes.map((t) => (
<SelectItem key={t.id} value={String(t.id)}> <SelectItem key={t.id} value={String(t.id)}>
{t.typeName} ({t.typeCode}) {t.typeName} ({t.typeCode})
</SelectItem> </SelectItem>
@@ -265,7 +314,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} /> <SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(disciplines || []).map((d: { id: number; disciplineCode?: string; codeNameEn?: string }) => ( {disciplines.map((d) => (
<SelectItem key={d.id} value={String(d.id)}> <SelectItem key={d.id} value={String(d.id)}>
{d.codeNameEn || d.disciplineCode} {d.codeNameEn || d.disciplineCode}
</SelectItem> </SelectItem>
@@ -295,11 +344,47 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
/> />
</div> </div>
{/* Remarks & Due Date */} {/* Date Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="remarks">Remarks</Label> <Label htmlFor="documentDate">Document Date</Label>
<Input id="remarks" {...register("remarks")} placeholder="Optional remarks" /> <Input
id="documentDate"
type="date"
{...register("documentDate")}
onChange={(e) => {
const val = e.target.value;
setValue("documentDate", val, { shouldValidate: true, shouldDirty: true });
if (val) {
setValue("issuedDate", val, { shouldValidate: true, shouldDirty: true });
setValue("receivedDate", val, { shouldValidate: true, shouldDirty: true });
const d = new Date(val);
d.setDate(d.getDate() + 7);
setValue("dueDate", d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuedDate">Issued Date</Label>
<Input id="issuedDate" type="date" {...register("issuedDate")} />
</div>
<div className="space-y-2">
<Label htmlFor="receivedDate">Received Date</Label>
<Input
id="receivedDate"
type="date"
{...register("receivedDate")}
onChange={(e) => {
const val = e.target.value;
setValue("receivedDate", val, { shouldValidate: true, shouldDirty: true });
if (val) {
const d = new Date(val);
d.setDate(d.getDate() + 7);
setValue("dueDate", d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
}
}}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="dueDate">Due Date</Label> <Label htmlFor="dueDate">Due Date</Label>
@@ -307,6 +392,14 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
</div> </div>
</div> </div>
{/* Remarks */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="remarks">Remarks</Label>
<Input id="remarks" {...register("remarks")} placeholder="Optional remarks" />
</div>
</div>
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description (Internal Note)</Label> <Label htmlFor="description">Description (Internal Note)</Label>
@@ -331,7 +424,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} /> <SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(organizations || []).map((org: Organization) => ( {organizationOptions.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}> <SelectItem key={org.uuid} value={org.uuid}>
{org.organizationName} ({org.organizationCode}) {org.organizationName} ({org.organizationCode})
</SelectItem> </SelectItem>
@@ -354,7 +447,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Corres
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} /> <SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(organizations || []).map((org: Organization) => ( {organizationOptions.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}> <SelectItem key={org.uuid} value={org.uuid}>
{org.organizationName} ({org.organizationCode}) {org.organizationName} ({org.organizationCode})
</SelectItem> </SelectItem>
@@ -56,6 +56,7 @@ export function TemplateEditor({
}: TemplateEditorProps) { }: TemplateEditorProps) {
const [format, setFormat] = useState(template?.formatTemplate || ''); const [format, setFormat] = useState(template?.formatTemplate || '');
const [typeId, setTypeId] = useState<string>(template?.correspondenceTypeId?.toString() || ''); const [typeId, setTypeId] = useState<string>(template?.correspondenceTypeId?.toString() || '');
const [disciplineId, setDisciplineId] = useState<string>(template?.disciplineId?.toString() || '0');
const [reset, setReset] = useState(template?.resetSequenceYearly ?? true); const [reset, setReset] = useState(template?.resetSequenceYearly ?? true);
const [preview, setPreview] = useState(''); const [preview, setPreview] = useState('');
@@ -89,6 +90,7 @@ export function TemplateEditor({
...template, ...template,
projectId: projectId, projectId: projectId,
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null, correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
disciplineId: Number(disciplineId),
formatTemplate: format, formatTemplate: format,
resetSequenceYearly: reset, resetSequenceYearly: reset,
}); });
@@ -139,6 +141,22 @@ export function TemplateEditor({
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div>
<Label>Discipline (Optional)</Label>
<Select value={disciplineId} onValueChange={setDisciplineId}>
<SelectTrigger>
<SelectValue placeholder="All Disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">All Disciplines</SelectItem>
{disciplines.map((d: any) => (
<SelectItem key={d.id} value={d.id.toString()}>
{d.disciplineCode} - {d.codeNameEn || d.codeNameTh}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div> <div>
<Label>Reset Rule</Label> <Label>Reset Rule</Label>
<div className="flex items-center h-10"> <div className="flex items-center h-10">
@@ -11,6 +11,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering'; import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { import {
Select, Select,
@@ -49,7 +50,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
disciplineId: "", disciplineId: "",
year: new Date().getFullYear(), year: new Date().getFullYear(),
}); });
const [generatedNumber, setGeneratedNumber] = useState(''); const [testResult, setTestResult] = useState<{ number: string; isDefault?: boolean } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Master Data Hooks // Master Data Hooks
@@ -66,18 +67,28 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
const handleGenerate = async () => { const handleGenerate = async () => {
if (!template) return; if (!template) return;
setLoading(true); setLoading(true);
setTestResult(null);
try { try {
const result = await numberingApi.previewNumber({ const payload = {
projectId: projectId, projectId: projectId,
originatorOrganizationId: testData.originatorId || "0", originatorOrganizationId: testData.originatorId || "0",
recipientOrganizationId: testData.recipientId || "0", recipientOrganizationId: testData.recipientId || "0",
correspondenceTypeId: parseInt(testData.correspondenceTypeId || "0"), correspondenceTypeId: parseInt(testData.correspondenceTypeId || "0"),
disciplineId: parseInt(testData.disciplineId || "0"), disciplineId: parseInt(testData.disciplineId || "0"),
year: testData.year
};
console.log("TemplateTester: Sending payload:", payload);
const result = await numberingApi.previewNumber(payload);
console.log("TemplateTester: Received result:", result);
setTestResult({
number: result.previewNumber,
isDefault: result.isDefault
}); });
setGeneratedNumber(result.previewNumber); } catch (error: any) {
} catch (error: unknown) { console.error("Test Preview Error:", error);
const err = error as { response?: { data?: { message?: string } }; message?: string }; const errMsg = error?.response?.data?.message || error?.message || "Unknown error";
setGeneratedNumber(`Error: ${err.response?.data?.message || err.message || "Unknown error"}`); setTestResult({ number: `Error: ${errMsg}`, isDefault: false });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -196,12 +207,24 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
Generate Test Number Generate Test Number
</Button> </Button>
{generatedNumber && ( {testResult && (
<Card className={`p-4 mt-4 border text-center ${generatedNumber.startsWith('Error:') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'}`}> <Card className={`p-4 mt-4 border text-center ${(testResult.number || '').startsWith('Error:') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'}`}>
<p className="text-sm text-muted-foreground mb-1">{generatedNumber.startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}</p> <div className="flex justify-between items-center mb-1">
<p className={`text-2xl font-mono font-bold ${generatedNumber.startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}> <p className="text-sm text-muted-foreground">{(testResult.number || '').startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}</p>
{generatedNumber} {testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
</p> <Badge variant="secondary" className="text-[10px] h-4 px-1">Default Template</Badge>
)}
{!testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
<Badge variant="outline" className="text-[10px] h-4 px-1 border-green-300 text-green-700 bg-green-50">Specific Template</Badge>
)}
</div>
<div className={`text-2xl font-mono font-bold ${(testResult.number || '').startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}>
{testResult.number || (
<div className="text-xs font-mono text-left bg-slate-100 p-2 overflow-auto max-h-48 border rounded text-foreground">
Empty Result. Raw: {JSON.stringify(testResult, null, 2)}
</div>
)}
</div>
</Card> </Card>
)} )}
+72 -60
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import type { RFA, RFAItem } from "@/types/rfa";
import { StatusBadge } from "@/components/common/status-badge"; import { StatusBadge } from "@/components/common/status-badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -9,39 +10,48 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useRouter } from "next/navigation";
import { useProcessRFA } from "@/hooks/use-rfa"; import { useProcessRFA } from "@/hooks/use-rfa";
interface RFADetailItem {
id: number;
itemNo: string;
description: string;
quantity: number;
unit: string;
status?: string;
}
interface RFADetailData {
uuid: string;
rfaNumber: string;
subject: string;
description?: string;
status: string;
createdAt: string;
contractName?: string;
disciplineName?: string;
items: RFADetailItem[];
}
interface RFADetailProps { interface RFADetailProps {
data: RFADetailData; data: RFA;
} }
export function RFADetail({ data }: RFADetailProps) { export function RFADetail({ data }: RFADetailProps) {
const router = useRouter();
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null); const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
const [comments, setComments] = useState(""); const [comments, setComments] = useState("");
const processMutation = useProcessRFA(); const processMutation = useProcessRFA();
const currentRevision = data.revisions.find((revision) => revision.isCurrent) ?? data.revisions[0];
const currentItems = currentRevision?.items ?? [];
const currentStatus = currentRevision?.statusCode?.statusName || currentRevision?.statusCode?.statusCode || "Unknown";
const createdAt = data.correspondence?.createdAt || currentRevision?.createdAt;
const getDrawingNumber = (item: RFAItem) =>
item.shopDrawingRevision?.shopDrawing?.drawingNumber ||
item.asBuiltDrawingRevision?.asBuiltDrawing?.drawingNumber ||
"-";
const getRevisionLabel = (item: RFAItem) => {
if (item.shopDrawingRevision?.revisionLabel) {
return item.shopDrawingRevision.revisionLabel;
}
if (item.shopDrawingRevision?.revisionNumber !== undefined) {
return String(item.shopDrawingRevision.revisionNumber);
}
if (item.asBuiltDrawingRevision?.revisionLabel) {
return item.asBuiltDrawingRevision.revisionLabel;
}
if (item.asBuiltDrawingRevision?.revisionNumber !== undefined) {
return String(item.asBuiltDrawingRevision.revisionNumber);
}
return "-";
};
const getRevisionTitle = (item: RFAItem) =>
item.shopDrawingRevision?.title || item.asBuiltDrawingRevision?.title || "-";
const handleProcess = () => { const handleProcess = () => {
if (!actionState) return; if (!actionState) return;
@@ -77,14 +87,16 @@ export function RFADetail({ data }: RFADetailProps) {
</Button> </Button>
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold">{data.rfaNumber}</h1> <h1 className="text-2xl font-bold">{data.correspondence?.correspondenceNumber || "RFA"}</h1>
<p className="text-muted-foreground"> {createdAt && (
Created on {format(new Date(data.createdAt), "dd MMM yyyy HH:mm")} <p className="text-muted-foreground">
</p> Created on {format(new Date(createdAt), "dd MMM yyyy HH:mm")}
</p>
)}
</div> </div>
</div> </div>
{data.status === "PENDING" && ( {currentStatus === "PENDING" && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
@@ -144,15 +156,15 @@ export function RFADetail({ data }: RFADetailProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<CardTitle className="text-xl">{data.subject}</CardTitle> <CardTitle className="text-xl">{currentRevision?.subject || "Untitled RFA"}</CardTitle>
<StatusBadge status={data.status} /> <StatusBadge status={currentStatus} />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div> <div>
<h3 className="font-semibold mb-2">Description</h3> <h3 className="font-semibold mb-2">Description</h3>
<p className="text-gray-700 whitespace-pre-wrap"> <p className="text-gray-700 whitespace-pre-wrap">
{data.description || "No description provided."} {currentRevision?.description || "No description provided."}
</p> </p>
</div> </div>
@@ -160,32 +172,32 @@ export function RFADetail({ data }: RFADetailProps) {
<div> <div>
<h3 className="font-semibold mb-3">RFA Items</h3> <h3 className="font-semibold mb-3">RFA Items</h3>
<div className="border rounded-lg overflow-hidden"> {currentItems.length === 0 ? (
<table className="w-full text-sm"> <p className="text-sm text-muted-foreground">No drawing items linked to this RFA.</p>
<thead className="bg-muted/50"> ) : (
<tr> <div className="border rounded-lg overflow-hidden">
<th className="px-4 py-3 text-left font-medium">Item No.</th> <table className="w-full text-sm">
<th className="px-4 py-3 text-left font-medium">Description</th> <thead className="bg-muted/50">
<th className="px-4 py-3 text-right font-medium">Qty</th> <tr>
<th className="px-4 py-3 text-left font-medium">Unit</th> <th className="px-4 py-3 text-left font-medium">Type</th>
<th className="px-4 py-3 text-left font-medium">Status</th> <th className="px-4 py-3 text-left font-medium">Drawing No.</th>
</tr> <th className="px-4 py-3 text-left font-medium">Revision</th>
</thead> <th className="px-4 py-3 text-left font-medium">Title</th>
<tbody className="divide-y">
{data.items.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3 font-medium">{item.itemNo}</td>
<td className="px-4 py-3">{item.description}</td>
<td className="px-4 py-3 text-right">{item.quantity}</td>
<td className="px-4 py-3 text-muted-foreground">{item.unit}</td>
<td className="px-4 py-3">
<StatusBadge status={item.status || "PENDING"} className="text-[10px] px-2 py-0.5 h-5" />
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody className="divide-y">
</table> {currentItems.map((item) => (
</div> <tr key={item.id}>
<td className="px-4 py-3 font-medium">{item.itemType}</td>
<td className="px-4 py-3">{getDrawingNumber(item)}</td>
<td className="px-4 py-3">{getRevisionLabel(item)}</td>
<td className="px-4 py-3 text-muted-foreground">{getRevisionTitle(item)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -199,15 +211,15 @@ export function RFADetail({ data }: RFADetailProps) {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Contract</p> <p className="text-sm font-medium text-muted-foreground">Project</p>
<p className="font-medium mt-1">{data.contractName}</p> <p className="font-medium mt-1">{data.correspondence?.project?.projectName || "-"}</p>
</div> </div>
<hr className="my-4 border-t" /> <hr className="my-4 border-t" />
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Discipline</p> <p className="text-sm font-medium text-muted-foreground">Discipline</p>
<p className="font-medium mt-1">{data.disciplineName}</p> <p className="font-medium mt-1">{data.discipline?.name || data.discipline?.code || "-"}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+506 -122
View File
@@ -1,14 +1,15 @@
"use client"; "use client";
import { useForm, useFieldArray } from "react-hook-form"; import { useForm, type SubmitErrorHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Plus, Trash2, Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -18,18 +19,14 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCreateRFA } from "@/hooks/use-rfa"; import { useCreateRFA } from "@/hooks/use-rfa";
import { useDisciplines, useContracts } from "@/hooks/use-master-data"; import { useDrawings } from "@/hooks/use-drawing";
import { useDisciplines, useContracts, useOrganizations } from "@/hooks/use-master-data";
import { useCorrespondenceTypes, useRfaTypes } from "@/hooks/use-reference-data";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
import { CreateRfaDto } from "@/types/dto/rfa/rfa.dto"; import { CreateRfaDto } from "@/types/dto/rfa/rfa.dto";
import { useState, useEffect } from "react"; import { useState, useEffect, type FormEvent } from "react";
import { correspondenceService } from "@/lib/services/correspondence.service"; import { correspondenceService } from "@/lib/services/correspondence.service";
const rfaItemSchema = z.object({
itemNo: z.string().min(1, "Item No is required"),
description: z.string().min(3, "Description is required"),
quantity: z.number().min(0, "Quantity must be positive"),
unit: z.string().min(1, "Unit is required"),
});
const rfaSchema = z.object({ const rfaSchema = z.object({
projectId: z.string().min(1, "Project is required"), // ADR-019: UUID projectId: z.string().min(1, "Project is required"), // ADR-019: UUID
contractId: z.string().min(1, "Contract is required"), contractId: z.string().min(1, "Contract is required"),
@@ -41,25 +38,137 @@ const rfaSchema = z.object({
remarks: z.string().optional(), remarks: z.string().optional(),
toOrganizationId: z.string().min(1, "Please select To Organization"), toOrganizationId: z.string().min(1, "Please select To Organization"),
dueDate: z.string().optional(), dueDate: z.string().optional(),
shopDrawingRevisionIds: z.array(z.number()).optional(), shopDrawingRevisionIds: z.array(z.string()).optional(),
items: z.array(rfaItemSchema).min(1, "At least one item is required"), asBuiltDrawingRevisionIds: z.array(z.string()).optional(),
}); });
type RFAFormData = z.infer<typeof rfaSchema>; type RFAFormData = z.infer<typeof rfaSchema>;
type ProjectOption = {
uuid?: string;
id?: number;
projectName?: string;
projectCode?: string;
};
type ContractOption = {
uuid?: string;
id?: number;
contractName?: string;
name?: string;
contractCode?: string;
};
type DisciplineOption = {
id: number;
disciplineCode: string;
codeNameEn?: string;
codeNameTh?: string;
};
type RfaTypeOption = {
id: number;
typeCode?: string;
typeName?: string;
typeNameEn?: string;
typeNameTh?: string;
};
type CorrespondenceTypeOption = {
id: number;
typeCode?: string;
typeName?: string;
};
type OrganizationOption = {
uuid?: string;
id?: number;
organizationCode?: string;
organizationName?: string;
};
type SelectableDrawingOption = {
uuid?: string;
drawingNumber?: string;
title?: string;
legacyDrawingNumber?: string;
currentRevisionUuid?: string;
currentRevision?: {
uuid?: string;
revisionLabel?: string;
revisionNumber?: number | string;
title?: string;
legacyDrawingNumber?: string;
};
};
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const dedupeByKey = <T,>(items: T[], getKey: (item: T) => string | number | undefined): T[] => {
const seen = new Set<string | number>();
return items.filter((item) => {
const key = getKey(item);
if (key === undefined || key === "" || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
const getOptionValue = (value?: string | number): string | undefined => {
if (value === undefined || value === null || value === "") {
return undefined;
}
return String(value);
};
export function RFAForm() { export function RFAForm() {
const router = useRouter(); const router = useRouter();
const createMutation = useCreateRFA(); const createMutation = useCreateRFA();
// ADR-019: Dynamic project selection
const { data: projectsData, isLoading: isLoadingProjects } = useProjects(); const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
const projects = projectsData?.data || projectsData || []; const projects = dedupeByKey(
extractArrayData<ProjectOption>(projectsData),
(project) => project.uuid ?? project.id
);
const { data: organizationsData, isLoading: isLoadingOrganizations } = useOrganizations({ isActive: true });
const organizations = dedupeByKey(
extractArrayData<OrganizationOption>(organizationsData),
(organization) => organization.uuid ?? organization.id
);
const { data: correspondenceTypesData } = useCorrespondenceTypes();
const correspondenceTypes = extractArrayData<CorrespondenceTypeOption>(correspondenceTypesData);
const rfaCorrespondenceType = correspondenceTypes.find(
(type) => type.typeCode?.toUpperCase() === "RFA"
);
const { const {
register, register,
control,
handleSubmit, handleSubmit,
setValue, setValue,
setError,
clearErrors,
watch, watch,
formState: { errors }, formState: { errors },
} = useForm<RFAFormData>({ } = useForm<RFAFormData>({
@@ -76,26 +185,89 @@ export function RFAForm() {
toOrganizationId: "", toOrganizationId: "",
dueDate: "", dueDate: "",
shopDrawingRevisionIds: [], shopDrawingRevisionIds: [],
items: [{ itemNo: "1", description: "", quantity: 0, unit: "" }], asBuiltDrawingRevisionIds: [],
}, },
}); });
const selectedProjectId = watch("projectId"); const selectedProjectId = watch("projectId");
const { data: contracts, isLoading: isLoadingContracts } = useContracts(selectedProjectId); const { data: contractsData, isLoading: isLoadingContracts } = useContracts(selectedProjectId);
const contracts = dedupeByKey(
extractArrayData<ContractOption>(contractsData),
(contract) => contract.uuid ?? contract.id
);
const selectedContractId = watch("contractId"); const selectedContractId = watch("contractId");
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId); const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
const disciplines = dedupeByKey(extractArrayData<DisciplineOption>(disciplinesData), (discipline) => discipline.id);
const { data: rfaTypesData, isLoading: isLoadingRfaTypes } = useRfaTypes(selectedContractId);
const rfaTypes = dedupeByKey(extractArrayData<RfaTypeOption>(rfaTypesData), (rfaType) => rfaType.id);
const [shopDrawingSearch, setShopDrawingSearch] = useState("");
const [shopDrawingPage, setShopDrawingPage] = useState(1);
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings("SHOP", {
projectUuid: selectedProjectId || "",
search: shopDrawingSearch,
page: shopDrawingPage,
limit: 10,
});
const shopDrawings = dedupeByKey(
extractArrayData<SelectableDrawingOption>(shopDrawingsData),
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid
);
const [asBuiltDrawingSearch, setAsBuiltDrawingSearch] = useState("");
const [asBuiltDrawingPage, setAsBuiltDrawingPage] = useState(1);
const { data: asBuiltDrawingsData, isLoading: isLoadingAsBuiltDrawings } = useDrawings("AS_BUILT", {
projectUuid: selectedProjectId || "",
search: asBuiltDrawingSearch,
page: asBuiltDrawingPage,
limit: 10,
});
const asBuiltDrawings = dedupeByKey(
extractArrayData<SelectableDrawingOption>(asBuiltDrawingsData),
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid
);
const selectedDisciplineId = watch("disciplineId");
// Watch fields for preview
const rfaTypeId = watch("rfaTypeId"); const rfaTypeId = watch("rfaTypeId");
const disciplineId = watch("disciplineId"); const disciplineId = watch("disciplineId");
const toOrganizationId = watch("toOrganizationId"); const toOrganizationId = watch("toOrganizationId");
const selectedShopDrawingRevisionIds = watch("shopDrawingRevisionIds") ?? [];
const selectedAsBuiltDrawingRevisionIds = watch("asBuiltDrawingRevisionIds") ?? [];
const selectedRfaType = rfaTypes.find((rfaType) => rfaType.id === rfaTypeId);
const selectedRfaTypeCode = selectedRfaType?.typeCode?.toUpperCase();
const requiresShopDrawings = selectedRfaTypeCode === "DDW" || selectedRfaTypeCode === "SDW";
const requiresAsBuiltDrawings = selectedRfaTypeCode === "ADW";
useEffect(() => {
// Reset page and search when project changes
setShopDrawingPage(1);
setShopDrawingSearch("");
setAsBuiltDrawingPage(1);
setAsBuiltDrawingSearch("");
if (requiresShopDrawings) {
setValue("asBuiltDrawingRevisionIds", []);
clearErrors("asBuiltDrawingRevisionIds");
return;
}
if (requiresAsBuiltDrawings) {
setValue("shopDrawingRevisionIds", []);
clearErrors("shopDrawingRevisionIds");
return;
}
setValue("shopDrawingRevisionIds", []);
setValue("asBuiltDrawingRevisionIds", []);
clearErrors("shopDrawingRevisionIds");
clearErrors("asBuiltDrawingRevisionIds");
}, [requiresShopDrawings, requiresAsBuiltDrawings, selectedProjectId, setValue, clearErrors]);
// -- Preview Logic -- // -- Preview Logic --
const [preview, setPreview] = useState<{ number: string; isDefaultTemplate: boolean } | null>(null); const [preview, setPreview] = useState<{ number: string; isDefaultTemplate: boolean } | null>(null);
useEffect(() => { useEffect(() => {
if (!rfaTypeId || !disciplineId || !toOrganizationId) { if (!selectedProjectId || !rfaCorrespondenceType?.id || !rfaTypeId || !disciplineId || !toOrganizationId) {
setPreview(null); setPreview(null);
return; return;
} }
@@ -104,11 +276,10 @@ export function RFAForm() {
try { try {
const res = await correspondenceService.previewNumber({ const res = await correspondenceService.previewNumber({
projectId: selectedProjectId, projectId: selectedProjectId,
typeId: rfaTypeId, // RfaTypeId acts as TypeId typeId: rfaCorrespondenceType.id,
disciplineId, disciplineId,
// RFA uses 'TO' organization as recipient
recipients: [{ organizationId: toOrganizationId, type: 'TO' }], recipients: [{ organizationId: toOrganizationId, type: 'TO' }],
dueDate: new Date().toISOString() subject: watch("subject") || "Preview Subject"
}); });
setPreview(res); setPreview(res);
} catch (err) { } catch (err) {
@@ -118,17 +289,32 @@ export function RFAForm() {
const timer = setTimeout(fetchPreview, 500); const timer = setTimeout(fetchPreview, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId]); }, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId, rfaCorrespondenceType?.id, watch]);
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
const onSubmit = (data: RFAFormData) => { const onSubmit = (data: RFAFormData) => {
if (requiresShopDrawings && data.shopDrawingRevisionIds?.length === 0) {
setError("shopDrawingRevisionIds", {
type: "manual",
message: "Please select at least one Shop Drawing Revision",
});
return;
}
if (requiresAsBuiltDrawings && data.asBuiltDrawingRevisionIds?.length === 0) {
setError("asBuiltDrawingRevisionIds", {
type: "manual",
message: "Please select at least one As-Built Drawing Revision",
});
return;
}
clearErrors("shopDrawingRevisionIds");
clearErrors("asBuiltDrawingRevisionIds");
const payload: CreateRfaDto = { const payload: CreateRfaDto = {
...data, ...data,
// ADR-019: projectId is already a UUID string from the form shopDrawingRevisionIds: requiresShopDrawings ? data.shopDrawingRevisionIds : undefined,
asBuiltDrawingRevisionIds: requiresAsBuiltDrawings ? data.asBuiltDrawingRevisionIds : undefined,
}; };
createMutation.mutate(payload, { createMutation.mutate(payload, {
onSuccess: () => { onSuccess: () => {
@@ -137,9 +323,14 @@ export function RFAForm() {
}); });
}; };
const onInvalidSubmit: SubmitErrorHandler<RFAFormData> = () => undefined;
const submitForm = handleSubmit(onSubmit, onInvalidSubmit);
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
void submitForm(event).catch(() => undefined);
};
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl space-y-6"> <form onSubmit={handleFormSubmit} className="max-w-4xl space-y-6">
{/* Preview Section */}
{preview && ( {preview && (
<Card className="p-4 bg-muted border-l-4 border-l-primary"> <Card className="p-4 bg-muted border-l-4 border-l-primary">
<p className="text-sm text-muted-foreground mb-1">Document Number Preview</p> <p className="text-sm text-muted-foreground mb-1">Document Number Preview</p>
@@ -154,7 +345,6 @@ export function RFAForm() {
</Card> </Card>
)} )}
{/* Basic Info */}
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold mb-4">RFA Information</h3> <h3 className="text-lg font-semibold mb-4">RFA Information</h3>
@@ -184,13 +374,17 @@ export function RFAForm() {
<Input id="description" {...register("description")} placeholder="Enter key description" /> <Input id="description" {...register("description")} placeholder="Enter key description" />
</div> </div>
{/* ADR-019: Project selector */}
<div> <div>
<Label>Project *</Label> <Label>Project *</Label>
<Select <Select
value={selectedProjectId || undefined}
onValueChange={(val) => { onValueChange={(val) => {
setValue("projectId", val); setValue("projectId", val);
setValue("contractId", ""); // Reset contract when project changes setValue("contractId", "");
setValue("disciplineId", 0);
setValue("rfaTypeId", 0);
setValue("shopDrawingRevisionIds", []);
setValue("asBuiltDrawingRevisionIds", []);
}} }}
disabled={isLoadingProjects} disabled={isLoadingProjects}
> >
@@ -198,11 +392,19 @@ export function RFAForm() {
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} /> <SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(Array.isArray(projects) ? projects : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => ( {projects.map((p) => {
<SelectItem key={p.uuid} value={p.uuid}> const projectValue = getOptionValue(p.uuid ?? p.id);
if (!projectValue) {
return null;
}
return (
<SelectItem key={projectValue} value={projectValue}>
{p.projectName || p.projectCode} {p.projectName || p.projectCode}
</SelectItem> </SelectItem>
))} );
})}
</SelectContent> </SelectContent>
</Select> </Select>
{errors.projectId && ( {errors.projectId && (
@@ -214,18 +416,33 @@ export function RFAForm() {
<div> <div>
<Label>Contract *</Label> <Label>Contract *</Label>
<Select <Select
onValueChange={(val) => setValue("contractId", val)} value={selectedContractId || undefined}
onValueChange={(val) => {
setValue("contractId", val);
setValue("disciplineId", 0);
setValue("rfaTypeId", 0);
setValue("shopDrawingRevisionIds", []);
setValue("asBuiltDrawingRevisionIds", []);
}}
disabled={!selectedProjectId || isLoadingContracts} disabled={!selectedProjectId || isLoadingContracts}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={isLoadingContracts ? "Loading..." : "Select Contract"} /> <SelectValue placeholder={isLoadingContracts ? "Loading..." : "Select Contract"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{contracts?.map((c: { uuid: string; contractName?: string; name?: string; contractCode?: string }) => ( {contracts.map((c) => {
<SelectItem key={c.uuid} value={c.uuid}> const contractValue = getOptionValue(c.uuid ?? c.id);
if (!contractValue) {
return null;
}
return (
<SelectItem key={contractValue} value={contractValue}>
{c.contractName || c.name || c.contractCode} {c.contractName || c.name || c.contractCode}
</SelectItem> </SelectItem>
))} );
})}
</SelectContent> </SelectContent>
</Select> </Select>
{errors.contractId && ( {errors.contractId && (
@@ -236,6 +453,7 @@ export function RFAForm() {
<div> <div>
<Label>Discipline *</Label> <Label>Discipline *</Label>
<Select <Select
value={selectedDisciplineId > 0 ? String(selectedDisciplineId) : undefined}
onValueChange={(val) => setValue("disciplineId", Number(val))} onValueChange={(val) => setValue("disciplineId", Number(val))}
disabled={!selectedContractId || isLoadingDisciplines} disabled={!selectedContractId || isLoadingDisciplines}
> >
@@ -243,12 +461,12 @@ export function RFAForm() {
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline"} /> <SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{disciplines?.map((d: { id: number; disciplineCode: string; codeNameEn?: string; codeNameTh?: string }) => ( {disciplines.map((d) => (
<SelectItem key={d.id} value={String(d.id)}> <SelectItem key={d.id} value={String(d.id)}>
{d.codeNameEn || d.codeNameTh || d.disciplineCode} ({d.disciplineCode}) {`${d.codeNameEn || d.codeNameTh || d.disciplineCode} (${d.disciplineCode})`}
</SelectItem> </SelectItem>
))} ))}
{!isLoadingDisciplines && !disciplines?.length && ( {!isLoadingDisciplines && disciplines.length === 0 && (
<SelectItem value="0" disabled>No disciplines found</SelectItem> <SelectItem value="0" disabled>No disciplines found</SelectItem>
)} )}
</SelectContent> </SelectContent>
@@ -258,96 +476,262 @@ export function RFAForm() {
)} )}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>RFA Type *</Label>
<Select
value={rfaTypeId > 0 ? String(rfaTypeId) : undefined}
onValueChange={(val) => {
setValue("rfaTypeId", Number(val));
setValue("shopDrawingRevisionIds", []);
setValue("asBuiltDrawingRevisionIds", []);
}}
disabled={!selectedContractId || isLoadingRfaTypes}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingRfaTypes ? "Loading..." : "Select RFA Type"} />
</SelectTrigger>
<SelectContent>
{rfaTypes.map((rfaType) => (
<SelectItem key={rfaType.id} value={String(rfaType.id)}>
{`${rfaType.typeCode || "RFA"} - ${rfaType.typeName || rfaType.typeNameEn || rfaType.typeNameTh || "Unnamed Type"}`}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.rfaTypeId && (
<p className="text-sm text-destructive mt-1">{errors.rfaTypeId.message}</p>
)}
</div>
<div>
<Label>To Organization *</Label>
<Select
value={toOrganizationId || undefined}
onValueChange={(val) => setValue("toOrganizationId", val)}
disabled={isLoadingOrganizations}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingOrganizations ? "Loading..." : "Select To Organization"} />
</SelectTrigger>
<SelectContent>
{organizations.map((organization) => {
const organizationValue = getOptionValue(organization.uuid ?? organization.id);
if (!organizationValue) {
return null;
}
return (
<SelectItem key={organizationValue} value={organizationValue}>
{`${organization.organizationCode || "ORG"} - ${organization.organizationName || "Unnamed Organization"}`}
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.toOrganizationId && (
<p className="text-sm text-destructive mt-1">{errors.toOrganizationId.message}</p>
)}
</div>
</div>
</div> </div>
</Card> </Card>
{/* RFA Items */} {(requiresShopDrawings || requiresAsBuiltDrawings) && (
<Card className="p-6"> <Card className="p-6">
<div className="flex justify-between items-center mb-4"> <h3 className="text-lg font-semibold mb-4">New Item</h3>
<h3 className="text-lg font-semibold">RFA Items</h3> <div className="space-y-3">
<Button <p className="text-sm text-muted-foreground">
type="button" {requiresShopDrawings
variant="outline" ? "RFA Type นี้ต้องอ้างอิง Shop Drawing Revision อย่างน้อย 1 รายการ"
size="sm" : "RFA Type นี้ต้องอ้างอิง As-Built Drawing Revision อย่างน้อย 1 รายการ"}
onClick={() => </p>
append({
itemNo: (fields.length + 1).toString(),
description: "",
quantity: 0,
unit: "",
})
}
>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
<div className="space-y-4"> {requiresShopDrawings && (
{fields.map((field, index) => ( <div className="space-y-3">
<Card key={field.id} className="p-4 bg-muted/20"> <div className="flex items-center gap-2 mb-2">
<div className="flex justify-between items-start mb-3"> <Input
<h4 className="font-medium text-sm">Item #{index + 1}</h4> placeholder="ค้นหาตาม Drawing Number..."
{fields.length > 1 && ( value={shopDrawingSearch}
<Button onChange={(e) => {
type="button" setShopDrawingSearch(e.target.value);
variant="ghost" setShopDrawingPage(1);
size="sm" }}
className="h-8 w-8 p-0 text-destructive hover:text-destructive" className="max-w-xs"
onClick={() => remove(index)} />
> </div>
<Trash2 className="h-4 w-4" />
</Button> {isLoadingShopDrawings && (
<p className="text-sm text-muted-foreground">Loading Shop Drawings...</p>
)}
{!isLoadingShopDrawings && shopDrawings.length === 0 && (
<p className="text-sm text-muted-foreground">No Shop Drawings found for the selected project.</p>
)}
<div className="grid grid-cols-1 gap-3">
{shopDrawings.map((drawing) => {
const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid;
if (!revisionUuid) {
return null;
}
return (
<label
key={revisionUuid}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
>
<Checkbox
checked={selectedShopDrawingRevisionIds.includes(revisionUuid)}
onCheckedChange={(checked) => {
const nextValues = checked === true
? [...selectedShopDrawingRevisionIds, revisionUuid]
: selectedShopDrawingRevisionIds.filter((value) => value !== revisionUuid);
setValue("shopDrawingRevisionIds", nextValues, { shouldDirty: true, shouldValidate: true });
clearErrors("shopDrawingRevisionIds");
}}
/>
<div className="space-y-1">
<p className="font-medium">{drawing.drawingNumber || "Unnamed Shop Drawing"}</p>
<p className="text-sm text-muted-foreground">{drawing.currentRevision?.title || drawing.title || "Untitled Revision"}</p>
<p className="text-xs text-muted-foreground">
Revision {drawing.currentRevision?.revisionLabel || drawing.currentRevision?.revisionNumber || "-"}
{drawing.currentRevision?.legacyDrawingNumber ? ` • Legacy ${drawing.currentRevision.legacyDrawingNumber}` : ""}
</p>
</div>
</label>
);
})}
</div>
{shopDrawingsData?.meta && shopDrawingsData.meta.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-xs text-muted-foreground">
Page {shopDrawingPage} of {shopDrawingsData.meta.totalPages}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={shopDrawingPage === 1 || isLoadingShopDrawings}
onClick={() => setShopDrawingPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={shopDrawingPage >= shopDrawingsData.meta.totalPages || isLoadingShopDrawings}
onClick={() => setShopDrawingPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
{errors.shopDrawingRevisionIds && (
<p className="text-sm text-destructive mt-2">{errors.shopDrawingRevisionIds.message}</p>
)} )}
</div> </div>
)}
<div className="grid grid-cols-1 md:grid-cols-12 gap-3"> {requiresAsBuiltDrawings && (
<div className="md:col-span-2"> <div className="space-y-3">
<Label className="text-xs">Item No.</Label> <div className="flex items-center gap-2 mb-2">
<Input {...register(`items.${index}.itemNo`)} placeholder="1.1" />
{errors.items?.[index]?.itemNo && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.itemNo?.message}</p>
)}
</div>
<div className="md:col-span-6">
<Label className="text-xs">Description *</Label>
<Input {...register(`items.${index}.description`)} placeholder="Item description" />
{errors.items?.[index]?.description && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.description?.message}</p>
)}
</div>
<div className="md:col-span-2">
<Label className="text-xs">Quantity</Label>
<Input <Input
type="number" placeholder="ค้นหาตาม Drawing Number..."
{...register(`items.${index}.quantity`, { value={asBuiltDrawingSearch}
valueAsNumber: true, onChange={(e) => {
})} setAsBuiltDrawingSearch(e.target.value);
setAsBuiltDrawingPage(1);
}}
className="max-w-xs"
/> />
{errors.items?.[index]?.quantity && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.quantity?.message}</p>
)}
</div> </div>
<div className="md:col-span-2">
<Label className="text-xs">Unit</Label> {isLoadingAsBuiltDrawings && (
<Input {...register(`items.${index}.unit`)} placeholder="pcs, m3" /> <p className="text-sm text-muted-foreground">Loading As-Built Drawings...</p>
{errors.items?.[index]?.unit && ( )}
<p className="text-xs text-destructive mt-1">{errors.items[index]?.unit?.message}</p> {!isLoadingAsBuiltDrawings && asBuiltDrawings.length === 0 && (
)} <p className="text-sm text-muted-foreground">No As-Built Drawings found for the selected project.</p>
)}
<div className="grid grid-cols-1 gap-3">
{asBuiltDrawings.map((drawing) => {
const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid;
if (!revisionUuid) {
return null;
}
return (
<label
key={revisionUuid}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
>
<Checkbox
checked={selectedAsBuiltDrawingRevisionIds.includes(revisionUuid)}
onCheckedChange={(checked) => {
const nextValues = checked === true
? [...selectedAsBuiltDrawingRevisionIds, revisionUuid]
: selectedAsBuiltDrawingRevisionIds.filter((value) => value !== revisionUuid);
setValue("asBuiltDrawingRevisionIds", nextValues, { shouldDirty: true, shouldValidate: true });
clearErrors("asBuiltDrawingRevisionIds");
}}
/>
<div className="space-y-1">
<p className="font-medium">{drawing.drawingNumber || "Unnamed As-Built Drawing"}</p>
<p className="text-sm text-muted-foreground">{drawing.currentRevision?.title || drawing.title || "Untitled Revision"}</p>
<p className="text-xs text-muted-foreground">
Revision {drawing.currentRevision?.revisionLabel || drawing.currentRevision?.revisionNumber || "-"}
{drawing.currentRevision?.legacyDrawingNumber ? ` • Legacy ${drawing.currentRevision.legacyDrawingNumber}` : ""}
</p>
</div>
</label>
);
})}
</div> </div>
{asBuiltDrawingsData?.meta && asBuiltDrawingsData.meta.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-xs text-muted-foreground">
Page {asBuiltDrawingPage} of {asBuiltDrawingsData.meta.totalPages}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={asBuiltDrawingPage === 1 || isLoadingAsBuiltDrawings}
onClick={() => setAsBuiltDrawingPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={asBuiltDrawingPage >= asBuiltDrawingsData.meta.totalPages || isLoadingAsBuiltDrawings}
onClick={() => setAsBuiltDrawingPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
{errors.asBuiltDrawingRevisionIds && (
<p className="text-sm text-destructive mt-2">{errors.asBuiltDrawingRevisionIds.message}</p>
)}
</div> </div>
</Card> )}
))} </div>
</div> </Card>
)}
{errors.items?.root && (
<p className="text-sm text-destructive mt-2">
{errors.items.root.message}
</p>
)}
</Card>
{/* Actions */}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => router.back()}> <Button type="button" variant="outline" onClick={() => router.back()}>
Cancel Cancel
+4 -2
View File
@@ -74,8 +74,10 @@ export function RFAList({ data }: RFAListProps) {
const handleViewFile = (e: React.MouseEvent) => { const handleViewFile = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
// Logic to find first attachment: Check items -> shopDrawingRevision -> attachments const firstItem = item.revisions?.[0]?.items?.[0];
const firstAttachment = item.revisions?.[0]?.items?.[0]?.shopDrawingRevision?.attachments?.[0]; const firstAttachment =
firstItem?.shopDrawingRevision?.attachments?.[0] ||
firstItem?.asBuiltDrawingRevision?.attachments?.[0];
if (firstAttachment?.url) { if (firstAttachment?.url) {
window.open(firstAttachment.url, '_blank'); window.open(firstAttachment.url, '_blank');
} else { } else {
+188 -77
View File
@@ -20,6 +20,52 @@ import 'reactflow/dist/style.css';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Plus, Download, Save, Layout } from 'lucide-react'; import { Plus, Download, Save, Layout } from 'lucide-react';
interface WorkflowStateNodeData {
label?: string;
name?: string;
role?: string;
type?: string;
}
interface RawTransitionShape {
to?: string;
target?: string;
require?: {
role?: string | string[];
};
}
interface RawStateShape {
id?: string;
name: string;
type?: string;
role?: string;
initial?: boolean;
terminal?: boolean;
on?: Record<string, RawTransitionShape>;
}
interface CompiledTransitionShape {
to?: string;
target?: string;
requirements?: {
roles?: string[];
};
}
interface CompiledStateShape {
initial?: boolean;
terminal?: boolean;
transitions?: Record<string, CompiledTransitionShape>;
}
interface ParsedDslShape {
workflow?: string;
initialState?: string;
states?: RawStateShape[] | Record<string, CompiledStateShape>;
dslDefinition?: string;
}
// Define custom node styles (simplified for now) // Define custom node styles (simplified for now)
const nodeStyle = { const nodeStyle = {
padding: '10px 20px', padding: '10px 20px',
@@ -55,75 +101,145 @@ const initialNodes: Node[] = [
interface VisualWorkflowBuilderProps { interface VisualWorkflowBuilderProps {
initialNodes?: Node[]; initialNodes?: Node[];
initialEdges?: Edge[]; initialEdges?: Edge[];
dslString?: string; // New prop dslString?: string;
onSave?: (nodes: Node[], edges: Edge[]) => void; onSave?: (nodes: Node[], edges: Edge[]) => void;
onDslChange?: (dsl: string) => void; onDslChange?: (dsl: string) => void;
} }
const createNode = (
name: string,
yOffset: number,
options?: {
isCondition?: boolean;
isStart?: boolean;
isEnd?: boolean;
role?: string;
type?: string;
}
): Node<WorkflowStateNodeData> => {
const isCondition = options?.isCondition === true;
const isStart = options?.isStart === true;
const isEnd = options?.isEnd === true;
let nodeType: Node['type'] = 'default';
let style = { ...nodeStyle };
if (isStart) {
nodeType = 'input';
style = { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' };
} else if (isEnd) {
nodeType = 'output';
style = { ...nodeStyle, background: '#ef4444', color: 'white', border: 'none' };
} else if (isCondition) {
style = conditionNodeStyle;
}
return {
id: name,
type: nodeType,
data: {
label: isStart || isEnd ? name : `${name}\n(${options?.role || 'No Role'})`,
name,
role: options?.role,
type: options?.type || (isStart ? 'START' : isEnd ? 'END' : 'TASK')
},
position: { x: 250, y: yOffset },
style
};
};
const createEdge = (source: string, target: string, label: string): Edge => ({
id: `e-${source}-${label}-${target}`,
source,
target,
label,
markerEnd: { type: MarkerType.ArrowClosed }
});
function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } { function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } {
const nodes: Node[] = []; const nodes: Node[] = [];
const edges: Edge[] = []; const edges: Edge[] = [];
let yOffset = 50; let yOffset = 50;
try { try {
const parsedDsl = JSON.parse(dsl); const parsedDsl = JSON.parse(dsl) as ParsedDslShape;
const states = parsedDsl.states || [];
states.forEach((state: { id?: string, name: string, type?: string, role?: string, initial?: boolean, terminal?: boolean, on?: Record<string, { to: string }> }) => { if (typeof parsedDsl.dslDefinition === 'string') {
const isCondition = state.type === 'CONDITION'; return parseDSL(parsedDsl.dslDefinition);
const isStart = state.initial === true || state.type === 'START';
const isEnd = state.terminal === true || state.type === 'END';
let nodeType = 'default';
let style = { ...nodeStyle };
if (isStart) {
nodeType = 'input';
style = { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' };
} else if (isEnd) {
nodeType = 'output';
style = { ...nodeStyle, background: '#ef4444', color: 'white', border: 'none' };
} else if (isCondition) {
style = conditionNodeStyle;
}
nodes.push({
id: state.name || state.id || `node-${Date.now()}`,
type: nodeType,
data: {
label: isStart || isEnd ? state.name : `${state.name}\n(${state.role || 'No Role'})`,
name: state.name,
role: state.role,
type: state.type || (isStart ? 'START' : isEnd ? 'END' : 'TASK')
},
position: { x: 250, y: yOffset },
style: style
});
if (state.on) {
const transitions = state.on;
Object.keys(transitions).forEach((eventName) => {
const trans = transitions[eventName];
if (trans && trans.to) {
edges.push({
id: `e-${state.name || state.id || 'node'}-${trans.to}`,
source: state.name || state.id || 'node',
target: trans.to,
label: eventName,
markerEnd: { type: MarkerType.ArrowClosed }
});
}
});
}
yOffset += 120;
});
} catch (e) {
// Failed to parse DSL as JSON - nodes/edges remain empty
} }
return { nodes, edges }; if (Array.isArray(parsedDsl.states)) {
parsedDsl.states.forEach((state) => {
const stateName = state.name || state.id || `node-${Date.now()}`;
const role =
state.role ||
(Array.isArray(state.on?.SUBMIT?.require?.role)
? state.on?.SUBMIT?.require?.role.join(', ')
: state.on?.SUBMIT?.require?.role);
const isCondition = state.type === 'CONDITION';
const isStart = state.initial === true || state.type === 'START';
const isEnd = state.terminal === true || state.type === 'END';
nodes.push(
createNode(stateName, yOffset, {
isCondition,
isStart,
isEnd,
role,
type: state.type
})
);
if (state.on) {
Object.entries(state.on).forEach(([eventName, transition]) => {
const target = transition?.to || transition?.target;
if (target) {
edges.push(createEdge(stateName, target, eventName));
}
});
}
yOffset += 120;
});
return { nodes, edges };
}
if (parsedDsl.states && typeof parsedDsl.states === 'object') {
Object.entries(parsedDsl.states).forEach(([stateName, state]) => {
const roles = state.transitions
? Object.values(state.transitions)
.flatMap((transition) => transition.requirements?.roles || [])
.filter((role, index, array) => array.indexOf(role) === index)
: [];
const isStart = parsedDsl.initialState === stateName || state.initial === true;
const isEnd = state.terminal === true;
nodes.push(
createNode(stateName, yOffset, {
isStart,
isEnd,
role: roles.join(', ')
})
);
if (state.transitions) {
Object.entries(state.transitions).forEach(([eventName, transition]) => {
const target = transition?.to || transition?.target;
if (target) {
edges.push(createEdge(stateName, target, eventName));
}
});
}
yOffset += 120;
});
}
} catch (e) {
// Failed to parse DSL as JSON - nodes/edges remain empty
}
return { nodes, edges };
} }
function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: propEdges, dslString, onSave, onDslChange }: VisualWorkflowBuilderProps) { function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: propEdges, dslString, onSave, onDslChange }: VisualWorkflowBuilderProps) {
@@ -135,14 +251,11 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
useEffect(() => { useEffect(() => {
if (dslString) { if (dslString) {
const { nodes: newNodes, edges: newEdges } = parseDSL(dslString); const { nodes: newNodes, edges: newEdges } = parseDSL(dslString);
if (newNodes.length > 0) { setNodes(newNodes.length > 0 ? newNodes : propNodes || initialNodes);
setNodes(newNodes); setEdges(newNodes.length > 0 ? newEdges : propEdges || []);
setEdges(newEdges); setTimeout(() => fitView(), 100);
// Fit view after update
setTimeout(() => fitView(), 100);
}
} }
}, [dslString, setNodes, setEdges, fitView]); }, [dslString, fitView, propEdges, propNodes, setEdges, setNodes]);
const onConnect = useCallback( const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)), (params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
@@ -153,7 +266,7 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
const id = `${type}-${Date.now()}`; const id = `${type}-${Date.now()}`;
const nodeType = type === 'condition' ? 'CONDITION' : type === 'end' ? 'END' : type === 'start' ? 'START' : 'TASK'; const nodeType = type === 'condition' ? 'CONDITION' : type === 'end' ? 'END' : type === 'start' ? 'START' : 'TASK';
const newNode: Node = { const newNode: Node<WorkflowStateNodeData> = {
id, id,
position: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 }, position: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
data: { label: label, name: label, role: 'User', type: nodeType }, data: { label: label, name: label, role: 'User', type: nodeType },
@@ -179,7 +292,6 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
// Generate JSON DSL // Generate JSON DSL
const generateDSL = () => { const generateDSL = () => {
let hasStart = false;
const states = nodes.map(n => { const states = nodes.map(n => {
const outgoingEdges = edges.filter(e => e.source === n.id); const outgoingEdges = edges.filter(e => e.source === n.id);
const onConfig: Record<string, { to: string }> = {}; const onConfig: Record<string, { to: string }> = {};
@@ -191,22 +303,21 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
const isStartNode = n.type === 'input'; const isStartNode = n.type === 'input';
const isEndNode = n.type === 'output'; const isEndNode = n.type === 'output';
const nodeData = n.data as WorkflowStateNodeData;
if (isStartNode) hasStart = true;
const stateObj: { name: string; type?: string; role?: string; initial?: boolean; terminal?: boolean; on?: Record<string, { to: string }> } = { const stateObj: { name: string; type?: string; role?: string; initial?: boolean; terminal?: boolean; on?: Record<string, { to: string }> } = {
name: n.data.name || n.data.label.split('\n')[0], name: nodeData.name || nodeData.label?.split('\n')[0] || n.id,
}; };
if (n.data.type && n.data.type !== 'START' && n.data.type !== 'END' && n.data.type !== 'TASK') { if (nodeData.type && nodeData.type !== 'START' && nodeData.type !== 'END' && nodeData.type !== 'TASK') {
stateObj.type = n.data.type; stateObj.type = nodeData.type;
} }
if (n.data.role && !isStartNode && !isEndNode) { if (nodeData.role && !isStartNode && !isEndNode) {
stateObj.role = n.data.role; stateObj.role = nodeData.role;
} }
if (isStartNode && !hasStart) { if (isStartNode) {
stateObj.initial = true; stateObj.initial = true;
} }
if (isEndNode) { if (isEndNode) {
+6
View File
@@ -23,6 +23,9 @@ export const drawingKeys = {
// --- Queries --- // --- Queries ---
export function useDrawings(type: DrawingType, params: DrawingSearchParams) { export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
const shouldEnable =
'projectUuid' in params ? Boolean(params.projectUuid) : true;
return useQuery({ return useQuery({
queryKey: drawingKeys.list(type, params), queryKey: drawingKeys.list(type, params),
queryFn: async () => { queryFn: async () => {
@@ -50,6 +53,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
type: 'SHOP', type: 'SHOP',
title: d.currentRevision?.title || 'Untitled', title: d.currentRevision?.title || 'Untitled',
revision: d.currentRevision?.revisionNumber, revision: d.currentRevision?.revisionNumber,
currentRevisionUuid: d.currentRevision?.uuid,
legacyDrawingNumber: d.currentRevision?.legacyDrawingNumber, legacyDrawingNumber: d.currentRevision?.legacyDrawingNumber,
})); }));
// Re-wrap to preserve meta // Re-wrap to preserve meta
@@ -65,6 +69,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
type: 'AS_BUILT', type: 'AS_BUILT',
title: d.currentRevision?.title || 'Untitled', title: d.currentRevision?.title || 'Untitled',
revision: d.currentRevision?.revisionNumber, revision: d.currentRevision?.revisionNumber,
currentRevisionUuid: d.currentRevision?.uuid,
})); }));
// Re-wrap to preserve meta // Re-wrap to preserve meta
response = { ...response, data: mappedData }; response = { ...response, data: mappedData };
@@ -72,6 +77,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
} }
return response; return response;
}, },
enabled: shouldEnable,
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });
} }
+3 -4
View File
@@ -6,7 +6,6 @@ import {
UpdateOrganizationDto, UpdateOrganizationDto,
SearchOrganizationDto, SearchOrganizationDto,
} from '@/types/dto/organization/organization.dto'; } from '@/types/dto/organization/organization.dto';
import { Organization } from '@/types/organization';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { organizationService } from '@/lib/services/organization.service'; import { organizationService } from '@/lib/services/organization.service';
import { projectService } from '@/lib/services/project.service'; import { projectService } from '@/lib/services/project.service';
@@ -20,7 +19,7 @@ export const masterDataKeys = {
}; };
export function useOrganizations(params?: SearchOrganizationDto) { export function useOrganizations(params?: SearchOrganizationDto) {
return useQuery<Organization[]>({ return useQuery({
queryKey: [...masterDataKeys.organizations(), params], queryKey: [...masterDataKeys.organizations(), params],
queryFn: () => organizationService.getAll(params), queryFn: () => organizationService.getAll(params),
}); });
@@ -113,7 +112,7 @@ export function useContractDrawingCategories(projectId?: number | string) {
}); });
} }
export function useShopMainCategories(projectId: number | string) { export function useShopMainCategories(projectId: number) {
return useQuery({ return useQuery({
queryKey: ['shop-main-categories', projectId], queryKey: ['shop-main-categories', projectId],
queryFn: () => masterDataService.getShopMainCategories(projectId), queryFn: () => masterDataService.getShopMainCategories(projectId),
@@ -121,7 +120,7 @@ export function useShopMainCategories(projectId: number | string) {
}); });
} }
export function useShopSubCategories(projectId: number | string, mainCategoryId?: number) { export function useShopSubCategories(projectId: number, mainCategoryId?: number) {
return useQuery({ return useQuery({
queryKey: ['shop-sub-categories', projectId, mainCategoryId], queryKey: ['shop-sub-categories', projectId, mainCategoryId],
queryFn: () => masterDataService.getShopSubCategories(projectId, mainCategoryId), queryFn: () => masterDataService.getShopSubCategories(projectId, mainCategoryId),
+1 -2
View File
@@ -1,6 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { projectService } from '@/lib/services/project.service'; import { projectService } from '@/lib/services/project.service';
import type { ProjectListItem } from '@/lib/services/project.service';
import { CreateProjectDto, UpdateProjectDto, SearchProjectDto } from '@/types/dto/project/project.dto'; import { CreateProjectDto, UpdateProjectDto, SearchProjectDto } from '@/types/dto/project/project.dto';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getApiErrorMessage } from '@/types/api-error'; import { getApiErrorMessage } from '@/types/api-error';
@@ -12,7 +11,7 @@ export const projectKeys = {
}; };
export function useProjects(params?: SearchProjectDto) { export function useProjects(params?: SearchProjectDto) {
return useQuery<ProjectListItem[]>({ return useQuery({
queryKey: projectKeys.list(params || {}), queryKey: projectKeys.list(params || {}),
queryFn: () => projectService.getAll(params), queryFn: () => projectService.getAll(params),
}); });
+2 -2
View File
@@ -6,13 +6,13 @@ import type { CreateCorrespondenceTypeDto, UpdateCorrespondenceTypeDto } from '@
export const referenceDataKeys = { export const referenceDataKeys = {
all: ['reference-data'] as const, all: ['reference-data'] as const,
rfaTypes: (contractId?: number) => [...referenceDataKeys.all, 'rfaTypes', contractId] as const, rfaTypes: (contractId?: number | string) => [...referenceDataKeys.all, 'rfaTypes', contractId] as const,
disciplines: (contractId?: number) => [...referenceDataKeys.all, 'disciplines', contractId] as const, disciplines: (contractId?: number) => [...referenceDataKeys.all, 'disciplines', contractId] as const,
correspondenceTypes: () => [...referenceDataKeys.all, 'correspondenceTypes'] as const, correspondenceTypes: () => [...referenceDataKeys.all, 'correspondenceTypes'] as const,
}; };
// --- RFA Types --- // --- RFA Types ---
export const useRfaTypes = (contractId?: number) => { export const useRfaTypes = (contractId?: number | string) => {
return useQuery({ return useQuery({
queryKey: referenceDataKeys.rfaTypes(contractId), queryKey: referenceDataKeys.rfaTypes(contractId),
queryFn: () => masterDataService.getRfaTypes(contractId), queryFn: () => masterDataService.getRfaTypes(contractId),
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userService } from '@/lib/services/user.service'; import { userService } from '@/lib/services/user.service';
import { CreateUserDto, UpdateUserDto, SearchUserDto } from '@/types/user'; import { CreateUserDto, UpdateUserDto, SearchUserDto, Role } from '@/types/user';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getApiErrorMessage } from '@/types/api-error'; import { getApiErrorMessage } from '@/types/api-error';
@@ -18,7 +18,7 @@ export function useUsers(params?: SearchUserDto) {
} }
export function useRoles() { export function useRoles() {
return useQuery({ return useQuery<Role[]>({
queryKey: ['roles'], queryKey: ['roles'],
queryFn: () => userService.getRoles(), queryFn: () => userService.getRoles(),
}); });
+3 -2
View File
@@ -6,6 +6,7 @@ import {
EvaluateWorkflowDto, EvaluateWorkflowDto,
GetAvailableActionsDto, GetAvailableActionsDto,
} from '@/types/dto/workflow-engine/workflow-engine.dto'; } from '@/types/dto/workflow-engine/workflow-engine.dto';
import { Workflow } from '@/types/workflow';
export const workflowKeys = { export const workflowKeys = {
all: ['workflows'] as const, all: ['workflows'] as const,
@@ -14,14 +15,14 @@ export const workflowKeys = {
}; };
export const useWorkflowDefinitions = () => { export const useWorkflowDefinitions = () => {
return useQuery({ return useQuery<Workflow[]>({
queryKey: workflowKeys.definitions(), queryKey: workflowKeys.definitions(),
queryFn: () => workflowEngineService.getDefinitions(), queryFn: () => workflowEngineService.getDefinitions(),
}); });
}; };
export const useWorkflowDefinition = (id: string | number) => { export const useWorkflowDefinition = (id: string | number) => {
return useQuery({ return useQuery<Workflow>({
queryKey: workflowKeys.definition(id), queryKey: workflowKeys.definition(id),
queryFn: () => workflowEngineService.getDefinitionById(id), queryFn: () => workflowEngineService.getDefinitionById(id),
enabled: !!id, enabled: !!id,
+47 -6
View File
@@ -24,8 +24,10 @@ export interface NumberingTemplate {
uuid?: string; uuid?: string;
}; };
formatTemplate: string; formatTemplate: string;
disciplineId: number;
description?: string; description?: string;
resetSequenceYearly: boolean; // Controls yearly counter reset resetSequenceYearly: boolean; // Controls yearly counter reset
isActive: number;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -38,8 +40,10 @@ export interface SaveTemplateDto {
projectId: number | string; projectId: number | string;
correspondenceTypeId: number | null; correspondenceTypeId: number | null;
formatTemplate: string; formatTemplate: string;
disciplineId?: number;
description?: string; description?: string;
resetSequenceYearly?: boolean; resetSequenceYearly?: boolean;
isActive?: number;
} }
/** /**
@@ -151,10 +155,22 @@ export const numberingApi = {
/** /**
* Save (create or update) a template * Save (create or update) a template
*/ */
saveTemplate: async (dto: Partial<NumberingTemplate>): Promise<NumberingTemplate> => { saveTemplate: async (dto: SaveTemplateDto): Promise<NumberingTemplate> => {
// Clean the DTO to avoid sending nested objects that might confuse TypeORM or violate constraints
const cleanDto: any = {
id: dto.id,
projectId: dto.projectId,
correspondenceTypeId: dto.correspondenceTypeId,
disciplineId: dto.disciplineId || 0,
formatTemplate: dto.formatTemplate,
description: dto.description,
resetSequenceYearly: dto.resetSequenceYearly,
isActive: dto.isActive ?? 1,
};
const res = await apiClient.post<any>( const res = await apiClient.post<any>(
'/admin/document-numbering/templates', '/admin/document-numbering/templates',
dto cleanDto
); );
return res.data.data || res.data; return res.data.data || res.data;
}, },
@@ -281,13 +297,38 @@ export const numberingApi = {
subTypeId?: number; subTypeId?: number;
rfaTypeId?: number; rfaTypeId?: number;
recipientOrganizationId?: number | string; recipientOrganizationId?: number | string;
}): Promise<{ previewNumber: string; nextSequence: number }> => { }): Promise<{ previewNumber: string; nextSequence: number; isDefault: boolean }> => {
const res = await apiClient.post<{ data: { previewNumber: string; nextSequence: number } }>( const res = await apiClient.post<any>(
'/document-numbering/preview', '/document-numbering/preview',
ctx ctx
); );
// Backend wraps response in { data: { ... }, message: "Success" }
return res.data.data || res.data; // Explicit debug log for frontend developers to see in browser console
console.log("[numberingApi.previewNumber] Raw Response Data:", res.data);
const body = res.data;
console.log("[numberingApi.previewNumber] Full Body:", body);
// Drill down to find the actual data object
let data = body;
let depth = 0;
while (data && typeof data === 'object' && !data.previewNumber && !data.number && data.data && depth < 3) {
data = data.data;
depth++;
}
console.log(`[numberingApi.previewNumber] Unwrapped at depth ${depth}:`, data);
// Final extraction
const previewNumber = data?.previewNumber || data?.number || (typeof data === 'string' ? data : '');
const nextSequence = data?.nextSequence ?? data?.sequence ?? 0;
const isDefault = data?.isDefault === true;
return {
previewNumber: previewNumber || JSON.stringify(body), // Fallback to body string if all else fails
nextSequence: nextSequence,
isDefault: isDefault
};
}, },
/** /**
+87 -90
View File
@@ -5,65 +5,77 @@ import { z } from "zod";
import type { User } from "next-auth"; import type { User } from "next-auth";
import type { JWT } from "next-auth/jwt"; import type { JWT } from "next-auth/jwt";
type ApiEnvelope<T> = {
data?: ApiEnvelope<T> | T;
message?: string;
statusCode?: number;
};
const authResponseSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1).optional(),
user: z
.object({
user_id: z.number(),
username: z.string().min(1),
email: z.string(),
firstName: z.string().min(1),
lastName: z.string().min(1),
role: z.string().min(1).optional(),
primaryOrganizationId: z.number().nullable().optional(),
})
.optional(),
});
// Schema for input validation // Schema for input validation
const loginSchema = z.object({ const loginSchema = z.object({
username: z.string().min(1), username: z.string().min(1),
password: z.string().min(1), password: z.string().min(1),
}); });
// ✅ ใช้แบบ SSR-safe (ดีที่สุด) const baseUrl = (typeof window === "undefined" ? process.env.INTERNAL_API_URL : null) || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
const baseUrl =
(typeof window === "undefined" ? process.env.INTERNAL_API_URL : null) ||
process.env.NEXT_PUBLIC_API_URL ||
"http://localhost:3001/api";
// Helper to parse JWT expiry // Helper to parse JWT expiry
function getJwtExpiry(token: string): number { function getJwtExpiry(token: string): number {
try { try {
const payload = JSON.parse(atob(token.split(".")[1])); const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000; return payload.exp * 1000; // Convert to ms
} catch { } catch {
return Date.now(); return Date.now(); // If invalid, treat as expired
} }
} }
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null { interface TokenPayload {
let current: ApiEnvelope<T> | T | null = payload; access_token: string;
refresh_token?: string;
}
while ( interface LoginPayload extends TokenPayload {
current && user: {
typeof current === "object" && user_id: number;
"data" in current && username: string;
current.data !== undefined email?: string;
) { firstName?: string;
current = current.data; lastName?: string;
role?: string;
primaryOrganizationId?: number;
};
}
function unwrapApiResponse(value: unknown): unknown {
let current = value;
for (let i = 0; i < 5; i += 1) {
if (!current || typeof current !== "object") {
return current;
}
const record = current as Record<string, unknown>;
if (typeof record.access_token === "string") {
return current;
}
if (!("data" in record)) {
return current;
}
current = record.data;
} }
return current; return current;
} }
function isTokenPayload(value: unknown): value is TokenPayload {
return !!value && typeof value === "object" && typeof (value as Record<string, unknown>).access_token === "string";
}
function isLoginPayload(value: unknown): value is LoginPayload {
if (!isTokenPayload(value)) {
return false;
}
const user = (value as unknown as { user?: unknown }).user;
return !!user && typeof user === "object" && typeof (user as Record<string, unknown>).username === "string";
}
async function refreshAccessToken(token: JWT) { async function refreshAccessToken(token: JWT) {
try { try {
const response = await fetch(`${baseUrl}/auth/refresh`, { const response = await fetch(`${baseUrl}/auth/refresh`, {
@@ -79,22 +91,21 @@ async function refreshAccessToken(token: JWT) {
throw refreshedTokens; throw refreshedTokens;
} }
const parsedAuthResponse = authResponseSchema.safeParse( const data = unwrapApiResponse(refreshedTokens);
unwrapApiData(refreshedTokens)
);
if (!parsedAuthResponse.success) { if (!isTokenPayload(data)) {
throw new Error("Invalid refresh token response"); throw new Error("Invalid refresh response format");
} }
return { return {
...token, ...token,
accessToken: parsedAuthResponse.data.access_token, accessToken: data.access_token,
accessTokenExpires: getJwtExpiry(parsedAuthResponse.data.access_token), accessTokenExpires: getJwtExpiry(data.access_token),
refreshToken: refreshToken: data.refresh_token ?? token.refreshToken,
parsedAuthResponse.data.refresh_token ?? token.refreshToken,
}; };
} catch { } catch (error) {
// RefreshAccessTokenError - token will be invalidated
return { return {
...token, ...token,
error: "RefreshAccessTokenError", error: "RefreshAccessTokenError",
@@ -119,14 +130,15 @@ export const {
if (!credentials?.username || !credentials?.password) return null; if (!credentials?.username || !credentials?.password) return null;
try { try {
const payload = loginSchema.parse({ // 1. Sanitize payload (Only send username and password)
username: credentials.username, const payload = {
password: credentials.password, username: credentials.username as string,
}); password: credentials.password as string,
};
console.log(`[AUTH] Attempting login at: ${baseUrl}/auth/login`); console.log(`[AUTH] Attempting login at: ${baseUrl}/auth/login`);
console.log(`[AUTH] INTERNAL_API_URL: ${process.env.INTERNAL_API_URL}`); console.log(`[AUTH] Current process.env.INTERNAL_API_URL: ${process.env.INTERNAL_API_URL}`);
console.log(`[AUTH] NEXT_PUBLIC_API_URL: ${process.env.NEXT_PUBLIC_API_URL}`); console.log(`[AUTH] Current process.env.NEXT_PUBLIC_API_URL: ${process.env.NEXT_PUBLIC_API_URL}`);
const res = await fetch(`${baseUrl}/auth/login`, { const res = await fetch(`${baseUrl}/auth/login`, {
method: "POST", method: "POST",
@@ -134,7 +146,7 @@ export const {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
cache: "no-store", cache: 'no-store', // Disable caching for auth requests
}); });
if (!res.ok) { if (!res.ok) {
@@ -144,47 +156,29 @@ export const {
return null; return null;
} }
const responseJson = await res.json(); const data = await res.json();
console.log("[AUTH] Backend raw response:", JSON.stringify(responseJson)); const backendData = unwrapApiResponse(data);
const parsedAuthResponse = authResponseSchema.safeParse( if (!isLoginPayload(backendData)) {
unwrapApiData(responseJson) console.error("[AUTH] Login failed: Invalid response format from backend (missing access_token)");
);
if (!parsedAuthResponse.success || !parsedAuthResponse.data.user) {
console.error(
"[AUTH] Invalid backend response:",
unwrapApiData(responseJson)
);
return null; return null;
} }
const backendData = parsedAuthResponse.data; console.log(`[AUTH] Login Successful for user: ${backendData.user?.username || 'unknown'}`);
const user = backendData.user;
if (!user) {
console.error("[AUTH] Invalid backend response:", backendData);
return null;
}
console.log(
`[AUTH] Login Successful for user: ${
user.username || "unknown"
}`
);
return { return {
id: user.user_id.toString(), id: backendData.user.user_id.toString(),
name: `${user.firstName} ${user.lastName}`, name: `${backendData.user.firstName ?? ""} ${backendData.user.lastName ?? ""}`.trim(),
email: user.email, email: backendData.user.email,
username: user.username, username: backendData.user.username,
role: user.role || "User", role: backendData.user.role || "User",
organizationId: user.primaryOrganizationId, organizationId: backendData.user.primaryOrganizationId,
accessToken: backendData.access_token, accessToken: backendData.access_token,
refreshToken: backendData.refresh_token, refreshToken: backendData.refresh_token,
} as User; } as User;
} catch (error) { } catch (error) {
console.error("[AUTH] Network Error:", error); console.error("[AUTH] Network/Fetch Error during authorize:", error);
return null; return null;
} }
}, },
@@ -200,7 +194,7 @@ export const {
return { return {
...token, ...token,
id: user.id, id: user.id,
username: user.username, username: user.username, // ✅ Save username
role: user.role, role: user.role,
organizationId: user.organizationId, organizationId: user.organizationId,
accessToken: user.accessToken, accessToken: user.accessToken,
@@ -209,20 +203,23 @@ export const {
}; };
} }
// Return previous token if valid (minus 10s buffer)
if (Date.now() < (token.accessTokenExpires as number) - 10000) { if (Date.now() < (token.accessTokenExpires as number) - 10000) {
return token; return token;
} }
// If existing token has an error, do not retry refresh (prevents infinite loop)
if (token.error) { if (token.error) {
return token; return token;
} }
// Token expired, refresh it
return refreshAccessToken(token); return refreshAccessToken(token);
}, },
async session({ session, token }) { async session({ session, token }) {
if (token && session.user) { if (token && session.user) {
session.user.id = token.id as string; session.user.id = token.id as string;
session.user.username = token.username as string; session.user.username = token.username as string; // ✅ Restore username
session.user.role = token.role as string; session.user.role = token.role as string;
session.user.organizationId = token.organizationId as number; session.user.organizationId = token.organizationId as number;
@@ -235,7 +232,7 @@ export const {
}, },
session: { session: {
strategy: "jwt", strategy: "jwt",
maxAge: 24 * 60 * 60, maxAge: 24 * 60 * 60, // 24 hours
}, },
secret: process.env.AUTH_SECRET, secret: process.env.AUTH_SECRET,
debug: process.env.NODE_ENV === "development", debug: process.env.NODE_ENV === "development",
+49 -36
View File
@@ -15,31 +15,23 @@ import {
SearchOrganizationDto, SearchOrganizationDto,
} from "@/types/dto/organization/organization.dto"; } from "@/types/dto/organization/organization.dto";
type ApiEnvelope<T> = { const extractArrayData = <T>(value: unknown): T[] => {
data?: ApiEnvelope<T> | T; let current: unknown = value;
message?: string;
statusCode?: number;
};
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null { for (let i = 0; i < 5; i += 1) {
let current: ApiEnvelope<T> | T | null = payload; if (Array.isArray(current)) {
return current as T[];
}
while ( if (!current || typeof current !== "object" || !("data" in current)) {
current && return [];
typeof current === "object" && }
"data" in current &&
current.data !== undefined current = (current as { data?: unknown }).data;
) {
current = current.data;
} }
return current; return Array.isArray(current) ? (current as T[]) : [];
} };
function unwrapArrayResponse<T>(payload: ApiEnvelope<T[]> | T[]): T[] {
const unwrapped = unwrapApiData(payload);
return Array.isArray(unwrapped) ? unwrapped : [];
}
export const masterDataService = { export const masterDataService = {
// --- Tags Management --- // --- Tags Management ---
@@ -47,7 +39,8 @@ export const masterDataService = {
/** ดึงรายการ Tags ทั้งหมด (Search & Pagination) */ /** ดึงรายการ Tags ทั้งหมด (Search & Pagination) */
getTags: async (params?: SearchTagDto) => { getTags: async (params?: SearchTagDto) => {
const response = await apiClient.get("/master/tags", { params }); const response = await apiClient.get("/master/tags", { params });
return unwrapArrayResponse(response.data); // Support both wrapped and unwrapped scenarios
return response.data.data || response.data;
}, },
/** สร้าง Tag ใหม่ */ /** สร้าง Tag ใหม่ */
@@ -73,7 +66,27 @@ export const masterDataService = {
/** ดึงรายชื่อองค์กรทั้งหมด */ /** ดึงรายชื่อองค์กรทั้งหมด */
getOrganizations: async (params?: SearchOrganizationDto) => { getOrganizations: async (params?: SearchOrganizationDto) => {
const response = await apiClient.get<Organization[] | { data: Organization[] }>("/organizations", { params }); const response = await apiClient.get<Organization[] | { data: Organization[] }>("/organizations", { params });
return unwrapArrayResponse(response.data as ApiEnvelope<Organization[]> | Organization[]); // Support paginated response
if (response.data && Array.isArray((response.data as { data: Organization[] }).data)) {
return (response.data as { data: Organization[] }).data;
}
// If response.data itself is an array
if (Array.isArray(response.data)) {
return response.data;
}
// If we're here, it might be { data: [], total: ... } but data is missing? or empty?
// Or it returned the object but data.data check failed (shouldn't happen if it follows schema).
// Let's default to [] if we can't find an array, because callers expect array.
// However, if we return [] we lose data if it was there but not recognized.
// Fallback: Check if response.data is object?
// If it's the paginated object, return the data array if it exists
if (response.data && (response.data as { data: Organization[] }).data) {
// Maybe it's not an array?
return Array.isArray((response.data as { data: Organization[] }).data) ? (response.data as { data: Organization[] }).data : [];
}
return []; // Return empty array to prevent map errors
}, },
/** สร้างองค์กรใหม่ */ /** สร้างองค์กรใหม่ */
@@ -82,7 +95,7 @@ export const masterDataService = {
return response.data; return response.data;
}, },
/** แก้ไองค์กร */ /** แก้ไองค์กร */
updateOrganization: async (uuid: string, data: UpdateOrganizationDto) => { updateOrganization: async (uuid: string, data: UpdateOrganizationDto) => {
const response = await apiClient.put(`/organizations/${uuid}`, data); const response = await apiClient.put(`/organizations/${uuid}`, data);
return response.data; return response.data;
@@ -102,7 +115,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/disciplines", { const response = await apiClient.get("/master/disciplines", {
params: { contractId } params: { contractId }
}); });
return unwrapArrayResponse(response.data); return extractArrayData(response.data);
}, },
/** สร้างสาขางานใหม่ */ /** สร้างสาขางานใหม่ */
@@ -124,7 +137,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/sub-types", { const response = await apiClient.get("/master/sub-types", {
params: { contractId, correspondenceTypeId: typeId } params: { contractId, correspondenceTypeId: typeId }
}); });
return unwrapArrayResponse(response.data); return extractArrayData(response.data);
}, },
/** สร้างประเภทย่อยใหม่ */ /** สร้างประเภทย่อยใหม่ */
@@ -140,7 +153,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/rfa-types", { const response = await apiClient.get("/master/rfa-types", {
params: { contractId } params: { contractId }
}); });
return unwrapArrayResponse(response.data); return extractArrayData(response.data);
}, },
/** สร้างประเภท RFA ใหม่ */ /** สร้างประเภท RFA ใหม่ */
@@ -161,7 +174,7 @@ export const masterDataService = {
// --- Correspondence Types Management --- // --- Correspondence Types Management ---
getCorrespondenceTypes: async () => { getCorrespondenceTypes: async () => {
const response = await apiClient.get("/master/correspondence-types"); const response = await apiClient.get("/master/correspondence-types");
return unwrapArrayResponse(response.data); return extractArrayData(response.data);
}, },
createCorrespondenceType: async (data: CreateCorrespondenceTypeDto) => { createCorrespondenceType: async (data: CreateCorrespondenceTypeDto) => {
@@ -193,21 +206,21 @@ export const masterDataService = {
// --- Drawing Categories --- // --- Drawing Categories ---
getContractDrawingCategories: async (projectId?: number | string) => { getContractDrawingCategories: async (projectId?: number | string) => {
const response = await apiClient.get("/drawings/master-data/contract/categories", { const response = await apiClient.get("/drawings/contract/categories", {
params: { projectId } params: { projectId }
}); });
return unwrapArrayResponse(response.data); return extractArrayData(response.data);
}, },
getShopMainCategories: async (projectId: number | string) => { getShopMainCategories: async (projectId: number) => {
const response = await apiClient.get("/drawings/master-data/shop/main-categories", { params: { projectId } }); const response = await apiClient.get("/drawings/shop/main-categories", { params: { projectId } });
return unwrapArrayResponse(response.data); return extractArrayData(response.data);
}, },
getShopSubCategories: async (projectId: number | string, mainCategoryId?: number) => { getShopSubCategories: async (projectId: number, mainCategoryId?: number) => {
const response = await apiClient.get("/drawings/master-data/shop/sub-categories", { const response = await apiClient.get("/drawings/shop/sub-categories", {
params: { projectId, mainCategoryId } params: { projectId, mainCategoryId }
}); });
return unwrapArrayResponse(response.data); return extractArrayData(response.data);
} }
}; };
+65 -3
View File
@@ -7,6 +7,68 @@ import {
CommitBatchDto, CommitBatchDto,
} from '@/types/migration'; } from '@/types/migration';
interface WrappedData {
data?: unknown;
}
const extractNestedData = <T,>(value: unknown): T => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (!current || typeof current !== 'object' || !('data' in current)) {
return current as T;
}
current = (current as WrappedData).data;
}
return current as T;
};
const normalizePaginatedResponse = <T,>(value: unknown): PaginatedResponse<T> => {
const extracted = extractNestedData<unknown>(value);
if (!extracted || typeof extracted !== 'object') {
return {
items: [],
total: 0,
page: 1,
limit: 0,
totalPages: 0,
};
}
const response = extracted as Partial<PaginatedResponse<T>> & { data?: unknown };
if (Array.isArray(response.items)) {
return {
items: response.items,
total: response.total ?? response.items.length,
page: response.page ?? 1,
limit: response.limit ?? response.items.length,
totalPages: response.totalPages ?? 1,
};
}
if (Array.isArray(response.data)) {
return {
items: response.data as T[],
total: response.total ?? response.data.length,
page: response.page ?? 1,
limit: response.limit ?? response.data.length,
totalPages: response.totalPages ?? 1,
};
}
return {
items: [],
total: 0,
page: 1,
limit: 0,
totalPages: 0,
};
};
export const migrationService = { export const migrationService = {
getReviewQueue: async (params: { getReviewQueue: async (params: {
page?: number; page?: number;
@@ -14,12 +76,12 @@ export const migrationService = {
status?: MigrationReviewStatus; status?: MigrationReviewStatus;
}): Promise<PaginatedResponse<MigrationReviewQueueItem>> => { }): Promise<PaginatedResponse<MigrationReviewQueueItem>> => {
const { data } = await api.get('/migration/queue', { params }); const { data } = await api.get('/migration/queue', { params });
return data?.data || data; return normalizePaginatedResponse<MigrationReviewQueueItem>(data);
}, },
getQueueItem: async (id: number): Promise<MigrationReviewQueueItem> => { getQueueItem: async (id: number): Promise<MigrationReviewQueueItem> => {
const { data } = await api.get(`/migration/queue/${id}`); const { data } = await api.get(`/migration/queue/${id}`);
return data?.data || data; return extractNestedData<MigrationReviewQueueItem>(data);
}, },
getErrors: async (params: { getErrors: async (params: {
@@ -27,7 +89,7 @@ export const migrationService = {
limit?: number; limit?: number;
}): Promise<PaginatedResponse<MigrationErrorItem>> => { }): Promise<PaginatedResponse<MigrationErrorItem>> => {
const { data } = await api.get('/migration/errors', { params }); const { data } = await api.get('/migration/errors', { params });
return data?.data || data; return normalizePaginatedResponse<MigrationErrorItem>(data);
}, },
approveQueueItem: async (id: number, payload: any, idempotencyKey: string) => { approveQueueItem: async (id: number, payload: any, idempotencyKey: string) => {
+7 -33
View File
@@ -4,45 +4,19 @@ import {
UpdateOrganizationDto, UpdateOrganizationDto,
SearchOrganizationDto, SearchOrganizationDto,
} from "@/types/dto/organization/organization.dto"; } from "@/types/dto/organization/organization.dto";
import { Organization } from "@/types/organization";
type ApiEnvelope<T> = {
data?: ApiEnvelope<T> | T;
message?: string;
statusCode?: number;
};
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null {
let current: ApiEnvelope<T> | T | null = payload;
while (
current &&
typeof current === "object" &&
"data" in current &&
current.data !== undefined
) {
current = current.data;
}
return current;
}
function unwrapArrayResponse<T>(payload: ApiEnvelope<T[]> | T[]): T[] {
const unwrapped = unwrapApiData(payload);
return Array.isArray(unwrapped) ? unwrapped : [];
}
export const organizationService = { export const organizationService = {
/** /**
* Get all organizations (supports filtering by projectId) * Get all organizations (supports filtering by projectId)
* GET /organizations?projectId=1 * GET /organizations?projectId=1
*/ */
getAll: async (params?: SearchOrganizationDto): Promise<Organization[]> => { getAll: async (params?: SearchOrganizationDto) => {
const response = await apiClient.get<ApiEnvelope<Organization[]> | Organization[]>( const response = await apiClient.get("/organizations", { params });
"/organizations", // Normalize response if wrapped in data.data or direct data
{ params } if (response.data && Array.isArray(response.data.data)) {
); return response.data.data;
return unwrapArrayResponse(response.data); }
return response.data.data || response.data;
}, },
/** /**
+7 -41
View File
@@ -6,42 +6,6 @@ import {
SearchProjectDto SearchProjectDto
} from "@/types/dto/project/project.dto"; } from "@/types/dto/project/project.dto";
export interface ProjectListItem {
uuid: string;
id?: number;
projectCode: string;
projectName: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
type ApiEnvelope<T> = {
data?: ApiEnvelope<T> | T;
message?: string;
statusCode?: number;
};
function unwrapApiData<T>(payload: ApiEnvelope<T> | T): ApiEnvelope<T> | T | null {
let current: ApiEnvelope<T> | T | null = payload;
while (
current &&
typeof current === "object" &&
"data" in current &&
current.data !== undefined
) {
current = current.data;
}
return current;
}
function unwrapArrayResponse<T>(payload: ApiEnvelope<T[]> | T[]): T[] {
const unwrapped = unwrapApiData(payload);
return Array.isArray(unwrapped) ? unwrapped : [];
}
export const projectService = { export const projectService = {
// --- Basic CRUD --- // --- Basic CRUD ---
@@ -49,12 +13,14 @@ export const projectService = {
* ( Search & Pagination) * ( Search & Pagination)
* ( getAllProjects params ) * ( getAllProjects params )
*/ */
getAll: async (params?: SearchProjectDto): Promise<ProjectListItem[]> => { getAll: async (params?: SearchProjectDto) => {
// GET /projects // GET /projects
const response = await apiClient.get< const response = await apiClient.get("/projects", { params });
ApiEnvelope<ProjectListItem[]> | ProjectListItem[] // Handle paginated response
>("/projects", { params }); if (response.data && Array.isArray(response.data.data)) {
return unwrapArrayResponse(response.data); return response.data.data;
}
return response.data;
}, },
/** ดึงรายละเอียดโครงการตาม UUID */ /** ดึงรายละเอียดโครงการตาม UUID */
+27 -4
View File
@@ -1,7 +1,7 @@
import apiClient from '@/lib/api/client'; import apiClient from '@/lib/api/client';
export interface Session { export interface Session {
id: string; // tokenId id: number;
userId: number; userId: number;
user: { user: {
username: string; username: string;
@@ -14,10 +14,33 @@ export interface Session {
isCurrent: boolean; isCurrent: boolean;
} }
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== 'object' || !('data' in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const transformSession = (session: Session | (Omit<Session, 'id'> & { id: string | number })): Session => ({
...session,
id: typeof session.id === 'number' ? session.id : Number(session.id),
});
export const sessionService = { export const sessionService = {
getActiveSessions: async () => { getActiveSessions: async (): Promise<Session[]> => {
const response = await apiClient.get<Session[] | { data: Session[] }>('/auth/sessions'); const response = await apiClient.get<Session[] | { data: Session[] } | { data: { data: Session[] } }>('/auth/sessions');
return (response.data as { data: Session[] }).data ?? response.data; return extractArrayData<Session | (Omit<Session, 'id'> & { id: string | number })>(response.data).map(transformSession);
}, },
revokeSession: async (sessionId: number) => { revokeSession: async (sessionId: number) => {
+22 -18
View File
@@ -1,5 +1,5 @@
import apiClient from "@/lib/api/client"; import apiClient from "@/lib/api/client";
import { CreateUserDto, UpdateUserDto, SearchUserDto, User } from "@/types/user"; import { CreateUserDto, UpdateUserDto, SearchUserDto, User, Role } from "@/types/user";
/** Raw API user shape (before transform) */ /** Raw API user shape (before transform) */
interface RawUser { interface RawUser {
@@ -9,6 +9,24 @@ interface RawUser {
[key: string]: unknown; [key: string]: unknown;
} }
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const transformUser = (user: RawUser): User => { const transformUser = (user: RawUser): User => {
return { return {
...(user as unknown as User), ...(user as unknown as User),
@@ -24,26 +42,12 @@ type UserListResponse = User[] | { data: User[] | { data: User[] } };
export const userService = { export const userService = {
getAll: async (params?: SearchUserDto) => { getAll: async (params?: SearchUserDto) => {
const response = await apiClient.get<UserListResponse>("/users", { params }); const response = await apiClient.get<UserListResponse>("/users", { params });
return extractArrayData<RawUser>(response.data).map(transformUser);
// Handle both paginated and non-paginated responses
let rawData: RawUser[] | unknown = response.data;
if (rawData && !Array.isArray(rawData) && 'data' in (rawData as object)) {
rawData = (rawData as { data: unknown }).data;
}
if (rawData && !Array.isArray(rawData) && typeof rawData === 'object' && 'data' in (rawData as object)) {
rawData = (rawData as { data: unknown }).data;
}
if (!Array.isArray(rawData)) return [];
return (rawData as RawUser[]).map(transformUser);
}, },
getRoles: async () => { getRoles: async (): Promise<Role[]> => {
const response = await apiClient.get<{ data: unknown } | unknown>("/users/roles"); const response = await apiClient.get<{ data: unknown } | unknown>("/users/roles");
if (response.data && typeof response.data === 'object' && 'data' in (response.data as object)) { return extractArrayData<Role>(response.data);
return (response.data as { data: unknown }).data;
}
return response.data;
}, },
getByUuid: async (uuid: string) => { getByUuid: async (uuid: string) => {
+104 -11
View File
@@ -7,18 +7,112 @@ import {
GetAvailableActionsDto, GetAvailableActionsDto,
} from '@/types/dto/workflow-engine/workflow-engine.dto'; } from '@/types/dto/workflow-engine/workflow-engine.dto';
import { Workflow } from '@/types/workflow'; import { Workflow, WorkflowType } from '@/types/workflow';
const mapWorkflow = (backendObj: any): Workflow => { interface WorkflowResponseShape {
data?: unknown;
}
interface WorkflowDslShape {
workflowName?: string;
description?: string;
dslDefinition?: string;
workflow?: string;
states?: unknown;
}
interface BackendWorkflowShape {
id?: string | number;
workflow_code?: string;
description?: string;
version?: number;
is_active?: boolean;
dsl?: string | WorkflowDslShape;
compiled?: {
states?: Record<string, unknown>;
};
updated_at?: string;
}
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== 'object' || !('data' in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const extractNestedData = <T,>(value: unknown): T => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (!current || typeof current !== 'object' || !('data' in current)) {
return current as T;
}
current = (current as WorkflowResponseShape).data;
}
return current as T;
};
const extractDslDefinition = (dsl: BackendWorkflowShape['dsl']): string => {
if (typeof dsl === 'string') {
return dsl;
}
if (!dsl || typeof dsl !== 'object') {
return '';
}
if (typeof dsl.dslDefinition === 'string') {
return dsl.dslDefinition;
}
return JSON.stringify(dsl, null, 2);
};
const normalizeWorkflowType = (workflowCode?: string): WorkflowType => {
const normalizedCode = workflowCode?.toUpperCase() ?? '';
if (normalizedCode.includes('RFA')) {
return 'RFA';
}
if (normalizedCode.includes('DRAWING')) {
return 'DRAWING';
}
return 'CORRESPONDENCE';
};
const mapWorkflow = (backendObj: BackendWorkflowShape): Workflow => {
if (!backendObj) throw new Error('Workflow not found'); if (!backendObj) throw new Error('Workflow not found');
return { return {
workflowId: backendObj.id, workflowId: backendObj.id ?? backendObj.workflow_code ?? '',
workflowName: backendObj.dsl?.workflowName || backendObj.workflow_code, workflowName:
description: backendObj.description || backendObj.dsl?.description || '', (typeof backendObj.dsl === 'object' ? backendObj.dsl?.workflowName : undefined) ||
workflowType: backendObj.workflow_code?.toUpperCase() || backendObj.workflow_code, (typeof backendObj.dsl === 'object' ? backendObj.dsl?.workflow : undefined) ||
backendObj.workflow_code ||
'',
description:
backendObj.description ||
(typeof backendObj.dsl === 'object' ? backendObj.dsl?.description : undefined) ||
'',
workflowType: normalizeWorkflowType(backendObj.workflow_code),
version: backendObj.version || 1, version: backendObj.version || 1,
isActive: backendObj.is_active, isActive: backendObj.is_active ?? false,
dslDefinition: typeof backendObj.dsl === 'string' ? backendObj.dsl : backendObj.dsl?.dslDefinition || JSON.stringify(backendObj.dsl, null, 2), dslDefinition: extractDslDefinition(backendObj.dsl),
stepCount: backendObj.compiled?.states ? Object.keys(backendObj.compiled.states).length : 0, stepCount: backendObj.compiled?.states ? Object.keys(backendObj.compiled.states).length : 0,
updatedAt: backendObj.updated_at || new Date().toISOString(), updatedAt: backendObj.updated_at || new Date().toISOString(),
}; };
@@ -53,8 +147,7 @@ export const workflowEngineService = {
*/ */
getDefinitions: async (): Promise<Workflow[]> => { getDefinitions: async (): Promise<Workflow[]> => {
const response = await apiClient.get('/workflow-engine/definitions'); const response = await apiClient.get('/workflow-engine/definitions');
const data = response.data?.data || response.data; return extractArrayData<BackendWorkflowShape>(response.data).map((workflow) => mapWorkflow(workflow));
return Array.isArray(data) ? data.map(mapWorkflow) : data;
}, },
/** /**
@@ -63,7 +156,7 @@ export const workflowEngineService = {
*/ */
getDefinitionById: async (id: string | number): Promise<Workflow> => { getDefinitionById: async (id: string | number): Promise<Workflow> => {
const response = await apiClient.get(`/workflow-engine/definitions/${id}`); const response = await apiClient.get(`/workflow-engine/definitions/${id}`);
const data = response.data?.data || response.data; const data = extractNestedData<BackendWorkflowShape>(response.data);
return mapWorkflow(data); return mapWorkflow(data);
}, },
+3 -3
View File
@@ -89,10 +89,10 @@ export interface CirculationListResponse {
* DTO for creating a circulation * DTO for creating a circulation
*/ */
export interface CreateCirculationDto { export interface CreateCirculationDto {
correspondenceId: string; // ADR-019: UUID string only correspondenceId: number | string;
projectId?: string; // ADR-019: UUID string only projectId?: number | string;
subject: string; subject: string;
assigneeIds: string[]; // ADR-019: UUID string only assigneeIds: (number | string)[];
remarks?: string; remarks?: string;
} }
+3 -3
View File
@@ -76,7 +76,7 @@ export interface Correspondence {
} }
export interface CreateCorrespondenceDto { export interface CreateCorrespondenceDto {
projectId: string; // ADR-019: UUID string only projectId: number;
typeId: number; typeId: number;
subTypeId?: number; subTypeId?: number;
disciplineId?: number; disciplineId?: number;
@@ -87,7 +87,7 @@ export interface CreateCorrespondenceDto {
description?: string; description?: string;
details?: Record<string, unknown>; details?: Record<string, unknown>;
isInternal?: boolean; isInternal?: boolean;
originatorId?: string; // ADR-019: UUID string only originatorId?: number;
recipients?: { organizationId: string; type: 'TO' | 'CC' }[]; // ADR-019: UUID string only recipients?: { organizationId: number; type: 'TO' | 'CC' }[];
attachments?: File[]; attachments?: File[];
} }
@@ -1,14 +1,14 @@
// File: src/types/dto/circulation/create-circulation.dto.ts // File: src/types/dto/circulation/create-circulation.dto.ts
export interface CreateCirculationDto { export interface CreateCirculationDto {
/** UUID ของเอกสารต้นเรื่องที่จะเวียน (ADR-019: UUID string only) */ /** เอกสารต้นเรื่องที่จะเวียน (Correspondence ID or UUID) */
correspondenceId: string; correspondenceId: number | string;
/** หัวข้อเรื่อง (Subject) */ /** หัวข้อเรื่อง (Subject) */
subject: string; subject: string;
/** UUID ของ User ที่ต้องการส่งให้ (ADR-019: UUID string only) */ /** รายชื่อ User ID/UUID ที่ต้องการส่งให้ (ผู้รับผิดชอบ) */
assigneeIds: string[]; assigneeIds: (number | string)[];
/** หมายเหตุเพิ่มเติม (ถ้ามี) */ /** หมายเหตุเพิ่มเติม (ถ้ามี) */
remarks?: string; remarks?: string;
+2 -2
View File
@@ -1,7 +1,7 @@
export interface CreateContractDto { export interface CreateContractDto {
contractCode: string; contractCode: string;
contractName: string; contractName: string;
projectId: string; // ADR-019: UUID string only projectId: number | string;
description?: string; description?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
@@ -11,7 +11,7 @@ export type UpdateContractDto = Partial<CreateContractDto>;
export interface SearchContractDto { export interface SearchContractDto {
search?: string; search?: string;
projectId?: string; // ADR-019: UUID string only projectId?: number | string;
page?: number; page?: number;
limit?: number; limit?: number;
} }
@@ -1,8 +1,8 @@
// File: src/types/dto/correspondence/create-correspondence.dto.ts // File: src/types/dto/correspondence/create-correspondence.dto.ts
export interface CreateCorrespondenceDto { export interface CreateCorrespondenceDto {
/** UUID ของโครงการ (ADR-019: UUID string only) */ /** ID or UUID ของโครงการ */
projectId: string; projectId: number | string;
/** ID ของประเภทเอกสาร (เช่น RFA, LETTER) */ /** ID ของประเภทเอกสาร (เช่น RFA, LETTER) */
typeId: number; typeId: number;
@@ -28,17 +28,26 @@ export interface CreateCorrespondenceDto {
/** กำหนดวันตอบกลับ (ISO Date String) */ /** กำหนดวันตอบกลับ (ISO Date String) */
dueDate?: string; dueDate?: string;
/** วันที่เอกสาร (ISO Date String) */
documentDate?: string;
/** วันที่ออกเอกสาร (ISO Date String) */
issuedDate?: string;
/** วันที่รับเอกสาร (ISO Date String) */
receivedDate?: string;
/** ข้อมูล JSON เฉพาะประเภท (เช่น RFI question, RFA details) */ /** ข้อมูล JSON เฉพาะประเภท (เช่น RFI question, RFA details) */
details?: Record<string, unknown>; details?: Record<string, unknown>;
/** เอกสารภายในหรือไม่ (True = ภายใน) */ /** เอกสารภายในหรือไม่ (True = ภายใน) */
isInternal?: boolean; isInternal?: boolean;
/** Field Impersonation () /** * Field Impersonation ()
* Admin (ADR-019: UUID string only) * Admin
*/ */
originatorId?: string; originatorId?: number | string;
/** รายชื่อผู้รับ (ADR-019: UUID string only) */ /** รายชื่อผู้รับ */
recipients?: { organizationId: string; type: 'TO' | 'CC' }[]; recipients?: { organizationId: number | string; type: 'TO' | 'CC' }[];
} }
+10 -11
View File
@@ -1,10 +1,9 @@
// File: src/types/dto/rfa/rfa.dto.ts // File: src/types/dto/rfa/rfa.dto.ts
import type { RFAItem } from '@/types/rfa';
// --- Create --- // --- Create ---
export interface CreateRfaDto { export interface CreateRfaDto {
/** UUID ของโครงการ (ADR-019: UUID string only) */ /** ID or UUID ของโครงการ */
projectId: string; projectId: number | string; // ADR-019: Accept UUID
/** ประเภท RFA (เช่น DWG, MAT) */ /** ประเภท RFA (เช่น DWG, MAT) */
rfaTypeId: number; rfaTypeId: number;
@@ -24,8 +23,8 @@ export interface CreateRfaDto {
/** Contract UUID (optional) */ /** Contract UUID (optional) */
contractId?: string; // ADR-019: Contract UUID contractId?: string; // ADR-019: Contract UUID
/** ส่งถึงใคร (สำหรับ Routing Step 1) (ADR-019: UUID string only) */ /** ส่งถึงใคร (สำหรับ Routing Step 1) */
toOrganizationId: string; toOrganizationId: number | string; // ADR-019: Accept UUID
/** รายละเอียดเพิ่มเติม */ /** รายละเอียดเพิ่มเติม */
description?: string; description?: string;
@@ -36,11 +35,11 @@ export interface CreateRfaDto {
/** กำหนดวันตอบกลับ (ISO Date String) */ /** กำหนดวันตอบกลับ (ISO Date String) */
dueDate?: string; dueDate?: string;
/** รายการ ID ของ Shop Drawings ที่แนบมา (ถ้ามี) */ /** รายการ ID หรือ UUID ของ Shop Drawing Revisions ที่แนบมา (ถ้ามี) */
shopDrawingRevisionIds?: number[]; shopDrawingRevisionIds?: Array<number | string>;
/** รายการ Items ของ RFA */ /** รายการ ID หรือ UUID ของ As-Built Drawing Revisions ที่แนบมา (ถ้ามี) */
items?: RFAItem[]; asBuiltDrawingRevisionIds?: Array<number | string>;
} }
// --- Update (Partial) --- // --- Update (Partial) ---
@@ -48,8 +47,8 @@ export type UpdateRfaDto = Partial<CreateRfaDto>;
// --- Search --- // --- Search ---
export interface SearchRfaDto { export interface SearchRfaDto {
/** Filter by Project UUID (ADR-019: UUID string only) */ /** Filter by Project ID or UUID (optional to allow cross-project search) */
projectId?: string; projectId?: number | string; // ADR-019: Accept UUID
/** กรองตามประเภท RFA */ /** กรองตามประเภท RFA */
rfaTypeId?: number; rfaTypeId?: number;
@@ -9,12 +9,12 @@ export enum TransmittalPurpose {
// --- Create --- // --- Create ---
export interface CreateTransmittalDto { export interface CreateTransmittalDto {
projectId?: string; // ADR-019: UUID string only projectId?: number | string; // ADR-019: Accept UUID
recipientOrganizationId?: string; // ADR-019: UUID string only recipientOrganizationId?: number | string; // ADR-019: Accept UUID
subject: string; subject: string;
purpose?: string; purpose?: string;
remarks?: string; remarks?: string;
correspondenceId: string; // ADR-019: UUID string only correspondenceId: number | string; // ADR-019: Accept UUID
items: CreateTransmittalItemDto[]; items: CreateTransmittalItemDto[];
} }
@@ -30,7 +30,7 @@ export type UpdateTransmittalDto = Partial<CreateTransmittalDto>;
// --- Search --- // --- Search ---
export interface SearchTransmittalDto { export interface SearchTransmittalDto {
/** บังคับระบุ Project */ /** บังคับระบุ Project */
projectId: string; // ADR-019: UUID string only projectId: number | string; // ADR-019: Accept UUID
purpose?: TransmittalPurpose; purpose?: TransmittalPurpose;
+1 -1
View File
@@ -8,7 +8,7 @@ export interface CreateUserDto {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
lineId?: string; lineId?: string;
primaryOrganizationId?: string; // ADR-019: UUID string only primaryOrganizationId?: number | string; // ADR-019: Accept UUID
isActive?: boolean; isActive?: boolean;
} }
+31 -17
View File
@@ -1,10 +1,30 @@
export interface RFAItem { export interface RFAItem {
id?: number; id?: number;
itemNo: string; itemType: "SHOP" | "AS_BUILT";
description: string; shopDrawingRevision?: {
quantity: number; uuid?: string;
unit: string; revisionLabel?: string;
status?: "PENDING" | "APPROVED" | "REJECTED"; revisionNumber?: number;
title?: string;
legacyDrawingNumber?: string;
attachments?: { id?: number; url?: string; name?: string }[];
shopDrawing?: {
uuid?: string;
drawingNumber?: string;
};
};
asBuiltDrawingRevision?: {
uuid?: string;
revisionLabel?: string;
revisionNumber?: number;
title?: string;
legacyDrawingNumber?: string;
attachments?: { id?: number; url?: string; name?: string }[];
asBuiltDrawing?: {
uuid?: string;
drawingNumber?: string;
};
};
} }
export interface RFA { export interface RFA {
@@ -17,17 +37,11 @@ export interface RFA {
id: number; id: number;
revisionNumber: number; revisionNumber: number;
subject: string; subject: string;
description?: string;
isCurrent: boolean; isCurrent: boolean;
createdAt?: string; createdAt?: string;
statusCode?: { statusCode: string; statusName: string }; statusCode?: { statusCode: string; statusName: string };
items?: { items?: RFAItem[];
shopDrawingRevision?: {
id: number;
revisionLabel: string;
shopDrawing?: { drawingType?: { hasNumber: boolean } }; // Mock structure
attachments?: { id: number; url: string; name: string }[]
}
}[];
}[]; }[];
discipline?: { discipline?: {
id: number; id: number;
@@ -54,9 +68,9 @@ export interface RFA {
} }
export interface CreateRFADto { export interface CreateRFADto {
projectId: string; // ADR-019: UUID string only projectId: number | string; // ADR-019: Accept UUID
contractId?: string; // ADR-019: Contract UUID contractId?: string; // ADR-019: Contract UUID
toOrganizationId?: string; // ADR-019: UUID string only toOrganizationId?: number | string; // ADR-019: Recipient org UUID
rfaTypeId: number; rfaTypeId: number;
disciplineId?: number; disciplineId?: number;
subject: string; subject: string;
@@ -66,6 +80,6 @@ export interface CreateRFADto {
description?: string; description?: string;
documentDate?: string; documentDate?: string;
details?: Record<string, unknown>; details?: Record<string, unknown>;
shopDrawingRevisionIds?: number[]; shopDrawingRevisionIds?: Array<number | string>;
items?: RFAItem[]; asBuiltDrawingRevisionIds?: Array<number | string>;
} }
+5 -5
View File
@@ -30,7 +30,7 @@ export interface TransmittalItem {
export interface Transmittal { export interface Transmittal {
uuid: string; // ADR-019: from correspondence.uuid uuid: string; // ADR-019: from correspondence.uuid
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
correspondenceId?: string; // ADR-019: UUID string only correspondenceId?: number | string;
transmittalNo: string; transmittalNo: string;
subject: string; subject: string;
purpose?: TransmittalPurpose; purpose?: TransmittalPurpose;
@@ -74,9 +74,9 @@ export interface CreateTransmittalItemDto {
* DTO for creating a transmittal * DTO for creating a transmittal
*/ */
export interface CreateTransmittalDto { export interface CreateTransmittalDto {
projectId?: string; // ADR-019: UUID string only projectId?: number | string; // ADR-019: Accept UUID
recipientOrganizationId?: string; // ADR-019: UUID string only recipientOrganizationId?: number | string; // ADR-019: Accept UUID
correspondenceId: string; // ADR-019: UUID string only correspondenceId: number | string; // ADR-019: Accept UUID
subject: string; subject: string;
purpose?: TransmittalPurpose; purpose?: TransmittalPurpose;
remarks?: string; remarks?: string;
@@ -89,6 +89,6 @@ export interface CreateTransmittalDto {
export interface SearchTransmittalDto { export interface SearchTransmittalDto {
page?: number; page?: number;
limit?: number; limit?: number;
projectId?: string; // ADR-019: UUID string only projectId?: number | string; // ADR-019: Accept UUID
search?: string; search?: string;
} }
+8 -7
View File
@@ -5,9 +5,10 @@ export interface Role {
} }
export interface UserOrganization { export interface UserOrganization {
uuid: string; // ADR-019: Public identifier organizationId: number;
organizationCode: string; // Matches backend Organization entity orgCode: string;
organizationName: string; // Matches backend Organization entity orgName: string;
orgNameTh?: string;
} }
export interface User { export interface User {
@@ -19,8 +20,8 @@ export interface User {
lastName: string; lastName: string;
isActive: boolean; isActive: boolean;
lineId?: string; lineId?: string;
organization?: UserOrganization; // ADR-019: use organization.uuid — never expose INT id primaryOrganizationId?: number | string; // ADR-019: May be INT or UUID
organization?: UserOrganization;
roles?: Role[]; roles?: Role[];
// Security fields (from backend v1.5.1) // Security fields (from backend v1.5.1)
@@ -41,7 +42,7 @@ export interface CreateUserDto {
password?: string; password?: string;
isActive: boolean; isActive: boolean;
lineId?: string; lineId?: string;
primaryOrganizationId?: string; // ADR-019: UUID string only primaryOrganizationId?: number | string; // ADR-019: Accept UUID
roleIds: number[]; roleIds: number[];
} }
@@ -52,5 +53,5 @@ export interface SearchUserDto {
limit?: number; limit?: number;
search?: string; search?: string;
roleId?: number; roleId?: number;
primaryOrganizationId?: string; // ADR-019: UUID string only primaryOrganizationId?: number | string; // ADR-019: Accept UUID
} }
+17 -155
View File
@@ -49,8 +49,8 @@ importers:
specifier: ^6.7.5 specifier: ^6.7.5
version: 6.8.0 version: 6.8.0
'@elastic/elasticsearch': '@elastic/elasticsearch':
specifier: ^9.3.4 specifier: ^8.13.0
version: 9.3.4 version: 8.13.0
'@nestjs-modules/ioredis': '@nestjs-modules/ioredis':
specifier: ^2.0.2 specifier: ^2.0.2
version: 2.0.2(@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2))(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/typeorm@11.0.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(redis@4.7.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))))(ioredis@5.8.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(redis@4.7.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))) version: 2.0.2(@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2))(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/typeorm@11.0.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(redis@4.7.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))))(ioredis@5.8.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(redis@4.7.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)))
@@ -74,7 +74,7 @@ importers:
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/elasticsearch': '@nestjs/elasticsearch':
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.1.0(@elastic/elasticsearch@9.3.4)(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) version: 11.1.0(@elastic/elasticsearch@8.13.0)(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/jwt': '@nestjs/jwt':
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)) version: 11.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))
@@ -1457,13 +1457,13 @@ packages:
resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==}
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
'@elastic/elasticsearch@9.3.4': '@elastic/elasticsearch@8.13.0':
resolution: {integrity: sha512-Mp14fPEYx+WTfZdcvAaZ9WkLYGHQCbwMx6EP5VCucYdhv4cn/g2sbnMT5HzK+gX3XEpBnnkEK/+WysCKzxuo3A==} resolution: {integrity: sha512-OAYgzqArPqgDaIJ1yT0RX31YCgr1lleo53zL+36i23PFjHu08CA6Uq+BmBzEV05yEidl+ILPdeSfF3G8hPG/JQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@elastic/transport@9.3.5': '@elastic/transport@8.10.1':
resolution: {integrity: sha512-hIMJbt1guqr3/N2zCN45k9hw9o78qcdsO0xietLe+Bfa+JL0YafHTgkWkM1oT3Ht5sGMJaDcJZiYomSMU6CtTA==} resolution: {integrity: sha512-xo2lPBAJEt81fQRAKa9T/gUq1SPGBHpSnVUXhoSpL996fPZRAfQwFA4BZtEUQL1p8Dezodd3ZN8Wwno+mYyKuw==}
engines: {node: '>=20'} engines: {node: '>=18'}
'@emnapi/core@1.7.1': '@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
@@ -3483,9 +3483,6 @@ packages:
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
'@tabby_ai/hijri-converter@1.0.5': '@tabby_ai/hijri-converter@1.0.5':
resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -3611,12 +3608,6 @@ packages:
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/command-line-args@5.2.3':
resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
'@types/command-line-usage@5.0.4':
resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==}
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -3788,9 +3779,6 @@ packages:
'@types/multer@2.0.0': '@types/multer@2.0.0':
resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==}
'@types/node@24.12.0':
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
'@types/node@25.5.0': '@types/node@25.5.0':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
@@ -4239,10 +4227,6 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
apache-arrow@21.1.0:
resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==}
hasBin: true
apache-crypt@1.2.6: apache-crypt@1.2.6:
resolution: {integrity: sha512-072WetlM4blL8PREJVeY+WHiUh1R5VNt2HfceGS8aKqttPHcmqE5pkKuXPz/ULmJOFkc8Hw3kfKl6vy7Qka6DA==} resolution: {integrity: sha512-072WetlM4blL8PREJVeY+WHiUh1R5VNt2HfceGS8aKqttPHcmqE5pkKuXPz/ULmJOFkc8Hw3kfKl6vy7Qka6DA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4281,10 +4265,6 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
array-back@6.2.2:
resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==}
engines: {node: '>=12.17'}
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4576,10 +4556,6 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
chalk-template@0.4.0:
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
engines: {node: '>=12'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -4731,19 +4707,6 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
command-line-args@6.0.1:
resolution: {integrity: sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==}
engines: {node: '>=12.20'}
peerDependencies:
'@75lb/nature': latest
peerDependenciesMeta:
'@75lb/nature':
optional: true
command-line-usage@7.0.3:
resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==}
engines: {node: '>=12.20.0'}
commander@14.0.2: commander@14.0.2:
resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -5504,15 +5467,6 @@ packages:
resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
find-replace@5.0.2:
resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==}
engines: {node: '>=14'}
peerDependencies:
'@75lb/nature': latest
peerDependenciesMeta:
'@75lb/nature':
optional: true
find-up@4.1.0: find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -5525,9 +5479,6 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'} engines: {node: '>=16'}
flatbuffers@25.9.23:
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
flatted@3.4.2: flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
@@ -6251,10 +6202,6 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
json-bignum@0.0.3:
resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==}
engines: {node: '>=0.8'}
json-buffer@3.0.1: json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -6430,9 +6377,6 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.debounce@4.0.8: lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -7433,8 +7377,8 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
secure-json-parse@4.1.0: secure-json-parse@3.0.2:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==}
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
@@ -7773,10 +7717,6 @@ packages:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
table-layout@4.1.1:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
tablesort@5.6.0: tablesort@5.6.0:
resolution: {integrity: sha512-cZZXK3G089PbpxH8N7vN7Z21SEKqXAaCiSVOmZdR/v7z8TFCsF/OFr0rzjhQuFlQQHy9uQtW9P2oQFJzJFGVrg==} resolution: {integrity: sha512-cZZXK3G089PbpxH8N7vN7Z21SEKqXAaCiSVOmZdR/v7z8TFCsF/OFr0rzjhQuFlQQHy9uQtW9P2oQFJzJFGVrg==}
engines: {node: '>= 16', npm: '>= 8'} engines: {node: '>= 16', npm: '>= 8'}
@@ -8089,10 +8029,6 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
typical@7.3.0:
resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
engines: {node: '>=12.17'}
uglify-js@3.19.3: uglify-js@3.19.3:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
@@ -8110,9 +8046,6 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici-types@7.18.2: undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -8435,10 +8368,6 @@ packages:
wordwrap@1.0.0: wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
wordwrapjs@5.1.1:
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
engines: {node: '>=12.17'}
wrap-ansi@6.2.0: wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -10057,23 +9986,21 @@ snapshots:
dependencies: dependencies:
'@types/hammerjs': 2.0.46 '@types/hammerjs': 2.0.46
'@elastic/elasticsearch@9.3.4': '@elastic/elasticsearch@8.13.0':
dependencies: dependencies:
'@elastic/transport': 9.3.5 '@elastic/transport': 8.10.1
apache-arrow: 21.1.0
tslib: 2.8.1 tslib: 2.8.1
transitivePeerDependencies: transitivePeerDependencies:
- '@75lb/nature'
- supports-color - supports-color
'@elastic/transport@9.3.5': '@elastic/transport@8.10.1':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
debug: 4.4.3 debug: 4.4.3
hpagent: 1.2.0 hpagent: 1.2.0
ms: 2.1.3 ms: 2.1.3
secure-json-parse: 4.1.0 secure-json-parse: 3.0.2
tslib: 2.8.1 tslib: 2.8.1
undici: 7.24.4 undici: 7.24.4
transitivePeerDependencies: transitivePeerDependencies:
@@ -10909,9 +10836,9 @@ snapshots:
'@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
'@nestjs/websockets': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/websockets': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/elasticsearch@11.1.0(@elastic/elasticsearch@9.3.4)(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': '@nestjs/elasticsearch@11.1.0(@elastic/elasticsearch@8.13.0)(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
dependencies: dependencies:
'@elastic/elasticsearch': 9.3.4 '@elastic/elasticsearch': 8.13.0
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
rxjs: 7.8.2 rxjs: 7.8.2
@@ -12095,10 +12022,6 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@swc/helpers@0.5.17':
dependencies:
tslib: 2.8.1
'@tabby_ai/hijri-converter@1.0.5': {} '@tabby_ai/hijri-converter@1.0.5': {}
'@tanstack/query-core@5.91.2': {} '@tanstack/query-core@5.91.2': {}
@@ -12235,10 +12158,6 @@ snapshots:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
assertion-error: 2.0.1 assertion-error: 2.0.1
'@types/command-line-args@5.2.3': {}
'@types/command-line-usage@5.0.4': {}
'@types/connect@3.4.38': '@types/connect@3.4.38':
dependencies: dependencies:
'@types/node': 25.5.0 '@types/node': 25.5.0
@@ -12444,10 +12363,6 @@ snapshots:
dependencies: dependencies:
'@types/express': 5.0.5 '@types/express': 5.0.5
'@types/node@24.12.0':
dependencies:
undici-types: 7.16.0
'@types/node@25.5.0': '@types/node@25.5.0':
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
@@ -12929,20 +12844,6 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.1 picomatch: 2.3.1
apache-arrow@21.1.0:
dependencies:
'@swc/helpers': 0.5.17
'@types/command-line-args': 5.2.3
'@types/command-line-usage': 5.0.4
'@types/node': 24.12.0
command-line-args: 6.0.1
command-line-usage: 7.0.3
flatbuffers: 25.9.23
json-bignum: 0.0.3
tslib: 2.8.1
transitivePeerDependencies:
- '@75lb/nature'
apache-crypt@1.2.6: apache-crypt@1.2.6:
dependencies: dependencies:
unix-crypt-td-js: 1.1.4 unix-crypt-td-js: 1.1.4
@@ -12973,8 +12874,6 @@ snapshots:
aria-query@5.3.2: {} aria-query@5.3.2: {}
array-back@6.2.2: {}
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -13348,10 +13247,6 @@ snapshots:
chai@6.2.2: {} chai@6.2.2: {}
chalk-template@0.4.0:
dependencies:
chalk: 4.1.2
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -13505,20 +13400,6 @@ snapshots:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
command-line-args@6.0.1:
dependencies:
array-back: 6.2.2
find-replace: 5.0.2
lodash.camelcase: 4.3.0
typical: 7.3.0
command-line-usage@7.0.3:
dependencies:
array-back: 6.2.2
chalk-template: 0.4.0
table-layout: 4.1.1
typical: 7.3.0
commander@14.0.2: {} commander@14.0.2: {}
commander@2.20.3: {} commander@2.20.3: {}
@@ -14463,8 +14344,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
find-replace@5.0.2: {}
find-up@4.1.0: find-up@4.1.0:
dependencies: dependencies:
locate-path: 5.0.0 locate-path: 5.0.0
@@ -14480,8 +14359,6 @@ snapshots:
flatted: 3.4.2 flatted: 3.4.2
keyv: 4.5.4 keyv: 4.5.4
flatbuffers@25.9.23: {}
flatted@3.4.2: {} flatted@3.4.2: {}
fn.name@1.1.0: {} fn.name@1.1.0: {}
@@ -15418,8 +15295,6 @@ snapshots:
jsesc@3.1.0: {} jsesc@3.1.0: {}
json-bignum@0.0.3: {}
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@2.3.1: {}
@@ -15570,8 +15445,6 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
lodash.debounce@4.0.8: {} lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {} lodash.defaults@4.2.0: {}
@@ -16568,7 +16441,7 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.18.0) ajv-formats: 2.1.1(ajv@8.18.0)
ajv-keywords: 5.1.0(ajv@8.18.0) ajv-keywords: 5.1.0(ajv@8.18.0)
secure-json-parse@4.1.0: {} secure-json-parse@3.0.2: {}
semver@6.3.1: {} semver@6.3.1: {}
@@ -16998,11 +16871,6 @@ snapshots:
dependencies: dependencies:
'@pkgr/core': 0.2.9 '@pkgr/core': 0.2.9
table-layout@4.1.1:
dependencies:
array-back: 6.2.2
wordwrapjs: 5.1.1
tablesort@5.6.0: {} tablesort@5.6.0: {}
tailwind-merge@3.5.0: {} tailwind-merge@3.5.0: {}
@@ -17308,8 +17176,6 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
typical@7.3.0: {}
uglify-js@3.19.3: uglify-js@3.19.3:
optional: true optional: true
@@ -17326,8 +17192,6 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
undici-types@7.16.0: {}
undici-types@7.18.2: {} undici-types@7.18.2: {}
undici@7.24.4: {} undici@7.24.4: {}
@@ -17665,8 +17529,6 @@ snapshots:
wordwrap@1.0.0: {} wordwrap@1.0.0: {}
wordwrapjs@5.1.1: {}
wrap-ansi@6.2.0: wrap-ansi@6.2.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -33,6 +33,11 @@ related:
## 3.3.4. การอ้างอิงและจัดกลุ่ม: ## 3.3.4. การอ้างอิงและจัดกลุ่ม:
- RFA สามารถอ้างถึง (Reference) แบบก่อสร้าง (Shop Drawing) ได้หลายฉบับ - RFA สามารถอ้างถึง (Reference) แบบก่อสร้าง (Shop Drawing) ได้หลายฉบับ
- การสร้าง RFA ต้องสร้างเอกสารแม่ใน `correspondences` โดยใช้ `correspondence_types.type_code = 'RFA'`
- ประเภทย่อยของ RFA ต้องเก็บใน `rfas.rfa_type_id`
- ถ้า `rfa_types.type_code` เป็น `DDW` หรือ `SDW` ระบบต้องบังคับให้เลือกอย่างน้อย 1 `shop_drawing_revision`
- ถ้า `rfa_types.type_code` เป็น `ADW` ระบบต้องบังคับให้เลือกอย่างน้อย 1 `asbuilt_drawing_revision`
- 1 แถวใน `rfa_items` ต้องอ้างอิง Drawing Revision ได้เพียง 1 รายการเท่านั้น โดยเป็น `shop_drawing_revision` หรือ `asbuilt_drawing_revision` อย่างใดอย่างหนึ่ง
## 3.3.5. Workflow (Unified Workflow): ## 3.3.5. Workflow (Unified Workflow):
@@ -522,7 +522,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| :----------- | :----------- | :-------------------------- | :------------------------------ | | :----------- | :----------- | :-------------------------- | :------------------------------ |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier |
| contract_id | INT | NOT NULL, FK | Contract reference | | contract_id | INT | NOT NULL, FK | Contract reference |
| type_code | VARCHAR(20) | NOT NULL | Type code (DWG, DOC, MAT, etc.) | | type_code | VARCHAR(20) | NOT NULL | Type code (DDW, SDW, ADW, DOC, MAT, etc.) |
| type_name_th | VARCHAR(100) | NOT NULL | Full type name (TH) | | type_name_th | VARCHAR(100) | NOT NULL | Full type name (TH) |
| type_name_en | VARCHAR(100) | NOT NULL | Full type name (EN) | | type_name_en | VARCHAR(100) | NOT NULL | Full type name (EN) |
| remark | TEXT | NULL | Remark | | remark | TEXT | NULL | Remark |
@@ -653,29 +653,41 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
### 4.6 rfa_items ### 4.6 rfa_items
**Purpose**: Junction table linking RFA revisions to shop drawing revisions (M:N) **Purpose**: Child table linking RFA revisions to drawing revisions that require approval
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| :----------------------- | :-------- | :-------------- | :----------------------- | | :------------------------- | :----------------------- | :------------------- | :--------------------------------- |
| rfa_revision_id | INT | PRIMARY KEY, FK | RFA Revision ID | | id | INT | PRIMARY KEY, AI | Unique identifier |
| shop_drawing_revision_id | INT | PRIMARY KEY, FK | Shop drawing revision ID | | rfa_revision_id | INT | NOT NULL, FK | RFA Revision ID |
| item_type | ENUM('SHOP','AS_BUILT') | NOT NULL | Drawing reference type |
| shop_drawing_revision_id | INT | NULL, FK | Shop drawing revision ID |
| asbuilt_drawing_revision_id| INT | NULL, FK | As-Built drawing revision ID |
**Indexes**: **Indexes**:
* PRIMARY KEY (rfa_revision_id, shop_drawing_revision_id) * PRIMARY KEY (id)
* FOREIGN KEY (rfa_revision_id) REFERENCES rfa_revisions(id) ON DELETE CASCADE * FOREIGN KEY (rfa_revision_id) REFERENCES rfa_revisions(id) ON DELETE CASCADE
* 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
* FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE
* UNIQUE KEY (rfa_revision_id, shop_drawing_revision_id)
* UNIQUE KEY (rfa_revision_id, asbuilt_drawing_revision_id)
* INDEX (item_type)
* INDEX (shop_drawing_revision_id) * INDEX (shop_drawing_revision_id)
* INDEX (asbuilt_drawing_revision_id)
**Relationships**: **Relationships**:
* Parent: rfa_revisions, shop_drawing_revisions * Parent: rfa_revisions, shop_drawing_revisions, asbuilt_drawing_revisions
**Business Rules**: **Business Rules**:
* Used primarily for RFA type = ' DWG ' (Shop Drawing) * `correspondences.correspondence_type_id` for an RFA must always point to `correspondence_types.type_code = 'RFA'`
* One RFA can contain multiple shop drawings * `rfas.rfa_type_id` stores the selected RFA subtype
* One shop drawing can be referenced by multiple RFAs * `DDW` and `SDW` RFA types must reference `shop_drawing_revisions`
* `ADW` RFA types must reference `asbuilt_drawing_revisions`
* Each `rfa_items` row must reference exactly one drawing revision target according to `item_type`
* One RFA can contain multiple drawing references
* One drawing revision can be referenced by multiple RFAs
--- ---
@@ -1515,7 +1527,8 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| ---------------------- | ------------ | --------------------------- | -------------------------------------------- | | ---------------------- | ------------ | --------------------------- | -------------------------------------------- |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique format ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique format ID |
| project_id | INT | NOT NULL, FK | Reference to projects | | project_id | INT | NOT NULL, FK | Reference to projects |
| correspondence_type_id | INT | NOT NULL, FK | Reference to correspondence_types | | correspondence_type_id | INT | NULL, FK | Reference to correspondence_types |
| discipline_id | INT | DEFAULT 0, FK | Reference to disciplines (0 = all) |
| format_string | VARCHAR(100) | NOT NULL | Format pattern (e.g., {ORG}-{TYPE}-{YYYY}-#) | | format_string | VARCHAR(100) | NOT NULL | Format pattern (e.g., {ORG}-{TYPE}-{YYYY}-#) |
| description | TEXT | NULL | Format description | | description | TEXT | NULL | Format description |
| reset_annually | BOOLEAN | DEFAULT TRUE | Start sequence new every year | | reset_annually | BOOLEAN | DEFAULT TRUE | Start sequence new every year |
@@ -1526,7 +1539,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* PRIMARY KEY (id) * PRIMARY KEY (id)
* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE * FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
* FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE * FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE
* UNIQUE KEY (project_id, correspondence_type_id) * UNIQUE KEY (project_id, correspondence_type_id, discipline_id)
* INDEX (is_active) * INDEX (is_active)
**Relationships**: **Relationships**:
@@ -469,17 +469,22 @@ CREATE TABLE rfa_revisions (
SET NULL SET NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางขยายของ correspondence_revisions สำหรับ RFA (1:1)'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางขยายของ correspondence_revisions สำหรับ RFA (1:1)';
-- ตารางเชื่อมระหว่าง rfa_revisions (ที่เป็นประเภท DWG) กับ shop_drawing_revisions (M:N) -- ตารางรายการอ้างอิง Drawing Revision ของ RFA (รองรับ Shop Drawing และ As-Built Drawing)
CREATE TABLE rfa_items ( CREATE TABLE rfa_items (
rfa_revision_id INT COMMENT 'ID ของ RFA Revision', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
shop_drawing_revision_id INT COMMENT 'ID ของ Shop Drawing Revision', rfa_revision_id INT NOT NULL COMMENT 'ID ของ RFA Revision',
PRIMARY KEY ( item_type ENUM('SHOP', 'AS_BUILT') NOT NULL COMMENT 'ประเภท Drawing Revision ที่ถูกอ้างอิง',
rfa_revision_id, shop_drawing_revision_id INT NULL COMMENT 'ID ของ Shop Drawing Revision',
shop_drawing_revision_id asbuilt_drawing_revision_id INT NULL COMMENT 'ID ของ As-Built Drawing Revision',
),
FOREIGN KEY (rfa_revision_id) REFERENCES rfa_revisions (id) ON DELETE CASCADE, FOREIGN KEY (rfa_revision_id) REFERENCES rfa_revisions (id) ON DELETE CASCADE,
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 = 'ตารางเชื่อมระหว่าง rfa_revisions (ที่เป็นประเภท DWG) กับ shop_drawing_revisions (M :N)'; FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions (id) ON DELETE CASCADE,
UNIQUE KEY uq_rfa_items_shop (rfa_revision_id, shop_drawing_revision_id),
UNIQUE KEY uq_rfa_items_asbuilt (rfa_revision_id, asbuilt_drawing_revision_id),
INDEX idx_rfa_items_type (item_type),
INDEX idx_rfa_items_shop (shop_drawing_revision_id),
INDEX idx_rfa_items_asbuilt (asbuilt_drawing_revision_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางรายการอ้างอิง Drawing Revision ของ RFA โดย 1 แถวต้องอ้างอิง Shop Drawing Revision หรือ As-Built Drawing Revision อย่างใดอย่างหนึ่ง';
-- ===================================================== -- =====================================================
-- 5. 📐 Drawings (แบบ, หมวดหมู่) -- 5. 📐 Drawings (แบบ, หมวดหมู่)
@@ -222,7 +222,7 @@ INSERT INTO users (
VALUES ( VALUES (
1, 1,
'superadmin', 'superadmin',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose', '$2b$10$60HqaxJFZSF8.n.kOPubge2pK3SXbz4tmNTmrQB/coZ8QXrFMcdIK',
'Super', 'Super',
'Admin', 'Admin',
'superadmin @example.com', 'superadmin @example.com',
@@ -232,7 +232,7 @@ VALUES (
( (
2, 2,
'admin', 'admin',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose', '$2b$10$60HqaxJFZSF8.n.kOPubge2pK3SXbz4tmNTmrQB/coZ8QXrFMcdIK',
'Admin', 'Admin',
'คคง.', 'คคง.',
'admin@example.com', 'admin@example.com',
@@ -242,7 +242,7 @@ VALUES (
( (
3, 3,
'editor01', 'editor01',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose', '$2b$10$60HqaxJFZSF8.n.kOPubge2pK3SXbz4tmNTmrQB/coZ8QXrFMcdIK',
'DC', 'DC',
'C1', 'C1',
'editor01 @example.com', 'editor01 @example.com',
@@ -252,7 +252,7 @@ VALUES (
( (
4, 4,
'viewer01', 'viewer01',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose', '$2b$10$60HqaxJFZSF8.n.kOPubge2pK3SXbz4tmNTmrQB/coZ8QXrFMcdIK',
'Viewer', 'Viewer',
'สคฉ.03', 'สคฉ.03',
'viewer01@example.com', 'viewer01@example.com',
@@ -273,7 +273,7 @@ INSERT INTO users (
VALUES ( VALUES (
5, 5,
'migration_bot', 'migration_bot',
'$2b$10$MpKnf1UEvlu8hZcqMkhMsuWG3gYD/priWTUr71GpF/uuroaGxtose', '$2b$10$60HqaxJFZSF8.n.kOPubge2pK3SXbz4tmNTmrQB/coZ8QXrFMcdIK',
'Migration', 'Migration',
'Bot', 'Bot',
'migration@system.internal', 'migration@system.internal',
@@ -125,16 +125,18 @@ LCBP3-DMS ต้องสร้างเลขที่เอกสารอั
CREATE TABLE document_number_formats ( CREATE TABLE document_number_formats (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
project_id INT NOT NULL, project_id INT NOT NULL,
correspondence_type_id INT NULL COMMENT 'Specific Type ID, or NULL for Project Default', -- CHANGED: Allow NULL correspondence_type_id INT NULL COMMENT 'Specific Type ID, or NULL for Project Default',
discipline_id INT DEFAULT 0 COMMENT 'Specific Discipline ID, or 0 for All',
format_template VARCHAR(100) NOT NULL COMMENT 'e.g. {PROJECT}-{TYPE}-{YEAR}-{SEQ:4}', format_template VARCHAR(100) NOT NULL COMMENT 'e.g. {PROJECT}-{TYPE}-{YEAR}-{SEQ:4}',
description TEXT, description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE, FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE,
-- Note: Application logic must enforce single default format per project -- Note: Application logic uses automated Upsert based on business key
UNIQUE KEY unique_format (project_id, correspondence_type_id) UNIQUE KEY unique_format (project_id, correspondence_type_id, discipline_id)
) ENGINE=InnoDB COMMENT='Template configurations for document numbering'; ) ENGINE=InnoDB COMMENT='Template configurations for document numbering';
-- Counter Table with Optimistic Locking -- Counter Table with Optimistic Locking
@@ -975,3 +977,4 @@ ensure:
| 1.0 | 2025-11-30 | Initial decision | | 1.0 | 2025-11-30 | Initial decision |
| 2.0 | 2025-12-02 | Updated with comprehensive error scenarios, monitoring, security, and all token types | | 2.0 | 2025-12-02 | Updated with comprehensive error scenarios, monitoring, security, and all token types |
| 3.0 | 2025-12-17 | Aligned with Requirements v1.6.2: updated counter schema, token definitions, Number State Machine | | 3.0 | 2025-12-17 | Aligned with Requirements v1.6.2: updated counter schema, token definitions, Number State Machine |
| 4.0 | 2026-03-21 | Added discipline_id to formats, implemented automated Upsert logic for template management |
+39
View File
@@ -0,0 +1,39 @@
const axios = require('axios');
async function test() {
try {
// Need to login first to get a token for superadmin
const loginRes = await axios.post('http://127.0.0.1:3001/api/auth/login', {
email: 'admin@lcbp3.com',
password: 'password123!' // Using common seed password
});
const token = loginRes.data.data.access_token;
console.log("Got token");
// Recreate the preview request
const previewRes = await axios.post('http://127.0.0.1:3001/api/document-numbering/preview', {
projectId: 1, // fallback
originatorOrganizationId: "0",
recipientOrganizationId: "0",
correspondenceTypeId: 0,
disciplineId: 0
}, {
headers: {
Authorization: `Bearer ${token}`
}
});
console.log("Status:", previewRes.status);
console.log("Body:", JSON.stringify(previewRes.data, null, 2));
} catch (err) {
if (err.response) {
console.log("Error Status:", err.response.status);
console.log("Error Body:", JSON.stringify(err.response.data, null, 2));
} else {
console.error(err);
}
}
}
test();