260321:1700 Correct Coresspondence / Doing RFA
This commit is contained in:
@@ -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
@@ -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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
+7
-7
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -94,8 +97,12 @@ export default function UsersPage() {
|
|||||||
header: "Organization",
|
header: "Organization",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const orgId = row.original.primaryOrganizationId;
|
const orgId = row.original.primaryOrganizationId;
|
||||||
const org = (organizations as Organization[]).find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString());
|
if (!orgId) {
|
||||||
return org ? org.organizationCode : "-";
|
return "All Organizations";
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = organizationList.find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString());
|
||||||
|
return org ? org.organizationCode : "All Organizations";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -185,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>
|
||||||
@@ -195,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) => (
|
||||||
@@ -204,7 +217,7 @@ export default function UsersPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable columns={columns} data={users || []} />
|
<DataTable columns={columns} data={userList} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<UserDialog
|
<UserDialog
|
||||||
|
|||||||
@@ -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 "New Workflow" to create one.
|
No workflow definitions found. Click "New Workflow" 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">
|
||||||
|
|||||||
+12
-3
@@ -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 ? (
|
||||||
+12
-3
@@ -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;
|
||||||
|
|||||||
@@ -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.primaryOrganizationId?.toString(),
|
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}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines }
|
|||||||
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
|
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
|
||||||
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({
|
||||||
@@ -34,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"]),
|
||||||
@@ -42,21 +46,62 @@ const correspondenceSchema = z.object({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof correspondenceSchema>;
|
type FormData = z.infer<typeof correspondenceSchema>;
|
||||||
|
|
||||||
|
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 }) {
|
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
|
// Extract initial values if editing
|
||||||
const currentRev = initialData?.revisions?.find((r: any) => 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?.projectId ? String(initialData.projectId) : 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 || currentRev?.title || "",
|
subject: currentRev?.subject || currentRev?.title || "",
|
||||||
@@ -64,6 +109,9 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
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,
|
||||||
|
documentDate: currentRev?.documentDate ? new Date(currentRev.documentDate).toISOString().split('T')[0] : undefined,
|
||||||
|
issuedDate: currentRev?.issuedDate ? new Date(currentRev.issuedDate).toISOString().split('T')[0] : undefined,
|
||||||
|
receivedDate: currentRev?.receivedDate ? new Date(currentRev.receivedDate).toISOString().split('T')[0] : undefined,
|
||||||
fromOrganizationId: initialData?.originatorId ? String(initialData.originatorId) : undefined,
|
fromOrganizationId: initialData?.originatorId ? String(initialData.originatorId) : undefined,
|
||||||
// Map initial recipient (TO) - Simplified for now
|
// Map initial recipient (TO) - Simplified for now
|
||||||
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId
|
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId
|
||||||
@@ -79,7 +127,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,6 +149,9 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
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' }
|
||||||
@@ -135,19 +187,14 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -219,8 +266,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
|
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(projects || []).map((p: any) => (
|
{projects.map((p) => (
|
||||||
<SelectItem key={p.id} value={String(p.id)}>
|
<SelectItem key={p.uuid || String(p.id)} value={p.uuid || String(p.id)}>
|
||||||
{p.projectName} ({p.projectCode})
|
{p.projectName} ({p.projectCode})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -243,7 +290,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
<SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} />
|
<SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(correspondenceTypes || []).map((t: any) => (
|
{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>
|
||||||
@@ -267,7 +314,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} />
|
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(disciplines || []).map((d: any) => (
|
{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>
|
||||||
@@ -297,11 +344,47 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
/>
|
/>
|
||||||
</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>
|
||||||
@@ -309,6 +392,14 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
</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>
|
||||||
@@ -333,7 +424,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
<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>
|
||||||
@@ -356,7 +447,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
|
|||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+61
-5
@@ -23,6 +23,59 @@ function getJwtExpiry(token: string): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TokenPayload {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginPayload extends TokenPayload {
|
||||||
|
user: {
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`, {
|
||||||
@@ -38,7 +91,11 @@ async function refreshAccessToken(token: JWT) {
|
|||||||
throw refreshedTokens;
|
throw refreshedTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = refreshedTokens.data || refreshedTokens;
|
const data = unwrapApiResponse(refreshedTokens);
|
||||||
|
|
||||||
|
if (!isTokenPayload(data)) {
|
||||||
|
throw new Error("Invalid refresh response format");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
@@ -100,10 +157,9 @@ export const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Handling both { data: { ... } } and direct { ... } response formats
|
const backendData = unwrapApiResponse(data);
|
||||||
const backendData = data.data || data;
|
|
||||||
|
|
||||||
if (!backendData || !backendData.access_token) {
|
if (!isLoginPayload(backendData)) {
|
||||||
console.error("[AUTH] Login failed: Invalid response format from backend (missing access_token)");
|
console.error("[AUTH] Login failed: Invalid response format from backend (missing access_token)");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -112,7 +168,7 @@ export const {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: backendData.user.user_id.toString(),
|
id: backendData.user.user_id.toString(),
|
||||||
name: `${backendData.user.firstName} ${backendData.user.lastName}`,
|
name: `${backendData.user.firstName ?? ""} ${backendData.user.lastName ?? ""}`.trim(),
|
||||||
email: backendData.user.email,
|
email: backendData.user.email,
|
||||||
username: backendData.user.username,
|
username: backendData.user.username,
|
||||||
role: backendData.user.role || "User",
|
role: backendData.user.role || "User",
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ import {
|
|||||||
SearchOrganizationDto,
|
SearchOrganizationDto,
|
||||||
} from "@/types/dto/organization/organization.dto";
|
} from "@/types/dto/organization/organization.dto";
|
||||||
|
|
||||||
|
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 const masterDataService = {
|
export const masterDataService = {
|
||||||
// --- Tags Management ---
|
// --- Tags Management ---
|
||||||
|
|
||||||
@@ -77,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;
|
||||||
@@ -97,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 response.data.data || response.data;
|
return extractArrayData(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** สร้างสาขางานใหม่ */
|
/** สร้างสาขางานใหม่ */
|
||||||
@@ -119,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 response.data.data || response.data;
|
return extractArrayData(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** สร้างประเภทย่อยใหม่ */
|
/** สร้างประเภทย่อยใหม่ */
|
||||||
@@ -135,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 response.data.data || response.data;
|
return extractArrayData(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** สร้างประเภท RFA ใหม่ */
|
/** สร้างประเภท RFA ใหม่ */
|
||||||
@@ -156,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 response.data.data || response.data;
|
return extractArrayData(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
createCorrespondenceType: async (data: CreateCorrespondenceTypeDto) => {
|
createCorrespondenceType: async (data: CreateCorrespondenceTypeDto) => {
|
||||||
@@ -191,18 +209,18 @@ export const masterDataService = {
|
|||||||
const response = await apiClient.get("/drawings/contract/categories", {
|
const response = await apiClient.get("/drawings/contract/categories", {
|
||||||
params: { projectId }
|
params: { projectId }
|
||||||
});
|
});
|
||||||
return response.data.data || response.data;
|
return extractArrayData(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
getShopMainCategories: async (projectId: number) => {
|
getShopMainCategories: async (projectId: number) => {
|
||||||
const response = await apiClient.get("/drawings/shop/main-categories", { params: { projectId } });
|
const response = await apiClient.get("/drawings/shop/main-categories", { params: { projectId } });
|
||||||
return response.data.data || response.data;
|
return extractArrayData(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
getShopSubCategories: async (projectId: number, mainCategoryId?: number) => {
|
getShopSubCategories: async (projectId: number, mainCategoryId?: number) => {
|
||||||
const response = await apiClient.get("/drawings/shop/sub-categories", {
|
const response = await apiClient.get("/drawings/shop/sub-categories", {
|
||||||
params: { projectId, mainCategoryId }
|
params: { projectId, mainCategoryId }
|
||||||
});
|
});
|
||||||
return response.data.data || response.data;
|
return extractArrayData(response.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ 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>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// 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 {
|
||||||
@@ -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) ---
|
||||||
|
|||||||
+29
-15
@@ -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;
|
||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+17
-155
@@ -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 |
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user