From 03d16cfd649085f40529e535795e010e8bf016b1 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 21 Mar 2026 17:00:41 +0700 Subject: [PATCH] 260321:1700 Correct Coresspondence / Doing RFA --- CHANGELOG.md | 10 + CONTRIBUTING.md | 6 +- README.md | 2 +- backend/docker-compose.yml | 8 +- backend/package.json | 6 +- .../common/services/uuid-resolver.service.ts | 28 + .../correspondence/correspondence.service.ts | 5 + .../dto/create-correspondence.dto.ts | 8 + .../document-numbering-admin.controller.ts | 14 +- .../document-numbering.controller.ts | 6 +- .../entities/document-number-format.entity.ts | 8 +- .../services/document-numbering.service.ts | 48 +- .../services/format.service.ts | 12 +- .../services/reservation.service.ts | 2 +- .../src/modules/master/dto/create-tag.dto.ts | 9 + .../rfa/dto/create-rfa-revision.dto.ts | 16 +- backend/src/modules/rfa/dto/create-rfa.dto.ts | 23 +- .../modules/rfa/entities/rfa-item.entity.ts | 34 +- backend/src/modules/rfa/rfa.module.ts | 6 + backend/src/modules/rfa/rfa.service.ts | 183 ++++- .../admin/access-control/users/page.tsx | 23 +- .../doc-control/numbering/[id]/edit/page.tsx | 2 +- .../admin/doc-control/workflows/page.tsx | 5 +- .../admin/migration/errors/page.tsx | 15 +- .../admin/migration/page.tsx | 15 +- .../admin/migration/review/[id]/page.tsx | 0 .../admin/monitoring/sessions/page.tsx | 22 +- .../components/admin/security/rbac-matrix.tsx | 32 +- frontend/components/admin/user-dialog.tsx | 16 +- frontend/components/correspondences/form.tsx | 141 +++- .../components/numbering/template-editor.tsx | 18 + .../components/numbering/template-tester.tsx | 47 +- frontend/components/rfas/detail.tsx | 132 ++-- frontend/components/rfas/form.tsx | 628 ++++++++++++++---- frontend/components/rfas/list.tsx | 6 +- .../components/workflows/visual-builder.tsx | 265 +++++--- frontend/hooks/use-drawing.ts | 6 + frontend/hooks/use-reference-data.ts | 4 +- frontend/hooks/use-users.ts | 4 +- frontend/hooks/use-workflows.ts | 5 +- frontend/lib/api/numbering.ts | 53 +- frontend/lib/auth.ts | 66 +- frontend/lib/services/master-data.service.ts | 34 +- frontend/lib/services/migration.service.ts | 68 +- frontend/lib/services/session.service.ts | 31 +- frontend/lib/services/user.service.ts | 40 +- .../lib/services/workflow-engine.service.ts | 115 +++- .../create-correspondence.dto.ts | 9 + frontend/types/dto/rfa/rfa.dto.ts | 9 +- frontend/types/rfa.ts | 44 +- pnpm-lock.yaml | 172 +---- .../01-03-modules/01-03-03-rfa.md | 5 + .../03-01-data-dictionary.md | 39 +- .../lcbp3-v1.8.0-schema-02-tables.sql | 23 +- .../lcbp3-v1.8.0-seed-basic.sql | 10 +- .../ADR-002-document-numbering-strategy.md | 9 +- test_preview.js | 39 ++ 57 files changed, 1923 insertions(+), 663 deletions(-) rename frontend/app/{(dashboard) => (admin)}/admin/migration/errors/page.tsx (86%) rename frontend/app/{(dashboard) => (admin)}/admin/migration/page.tsx (93%) rename frontend/app/{(dashboard) => (admin)}/admin/migration/review/[id]/page.tsx (100%) create mode 100644 test_preview.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3e34b..12fd8d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [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) #### 🔧 **Backend Dependency Resolution** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37b48c9..badaa1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -180,7 +180,7 @@ POST /api/correspondences --- -**Last Updated**: 2026-03-19 +**Last Updated**: 2026-03-21 **Version**: 1.8.1 **Status**: Draft | Review | Approved ``` @@ -541,11 +541,11 @@ graph LR | 1.0.0 | 2025-01-15 | John Doe | Initial version | | 1.1.0 | 2025-02-20 | Jane Smith | Add CC support | | 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 **Status**: Approved -**Last Updated**: 2026-03-19 +**Last Updated**: 2026-03-21 **Security**: 0 vulnerabilities (backend) ``` diff --git a/README.md b/README.md index d033f75..6a794a7 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index e395be2..2a9505a 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -4,10 +4,10 @@ services: container_name: mariadb-local restart: always environment: - MYSQL_ROOT_PASSWORD: Center#2025 + MYSQL_ROOT_PASSWORD: Center2025 MYSQL_DATABASE: lcbp3_dev MYSQL_USER: admin - MYSQL_PASSWORD: Center#2025 + MYSQL_PASSWORD: Center2025 ports: - '3306:3306' volumes: @@ -47,9 +47,9 @@ services: environment: - discovery.type=single-node - xpack.security.enabled=false # āļ›āļīāļ” security āđ€āļžāļ·āđˆāļ­āļ„āļ§āļēāļĄāļ‡āđˆāļēāļĒāđƒāļ™ Dev (Prod āļ•āđ‰āļ­āļ‡āđ€āļ›āļīāļ”) - - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + - 'ES_JAVA_OPTS=-Xms512m -Xmx512m' ports: - - "9200:9200" + - '9200:9200' volumes: - esdata:/usr/share/elasticsearch/data networks: diff --git a/backend/package.json b/backend/package.json index 4f10263..e274a39 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@casl/ability": "^6.7.5", - "@elastic/elasticsearch": "^9.3.4", + "@elastic/elasticsearch": "^8.13.0", "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/axios": "^4.0.1", "@nestjs/bullmq": "^11.0.4", @@ -61,9 +61,9 @@ "helmet": "^8.1.0", "ioredis": "^5.8.2", "joi": "^18.0.1", + "ms": "^2.1.3", "multer": "^2.0.2", "mysql2": "^3.15.3", - "ms": "^2.1.3", "nest-winston": "^1.10.2", "nodemailer": "^8.0.3", "opossum": "^9.0.0", @@ -92,8 +92,8 @@ "@types/express": "^5.0.0", "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", - "@types/multer": "^2.0.0", "@types/ms": "^2.1.0", + "@types/multer": "^2.0.0", "@types/node": "^25.5.0", "@types/opossum": "^8.1.9", "@types/passport-jwt": "^4.0.1", diff --git a/backend/src/common/services/uuid-resolver.service.ts b/backend/src/common/services/uuid-resolver.service.ts index 15bc84f..9245c82 100644 --- a/backend/src/common/services/uuid-resolver.service.ts +++ b/backend/src/common/services/uuid-resolver.service.ts @@ -102,4 +102,32 @@ export class UuidResolverService { async resolveContractId(contractId: number | string): Promise { return this.resolve('Contract', 'contracts', 'id', contractId); } + + /** + * Resolve shopDrawingRevisionId (INT or UUID string) to internal INT ID. + */ + async resolveShopDrawingRevisionId( + shopDrawingRevisionId: number | string + ): Promise { + 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 { + return this.resolve( + 'As-Built Drawing Revision', + 'asbuilt_drawing_revisions', + 'id', + asBuiltDrawingRevisionId + ); + } } diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts index dcc27e6..1a7f2fe 100644 --- a/backend/src/modules/correspondence/correspondence.service.ts +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -207,6 +207,9 @@ export class CorrespondenceService { issuedDate: createDto.issuedDate ? new Date(createDto.issuedDate) : undefined, + receivedDate: createDto.receivedDate + ? new Date(createDto.receivedDate) + : undefined, description: createDto.description, details: createDto.details, createdBy: user.user_id, @@ -521,6 +524,8 @@ export class CorrespondenceService { revisionUpdate.documentDate = new Date(updateDto.documentDate); if (updateDto.issuedDate) revisionUpdate.issuedDate = new Date(updateDto.issuedDate); + if (updateDto.receivedDate) + revisionUpdate.receivedDate = new Date(updateDto.receivedDate); if (updateDto.description) revisionUpdate.description = updateDto.description; if (updateDto.details) revisionUpdate.details = updateDto.details; diff --git a/backend/src/modules/correspondence/dto/create-correspondence.dto.ts b/backend/src/modules/correspondence/dto/create-correspondence.dto.ts index aec8c62..86487d7 100644 --- a/backend/src/modules/correspondence/dto/create-correspondence.dto.ts +++ b/backend/src/modules/correspondence/dto/create-correspondence.dto.ts @@ -99,6 +99,14 @@ export class CreateCorrespondenceDto { @IsOptional() issuedDate?: string; + @ApiPropertyOptional({ + description: 'Received Date (āļ§āļąāļ™āļ—āļĩāđˆāļĢāļąāļšāđ€āļ­āļāļŠāļēāļĢ)', + example: '2025-12-06T00:00:00Z', + }) + @IsDateString() + @IsOptional() + receivedDate?: string; + @ApiPropertyOptional({ description: 'Attachment temp IDs from upload phase (Two-Phase Storage)', example: ['uuid-temp-1', 'uuid-temp-2'], diff --git a/backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts b/backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts index 9119861..bf61b54 100644 --- a/backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts +++ b/backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts @@ -32,7 +32,7 @@ export class DocumentNumberingAdminController { @Get('templates') @ApiOperation({ summary: 'Get all document numbering templates' }) - @RequirePermission('system.manage_settings') + @RequirePermission('numbering.view_formats') async getTemplates(@Query('projectId') projectId?: number) { if (projectId) { return this.service.getTemplatesByProject(projectId); @@ -42,7 +42,7 @@ export class DocumentNumberingAdminController { @Post('templates') @ApiOperation({ summary: 'Create or Update a numbering template' }) - @RequirePermission('system.manage_settings') + @RequirePermission('numbering.manage_formats') async saveTemplate( @Body() dto: Partial & { projectId?: number | string } ) { @@ -51,7 +51,7 @@ export class DocumentNumberingAdminController { @Delete('templates/:id') @ApiOperation({ summary: 'Delete a numbering template' }) - @RequirePermission('system.manage_settings') + @RequirePermission('numbering.manage_formats') async deleteTemplate(@Param('id', ParseIntPipe) id: number) { await this.service.deleteTemplate(id); return { success: true }; @@ -78,7 +78,7 @@ export class DocumentNumberingAdminController { @ApiOperation({ summary: 'Manually override or set a document number counter', }) - @RequirePermission('system.manage_settings') + @RequirePermission('numbering.manage_formats') async manualOverride( @Body() dto: ManualOverrideDto, @CurrentUser() user: User @@ -88,7 +88,7 @@ export class DocumentNumberingAdminController { @Post('void-and-replace') @ApiOperation({ summary: 'Void a number and replace with a new generation' }) - @RequirePermission('system.manage_settings') + @RequirePermission('numbering.manage_formats') async voidAndReplace( @Body() dto: { @@ -104,7 +104,7 @@ export class DocumentNumberingAdminController { @Post('cancel') @ApiOperation({ summary: 'Cancel/Skip a specific document number' }) - @RequirePermission('system.manage_settings') + @RequirePermission('numbering.manage_formats') async cancelNumber( @Body() dto: { @@ -119,7 +119,7 @@ export class DocumentNumberingAdminController { @Post('bulk-import') @ApiOperation({ summary: 'Bulk import/set document number counters' }) - @RequirePermission('system.manage_settings') + @RequirePermission('numbering.manage_formats') async bulkImport(@Body() items: ManualOverrideDto[]) { return this.service.bulkImport(items); } diff --git a/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts b/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts index f80830e..14e540d 100644 --- a/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts +++ b/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts @@ -68,7 +68,7 @@ export class DocumentNumberingController { @Patch('counters/:id') @ApiOperation({ summary: 'Update counter sequence value (Admin only)' }) - @RequirePermission('system.manage_settings') + @RequirePermission('numbering.manage_formats') async updateCounter( @Param('id', ParseIntPipe) id: number, @Body('sequence') sequence: number @@ -105,7 +105,7 @@ export class DocumentNumberingController { ) : undefined; - return this.numberingService.previewNumber({ + const result = await this.numberingService.previewNumber({ projectId: resolvedProjectId, originatorOrganizationId: resolvedOriginatorId, typeId: dto.correspondenceTypeId, @@ -116,5 +116,7 @@ export class DocumentNumberingController { year: dto.year, customTokens: dto.customTokens, }); + console.log('[DocumentNumberingController] Preview result:', JSON.stringify(result)); + return result; } } diff --git a/backend/src/modules/document-numbering/entities/document-number-format.entity.ts b/backend/src/modules/document-numbering/entities/document-number-format.entity.ts index 28f4846..b979247 100644 --- a/backend/src/modules/document-numbering/entities/document-number-format.entity.ts +++ b/backend/src/modules/document-numbering/entities/document-number-format.entity.ts @@ -14,7 +14,7 @@ import { Project } from '../../project/entities/project.entity'; import { CorrespondenceType } from '../../correspondence/entities/correspondence-type.entity'; @Entity('document_number_formats') -@Unique(['projectId', 'correspondenceTypeId']) +@Unique(['projectId', 'correspondenceTypeId', 'disciplineId']) export class DocumentNumberFormat { @PrimaryGeneratedColumn() id!: number; @@ -25,6 +25,9 @@ export class DocumentNumberFormat { @Column({ name: 'correspondence_type_id', nullable: true }) correspondenceTypeId?: number; + @Column({ name: 'discipline_id', default: 0 }) + disciplineId!: number; + @Column({ name: 'format_string', length: 100 }) formatTemplate!: string; @@ -35,6 +38,9 @@ export class DocumentNumberFormat { @Column({ name: 'reset_annually', default: true }) resetSequenceYearly!: boolean; + @Column({ name: 'is_active', default: 1 }) + isActive!: number; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/backend/src/modules/document-numbering/services/document-numbering.service.ts b/backend/src/modules/document-numbering/services/document-numbering.service.ts index 0cca148..4145df6 100644 --- a/backend/src/modules/document-numbering/services/document-numbering.service.ts +++ b/backend/src/modules/document-numbering/services/document-numbering.service.ts @@ -5,7 +5,13 @@ import { NotFoundException, } from '@nestjs/common'; 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 { DocumentNumberFormat } from '../entities/document-number-format.entity'; @@ -121,7 +127,7 @@ export class DocumentNumberingService { const sequence = await this.counterService.incrementCounter(key); // 4. Format Number - const documentNumber = await this.formatService.format({ + const { previewNumber: documentNumber } = await this.formatService.format({ projectId: ctx.projectId, correspondenceTypeId: ctx.typeId, subTypeId: ctx.subTypeId, @@ -193,7 +199,7 @@ export class DocumentNumberingService { async previewNumber( ctx: GenerateNumberContext - ): Promise<{ previewNumber: string; nextSequence: number }> { + ): Promise<{ previewNumber: string; nextSequence: number; isDefault: boolean }> { const currentYear = new Date().getFullYear(); const resetScope = `YEAR_${currentYear}`; @@ -211,7 +217,7 @@ export class DocumentNumberingService { const currentSeq = await this.counterService.getCurrentCounter(key); const nextSequence = currentSeq + 1; - const previewNumber = await this.formatService.format({ + const { previewNumber, isDefault } = await this.formatService.format({ projectId: ctx.projectId, correspondenceTypeId: ctx.typeId, subTypeId: ctx.subTypeId, @@ -224,7 +230,7 @@ export class DocumentNumberingService { recipientOrganizationId: ctx.recipientOrganizationId, }); - return { previewNumber, nextSequence }; + return { previewNumber, nextSequence, isDefault }; } /** @@ -258,10 +264,36 @@ export class DocumentNumberingService { async saveTemplate( dto: Partial & { projectId?: number | string } ) { - if (dto.projectId) { - dto.projectId = await this.uuidResolver.resolveProjectId(dto.projectId); + try { + 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) { diff --git a/backend/src/modules/document-numbering/services/format.service.ts b/backend/src/modules/document-numbering/services/format.service.ts index 853b9c7..9f348a2 100644 --- a/backend/src/modules/document-numbering/services/format.service.ts +++ b/backend/src/modules/document-numbering/services/format.service.ts @@ -39,12 +39,14 @@ export class FormatService { private disciplineRepo: Repository ) {} - async format(options: FormatOptions): Promise { - const { template } = await this.resolveFormatAndScope(options); + async format(options: FormatOptions): Promise<{ previewNumber: string; isDefault: boolean }> { + const { template, isDefault } = await this.resolveFormatAndScope(options); const currentYear = options.year || new Date().getFullYear(); 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 --- @@ -52,6 +54,7 @@ export class FormatService { private async resolveFormatAndScope(options: FormatOptions): Promise<{ template: string; resetSequenceYearly: boolean; + isDefault: boolean; }> { // 1. Specific Format const specificFormat = await this.formatRepo.findOne({ @@ -64,6 +67,7 @@ export class FormatService { return { template: specificFormat.formatTemplate, resetSequenceYearly: specificFormat.resetSequenceYearly, + isDefault: false, }; // 2. Default Format @@ -74,12 +78,14 @@ export class FormatService { return { template: defaultFormat.formatTemplate, resetSequenceYearly: defaultFormat.resetSequenceYearly, + isDefault: true, }; // 3. Fallback return { template: '{ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE}', resetSequenceYearly: true, + isDefault: true, }; } diff --git a/backend/src/modules/document-numbering/services/reservation.service.ts b/backend/src/modules/document-numbering/services/reservation.service.ts index cbc7ccd..0a280d7 100644 --- a/backend/src/modules/document-numbering/services/reservation.service.ts +++ b/backend/src/modules/document-numbering/services/reservation.service.ts @@ -61,7 +61,7 @@ export class ReservationService { const sequence = await this.counterService.incrementCounter(counterKey); // Format document number - const documentNumber = await this.formatService.format({ + const { previewNumber: documentNumber } = await this.formatService.format({ ...dto, sequence, resetScope: counterKey.resetScope, diff --git a/backend/src/modules/master/dto/create-tag.dto.ts b/backend/src/modules/master/dto/create-tag.dto.ts index 836a8e9..70f461b 100644 --- a/backend/src/modules/master/dto/create-tag.dto.ts +++ b/backend/src/modules/master/dto/create-tag.dto.ts @@ -12,6 +12,15 @@ export class CreateTagDto { @IsOptional() description?: string; + @ApiProperty({ + example: 'red', + description: 'āļĢāļŦāļąāļŠāļŠāļĩ āļŦāļĢāļ·āļ­āļŠāļ·āđˆāļ­āļ„āļĨāļēāļŠāļŠāļģāļŦāļĢāļąāļš UI', + required: false, + }) + @IsString() + @IsOptional() + color_code?: string; + @ApiProperty({ example: 1, description: 'Project ID or UUID', diff --git a/backend/src/modules/rfa/dto/create-rfa-revision.dto.ts b/backend/src/modules/rfa/dto/create-rfa-revision.dto.ts index 8b2301b..554409a 100644 --- a/backend/src/modules/rfa/dto/create-rfa-revision.dto.ts +++ b/backend/src/modules/rfa/dto/create-rfa-revision.dto.ts @@ -86,13 +86,21 @@ export class CreateRfaRevisionDto { }) @IsObject() @IsOptional() - details?: Record; + details?: Record; @ApiPropertyOptional({ - description: 'Linked Shop Drawing Revision IDs', - example: [1, 2], + description: 'Linked Shop Drawing Revision IDs or UUIDs', + example: ['shop-revision-uuid-1', 'shop-revision-uuid-2'], }) @IsArray() @IsOptional() - shopDrawingRevisionIds?: number[]; // IDs of linked Shop Drawings + shopDrawingRevisionIds?: Array; + + @ApiPropertyOptional({ + description: 'Linked As-Built Drawing Revision IDs or UUIDs', + example: ['asbuilt-revision-uuid-1'], + }) + @IsArray() + @IsOptional() + asBuiltDrawingRevisionIds?: Array; } diff --git a/backend/src/modules/rfa/dto/create-rfa.dto.ts b/backend/src/modules/rfa/dto/create-rfa.dto.ts index c9274f6..65d28f7 100644 --- a/backend/src/modules/rfa/dto/create-rfa.dto.ts +++ b/backend/src/modules/rfa/dto/create-rfa.dto.ts @@ -20,9 +20,9 @@ export class CreateRfaDto { @IsOptional() contractId?: string; - @ApiProperty({ description: 'To Organization ID or UUID', required: false }) - @IsOptional() - toOrganizationId?: number | string; + @ApiProperty({ description: 'To Organization ID or UUID' }) + @IsNotEmpty() + toOrganizationId!: number | string; @ApiProperty({ description: 'ID āļ‚āļ­āļ‡āļ›āļĢāļ°āđ€āļ āļ— RFA', example: 1 }) @IsInt() @@ -76,14 +76,23 @@ export class CreateRfaDto { }) @IsObject() @IsOptional() - details?: Record; + details?: Record; @ApiProperty({ - description: 'āļĢāļēāļĒāļāļēāļĢ Shop Drawing Revisions āļ—āļĩāđˆāđāļ™āļšāļĄāļēāļ”āđ‰āļ§āļĒ', + description: 'āļĢāļēāļĒāļāļēāļĢ Shop Drawing Revision IDs āļŦāļĢāļ·āļ­ UUIDs āļ—āļĩāđˆāđāļ™āļšāļĄāļēāļ”āđ‰āļ§āļĒ', required: false, - type: [Number], + type: [String], }) @IsArray() @IsOptional() - shopDrawingRevisionIds?: number[]; + shopDrawingRevisionIds?: Array; + + @ApiProperty({ + description: 'āļĢāļēāļĒāļāļēāļĢ As-Built Drawing Revision IDs āļŦāļĢāļ·āļ­ UUIDs āļ—āļĩāđˆāđāļ™āļšāļĄāļēāļ”āđ‰āļ§āļĒ', + required: false, + type: [String], + }) + @IsArray() + @IsOptional() + asBuiltDrawingRevisionIds?: Array; } diff --git a/backend/src/modules/rfa/entities/rfa-item.entity.ts b/backend/src/modules/rfa/entities/rfa-item.entity.ts index 238a1a1..62c5e70 100644 --- a/backend/src/modules/rfa/entities/rfa-item.entity.ts +++ b/backend/src/modules/rfa/entities/rfa-item.entity.ts @@ -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 { AsBuiltDrawingRevision } from '../../drawing/entities/asbuilt-drawing-revision.entity'; import { ShopDrawingRevision } from '../../drawing/entities/shop-drawing-revision.entity'; @Entity('rfa_items') export class RfaItem { - @PrimaryColumn({ name: 'rfa_revision_id' }) + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'rfa_revision_id' }) rfaRevisionId!: number; - @PrimaryColumn({ name: 'shop_drawing_revision_id' }) - shopDrawingRevisionId!: number; + @Column({ + 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 @ManyToOne(() => RfaRevision, (rfaRev) => rfaRev.items, { @@ -19,5 +39,9 @@ export class RfaItem { @ManyToOne(() => ShopDrawingRevision) @JoinColumn({ name: 'shop_drawing_revision_id' }) - shopDrawingRevision!: ShopDrawingRevision; + shopDrawingRevision?: ShopDrawingRevision; + + @ManyToOne(() => AsBuiltDrawingRevision) + @JoinColumn({ name: 'asbuilt_drawing_revision_id' }) + asBuiltDrawingRevision?: AsBuiltDrawingRevision; } diff --git a/backend/src/modules/rfa/rfa.module.ts b/backend/src/modules/rfa/rfa.module.ts index 13e9229..ec8d242 100644 --- a/backend/src/modules/rfa/rfa.module.ts +++ b/backend/src/modules/rfa/rfa.module.ts @@ -7,10 +7,13 @@ import { CorrespondenceRouting } from '../correspondence/entities/correspondence import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.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 { RoutingTemplate } from '../correspondence/entities/routing-template.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 { Discipline } from '../master/entities/discipline.entity'; import { RfaApproveCode } from './entities/rfa-approve-code.entity'; import { RfaItem } from './entities/rfa-item.entity'; import { RfaRevision } from './entities/rfa-revision.entity'; @@ -46,7 +49,10 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module' Correspondence, CorrespondenceRevision, CorrespondenceStatus, + CorrespondenceType, + AsBuiltDrawingRevision, ShopDrawingRevision, + Discipline, RfaWorkflow, RfaWorkflowTemplate, RfaWorkflowTemplateStep, diff --git a/backend/src/modules/rfa/rfa.service.ts b/backend/src/modules/rfa/rfa.service.ts index 3ea37ac..b1c3e72 100644 --- a/backend/src/modules/rfa/rfa.service.ts +++ b/backend/src/modules/rfa/rfa.service.ts @@ -13,12 +13,16 @@ import { DataSource, In, Repository } from 'typeorm'; // Entities import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity'; +import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.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 { 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 { Discipline } from '../master/entities/discipline.entity'; import { User } from '../user/entities/user.entity'; import { RfaApproveCode } from './entities/rfa-approve-code.entity'; import { RfaItem } from './entities/rfa-item.entity'; @@ -72,10 +76,16 @@ export class RfaService { private corrRevRepo: Repository, @InjectRepository(CorrespondenceStatus) private corrStatusRepo: Repository, + @InjectRepository(CorrespondenceType) + private correspondenceTypeRepo: Repository, + @InjectRepository(Discipline) + private disciplineRepo: Repository, @InjectRepository(RfaStatusCode) private rfaStatusRepo: Repository, @InjectRepository(RfaApproveCode) private rfaApproveRepo: Repository, + @InjectRepository(AsBuiltDrawingRevision) + private asBuiltDrawingRevRepo: Repository, @InjectRepository(ShopDrawingRevision) private shopDrawingRevRepo: Repository, @InjectRepository(CorrespondenceRouting) @@ -105,6 +115,104 @@ export class RfaService { }); 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({ where: { statusCode: 'DFT' }, }); @@ -135,7 +243,9 @@ export class RfaService { const docNumber = await this.numberingService.generateNextNumber({ projectId: internalProjectId, originatorOrganizationId: userOrgId, - typeId: createDto.rfaTypeId, + recipientOrganizationId: internalRecipientOrgId, + typeId: correspondenceType.id, + rfaTypeId: createDto.rfaTypeId, disciplineId: createDto.disciplineId ?? 0, // ✅ āļŠāđˆāļ‡ disciplineId āđ„āļ›āļ”āđ‰āļ§āļĒ (0 āļ–āđ‰āļēāđ„āļĄāđˆāļĄāļĩ) year: new Date().getFullYear(), customTokens: { @@ -159,7 +269,7 @@ export class RfaService { // 1. Create Correspondence Record const correspondence = queryRunner.manager.create(Correspondence, { correspondenceNumber: docNumber.number, - correspondenceTypeId: createDto.rfaTypeId, + correspondenceTypeId: correspondenceType.id, projectId: internalProjectId, originatorId: userOrgId, isInternal: false, @@ -168,6 +278,15 @@ export class RfaService { }); 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 const rfa = queryRunner.manager.create(Rfa, { id: savedCorr.id, // ✅ CTI Key share @@ -205,25 +324,51 @@ export class RfaService { }); const savedRevision = await queryRunner.manager.save(rfaRevision); - // 4. Link Shop Drawings - if ( - createDto.shopDrawingRevisionIds && - createDto.shopDrawingRevisionIds.length > 0 - ) { + const rfaItems: RfaItem[] = []; + + if (shopDrawingRevisionIds.length > 0) { 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'); } - const rfaItems = shopDrawings.map((sd) => - queryRunner.manager.create(RfaItem, { - rfaRevisionId: savedRevision.id, // Correctly link to RfaRevision - shopDrawingRevisionId: sd.id, - }) + rfaItems.push( + ...shopDrawings.map((sd) => + queryRunner.manager.create(RfaItem, { + 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); } @@ -303,7 +448,11 @@ export class RfaService { .leftJoinAndSelect('rfaRev.statusCode', 'status') .leftJoinAndSelect('rfaRev.items', 'items') .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') if (revisionStatus === 'CURRENT') { @@ -405,6 +554,10 @@ export class RfaService { 'correspondence.revisions.rfaRevision.items', 'correspondence.revisions.rfaRevision.items.shopDrawingRevision', '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: { correspondence: { revisions: { revisionNumber: 'DESC' } }, diff --git a/frontend/app/(admin)/admin/access-control/users/page.tsx b/frontend/app/(admin)/admin/access-control/users/page.tsx index d38f259..aa3a79b 100644 --- a/frontend/app/(admin)/admin/access-control/users/page.tsx +++ b/frontend/app/(admin)/admin/access-control/users/page.tsx @@ -37,17 +37,20 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { Organization } from "@/types/organization"; +import { getApiErrorMessage } from "@/types/api-error"; export default function UsersPage() { const [search, setSearch] = useState(""); const [selectedOrgId, setSelectedOrgId] = useState(null); - const { data: users, isLoading } = useUsers({ + const { data: users, isLoading, isError, error } = useUsers({ search: search || undefined, primaryOrganizationId: selectedOrgId ?? undefined, }); const { data: organizations = [] } = useOrganizations(); + const userList = Array.isArray(users) ? users : []; + const organizationList = Array.isArray(organizations) ? organizations : []; const deleteMutation = useDeleteUser(); const [dialogOpen, setDialogOpen] = useState(false); @@ -94,8 +97,12 @@ export default function UsersPage() { header: "Organization", cell: ({ row }) => { const orgId = row.original.primaryOrganizationId; - const org = (organizations as Organization[]).find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString()); - return org ? org.organizationCode : "-"; + if (!orgId) { + return "All Organizations"; + } + + const org = organizationList.find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString()); + return org ? org.organizationCode : "All Organizations"; }, }, { @@ -185,7 +192,7 @@ export default function UsersPage() { All Organizations - {Array.isArray(organizations) && (organizations as Organization[]).map((org) => ( + {organizationList.map((org) => ( {org.organizationCode} - {org.organizationName} @@ -195,6 +202,12 @@ export default function UsersPage() { + {isError && ( +
+ {getApiErrorMessage(error, "Failed to load users")} +
+ )} + {isLoading ? (
{[1, 2, 3, 4, 5].map((i) => ( @@ -204,7 +217,7 @@ export default function UsersPage() { ))}
) : ( - + )} p.id === projectId)?.projectName || + projects.find((p: { id?: number; uuid?: string; projectCode: string; projectName: string }) => p.id === projectId)?.projectName || 'LCBP3'; useEffect(() => { diff --git a/frontend/app/(admin)/admin/doc-control/workflows/page.tsx b/frontend/app/(admin)/admin/doc-control/workflows/page.tsx index f78242e..49118f7 100644 --- a/frontend/app/(admin)/admin/doc-control/workflows/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/workflows/page.tsx @@ -10,6 +10,7 @@ import { Workflow } from '@/types/workflow'; export default function WorkflowsPage() { const { data: workflows = [], isLoading: loading, error } = useWorkflowDefinitions(); + const workflowList = Array.isArray(workflows) ? workflows : []; return (
@@ -34,13 +35,13 @@ export default function WorkflowsPage() {
Failed to load workflows. Please try again later.
- ) : workflows.length === 0 ? ( + ) : workflowList.length === 0 ? (
No workflow definitions found. Click "New Workflow" to create one.
) : (
- {workflows.map((workflow: Workflow) => ( + {workflowList.map((workflow: Workflow) => (
diff --git a/frontend/app/(dashboard)/admin/migration/errors/page.tsx b/frontend/app/(admin)/admin/migration/errors/page.tsx similarity index 86% rename from frontend/app/(dashboard)/admin/migration/errors/page.tsx rename to frontend/app/(admin)/admin/migration/errors/page.tsx index a8324c2..96d59d1 100644 --- a/frontend/app/(dashboard)/admin/migration/errors/page.tsx +++ b/frontend/app/(admin)/admin/migration/errors/page.tsx @@ -17,10 +17,12 @@ import { format } from "date-fns"; import { ArrowLeftIcon } from "lucide-react"; import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { getApiErrorMessage } from "@/types/api-error"; export default function MigrationErrorsPage() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); useEffect(() => { fetchData(); @@ -29,10 +31,12 @@ export default function MigrationErrorsPage() { const fetchData = async () => { try { setLoading(true); + setErrorMessage(null); const res = await migrationService.getErrors({ limit: 100 }); - setItems(res.items); - } catch (error) { - // Failed to fetch errors - loading state handles display + setItems(Array.isArray(res.items) ? res.items : []); + } catch (error: unknown) { + setItems([]); + setErrorMessage(getApiErrorMessage(error, "Failed to load errors")); } finally { setLoading(false); } @@ -59,6 +63,11 @@ export default function MigrationErrorsPage() { Error Audit Log + {errorMessage && ( +
+ {errorMessage} +
+ )} {loading ? (
Loading errors...
) : items.length === 0 ? ( diff --git a/frontend/app/(dashboard)/admin/migration/page.tsx b/frontend/app/(admin)/admin/migration/page.tsx similarity index 93% rename from frontend/app/(dashboard)/admin/migration/page.tsx rename to frontend/app/(admin)/admin/migration/page.tsx index b2c7112..c6ff4e9 100644 --- a/frontend/app/(dashboard)/admin/migration/page.tsx +++ b/frontend/app/(admin)/admin/migration/page.tsx @@ -20,6 +20,7 @@ import { format } from "date-fns"; import { EyeIcon, FileXIcon, CheckSquareIcon } from "lucide-react"; import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { getApiErrorMessage } from "@/types/api-error"; export default function MigrationReviewQueuePage() { const [items, setItems] = useState([]); @@ -27,6 +28,7 @@ export default function MigrationReviewQueuePage() { const [submitting, setSubmitting] = useState(false); const [statusFilter, setStatusFilter] = useState("PENDING"); const [selectedIds, setSelectedIds] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); useEffect(() => { fetchData(); @@ -35,14 +37,16 @@ export default function MigrationReviewQueuePage() { const fetchData = async () => { try { setLoading(true); + setErrorMessage(null); const res = await migrationService.getReviewQueue({ status: statusFilter === "ALL" ? undefined : (statusFilter as MigrationReviewStatus), limit: 50, }); - setItems(res.items); + setItems(Array.isArray(res.items) ? res.items : []); setSelectedIds([]); // reset selection on fetch - } catch (error) { - // Failed to fetch queue - loading state handles display + } catch (error: unknown) { + setItems([]); + setErrorMessage(getApiErrorMessage(error, "Failed to load queue")); } finally { setLoading(false); } @@ -148,6 +152,11 @@ export default function MigrationReviewQueuePage() { Queue Items - {statusFilter} + {errorMessage && ( +
+ {errorMessage} +
+ )} {loading ? (
Loading queue...
) : items.length === 0 ? ( diff --git a/frontend/app/(dashboard)/admin/migration/review/[id]/page.tsx b/frontend/app/(admin)/admin/migration/review/[id]/page.tsx similarity index 100% rename from frontend/app/(dashboard)/admin/migration/review/[id]/page.tsx rename to frontend/app/(admin)/admin/migration/review/[id]/page.tsx diff --git a/frontend/app/(admin)/admin/monitoring/sessions/page.tsx b/frontend/app/(admin)/admin/monitoring/sessions/page.tsx index dcc7692..8c8efb7 100644 --- a/frontend/app/(admin)/admin/monitoring/sessions/page.tsx +++ b/frontend/app/(admin)/admin/monitoring/sessions/page.tsx @@ -1,12 +1,13 @@ 'use client'; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; 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 { getApiErrorMessage } from '@/types/api-error'; export default function SessionManagementPage() { const queryClient = useQueryClient(); @@ -15,10 +16,11 @@ export default function SessionManagementPage() { data: sessions, isLoading, error, - } = useQuery({ + } = useQuery({ queryKey: ['sessions'], queryFn: sessionService.getActiveSessions, }); + const sessionList = Array.isArray(sessions) ? sessions : []; const revokeMutation = useMutation({ mutationFn: sessionService.revokeSession, @@ -26,8 +28,10 @@ export default function SessionManagementPage() { toast.success('Session revoked successfully'); queryClient.invalidateQueries({ queryKey: ['sessions'] }); }, - onError: (error) => { - toast.error('Failed to revoke session'); + onError: (mutationError: unknown) => { + toast.error('Failed to revoke session', { + description: getApiErrorMessage(mutationError, 'Unknown error'), + }); }, }); @@ -46,7 +50,7 @@ export default function SessionManagementPage() { } if (error) { - return
Failed to load sessions. Please try again.
; + return
{getApiErrorMessage(error, 'Failed to load sessions. Please try again.')}
; } return ( @@ -67,7 +71,7 @@ export default function SessionManagementPage() { - {sessions?.map((session: any) => ( + {sessionList.map((session) => (
@@ -94,7 +98,7 @@ export default function SessionManagementPage() { variant="destructive" size="sm" className="h-8" - onClick={() => handleRevoke(Number(session.id))} + onClick={() => handleRevoke(session.id)} disabled={revokeMutation.isPending} > @@ -103,7 +107,7 @@ export default function SessionManagementPage() { ))} - {(!sessions || sessions.length === 0) && ( + {sessionList.length === 0 && ( No active sessions found. diff --git a/frontend/components/admin/security/rbac-matrix.tsx b/frontend/components/admin/security/rbac-matrix.tsx index fb1d44d..40f6e88 100644 --- a/frontend/components/admin/security/rbac-matrix.tsx +++ b/frontend/components/admin/security/rbac-matrix.tsx @@ -34,16 +34,32 @@ interface RbacMatrixProps { rolePermissions: Record; // roleId -> permissionIds[] } +const extractArrayData = (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 = { getRoles: async (): Promise => { const response = await apiClient.get("/users/roles"); - const data = response.data?.data || response.data; - return Array.isArray(data) ? data : []; + return extractArrayData(response.data); }, getPermissions: async (): Promise => { const response = await apiClient.get("/users/permissions"); - const data = response.data?.data || response.data; - return Array.isArray(data) ? data : []; + return extractArrayData(response.data); }, updateRolePermissions: async (roleId: number, permissionIds: number[]) => { // 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 roleList = Array.isArray(roles) ? roles : []; + const permissionList = Array.isArray(permissions) ? permissions : []; if (rolesLoading || permsLoading) { return ( @@ -125,7 +143,7 @@ export function RbacMatrix() { Permission - {roles.map((role) => ( + {roleList.map((role) => ( {role.roleName} @@ -133,13 +151,13 @@ export function RbacMatrix() { - {permissions.map((perm) => ( + {permissionList.map((perm) => (
{perm.permissionName}
{perm.description}
- {roles.map((role) => { + {roleList.map((role) => { // Assume role.permissions is populated const currentRolePerms = role.permissions?.map((p) => p.permissionId) || []; const activePerms = pendingChanges[role.roleId] || currentRolePerms; diff --git a/frontend/components/admin/user-dialog.tsx b/frontend/components/admin/user-dialog.tsx index d120805..dc7c38a 100644 --- a/frontend/components/admin/user-dialog.tsx +++ b/frontend/components/admin/user-dialog.tsx @@ -26,6 +26,8 @@ import { } from "@/components/ui/select"; import { Eye, EyeOff } from "lucide-react"; +const ALL_ORGANIZATIONS_VALUE = "all"; + // Update schema to include confirmPassword const userSchema = z.object({ 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, roleIds: [], lineId: "", - primaryOrganizationId: undefined, + primaryOrganizationId: ALL_ORGANIZATIONS_VALUE, password: "", confirmPassword: "" }, @@ -107,7 +109,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) { lastName: user.lastName, isActive: user.isActive, lineId: user.lineId || "", - primaryOrganizationId: user.primaryOrganizationId?.toString(), + primaryOrganizationId: user.primaryOrganizationId?.toString() || ALL_ORGANIZATIONS_VALUE, roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [], password: "", confirmPassword: "" @@ -120,7 +122,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) { lastName: "", isActive: true, lineId: "", - primaryOrganizationId: undefined, + primaryOrganizationId: ALL_ORGANIZATIONS_VALUE, roleIds: [], password: "", confirmPassword: "" @@ -148,6 +150,9 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) { const payload = { ...data }; delete payload.confirmPassword; // Don't send to API if (!payload.password) delete payload.password; // Don't send empty password on edit + if (payload.primaryOrganizationId === ALL_ORGANIZATIONS_VALUE) { + delete payload.primaryOrganizationId; + } if (user) { updateUser.mutate( @@ -231,7 +236,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
+ + { + 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 }); + } + }} + /> +
+
+ + +
+
+ + { + 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 }); + } + }} + />
@@ -309,6 +392,14 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
+ {/* Remarks */} +
+
+ + +
+
+ {/* Description */}
@@ -333,7 +424,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u - {(organizations || []).map((org: Organization) => ( + {organizationOptions.map((org) => ( {org.organizationName} ({org.organizationCode}) @@ -356,7 +447,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u - {(organizations || []).map((org: Organization) => ( + {organizationOptions.map((org) => ( {org.organizationName} ({org.organizationCode}) diff --git a/frontend/components/numbering/template-editor.tsx b/frontend/components/numbering/template-editor.tsx index 0025385..568460b 100644 --- a/frontend/components/numbering/template-editor.tsx +++ b/frontend/components/numbering/template-editor.tsx @@ -56,6 +56,7 @@ export function TemplateEditor({ }: TemplateEditorProps) { const [format, setFormat] = useState(template?.formatTemplate || ''); const [typeId, setTypeId] = useState(template?.correspondenceTypeId?.toString() || ''); + const [disciplineId, setDisciplineId] = useState(template?.disciplineId?.toString() || '0'); const [reset, setReset] = useState(template?.resetSequenceYearly ?? true); const [preview, setPreview] = useState(''); @@ -89,6 +90,7 @@ export function TemplateEditor({ ...template, projectId: projectId, correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null, + disciplineId: Number(disciplineId), formatTemplate: format, resetSequenceYearly: reset, }); @@ -139,6 +141,22 @@ export function TemplateEditor({
+
+ + +
diff --git a/frontend/components/numbering/template-tester.tsx b/frontend/components/numbering/template-tester.tsx index ce70e6a..d394e04 100644 --- a/frontend/components/numbering/template-tester.tsx +++ b/frontend/components/numbering/template-tester.tsx @@ -11,6 +11,7 @@ import { import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { NumberingTemplate, numberingApi } from '@/lib/api/numbering'; +import { Badge } from '@/components/ui/badge'; import { Loader2 } from 'lucide-react'; import { Select, @@ -49,7 +50,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP disciplineId: "", year: new Date().getFullYear(), }); - const [generatedNumber, setGeneratedNumber] = useState(''); + const [testResult, setTestResult] = useState<{ number: string; isDefault?: boolean } | null>(null); const [loading, setLoading] = useState(false); // Master Data Hooks @@ -66,18 +67,28 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP const handleGenerate = async () => { if (!template) return; setLoading(true); + setTestResult(null); try { - const result = await numberingApi.previewNumber({ + const payload = { projectId: projectId, originatorOrganizationId: testData.originatorId || "0", recipientOrganizationId: testData.recipientId || "0", correspondenceTypeId: parseInt(testData.correspondenceTypeId || "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: unknown) { - const err = error as { response?: { data?: { message?: string } }; message?: string }; - setGeneratedNumber(`Error: ${err.response?.data?.message || err.message || "Unknown error"}`); + } catch (error: any) { + console.error("Test Preview Error:", error); + const errMsg = error?.response?.data?.message || error?.message || "Unknown error"; + setTestResult({ number: `Error: ${errMsg}`, isDefault: false }); } finally { setLoading(false); } @@ -196,12 +207,24 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP Generate Test Number - {generatedNumber && ( - -

{generatedNumber.startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}

-

- {generatedNumber} -

+ {testResult && ( + +
+

{(testResult.number || '').startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}

+ {testResult.isDefault && !(testResult.number || '').startsWith('Error:') && ( + Default Template + )} + {!testResult.isDefault && !(testResult.number || '').startsWith('Error:') && ( + Specific Template + )} +
+
+ {testResult.number || ( +
+ Empty Result. Raw: {JSON.stringify(testResult, null, 2)} +
+ )} +
)} diff --git a/frontend/components/rfas/detail.tsx b/frontend/components/rfas/detail.tsx index 72868c5..1abdfdd 100644 --- a/frontend/components/rfas/detail.tsx +++ b/frontend/components/rfas/detail.tsx @@ -1,5 +1,6 @@ "use client"; +import type { RFA, RFAItem } from "@/types/rfa"; import { StatusBadge } from "@/components/common/status-badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -9,39 +10,48 @@ import Link from "next/link"; import { useState } from "react"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { useRouter } from "next/navigation"; 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 { - data: RFADetailData; + data: RFA; } export function RFADetail({ data }: RFADetailProps) { - const router = useRouter(); const [actionState, setActionState] = useState<"approve" | "reject" | null>(null); const [comments, setComments] = useState(""); 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 = () => { if (!actionState) return; @@ -77,14 +87,16 @@ export function RFADetail({ data }: RFADetailProps) {
-

{data.rfaNumber}

-

- Created on {format(new Date(data.createdAt), "dd MMM yyyy HH:mm")} -

+

{data.correspondence?.correspondenceNumber || "RFA"}

+ {createdAt && ( +

+ Created on {format(new Date(createdAt), "dd MMM yyyy HH:mm")} +

+ )}
- {data.status === "PENDING" && ( + {currentStatus === "PENDING" && (
@@ -199,15 +211,15 @@ export function RFADetail({ data }: RFADetailProps) {
-

Contract

-

{data.contractName}

+

Project

+

{data.correspondence?.project?.projectName || "-"}


Discipline

-

{data.disciplineName}

+

{data.discipline?.name || data.discipline?.code || "-"}

diff --git a/frontend/components/rfas/form.tsx b/frontend/components/rfas/form.tsx index c0fc3f8..fa8c50f 100644 --- a/frontend/components/rfas/form.tsx +++ b/frontend/components/rfas/form.tsx @@ -1,14 +1,15 @@ "use client"; -import { useForm, useFieldArray } from "react-hook-form"; +import { useForm, type SubmitErrorHandler } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Card } from "@/components/ui/card"; -import { Plus, Trash2, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { Select, SelectContent, @@ -18,18 +19,14 @@ import { } from "@/components/ui/select"; import { useRouter } from "next/navigation"; 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 { 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"; -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({ projectId: z.string().min(1, "Project is required"), // ADR-019: UUID contractId: z.string().min(1, "Contract is required"), @@ -41,25 +38,137 @@ const rfaSchema = z.object({ remarks: z.string().optional(), toOrganizationId: z.string().min(1, "Please select To Organization"), dueDate: z.string().optional(), - shopDrawingRevisionIds: z.array(z.number()).optional(), - items: z.array(rfaItemSchema).min(1, "At least one item is required"), + shopDrawingRevisionIds: z.array(z.string()).optional(), + asBuiltDrawingRevisionIds: z.array(z.string()).optional(), }); type RFAFormData = z.infer; +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 = (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 = (items: T[], getKey: (item: T) => string | number | undefined): T[] => { + const seen = new Set(); + + 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() { const router = useRouter(); const createMutation = useCreateRFA(); - // ADR-019: Dynamic project selection const { data: projectsData, isLoading: isLoadingProjects } = useProjects(); - const projects = projectsData?.data || projectsData || []; + const projects = dedupeByKey( + extractArrayData(projectsData), + (project) => project.uuid ?? project.id + ); + const { data: organizationsData, isLoading: isLoadingOrganizations } = useOrganizations({ isActive: true }); + const organizations = dedupeByKey( + extractArrayData(organizationsData), + (organization) => organization.uuid ?? organization.id + ); + const { data: correspondenceTypesData } = useCorrespondenceTypes(); + const correspondenceTypes = extractArrayData(correspondenceTypesData); + const rfaCorrespondenceType = correspondenceTypes.find( + (type) => type.typeCode?.toUpperCase() === "RFA" + ); const { register, - control, handleSubmit, setValue, + setError, + clearErrors, watch, formState: { errors }, } = useForm({ @@ -76,26 +185,89 @@ export function RFAForm() { toOrganizationId: "", dueDate: "", shopDrawingRevisionIds: [], - items: [{ itemNo: "1", description: "", quantity: 0, unit: "" }], + asBuiltDrawingRevisionIds: [], }, }); const selectedProjectId = watch("projectId"); - const { data: contracts, isLoading: isLoadingContracts } = useContracts(selectedProjectId); + const { data: contractsData, isLoading: isLoadingContracts } = useContracts(selectedProjectId); + const contracts = dedupeByKey( + extractArrayData(contractsData), + (contract) => contract.uuid ?? contract.id + ); const selectedContractId = watch("contractId"); - const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId); + const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId); + const disciplines = dedupeByKey(extractArrayData(disciplinesData), (discipline) => discipline.id); + const { data: rfaTypesData, isLoading: isLoadingRfaTypes } = useRfaTypes(selectedContractId); + const rfaTypes = dedupeByKey(extractArrayData(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(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(asBuiltDrawingsData), + (drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid + ); + const selectedDisciplineId = watch("disciplineId"); - // Watch fields for preview const rfaTypeId = watch("rfaTypeId"); const disciplineId = watch("disciplineId"); 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 -- const [preview, setPreview] = useState<{ number: string; isDefaultTemplate: boolean } | null>(null); useEffect(() => { - if (!rfaTypeId || !disciplineId || !toOrganizationId) { + if (!selectedProjectId || !rfaCorrespondenceType?.id || !rfaTypeId || !disciplineId || !toOrganizationId) { setPreview(null); return; } @@ -104,11 +276,10 @@ export function RFAForm() { try { const res = await correspondenceService.previewNumber({ projectId: selectedProjectId, - typeId: rfaTypeId, // RfaTypeId acts as TypeId + typeId: rfaCorrespondenceType.id, disciplineId, - // RFA uses 'TO' organization as recipient recipients: [{ organizationId: toOrganizationId, type: 'TO' }], - dueDate: new Date().toISOString() + subject: watch("subject") || "Preview Subject" }); setPreview(res); } catch (err) { @@ -118,17 +289,32 @@ export function RFAForm() { const timer = setTimeout(fetchPreview, 500); return () => clearTimeout(timer); - }, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId]); - - const { fields, append, remove } = useFieldArray({ - control, - name: "items", - }); + }, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId, rfaCorrespondenceType?.id, watch]); 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 = { ...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, { onSuccess: () => { @@ -137,9 +323,14 @@ export function RFAForm() { }); }; + const onInvalidSubmit: SubmitErrorHandler = () => undefined; + const submitForm = handleSubmit(onSubmit, onInvalidSubmit); + const handleFormSubmit = (event: FormEvent) => { + void submitForm(event).catch(() => undefined); + }; + return ( -
- {/* Preview Section */} + {preview && (

Document Number Preview

@@ -154,7 +345,6 @@ export function RFAForm() {
)} - {/* Basic Info */}

RFA Information

@@ -184,13 +374,17 @@ export function RFAForm() {
- {/* ADR-019: Project selector */}
{errors.projectId && ( @@ -214,18 +416,33 @@ export function RFAForm() {
{errors.contractId && ( @@ -236,6 +453,7 @@ export function RFAForm() {
0 ? String(rfaTypeId) : undefined} + onValueChange={(val) => { + setValue("rfaTypeId", Number(val)); + setValue("shopDrawingRevisionIds", []); + setValue("asBuiltDrawingRevisionIds", []); + }} + disabled={!selectedContractId || isLoadingRfaTypes} + > + + + + + {rfaTypes.map((rfaType) => ( + + {`${rfaType.typeCode || "RFA"} - ${rfaType.typeName || rfaType.typeNameEn || rfaType.typeNameTh || "Unnamed Type"}`} + + ))} + + + {errors.rfaTypeId && ( +

{errors.rfaTypeId.message}

+ )} +
+ +
+ + + {errors.toOrganizationId && ( +

{errors.toOrganizationId.message}

+ )} +
+
- {/* RFA Items */} - -
-

RFA Items

- -
+ {(requiresShopDrawings || requiresAsBuiltDrawings) && ( + +

New Item

+
+

+ {requiresShopDrawings + ? "RFA Type āļ™āļĩāđ‰āļ•āđ‰āļ­āļ‡āļ­āđ‰āļēāļ‡āļ­āļīāļ‡ Shop Drawing Revision āļ­āļĒāđˆāļēāļ‡āļ™āđ‰āļ­āļĒ 1 āļĢāļēāļĒāļāļēāļĢ" + : "RFA Type āļ™āļĩāđ‰āļ•āđ‰āļ­āļ‡āļ­āđ‰āļēāļ‡āļ­āļīāļ‡ As-Built Drawing Revision āļ­āļĒāđˆāļēāļ‡āļ™āđ‰āļ­āļĒ 1 āļĢāļēāļĒāļāļēāļĢ"} +

-
- {fields.map((field, index) => ( - -
-

Item #{index + 1}

- {fields.length > 1 && ( - + {requiresShopDrawings && ( +
+
+ { + setShopDrawingSearch(e.target.value); + setShopDrawingPage(1); + }} + className="max-w-xs" + /> +
+ + {isLoadingShopDrawings && ( +

Loading Shop Drawings...

+ )} + {!isLoadingShopDrawings && shopDrawings.length === 0 && ( +

No Shop Drawings found for the selected project.

+ )} +
+ {shopDrawings.map((drawing) => { + const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid; + + if (!revisionUuid) { + return null; + } + + return ( + + ); + })} +
+ + {shopDrawingsData?.meta && shopDrawingsData.meta.totalPages > 1 && ( +
+

+ Page {shopDrawingPage} of {shopDrawingsData.meta.totalPages} +

+
+ + +
+
+ )} + + {errors.shopDrawingRevisionIds && ( +

{errors.shopDrawingRevisionIds.message}

)}
+ )} -
-
- - - {errors.items?.[index]?.itemNo && ( -

{errors.items[index]?.itemNo?.message}

- )} -
-
- - - {errors.items?.[index]?.description && ( -

{errors.items[index]?.description?.message}

- )} -
-
- + {requiresAsBuiltDrawings && ( +
+
{ + setAsBuiltDrawingSearch(e.target.value); + setAsBuiltDrawingPage(1); + }} + className="max-w-xs" /> - {errors.items?.[index]?.quantity && ( -

{errors.items[index]?.quantity?.message}

- )}
-
- - - {errors.items?.[index]?.unit && ( -

{errors.items[index]?.unit?.message}

- )} + + {isLoadingAsBuiltDrawings && ( +

Loading As-Built Drawings...

+ )} + {!isLoadingAsBuiltDrawings && asBuiltDrawings.length === 0 && ( +

No As-Built Drawings found for the selected project.

+ )} +
+ {asBuiltDrawings.map((drawing) => { + const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid; + + if (!revisionUuid) { + return null; + } + + return ( + + ); + })}
+ + {asBuiltDrawingsData?.meta && asBuiltDrawingsData.meta.totalPages > 1 && ( +
+

+ Page {asBuiltDrawingPage} of {asBuiltDrawingsData.meta.totalPages} +

+
+ + +
+
+ )} + + {errors.asBuiltDrawingRevisionIds && ( +

{errors.asBuiltDrawingRevisionIds.message}

+ )}
- - ))} -
+ )} +
+ + )} - {errors.items?.root && ( -

- {errors.items.root.message} -

- )} - - - {/* Actions */}