From aa82b890a58554e6903b66e796a677ae221a2d7a Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 24 Mar 2026 21:33:59 +0700 Subject: [PATCH] 260324:2133 Refactor correspondence & rfa --- CHANGELOG.md | 57 +++ backend/eslint.config.mjs | 1 + backend/src/app.module.ts | 4 + .../circulation/circulation.service.ts | 14 +- .../circulation/dto/search-circulation.dto.ts | 6 +- .../correspondence-workflow.service.ts | 42 ++- .../correspondence.controller.ts | 82 +++++ .../correspondence/correspondence.module.ts | 11 +- .../correspondence/correspondence.service.ts | 335 +++++++++++++++++- .../correspondence/dto/bulk-cancel.dto.ts | 18 + .../dto/cancel-correspondence.dto.ts | 12 + .../dto/search-correspondence.dto.ts | 8 + .../due-date-reminder.service.spec.ts | 232 ++++++++++++ .../due-date-reminder.service.ts | 75 ++++ .../entities/correspondence-tag.entity.ts | 20 ++ .../modules/search/dto/search-query.dto.ts | 4 + backend/src/modules/search/search.service.ts | 3 +- .../correspondences/[uuid]/edit/page.tsx | 53 +++ frontend/app/(dashboard)/search/page.tsx | 56 ++- .../circulation-status-card.tsx | 134 +++++++ .../correspondences-content.tsx | 141 +++++++- .../components/correspondences/detail.tsx | 271 ++++++++++---- frontend/components/correspondences/form.tsx | 71 +++- frontend/components/correspondences/list.tsx | 53 +-- .../correspondences/reference-selector.tsx | 215 +++++++++++ .../correspondences/revision-history.tsx | 99 ++++++ .../correspondences/tag-manager.tsx | 153 ++++++++ frontend/components/search/filters.tsx | 97 ++--- frontend/components/search/results.tsx | 103 ++++-- .../hooks/__tests__/use-circulation.test.ts | 107 ++++++ frontend/hooks/use-circulation.ts | 15 + frontend/hooks/use-correspondence.ts | 107 ++++++ frontend/lib/api/files.ts | 26 ++ frontend/lib/services/circulation.service.ts | 10 + .../lib/services/correspondence.service.ts | 50 ++- frontend/types/correspondence.ts | 3 + .../dto/correspondence/add-reference.dto.ts | 8 +- .../create-correspondence.dto.ts | 3 + .../search-correspondence.dto.ts | 1 + frontend/types/dto/search/search-query.dto.ts | 3 + specs/01-requirements/01-01-objectives.md | 122 ++++++- .../01-03-modules/01-03-02-correspondence.md | 25 +- 42 files changed, 2617 insertions(+), 233 deletions(-) create mode 100644 backend/src/modules/correspondence/dto/bulk-cancel.dto.ts create mode 100644 backend/src/modules/correspondence/dto/cancel-correspondence.dto.ts create mode 100644 backend/src/modules/correspondence/due-date-reminder.service.spec.ts create mode 100644 backend/src/modules/correspondence/due-date-reminder.service.ts create mode 100644 backend/src/modules/correspondence/entities/correspondence-tag.entity.ts create mode 100644 frontend/app/(dashboard)/correspondences/[uuid]/edit/page.tsx create mode 100644 frontend/components/correspondences/circulation-status-card.tsx create mode 100644 frontend/components/correspondences/reference-selector.tsx create mode 100644 frontend/components/correspondences/revision-history.tsx create mode 100644 frontend/components/correspondences/tag-manager.tsx create mode 100644 frontend/hooks/__tests__/use-circulation.test.ts create mode 100644 frontend/hooks/use-circulation.ts create mode 100644 frontend/lib/api/files.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe8d87..cce5d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,63 @@ ## [Unreleased] +### Correspondence Module — Phase 7 Complete (2026-03-24) + +#### 📧 **Email Notification Wiring** (Phase 7.1) + +- **Changed**: Notification `type` on correspondence events: `'SYSTEM'` → `'EMAIL'` in 3 places + - `correspondence.service.ts` — cancel event + - `correspondence-workflow.service.ts` — submit event (recipient orgs) + - `due-date-reminder.service.ts` — due date cron +- **Effect**: `NotificationProcessor` now also dispatches Nodemailer email (immediate or digest per user preference) alongside in-app + WebSocket push + +#### 🧪 **Unit Tests** (Phase 7.2) + +- **Backend** (`due-date-reminder.service.spec.ts` — 8 tests): + - No revisions → no notifications + - Skip: no correspondence, CANCELLED, CLBOWN, no doc-control user found + - Happy path: correct `userId`, `type: 'EMAIL'`, `link`, `entityId` + - Error isolation: one failed revision doesn't stop others + - `daysLeft` message format validation +- **Frontend** (`hooks/__tests__/use-circulation.test.ts` — 5 tests): + - Cache key generation for `circulationKeys.byCorrespondence()` + - Successful fetch, disabled when UUID empty, error handling, query key assertion + +#### 📦 **Bulk Operations** (Phase 7.3) + +- **Backend**: `BulkCancelDto` (`uuids[]` + `reason`) → `POST /correspondences/bulk-cancel` (returns `{ succeeded[], failed[] }`) +- **Backend**: `GET /correspondences/export-csv` — streams UTF-8 BOM CSV with all filtered correspondence data +- **Frontend**: **Export CSV** button in `/correspondences` filter bar — respects active search/status/revision filters, triggers browser download + +### Correspondence Module — Phase 6 Complete (2026-03-24) + +#### 🔖 **Tag Manager** (Phase 5 → 6 bridge) + +- **New Entity**: `CorrespondenceTag` (`correspondence_tags` junction table) — composite PK, eager-loads `Tag` +- **Backend**: `getTags()`, `addTag()`, `removeTag()` in `CorrespondenceService`; `GET/POST/DELETE /:uuid/tags` endpoints +- **Frontend**: `correspondenceService.getTags/addTag/removeTag` → `useCorrespondenceTags`, `useAddTag`, `useRemoveTag` hooks → `TagManager` component in detail sidebar + +#### 🔍 **Search Enhancement** (Phase 6.1) + +- **Backend**: Added `status` filter to `SearchQueryDto` and `SearchService.search()` ES query +- **Frontend**: `SearchFilters` now controlled (accepts `filters` prop), proper status codes (`SUBOWN`, `CLBOWN`, `CCBOWN`), active filter badge count +- **Frontend**: Added pagination (Prev/Next) to `/search` page with `PAGE_SIZE = 20` +- **Frontend**: Improved result cards — type-colored icons, color-coded status badges, doc number prominence, correct drawing links + +#### 🔄 **Circulation Status Card** (Phase 6.2) + +- **Backend**: Added `correspondenceUuid` filter to `SearchCirculationDto`; `findAll` now joins correspondence + routings when filtering by UUID +- **Frontend**: `circulationService.getByCorrespondenceUuid()` → `useCirculationsByCorrespondence` hook → `CirculationStatusCard` component showing per-circulation status + routing assignees in correspondence detail sidebar + +#### 📜 **Revision History UI** (Phase 6.3) + +- **Frontend**: `RevisionHistory` component — vertical timeline showing all revisions sorted desc, active revision marker, color-coded status, date + remarks; rendered from existing `data.revisions` (no new endpoint needed) + +#### ⏰ **Due Date Reminder Cron** (Phase 6.4) + +- **Backend**: Registered `ScheduleModule.forRoot()` in `AppModule` +- **Backend**: `DueDateReminderService` — `@Cron(EVERY_DAY_AT_8AM)` queries revisions where `dueDate` between now and +3 days, skips CANCELLED/CLBOWN, sends in-app notification via `NotificationService` with link to correspondence + ### CI/CD & Deployment Simplification (2026-03-24) #### 🚀 **deploy.sh v2.0 — Rewrote deployment script** diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index 946b3d4..9b15bf6 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -56,6 +56,7 @@ export default tseslint.config( files: ['**/*.spec.ts', '**/*.e2e-spec.ts'], rules: { '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', }, } ); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b0b455d..f46900c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bullmq'; +import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { WinstonModule } from 'nest-winston'; import { CacheModule } from '@nestjs/cache-manager'; @@ -149,6 +150,9 @@ import { MigrationModule } from './modules/migration/migration.module'; inject: [ConfigService], }), + // ⏰ Scheduler (Cron Jobs) + ScheduleModule.forRoot(), + // 📊 Monitoring & Resilience MonitoringModule, ResilienceModule, diff --git a/backend/src/modules/circulation/circulation.service.ts b/backend/src/modules/circulation/circulation.service.ts index b86c837..061b15b 100644 --- a/backend/src/modules/circulation/circulation.service.ts +++ b/backend/src/modules/circulation/circulation.service.ts @@ -95,13 +95,23 @@ export class CirculationService { } async findAll(searchDto: SearchCirculationDto, user: User) { - const { status, page = 1, limit = 20 } = searchDto; + const { status, correspondenceUuid, page = 1, limit = 20 } = searchDto; const query = this.circulationRepo .createQueryBuilder('c') .leftJoinAndSelect('c.creator', 'creator') - .where('c.organizationId = :orgId', { + .leftJoinAndSelect('c.routings', 'routings') + .leftJoinAndSelect('routings.assignee', 'assignee') + .leftJoinAndSelect('c.correspondence', 'correspondence'); + + if (correspondenceUuid) { + query.where('correspondence.uuid = :corrUuid', { + corrUuid: correspondenceUuid, + }); + } else { + query.where('c.organizationId = :orgId', { orgId: user.primaryOrganizationId, }); + } if (status) { query.andWhere('c.statusCode = :status', { status }); diff --git a/backend/src/modules/circulation/dto/search-circulation.dto.ts b/backend/src/modules/circulation/dto/search-circulation.dto.ts index 2545926..088c06e 100644 --- a/backend/src/modules/circulation/dto/search-circulation.dto.ts +++ b/backend/src/modules/circulation/dto/search-circulation.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsOptional, IsString } from 'class-validator'; +import { IsInt, IsOptional, IsString, IsUUID } from 'class-validator'; import { Type } from 'class-transformer'; export class SearchCirculationDto { @@ -6,6 +6,10 @@ export class SearchCirculationDto { @IsString() search?: string; // ค้นหาจาก Subject หรือ No. + @IsOptional() + @IsUUID('all') + correspondenceUuid?: string; // กรองตาม correspondence UUID (ADR-019) + @IsOptional() @IsString() status?: string; // OPEN, COMPLETED diff --git a/backend/src/modules/correspondence/correspondence-workflow.service.ts b/backend/src/modules/correspondence/correspondence-workflow.service.ts index 9d445be..4e86261 100644 --- a/backend/src/modules/correspondence/correspondence-workflow.service.ts +++ b/backend/src/modules/correspondence/correspondence-workflow.service.ts @@ -9,6 +9,9 @@ import { WorkflowEngineService } from '../workflow-engine/workflow-engine.servic import { CorrespondenceRevision } from './entities/correspondence-revision.entity'; import { CorrespondenceStatus } from './entities/correspondence-status.entity'; import { Correspondence } from './entities/correspondence.entity'; +import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity'; +import { NotificationService } from '../notification/notification.service'; +import { UserService } from '../user/user.service'; @Injectable() export class CorrespondenceWorkflowService { @@ -23,7 +26,11 @@ export class CorrespondenceWorkflowService { private readonly revisionRepo: Repository, @InjectRepository(CorrespondenceStatus) private readonly statusRepo: Repository, - private readonly dataSource: DataSource + @InjectRepository(CorrespondenceRecipient) + private readonly recipientRepo: Repository, + private readonly dataSource: DataSource, + private readonly notificationService: NotificationService, + private readonly userService: UserService ) {} async submitWorkflow( @@ -82,6 +89,39 @@ export class CorrespondenceWorkflowService { await queryRunner.commitTransaction(); + // Notify TO recipient org users (fire-and-forget) + const corrForNotify = revision.correspondence; + if (corrForNotify) { + void this.recipientRepo + .find({ + where: { + correspondenceId: corrForNotify.id, + recipientType: 'TO', + }, + }) + .then(async (recipients) => { + for (const r of recipients) { + const targetUserId = await this.userService.findDocControlIdByOrg( + r.recipientOrganizationId + ); + if (targetUserId) { + await this.notificationService.send({ + userId: targetUserId, + title: 'New Correspondence Received', + message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`, + type: 'EMAIL', + entityType: 'correspondence', + entityId: revision.correspondenceId, + link: `/correspondences/${corrForNotify.uuid}`, + }); + } + } + }) + .catch((err: Error) => + this.logger.warn(`Submit notification failed: ${err.message}`) + ); + } + return { instanceId: instance.id, currentState: transitionResult.nextState, diff --git a/backend/src/modules/correspondence/correspondence.controller.ts b/backend/src/modules/correspondence/correspondence.controller.ts index 96c614a..79982d3 100644 --- a/backend/src/modules/correspondence/correspondence.controller.ts +++ b/backend/src/modules/correspondence/correspondence.controller.ts @@ -9,7 +9,11 @@ import { Query, Delete, Put, + ParseIntPipe, + Res, + HttpCode, } from '@nestjs/common'; +import type { Response } from 'express'; import { ApiTags, ApiOperation, @@ -24,6 +28,8 @@ import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto'; import { WorkflowActionDto } from './dto/workflow-action.dto'; import { AddReferenceDto } from './dto/add-reference.dto'; import { SearchCorrespondenceDto } from './dto/search-correspondence.dto'; +import { CancelCorrespondenceDto } from './dto/cancel-correspondence.dto'; +import { BulkCancelDto } from './dto/bulk-cancel.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { RbacGuard } from '../../common/guards/rbac.guard'; @@ -199,4 +205,80 @@ export class CorrespondenceController { const target = await this.correspondenceService.findOneByUuid(targetUuid); return this.correspondenceService.removeReference(corr.id, target.id); } + + @Get(':uuid/tags') + @ApiOperation({ summary: 'Get tags for a correspondence' }) + @RequirePermission('document.view') + async getTags(@Param('uuid', ParseUuidPipe) uuid: string) { + const corr = await this.correspondenceService.findOneByUuid(uuid); + return this.correspondenceService.getTags(corr.id); + } + + @Post(':uuid/tags/:tagId') + @ApiOperation({ summary: 'Add tag to a correspondence' }) + @RequirePermission('document.edit') + async addTag( + @Param('uuid', ParseUuidPipe) uuid: string, + @Param('tagId', ParseIntPipe) tagId: number + ) { + const corr = await this.correspondenceService.findOneByUuid(uuid); + return this.correspondenceService.addTag(corr.id, tagId); + } + + @Delete(':uuid/tags/:tagId') + @ApiOperation({ summary: 'Remove tag from a correspondence' }) + @RequirePermission('document.edit') + async removeTag( + @Param('uuid', ParseUuidPipe) uuid: string, + @Param('tagId', ParseIntPipe) tagId: number + ) { + const corr = await this.correspondenceService.findOneByUuid(uuid); + return this.correspondenceService.removeTag(corr.id, tagId); + } + + @Post('bulk-cancel') + @HttpCode(200) + @ApiOperation({ summary: 'Bulk cancel correspondences (Org Admin+)' }) + @RequirePermission('correspondence.cancel') + @Audit('correspondence.bulk_cancel', 'correspondence') + async bulkCancel( + @Body() dto: BulkCancelDto, + @Request() req: RequestWithUser + ) { + return this.correspondenceService.bulkCancel( + dto.uuids, + dto.reason, + req.user + ); + } + + @Get('export-csv') + @ApiOperation({ summary: 'Export correspondence list as CSV' }) + @RequirePermission('document.view') + async exportCsv( + @Query() searchDto: SearchCorrespondenceDto, + @Res() res: Response + ) { + const csv = await this.correspondenceService.exportCsv(searchDto); + const filename = `correspondences-${new Date().toISOString().split('T')[0]}.csv`; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send('\uFEFF' + csv); + } + + @Delete(':uuid') + @ApiOperation({ summary: 'Cancel correspondence (Admin only)' }) + @ApiResponse({ + status: 200, + description: 'Correspondence cancelled successfully.', + }) + @RequirePermission('correspondence.cancel') + @Audit('correspondence.cancel', 'correspondence') + async cancel( + @Param('uuid', ParseUuidPipe) uuid: string, + @Body() cancelDto: CancelCorrespondenceDto, + @Request() req: RequestWithUser + ) { + return this.correspondenceService.cancel(uuid, cancelDto.reason, req.user); + } } diff --git a/backend/src/modules/correspondence/correspondence.module.ts b/backend/src/modules/correspondence/correspondence.module.ts index 140f888..4fa3f31 100644 --- a/backend/src/modules/correspondence/correspondence.module.ts +++ b/backend/src/modules/correspondence/correspondence.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CorrespondenceController } from './correspondence.controller'; import { CorrespondenceService } from './correspondence.service'; import { CorrespondenceWorkflowService } from './correspondence-workflow.service'; +import { DueDateReminderService } from './due-date-reminder.service'; // Entities import { Correspondence } from './entities/correspondence.entity'; @@ -11,6 +12,7 @@ import { CorrespondenceType } from './entities/correspondence-type.entity'; import { CorrespondenceStatus } from './entities/correspondence-status.entity'; import { CorrespondenceReference } from './entities/correspondence-reference.entity'; import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity'; +import { CorrespondenceTag } from './entities/correspondence-tag.entity'; import { Organization } from '../organization/entities/organization.entity'; // Dependent Modules @@ -20,6 +22,7 @@ import { UserModule } from '../user/user.module'; import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'; import { SearchModule } from '../search/search.module'; import { FileStorageModule } from '../../common/file-storage/file-storage.module'; +import { NotificationModule } from '../notification/notification.module'; /** * CorrespondenceModule @@ -36,6 +39,7 @@ import { FileStorageModule } from '../../common/file-storage/file-storage.module CorrespondenceStatus, CorrespondenceReference, CorrespondenceRecipient, + CorrespondenceTag, Organization, ]), DocumentNumberingModule, @@ -44,9 +48,14 @@ import { FileStorageModule } from '../../common/file-storage/file-storage.module WorkflowEngineModule, SearchModule, FileStorageModule, + NotificationModule, ], controllers: [CorrespondenceController], - providers: [CorrespondenceService, CorrespondenceWorkflowService], + providers: [ + CorrespondenceService, + CorrespondenceWorkflowService, + DueDateReminderService, + ], exports: [CorrespondenceService, CorrespondenceWorkflowService], }) export class CorrespondenceModule {} diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts index 6be4f6f..0577f82 100644 --- a/backend/src/modules/correspondence/correspondence.service.ts +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -18,6 +18,8 @@ import { CorrespondenceType } from './entities/correspondence-type.entity'; import { CorrespondenceStatus } from './entities/correspondence-status.entity'; import { CorrespondenceReference } from './entities/correspondence-reference.entity'; import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity'; +import { CorrespondenceTag } from './entities/correspondence-tag.entity'; +import { Tag } from '../master/entities/tag.entity'; import { User } from '../user/entities/user.entity'; import { Organization } from '../organization/entities/organization.entity'; @@ -35,6 +37,7 @@ import { UserService } from '../user/user.service'; import { SearchService } from '../search/search.service'; import { FileStorageService } from '../../common/file-storage/file-storage.service'; import { UuidResolverService } from '../../common/services/uuid-resolver.service'; +import { NotificationService } from '../notification/notification.service'; /** * CorrespondenceService - Document management (CRUD) @@ -58,6 +61,8 @@ export class CorrespondenceService { private statusRepo: Repository, @InjectRepository(CorrespondenceReference) private referenceRepo: Repository, + @InjectRepository(CorrespondenceTag) + private tagRepo: Repository, private numberingService: DocumentNumberingService, private jsonSchemaService: JsonSchemaService, private workflowEngine: WorkflowEngineService, @@ -65,10 +70,79 @@ export class CorrespondenceService { private dataSource: DataSource, private searchService: SearchService, private fileStorageService: FileStorageService, - private uuidResolver: UuidResolverService + private uuidResolver: UuidResolverService, + private notificationService: NotificationService ) {} + /** + * Business Rule Validation: EC-CORR-003 - Correspondence to Self + * Prevent external correspondence to same organization + */ + private async validateCorrespondenceRecipients( + createDto: CreateCorrespondenceDto, + user: User + ): Promise { + // Get user's organization + let userOrgId = user.primaryOrganizationId; + if (!userOrgId) { + const fullUser = await this.userService.findOne(user.user_id); + if (fullUser) { + userOrgId = fullUser.primaryOrganizationId; + } + } + + if (!userOrgId) { + throw new BadRequestException( + 'User must belong to an organization to create documents' + ); + } + + // For impersonation, use the specified originator + const originatorOrgId = createDto.originatorId + ? await this.uuidResolver.resolveOrganizationId(createDto.originatorId) + : userOrgId; + + // Check if it's internal communication + if (createDto.isInternal) { + // Internal communications should use Circulation instead + throw new BadRequestException( + 'Internal communications should use Circulation Sheet instead of Correspondence' + ); + } + + // Validate recipients + if (!createDto.recipients || createDto.recipients.length === 0) { + throw new BadRequestException( + 'At least one recipient (TO or CC) is required' + ); + } + + const toRecipients = createDto.recipients.filter((r) => r.type === 'TO'); + const ccRecipients = createDto.recipients.filter((r) => r.type === 'CC'); + + if (toRecipients.length === 0 && ccRecipients.length === 0) { + throw new BadRequestException( + 'At least one TO or CC recipient is required' + ); + } + + // Check for same organization correspondence + for (const recipient of createDto.recipients) { + const recipientOrgId = await this.uuidResolver.resolveOrganizationId( + recipient.organizationId + ); + + if (recipientOrgId === originatorOrgId) { + throw new BadRequestException( + 'Cannot send correspondence to your own organization. Use Circulation Sheet for internal communication.' + ); + } + } + } + async create(createDto: CreateCorrespondenceDto, user: User) { + // Business Rule Validation: EC-CORR-003 - Correspondence to Self + await this.validateCorrespondenceRecipients(createDto, user); // ADR-019: Resolve UUID references to internal INT IDs const resolvedProjectId = await this.uuidResolver.resolveProjectId( createDto.projectId @@ -270,6 +344,7 @@ export class CorrespondenceService { // Fire-and-forget search indexing (non-blocking, void intentional) void this.searchService.indexDocument({ id: savedCorr.id, + uuid: savedCorr.uuid, type: 'correspondence', docNumber: docNumber.number, title: createDto.subject, @@ -300,6 +375,7 @@ export class CorrespondenceService { typeId, projectId, statusId, + status, page = 1, limit = 10, } = searchDto; @@ -336,6 +412,10 @@ export class CorrespondenceService { query.andWhere('rev.statusId = :statusId', { statusId }); } + if (status) { + query.andWhere('status.statusCode = :status', { status }); + } + if (search) { query.andWhere( '(corr.correspondenceNumber LIKE :search OR rev.subject LIKE :search)', @@ -444,6 +524,45 @@ export class CorrespondenceService { } } + async getTags(id: number) { + const rows = await this.tagRepo.find({ + where: { correspondenceId: id }, + relations: ['tag'], + }); + return rows.map((r) => r.tag).filter(Boolean); + } + + async addTag(id: number, tagId: number) { + const correspondence = await this.correspondenceRepo.findOne({ + where: { id }, + }); + if (!correspondence) { + throw new NotFoundException(`Correspondence ${id} not found`); + } + + const tag = await this.dataSource.manager.findOne(Tag, { + where: { id: tagId }, + }); + if (!tag) { + throw new NotFoundException(`Tag ${tagId} not found`); + } + + const exists = await this.tagRepo.findOne({ + where: { correspondenceId: id, tagId }, + }); + if (exists) return exists; + + const row = this.tagRepo.create({ correspondenceId: id, tagId }); + return this.tagRepo.save(row); + } + + async removeTag(id: number, tagId: number) { + const result = await this.tagRepo.delete({ correspondenceId: id, tagId }); + if (result.affected === 0) { + throw new NotFoundException('Tag assignment not found'); + } + } + async getReferences(id: number) { const outgoing = await this.referenceRepo.find({ where: { sourceId: id }, @@ -690,7 +809,22 @@ export class CorrespondenceService { } } - return this.findOne(id); + const updated = await this.findOne(id); + + // Re-index updated document in Elasticsearch (fire-and-forget) + void this.searchService.indexDocument({ + id: updated.id, + uuid: updated.uuid, + type: 'correspondence', + docNumber: updated.correspondenceNumber, + title: updateDto.subject ?? updated.revisions?.[0]?.subject, + description: updateDto.description ?? updated.revisions?.[0]?.description, + status: 'DRAFT', + projectId: updated.projectId, + createdAt: updated.createdAt, + }); + + return updated; } async previewDocumentNumber(createDto: CreateCorrespondenceDto, user: User) { @@ -757,4 +891,201 @@ export class CorrespondenceService { }, }); } + + /** + * Business Rule Implementation: EC-CORR-001 - Cancel Correspondence with Downstream Circulation + * Cancel correspondence and handle related circulations + */ + async cancel(uuid: string, reason: string, user: User) { + const correspondence = await this.findOneByUuid(uuid); + + // Check if user has permission to cancel (Org Admin or Superadmin only) + const permissions = await this.userService.getUserPermissions(user.user_id); + const canCancel = + permissions.includes('correspondence.cancel') || + permissions.includes('system.manage_all'); + + if (!canCancel) { + throw new ForbiddenException( + 'Only administrators can cancel correspondences' + ); + } + + // Check if there are any active circulations + const circulationRepo = this.dataSource.getRepository('Circulation'); + const activeCirculations = await circulationRepo.find({ + where: { + correspondenceId: correspondence.id, + status: 'OPEN', + }, + }); + + const warningMessage = + activeCirculations.length > 0 + ? `There are ${activeCirculations.length} active circulation(s) for this correspondence. Canceling will force close all related circulations.` + : ''; + + // Get the current revision to update status + const currentRevision = await this.revisionRepo.findOne({ + where: { + correspondenceId: correspondence.id, + isCurrent: true, + }, + }); + + if (!currentRevision) { + throw new NotFoundException('Current revision not found'); + } + + // Get cancelled status + const cancelledStatus = await this.statusRepo.findOne({ + where: { statusCode: 'CANCELLED' }, + }); + + if (!cancelledStatus) { + throw new InternalServerErrorException('CANCELLED status not found'); + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Update correspondence revision status to CANCELLED + await queryRunner.manager.update( + CorrespondenceRevision, + currentRevision.id, + { + statusId: cancelledStatus.id, + remarks: `Cancelled: ${reason}`, + } + ); + + // Force close all active circulations + if (activeCirculations.length > 0) { + await queryRunner.manager.update( + 'Circulation', + { + correspondenceId: correspondence.id, + status: 'OPEN', + }, + { + status: 'FORCE_CLOSED', + closedAt: new Date(), + closedBy: user.user_id, + closeReason: `Correspondence cancelled: ${reason}`, + } + ); + } + + await queryRunner.commitTransaction(); + + // Re-index cancelled status in Elasticsearch (fire-and-forget) + void this.searchService.indexDocument({ + id: correspondence.id, + uuid: correspondence.uuid, + type: 'correspondence', + docNumber: correspondence.correspondenceNumber, + title: currentRevision.subject, + status: 'CANCELLED', + projectId: correspondence.projectId, + createdAt: correspondence.createdAt, + }); + + // Notify originator's doc-control user about cancellation (fire-and-forget) + if (correspondence.originatorId) { + void this.userService + .findDocControlIdByOrg(correspondence.originatorId) + .then((targetUserId) => { + if (targetUserId) { + void this.notificationService.send({ + userId: targetUserId, + title: 'Correspondence Cancelled', + message: `${correspondence.correspondenceNumber} — ${currentRevision.subject} has been cancelled. Reason: ${reason}`, + type: 'EMAIL', + entityType: 'correspondence', + entityId: correspondence.id, + link: `/correspondences/${correspondence.uuid}`, + }); + } + }) + .catch((err: Error) => + this.logger.warn(`Cancel notification failed: ${err.message}`) + ); + } + + return { + success: true, + message: warningMessage || 'Correspondence cancelled successfully', + activeCirculationsCount: activeCirculations.length, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Failed to cancel correspondence: ${(error as Error).message}` + ); + throw error; + } finally { + await queryRunner.release(); + } + } + + async bulkCancel( + uuids: string[], + reason: string, + user: User + ): Promise<{ succeeded: string[]; failed: string[] }> { + const succeeded: string[] = []; + const failed: string[] = []; + + for (const uuid of uuids) { + try { + await this.cancel(uuid, reason, user); + succeeded.push(uuid); + } catch { + failed.push(uuid); + } + } + + return { succeeded, failed }; + } + + async exportCsv(searchDto: SearchCorrespondenceDto): Promise { + const { data } = await this.findAll(searchDto); + + const header = [ + 'Document No.', + 'Rev', + 'Subject', + 'Type', + 'Status', + 'Project', + 'From', + 'Due Date', + 'Created At', + ]; + const rows = data.map((rev) => { + const corr = rev.correspondence ?? (rev as unknown as Correspondence); + return [ + this.escapeCsv(corr.correspondenceNumber ?? ''), + this.escapeCsv(rev.revisionLabel ?? String(rev.revisionNumber ?? 0)), + this.escapeCsv(rev.subject ?? ''), + this.escapeCsv(corr.type?.typeCode ?? ''), + this.escapeCsv(rev.status?.statusCode ?? ''), + this.escapeCsv(corr.project?.projectCode ?? ''), + this.escapeCsv(corr.originator?.organizationCode ?? ''), + rev.dueDate ? new Date(rev.dueDate).toISOString().split('T')[0] : '', + new Date(rev.createdAt).toISOString().split('T')[0], + ].join(','); + }); + + return [header.join(','), ...rows].join('\n'); + } + + private escapeCsv(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; + } } diff --git a/backend/src/modules/correspondence/dto/bulk-cancel.dto.ts b/backend/src/modules/correspondence/dto/bulk-cancel.dto.ts new file mode 100644 index 0000000..f542c99 --- /dev/null +++ b/backend/src/modules/correspondence/dto/bulk-cancel.dto.ts @@ -0,0 +1,18 @@ +import { + IsArray, + IsString, + IsUUID, + MinLength, + ArrayMinSize, +} from 'class-validator'; + +export class BulkCancelDto { + @IsArray() + @ArrayMinSize(1) + @IsUUID('all', { each: true }) + uuids!: string[]; + + @IsString() + @MinLength(3) + reason!: string; +} diff --git a/backend/src/modules/correspondence/dto/cancel-correspondence.dto.ts b/backend/src/modules/correspondence/dto/cancel-correspondence.dto.ts new file mode 100644 index 0000000..48ad3b1 --- /dev/null +++ b/backend/src/modules/correspondence/dto/cancel-correspondence.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CancelCorrespondenceDto { + @ApiProperty({ + description: 'Reason for cancelling the correspondence', + example: 'Document was created in error', + }) + @IsString() + @IsNotEmpty() + reason!: string; +} diff --git a/backend/src/modules/correspondence/dto/search-correspondence.dto.ts b/backend/src/modules/correspondence/dto/search-correspondence.dto.ts index 272f55e..72ab636 100644 --- a/backend/src/modules/correspondence/dto/search-correspondence.dto.ts +++ b/backend/src/modules/correspondence/dto/search-correspondence.dto.ts @@ -28,6 +28,14 @@ export class SearchCorrespondenceDto { @IsInt() statusId?: number; + @ApiPropertyOptional({ + description: + 'Filter by Status code (e.g. DRAFT, IN_REVIEW, APPROVED, CANCELLED)', + }) + @IsOptional() + @IsString() + status?: string; + @ApiPropertyOptional({ description: 'Revision Filter: CURRENT (default), ALL, OLD', }) diff --git a/backend/src/modules/correspondence/due-date-reminder.service.spec.ts b/backend/src/modules/correspondence/due-date-reminder.service.spec.ts new file mode 100644 index 0000000..a708a3d --- /dev/null +++ b/backend/src/modules/correspondence/due-date-reminder.service.spec.ts @@ -0,0 +1,232 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DueDateReminderService } from './due-date-reminder.service'; +import { CorrespondenceRevision } from './entities/correspondence-revision.entity'; +import { NotificationService } from '../notification/notification.service'; +import { UserService } from '../user/user.service'; + +describe('DueDateReminderService', () => { + let service: DueDateReminderService; + let revisionRepo: { find: jest.Mock }; + let notificationService: { send: jest.Mock }; + let userService: { findDocControlIdByOrg: jest.Mock }; + + const mockRevisionRepo = () => ({ + find: jest.fn(), + }); + + const mockNotificationService = () => ({ + send: jest.fn().mockResolvedValue(undefined), + }); + + const mockUserService = () => ({ + findDocControlIdByOrg: jest.fn(), + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DueDateReminderService, + { + provide: getRepositoryToken(CorrespondenceRevision), + useFactory: mockRevisionRepo, + }, + { + provide: NotificationService, + useFactory: mockNotificationService, + }, + { + provide: UserService, + useFactory: mockUserService, + }, + ], + }).compile(); + + service = module.get(DueDateReminderService); + revisionRepo = module.get(getRepositoryToken(CorrespondenceRevision)); + notificationService = module.get(NotificationService); + userService = module.get(UserService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('sendDueDateReminders', () => { + it('should do nothing when no revisions are approaching due date', async () => { + revisionRepo.find.mockResolvedValue([]); + + await service.sendDueDateReminders(); + + expect(notificationService.send).not.toHaveBeenCalled(); + }); + + it('should skip revisions with no correspondence', async () => { + revisionRepo.find.mockResolvedValue([ + { + correspondence: null, + status: { statusCode: 'DRAFT' }, + dueDate: new Date(), + }, + ]); + + await service.sendDueDateReminders(); + + expect(notificationService.send).not.toHaveBeenCalled(); + }); + + it('should skip cancelled correspondences', async () => { + revisionRepo.find.mockResolvedValue([ + { + correspondence: { + id: 1, + uuid: 'uuid-1', + correspondenceNumber: 'LC-001', + originatorId: 10, + }, + status: { statusCode: 'CANCELLED' }, + subject: 'Test', + dueDate: new Date(Date.now() + 86400000), + }, + ]); + + await service.sendDueDateReminders(); + + expect(notificationService.send).not.toHaveBeenCalled(); + }); + + it('should skip closed (CLBOWN) correspondences', async () => { + revisionRepo.find.mockResolvedValue([ + { + correspondence: { + id: 1, + uuid: 'uuid-1', + correspondenceNumber: 'LC-001', + originatorId: 10, + }, + status: { statusCode: 'CLBOWN' }, + subject: 'Test', + dueDate: new Date(Date.now() + 86400000), + }, + ]); + + await service.sendDueDateReminders(); + + expect(notificationService.send).not.toHaveBeenCalled(); + }); + + it('should skip when no doc-control user found for org', async () => { + revisionRepo.find.mockResolvedValue([ + { + correspondence: { + id: 1, + uuid: 'uuid-1', + correspondenceNumber: 'LC-001', + originatorId: 10, + }, + status: { statusCode: 'DRAFT' }, + subject: 'Test Subject', + dueDate: new Date(Date.now() + 86400000), + }, + ]); + userService.findDocControlIdByOrg.mockResolvedValue(null); + + await service.sendDueDateReminders(); + + expect(notificationService.send).not.toHaveBeenCalled(); + }); + + it('should send EMAIL notification for a valid approaching due date', async () => { + const dueDate = new Date(Date.now() + 86400000 * 2); // 2 days later + revisionRepo.find.mockResolvedValue([ + { + correspondence: { + id: 5, + uuid: 'corr-uuid-1', + correspondenceNumber: 'LC-TEST-001', + originatorId: 10, + }, + status: { statusCode: 'SUBOWN' }, + subject: 'Design Review Request', + dueDate, + }, + ]); + userService.findDocControlIdByOrg.mockResolvedValue(42); + + await service.sendDueDateReminders(); + + expect(userService.findDocControlIdByOrg).toHaveBeenCalledWith(10); + expect(notificationService.send).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 42, + title: 'Due Date Approaching', + type: 'EMAIL', + entityType: 'correspondence', + entityId: 5, + link: '/correspondences/corr-uuid-1', + }) + ); + }); + + it('should handle errors per revision without stopping other notifications', async () => { + const dueDate = new Date(Date.now() + 86400000); + revisionRepo.find.mockResolvedValue([ + { + correspondence: { + id: 1, + uuid: 'uuid-1', + correspondenceNumber: 'LC-001', + originatorId: 10, + }, + status: { statusCode: 'SUBOWN' }, + subject: 'First', + dueDate, + }, + { + correspondence: { + id: 2, + uuid: 'uuid-2', + correspondenceNumber: 'LC-002', + originatorId: 20, + }, + status: { statusCode: 'SUBOWN' }, + subject: 'Second', + dueDate, + }, + ]); + userService.findDocControlIdByOrg + .mockResolvedValueOnce(42) + .mockRejectedValueOnce(new Error('DB error')); + + await service.sendDueDateReminders(); + + expect(notificationService.send).toHaveBeenCalledTimes(1); + }); + + it('should correctly calculate daysLeft in the message', async () => { + const dueDate = new Date(Date.now() + 86400000); // exactly 1 day + revisionRepo.find.mockResolvedValue([ + { + correspondence: { + id: 3, + uuid: 'uuid-3', + correspondenceNumber: 'LC-003', + originatorId: 5, + }, + status: { statusCode: 'DRAFT' }, + subject: 'Urgent Document', + dueDate, + }, + ]); + userService.findDocControlIdByOrg.mockResolvedValue(99); + + await service.sendDueDateReminders(); + + expect(notificationService.send).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('1 day'), + }) + ); + }); + }); +}); diff --git a/backend/src/modules/correspondence/due-date-reminder.service.ts b/backend/src/modules/correspondence/due-date-reminder.service.ts new file mode 100644 index 0000000..0f4deea --- /dev/null +++ b/backend/src/modules/correspondence/due-date-reminder.service.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { CorrespondenceRevision } from './entities/correspondence-revision.entity'; +import { NotificationService } from '../notification/notification.service'; +import { UserService } from '../user/user.service'; + +@Injectable() +export class DueDateReminderService { + private readonly logger = new Logger(DueDateReminderService.name); + + constructor( + @InjectRepository(CorrespondenceRevision) + private revisionRepo: Repository, + private notificationService: NotificationService, + private userService: UserService + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_8AM) + async sendDueDateReminders() { + this.logger.log('Running due date reminder check...'); + + const now = new Date(); + const threeDaysLater = new Date(now); + threeDaysLater.setDate(threeDaysLater.getDate() + 3); + + const revisions = await this.revisionRepo.find({ + where: { + isCurrent: true, + dueDate: Between(now, threeDaysLater), + }, + relations: ['correspondence', 'correspondence.originator', 'status'], + }); + + this.logger.log( + `Found ${revisions.length} correspondences approaching due date` + ); + + for (const revision of revisions) { + const corr = revision.correspondence; + if (!corr) continue; + + const statusCode = revision.status?.statusCode ?? ''; + if (statusCode === 'CANCELLED' || statusCode === 'CLBOWN') continue; + + if (!corr.originatorId) continue; + + try { + const targetUserId = await this.userService.findDocControlIdByOrg( + corr.originatorId + ); + if (!targetUserId) continue; + + const daysLeft = Math.ceil( + (revision.dueDate!.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + + await this.notificationService.send({ + userId: targetUserId, + title: 'Due Date Approaching', + message: `${corr.correspondenceNumber} — "${revision.subject}" is due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}.`, + type: 'EMAIL', + entityType: 'correspondence', + entityId: corr.id, + link: `/correspondences/${corr.uuid}`, + }); + } catch (err) { + this.logger.warn( + `Due date reminder failed for ${corr.correspondenceNumber}: ${(err as Error).message}` + ); + } + } + } +} diff --git a/backend/src/modules/correspondence/entities/correspondence-tag.entity.ts b/backend/src/modules/correspondence/entities/correspondence-tag.entity.ts new file mode 100644 index 0000000..51064f7 --- /dev/null +++ b/backend/src/modules/correspondence/entities/correspondence-tag.entity.ts @@ -0,0 +1,20 @@ +import { Entity, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Correspondence } from './correspondence.entity'; +import { Tag } from '../../master/entities/tag.entity'; + +@Entity('correspondence_tags') +export class CorrespondenceTag { + @PrimaryColumn({ name: 'correspondence_id' }) + correspondenceId!: number; + + @PrimaryColumn({ name: 'tag_id' }) + tagId!: number; + + @ManyToOne(() => Correspondence, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'correspondence_id' }) + correspondence?: Correspondence; + + @ManyToOne(() => Tag, { onDelete: 'CASCADE', eager: true }) + @JoinColumn({ name: 'tag_id' }) + tag?: Tag; +} diff --git a/backend/src/modules/search/dto/search-query.dto.ts b/backend/src/modules/search/dto/search-query.dto.ts index 8712cf5..96db963 100644 --- a/backend/src/modules/search/dto/search-query.dto.ts +++ b/backend/src/modules/search/dto/search-query.dto.ts @@ -10,6 +10,10 @@ export class SearchQueryDto { @IsOptional() type?: string; // กรองประเภท: 'rfa', 'correspondence', 'drawing' + @IsString() + @IsOptional() + status?: string; // กรองสถานะ: 'DRAFT', 'SUBOWN', 'CLBOWN', 'CANCELLED', ... + @IsInt() @Type(() => Number) @IsOptional() diff --git a/backend/src/modules/search/search.service.ts b/backend/src/modules/search/search.service.ts index 02dd0c0..dcf68ff 100644 --- a/backend/src/modules/search/search.service.ts +++ b/backend/src/modules/search/search.service.ts @@ -108,7 +108,7 @@ export class SearchService implements OnModuleInit { * ค้นหาเอกสาร (Full-text Search) */ async search(queryDto: SearchQueryDto) { - const { q, type, projectId, page = 1, limit = 20 } = queryDto; + const { q, type, status, projectId, page = 1, limit = 20 } = queryDto; const from = (page - 1) * limit; // Early fallback if Elasticsearch is not available @@ -135,6 +135,7 @@ export class SearchService implements OnModuleInit { // 2. Filter logic const filterQueries: Record[] = []; if (type) filterQueries.push({ term: { type } }); + if (status) filterQueries.push({ term: { status } }); if (projectId) filterQueries.push({ term: { projectId } }); try { diff --git a/frontend/app/(dashboard)/correspondences/[uuid]/edit/page.tsx b/frontend/app/(dashboard)/correspondences/[uuid]/edit/page.tsx new file mode 100644 index 0000000..fdb4ed7 --- /dev/null +++ b/frontend/app/(dashboard)/correspondences/[uuid]/edit/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { CorrespondenceForm } from '@/components/correspondences/form'; +import { useCorrespondence } from '@/hooks/use-correspondence'; +import { Loader2 } from 'lucide-react'; +import { useParams } from 'next/navigation'; + +export default function EditCorrespondencePage() { + const params = useParams(); + const uuid = (params?.uuid as string) ?? ''; + + const { data: correspondence, isLoading, isError } = useCorrespondence(uuid); + + if (!uuid) { + return ( +
+

Invalid Correspondence UUID

+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !correspondence) { + return ( +
+

Failed to load correspondence

+

Please try again later or verify the UUID.

+
+ ); + } + + return ( +
+
+

Edit Correspondence

+

+ Editing: {correspondence.correspondenceNumber} +

+
+ +
+ +
+
+ ); +} diff --git a/frontend/app/(dashboard)/search/page.tsx b/frontend/app/(dashboard)/search/page.tsx index 010ce7a..26a4c13 100644 --- a/frontend/app/(dashboard)/search/page.tsx +++ b/frontend/app/(dashboard)/search/page.tsx @@ -7,34 +7,40 @@ import { SearchResults } from '@/components/search/results'; import { SearchFilters as FilterType } from '@/types/search'; import { useSearch } from '@/hooks/use-search'; import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +const PAGE_SIZE = 20; function SearchContent() { const searchParams = useSearchParams(); - // URL Params state const query = searchParams.get('q') || ''; const typeParam = searchParams.get('type'); const statusParam = searchParams.get('status'); - // Local Filter State (synced with URL initially, but can be independent before apply) - // For simplicity, we'll keep filters in sync with valid search params or local state that pushes to URL const [filters, setFilters] = useState({ types: typeParam ? [typeParam] : [], statuses: statusParam ? [statusParam] : [], }); + const [page, setPage] = useState(1); - // Construct search DTO — only send fields recognized by backend SearchQueryDto - // Backend uses forbidNonWhitelisted: true, so unknown fields (types[], statuses[]) cause 400 const searchDto = { q: query, type: filters.types?.length ? filters.types[0] : undefined, + status: filters.statuses?.length ? filters.statuses[0] : undefined, + page, + limit: PAGE_SIZE, }; const { data: results, isLoading, isError } = useSearch(searchDto); + const total = results?.meta?.total ?? 0; + const totalPages = Math.ceil(total / PAGE_SIZE); + const handleFilterChange = (newFilters: FilterType) => { setFilters(newFilters); - // Optional: Update URL to reflect filters? + setPage(1); }; return ( @@ -44,20 +50,50 @@ function SearchContent() {

{isLoading ? 'Searching...' - : `Found ${results?.meta?.total ?? results?.data?.length ?? 0} results for "${query}"`} + : `Found ${total} results${query ? ` for "${query}"` : ''}`}

- +
-
+
{isError ? (
Failed to load search results.
) : ( - + <> + + + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + )}
diff --git a/frontend/components/correspondences/circulation-status-card.tsx b/frontend/components/correspondences/circulation-status-card.tsx new file mode 100644 index 0000000..bd31d99 --- /dev/null +++ b/frontend/components/correspondences/circulation-status-card.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Loader2, GitBranch, ChevronRight, CheckCircle2, Clock, XCircle, Circle } from 'lucide-react'; +import { useCirculationsByCorrespondence } from '@/hooks/use-circulation'; +import { Circulation, CirculationRouting } from '@/types/circulation'; +import { format } from 'date-fns'; +import Link from 'next/link'; + +interface CirculationStatusCardProps { + correspondenceUuid: string; +} + +const ROUTING_STATUS_META: Record = { + PENDING: { icon: Clock, color: 'text-yellow-500', label: 'Pending' }, + IN_PROGRESS: { icon: Circle, color: 'text-blue-500', label: 'In Progress' }, + COMPLETED: { icon: CheckCircle2, color: 'text-green-500', label: 'Completed' }, + REJECTED: { icon: XCircle, color: 'text-red-500', label: 'Rejected' }, +}; + +const CIRC_STATUS_CLASS: Record = { + OPEN: 'bg-blue-100 text-blue-700', + COMPLETED: 'bg-green-100 text-green-700', + CANCELLED: 'bg-slate-100 text-slate-500', +}; + +function RoutingStep({ routing }: { routing: CirculationRouting }) { + const meta = ROUTING_STATUS_META[routing.status] ?? ROUTING_STATUS_META.PENDING; + const Icon = meta.icon; + const assigneeName = routing.assignee + ? `${routing.assignee.first_name ?? ''} ${routing.assignee.last_name ?? ''}`.trim() || + routing.assignee.username + : '—'; + + return ( +
+ + {assigneeName} + {routing.completedAt && ( + + {format(new Date(routing.completedAt), 'dd MMM')} + + )} +
+ ); +} + +function CirculationItem({ circ }: { circ: Circulation }) { + const statusClass = CIRC_STATUS_CLASS[circ.statusCode] ?? 'bg-gray-100 text-gray-700'; + const routings = circ.routings ?? []; + + return ( +
+
+
+

{circ.circulationNo}

+

{circ.subject}

+
+ + {circ.statusCode} + +
+ + {routings.length > 0 && ( +
+ {routings.slice(0, 3).map((r) => ( + + ))} + {routings.length > 3 && ( +

+ +{routings.length - 3} more assignees +

+ )} +
+ )} + + + + +
+ ); +} + +export function CirculationStatusCard({ correspondenceUuid }: CirculationStatusCardProps) { + const { data, isLoading } = useCirculationsByCorrespondence(correspondenceUuid); + + const circulations: Circulation[] = Array.isArray(data) + ? data + : Array.isArray(data?.data) + ? data.data + : []; + + return ( + + + + + Circulations + {circulations.length > 0 && ( + + {circulations.length} + + )} + + + + {isLoading ? ( +
+ + Loading... +
+ ) : circulations.length === 0 ? ( +

No circulations yet

+ ) : ( + circulations.map((circ) => ( + + )) + )} + + + + +
+
+ ); +} diff --git a/frontend/components/correspondences/correspondences-content.tsx b/frontend/components/correspondences/correspondences-content.tsx index 270b8bb..3715ecc 100644 --- a/frontend/components/correspondences/correspondences-content.tsx +++ b/frontend/components/correspondences/correspondences-content.tsx @@ -3,25 +3,84 @@ import { CorrespondenceList } from '@/components/correspondences/list'; import { Pagination } from '@/components/common/pagination'; import { useCorrespondences } from '@/hooks/use-correspondence'; -import { useSearchParams } from 'next/navigation'; -import { Loader2 } from 'lucide-react'; +import { useSearchParams, useRouter, usePathname } from 'next/navigation'; +import { Loader2, Search, X, Download } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import Link from 'next/link'; +import { useCallback, useState } from 'react'; +import apiClient from '@/lib/api/client'; + +const STATUS_FILTERS = [ + { value: '', label: 'All Statuses' }, + { value: 'DRAFT', label: 'Draft' }, + { value: 'IN_REVIEW', label: 'In Review' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'CANCELLED', label: 'Cancelled' }, +]; export function CorrespondencesContent() { + const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const page = Number(searchParams.get('page') || '1'); - const _status = searchParams.get('status') || undefined; + const statusFilter = searchParams.get('status') || ''; const search = searchParams.get('search') || undefined; - const revisionStatus = (searchParams.get('revisionStatus') as 'CURRENT' | 'ALL' | 'OLD') || 'CURRENT'; + const [searchInput, setSearchInput] = useState(search || ''); + const [exporting, setExporting] = useState(false); + + const handleExportCsv = async () => { + setExporting(true); + try { + const params: Record = {}; + if (search) params.search = search; + if (statusFilter) params.status = statusFilter; + if (revisionStatus) params.revisionStatus = revisionStatus; + + const response = await apiClient.get('/correspondences/export-csv', { + params, + responseType: 'blob', + }); + + const url = URL.createObjectURL(new Blob([response.data], { type: 'text/csv' })); + const a = document.createElement('a'); + a.href = url; + a.download = `correspondences-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + }; + const { data, isLoading, isError } = useCorrespondences({ page, search, + status: statusFilter || undefined, revisionStatus, }); + const buildUrl = useCallback((updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + Object.entries(updates).forEach(([k, v]) => { + if (v) params.set(k, v); + else params.delete(k); + }); + params.set('page', '1'); + return `${pathname}?${params.toString()}`; + }, [searchParams, pathname]); + + const handleSearch = () => { + router.push(buildUrl({ search: searchInput })); + }; + + const handleClearSearch = () => { + setSearchInput(''); + router.push(buildUrl({ search: '' })); + }; + if (isLoading) { return (
@@ -36,20 +95,78 @@ export function CorrespondencesContent() { return ( <> -
+ {/* Filters bar */} +
+ {/* Search */} +
+
+ + setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Search by number or subject..." + className="pl-8 pr-8 h-9 text-sm" + /> + {searchInput && ( + + )} +
+ +
+ + {/* Status filter */}
- {['ALL', 'CURRENT', 'OLD'].map((status) => ( - - ))}
+ + {/* Revision filter */} +
+ {(['CURRENT', 'ALL', 'OLD'] as const).map((rs) => ( + + + + ))} +
+ + {/* Export */} +
+
(null); + const cancelMutation = useCancelCorrespondence(); + const [actionState, setActionState] = useState<'approve' | 'reject' | 'cancel' | null>(null); const [comments, setComments] = useState(''); + const [cancelReason, setCancelReason] = useState(''); if (!data) return
No data found
; - // Derive Current Revision Data const currentRevision = data.revisions?.find((r) => r.isCurrent) || data.revisions?.[0]; const subject = currentRevision?.subject || '-'; const description = currentRevision?.description || '-'; - const status = currentRevision?.status?.statusCode || 'UNKNOWN'; // e.g. DRAFT + const status = currentRevision?.status?.statusCode || 'UNKNOWN'; const attachments = currentRevision?.attachments || []; + const importance = (currentRevision?.details?.importance as string) || 'NORMAL'; - // Note: Importance might be in details - const importance = currentRevision?.details?.importance || 'NORMAL'; + const toRecipients = data.recipients?.filter((r) => r.recipientType === 'TO') || []; + const ccRecipients = data.recipients?.filter((r) => r.recipientType === 'CC') || []; const handleSubmit = () => { if (confirm('Are you sure you want to submit this correspondence?')) { - submitMutation.mutate({ - uuid: data.uuid, - data: {}, - }); + submitMutation.mutate({ uuid: data.uuid, data: {} }); } }; const handleProcess = () => { - if (!actionState) return; - + if (!actionState || actionState === 'cancel') return; const action = actionState === 'approve' ? 'APPROVE' : 'REJECT'; processMutation.mutate( - { - uuid: data.uuid, - data: { - action, - comments, - }, - }, - { - onSuccess: () => { - setActionState(null); - setComments(''); - }, - } + { uuid: data.uuid, data: { action, comments } }, + { onSuccess: () => { setActionState(null); setComments(''); } } + ); + }; + + const handleCancel = () => { + if (!cancelReason.trim()) return; + cancelMutation.mutate( + { uuid: data.uuid, reason: cancelReason }, + { onSuccess: () => { setActionState(null); setCancelReason(''); } } ); }; return (
+ {/* EC-CORR-002 Warning: Replying to cancelled document */} + {status === 'CANCELLED' && ( +
+ +
+

This correspondence has been cancelled

+

+ You can still create a new correspondence referencing this document to acknowledge the cancellation. +

+
+
+ )} + {/* Header / Actions */}
@@ -82,7 +96,6 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
- {/* EDIT BUTTON LOGIC: Show if DRAFT */} {status === 'DRAFT' && ( )} - {status === 'DRAFT' && ( )} + {status !== 'CANCELLED' && ( + + )}
- {/* Action Input Area */} - {actionState && ( + {/* Approve / Reject Input Area */} + {(actionState === 'approve' || actionState === 'reject') && ( @@ -136,7 +158,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
+ +
+ + + )} +
{/* Main Content */}
@@ -163,11 +227,12 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
-
-

Description

-

{description}

-
- + {description && description !== '-' && ( +
+

Description

+

{description}

+
+ )} {currentRevision?.body && (

Content

@@ -176,7 +241,6 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
)} - {currentRevision?.remarks && (

Remarks

@@ -188,11 +252,11 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {

Attachments

- {attachments && attachments.length > 0 ? ( + {attachments.length > 0 ? (
{attachments.map((file, index) => (
@@ -215,46 +279,121 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
- {/* Sidebar Info */} + {/* Sidebar */}
+ {/* Core Info */} Information - +
-

Importance

-
- - {String(importance)} - +

Importance

+ + {String(importance)} + +
+ +
+

Document Type

+

{data.type?.typeName || '-'} ({data.type?.typeCode || '-'})

+
+ +
+ +
+

Originator (From)

+

{data.originator?.organizationName || '-'}

+

{data.originator?.organizationCode}

+
+ +
+

To

+ {toRecipients.length > 0 ? ( +
+ {toRecipients.map((r) => ( +
+

{r.recipientOrganization?.organizationName || '-'}

+

{r.recipientOrganization?.organizationCode}

+
+ ))} +
+ ) : ( +

-

+ )} +
+ + {ccRecipients.length > 0 && ( +
+

CC

+
+ {ccRecipients.map((r) => ( + + {r.recipientOrganization?.organizationCode || '-'} + + ))} +
-
+ )} -
+
-

Originator

-

{data.originator?.organizationName || '-'}

-

{data.originator?.organizationCode || '-'}

-
- -
-

Project

-

{data.project?.projectName || '-'}

-

{data.project?.projectCode || '-'}

+

Project

+

{data.project?.projectName || '-'}

+

{data.project?.projectCode}

+ + {/* Dates */} + + + Dates + + + {( + [ + { label: 'Document Date', value: currentRevision?.documentDate }, + { label: 'Issued Date', value: currentRevision?.issuedDate }, + { label: 'Received Date', value: currentRevision?.receivedDate }, + { label: 'Due Date', value: currentRevision?.dueDate }, + ] as { label: string; value?: string }[] + ).map(({ label, value }) => ( +
+ {label} + + {value ? format(new Date(value), 'dd MMM yyyy') : '-'} + +
+ ))} +
+
+ + {/* Circulations */} + + + {/* Tags */} + + + {/* References */} + + + {/* Revision History */} + {data.revisions && data.revisions.length > 0 && ( + + )}
diff --git a/frontend/components/correspondences/form.tsx b/frontend/components/correspondences/form.tsx index 80df1f9..c6301eb 100644 --- a/frontend/components/correspondences/form.tsx +++ b/frontend/components/correspondences/form.tsx @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; import { FileUploadZone } from '@/components/custom/file-upload-zone'; import { useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; @@ -18,6 +19,7 @@ import { CreateCorrespondenceDto } from '@/types/dto/correspondence/create-corre import { useState, useEffect } from 'react'; import { correspondenceService as _correspondenceService } from '@/lib/services/correspondence.service'; import { numberingApi } from '@/lib/api/numbering'; +import { filesApi } from '@/lib/api/files'; // Updated Zod Schema with all required fields const correspondenceSchema = z.object({ @@ -34,6 +36,7 @@ const correspondenceSchema = z.object({ receivedDate: z.string().optional(), fromOrganizationId: z.string().min(1, 'Please select From Organization'), toOrganizationId: z.string().min(1, 'Please select To Organization'), + ccOrganizationIds: z.array(z.string()).optional(), // CC organizations support importance: z.enum(['NORMAL', 'HIGH', 'URGENT']), attachments: z.array(z.instanceof(File)).optional(), }); @@ -159,7 +162,30 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Initia const fromOrgId = watch('fromOrganizationId'); const toOrgId = watch('toOrganizationId'); - const onSubmit = (data: FormData) => { + const [isUploading, setIsUploading] = useState(false); + + const onSubmit = async (data: FormData) => { + // Build recipients array with TO and CC + const recipients = [ + { organizationId: data.toOrganizationId, type: 'TO' as const }, + ...(data.ccOrganizationIds?.map(orgId => ({ organizationId: orgId, type: 'CC' as const })) || []) + ]; + + // Phase 1: Upload attachments to temp storage + let attachmentTempIds: string[] | undefined; + const validFiles = (data.attachments || []).filter((f): f is File => f instanceof File && !('validationError' in f && (f as { validationError?: string }).validationError)); + if (validFiles.length > 0) { + setIsUploading(true); + try { + const uploaded = await filesApi.uploadMany(validFiles); + attachmentTempIds = uploaded.map((u) => u.tempId); + } catch (_err) { + setIsUploading(false); + return; + } + setIsUploading(false); + } + const payload: CreateCorrespondenceDto = { projectId: data.projectId, typeId: data.documentTypeId, @@ -173,29 +199,26 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Initia issuedDate: data.issuedDate ? new Date(data.issuedDate).toISOString() : undefined, receivedDate: data.receivedDate ? new Date(data.receivedDate).toISOString() : undefined, originatorId: data.fromOrganizationId, - recipients: [{ organizationId: data.toOrganizationId, type: 'TO' }], + attachmentTempIds, + recipients, details: { importance: data.importance, }, }; if (uuid && initialData) { - // UPDATE Mode updateMutation.mutate( { uuid, data: payload }, - { - onSuccess: () => router.push(`/correspondences/${uuid}`), - } + { onSuccess: () => router.push(`/correspondences/${uuid}`) } ); } else { - // CREATE Mode createMutation.mutate(payload, { onSuccess: () => router.push('/correspondences'), }); } }; - const isPending = createMutation.isPending || updateMutation.isPending; + const isPending = createMutation.isPending || updateMutation.isPending || isUploading; // -- Preview Logic -- const [preview, setPreview] = useState<{ number: string; isDefaultTemplate: boolean } | null>(null); @@ -464,6 +487,36 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Initia {errors.toOrganizationId &&

{errors.toOrganizationId.message}

}
+ +
+ +
+ {organizationOptions + .filter(org => org.uuid !== toOrgId) // Exclude TO organization + .map((org) => ( +
+ { + const currentCC = watch('ccOrganizationIds') || []; + if (checked) { + setValue('ccOrganizationIds', [...currentCC, org.uuid]); + } else { + setValue('ccOrganizationIds', currentCC.filter(id => id !== org.uuid)); + } + }} + /> + +
+ ))} +
+

+ Select organizations to receive a copy of this correspondence +

+
{/* Importance */} @@ -504,7 +557,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: Initia
diff --git a/frontend/components/correspondences/list.tsx b/frontend/components/correspondences/list.tsx index c5e3c9a..aacba78 100644 --- a/frontend/components/correspondences/list.tsx +++ b/frontend/components/correspondences/list.tsx @@ -5,7 +5,7 @@ import { DataTable } from '@/components/common/data-table'; import { ColumnDef } from '@tanstack/react-table'; import { StatusBadge } from '@/components/common/status-badge'; import { Button } from '@/components/ui/button'; -import { Eye, Edit, FileText } from 'lucide-react'; +import { Eye, Edit } from 'lucide-react'; import Link from 'next/link'; import { format } from 'date-fns'; @@ -36,6 +36,15 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
), }, + { + accessorKey: 'correspondence.type.typeCode', + header: 'Type', + cell: ({ row }) => ( + + {row.original.correspondence?.type?.typeCode || '-'} + + ), + }, { accessorKey: 'correspondence.originator.organizationCode', header: 'From', @@ -43,6 +52,27 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) { {row.original.correspondence?.originator?.organizationCode || '-'} ), }, + { + accessorKey: 'correspondence.project.projectCode', + header: 'Project', + cell: ({ row }) => ( + {row.original.correspondence?.project?.projectCode || '-'} + ), + }, + { + accessorKey: 'dueDate', + header: 'Due Date', + cell: ({ row }) => { + const due = row.original.dueDate; + if (!due) return -; + const isOverdue = new Date(due) < new Date() && row.original.status?.statusCode !== 'CANCELLED'; + return ( + + {format(new Date(due), 'dd MMM yyyy')} + + ); + }, + }, { accessorKey: 'createdAt', header: 'Created', @@ -70,27 +100,6 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) { - {statusCode === 'DRAFT' && ( + + {canEdit && direction === 'out' && ( + + )} +
+ + ); + + return ( + + + + + Referenced Documents + {totalCount > 0 && ( + + {totalCount} + + )} + + + + {isLoadingRefs ? ( +
+ + Loading references... +
+ ) : totalCount === 0 ? ( +

No referenced documents

+ ) : ( +
+ {outgoing.map((r) => r.target && renderRef(r.target, 'out'))} + {incoming.map((r) => r.source && renderRef(r.source, 'in'))} +
+ )} + + {canEdit && ( +
+ {isSearching ? ( + <> +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by number or subject..." + className="pl-7 h-8 text-sm" + /> +
+ +
+ + {searchQuery.trim().length >= 2 && ( +
+ {isSearchFetching ? ( +
+ + Searching... +
+ ) : searchItems.length === 0 ? ( +

No results found

+ ) : ( + searchItems + .filter((item: CorrespondenceRevisionItem) => { + const itemUuid = item.correspondence?.uuid; + return itemUuid && itemUuid !== uuid && !existingRefUuids.has(itemUuid); + }) + .map((item: CorrespondenceRevisionItem) => ( + + )) + )} +
+ )} + + ) : ( + + )} +
+ )} +
+
+ ); +} + diff --git a/frontend/components/correspondences/revision-history.tsx b/frontend/components/correspondences/revision-history.tsx new file mode 100644 index 0000000..512bbd9 --- /dev/null +++ b/frontend/components/correspondences/revision-history.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { History, CheckCircle2 } from 'lucide-react'; +import { CorrespondenceRevision } from '@/types/correspondence'; +import { format } from 'date-fns'; + +interface RevisionHistoryProps { + revisions: CorrespondenceRevision[]; +} + +const STATUS_CLASS: Record = { + DRAFT: 'bg-gray-100 text-gray-700', + SUBOWN: 'bg-yellow-100 text-yellow-700', + CLBOWN: 'bg-green-100 text-green-700', + CCBOWN: 'bg-red-100 text-red-700', + CANCELLED: 'bg-slate-100 text-slate-500', +}; + +const STATUS_LABEL: Record = { + DRAFT: 'Draft', + SUBOWN: 'Submitted', + CLBOWN: 'Approved', + CCBOWN: 'Rejected', + CANCELLED: 'Cancelled', +}; + +export function RevisionHistory({ revisions }: RevisionHistoryProps) { + if (!revisions || revisions.length === 0) return null; + + const sorted = [...revisions].sort((a, b) => b.revisionNumber - a.revisionNumber); + + return ( + + + + + Revision History + + {revisions.length} + + + + +
+
+ +
+ {sorted.map((rev) => { + const statusCode = rev.status?.statusCode ?? ''; + const statusLabel = STATUS_LABEL[statusCode] ?? statusCode; + const statusClass = STATUS_CLASS[statusCode] ?? 'bg-gray-100 text-gray-700'; + const isCurrent = rev.isCurrent; + + return ( +
+
+ +
+
+ + Rev. {rev.revisionLabel ?? String(rev.revisionNumber).padStart(2, '0')} + + {isCurrent && ( + + )} + + {statusLabel} + +
+ +

{rev.subject}

+ +

+ {format(new Date(rev.createdAt), 'dd MMM yyyy, HH:mm')} +

+ + {rev.remarks && ( +

+ {rev.remarks} +

+ )} +
+
+ ); + })} +
+
+ + + ); +} diff --git a/frontend/components/correspondences/tag-manager.tsx b/frontend/components/correspondences/tag-manager.tsx new file mode 100644 index 0000000..e1bcf36 --- /dev/null +++ b/frontend/components/correspondences/tag-manager.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Loader2, Tag as TagIcon, X, Plus, ChevronDown } from 'lucide-react'; +import { useCorrespondenceTags, useAddTag, useRemoveTag } from '@/hooks/use-correspondence'; +import { useQuery } from '@tanstack/react-query'; +import { masterDataService } from '@/lib/services/master-data.service'; +import { Tag } from '@/types/master-data'; + +interface TagManagerProps { + uuid: string; + canEdit: boolean; +} + +export function TagManager({ uuid, canEdit }: TagManagerProps) { + const [isOpen, setIsOpen] = useState(false); + + const { data: assignedRaw, isLoading } = useCorrespondenceTags(uuid); + const { data: allTagsRaw } = useQuery({ + queryKey: ['master', 'tags'], + queryFn: () => masterDataService.getTags(), + enabled: isOpen, + }); + + const addMutation = useAddTag(); + const removeMutation = useRemoveTag(); + + const assigned: Tag[] = Array.isArray(assignedRaw) ? (assignedRaw as Tag[]) : []; + const allTags: Tag[] = Array.isArray(allTagsRaw) ? (allTagsRaw as Tag[]) : []; + const assignedIds = new Set(assigned.map((t) => t.id)); + const available = allTags.filter((t) => !assignedIds.has(t.id)); + + const handleAdd = (tagId: number) => { + addMutation.mutate({ uuid, tagId }); + }; + + const handleRemove = (tagId: number) => { + removeMutation.mutate({ uuid, tagId }); + }; + + const getTagColor = (color?: string) => { + if (!color || color === 'default') return '#e2e8f0'; + return color; + }; + + return ( + + + + + Tags + {assigned.length > 0 && ( + + {assigned.length} + + )} + + + + {isLoading ? ( +
+ + Loading tags... +
+ ) : assigned.length === 0 ? ( +

No tags assigned

+ ) : ( +
+ {assigned.map((tag) => ( + + + {tag.tag_name} + {canEdit && ( + + )} + + ))} +
+ )} + + {canEdit && ( +
+ {isOpen ? ( +
+
+ {available.length === 0 ? ( +

+ {allTags.length === 0 ? 'Loading...' : 'All tags already assigned'} +

+ ) : ( + available.map((tag) => ( + + )) + )} +
+ +
+ ) : ( + + )} +
+ )} +
+
+ ); +} diff --git a/frontend/components/search/filters.tsx b/frontend/components/search/filters.tsx index c17462f..df4c180 100644 --- a/frontend/components/search/filters.tsx +++ b/frontend/components/search/filters.tsx @@ -4,57 +4,66 @@ import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { SearchFilters as FilterType } from '@/types/search'; -import { useState } from 'react'; + +const DOC_TYPES = [ + { value: 'correspondence', label: 'Correspondence' }, + { value: 'rfa', label: 'RFA' }, + { value: 'drawing', label: 'Drawing' }, +]; + +const STATUSES = [ + { value: 'DRAFT', label: 'Draft' }, + { value: 'SUBOWN', label: 'Submitted' }, + { value: 'CLBOWN', label: 'Approved' }, + { value: 'CCBOWN', label: 'Rejected' }, + { value: 'CANCELLED', label: 'Cancelled' }, +]; interface SearchFiltersProps { + filters: FilterType; onFilterChange: (filters: FilterType) => void; } -export function SearchFilters({ onFilterChange }: SearchFiltersProps) { - const [filters, setFilters] = useState({ - types: [], - statuses: [], - }); +export function SearchFilters({ filters, onFilterChange }: SearchFiltersProps) { + const activeCount = (filters.types?.length ?? 0) + (filters.statuses?.length ?? 0); const handleTypeChange = (type: string, checked: boolean) => { - const currentTypes = filters.types || []; - const newTypes = checked ? [...currentTypes, type] : currentTypes.filter((t) => t !== type); - - const newFilters = { ...filters, types: newTypes }; - setFilters(newFilters); - onFilterChange(newFilters); + const current = filters.types || []; + const next = checked ? [...current, type] : current.filter((t) => t !== type); + onFilterChange({ ...filters, types: next }); }; const handleStatusChange = (status: string, checked: boolean) => { - const currentStatuses = filters.statuses || []; - const newStatuses = checked ? [...currentStatuses, status] : currentStatuses.filter((s) => s !== status); - - const newFilters = { ...filters, statuses: newStatuses }; - setFilters(newFilters); - onFilterChange(newFilters); + const current = filters.statuses || []; + const next = checked ? [...current, status] : current.filter((s) => s !== status); + onFilterChange({ ...filters, statuses: next }); }; - const clearFilters = () => { - const newFilters = { types: [], statuses: [] }; - setFilters(newFilters); - onFilterChange(newFilters); - }; + const clearFilters = () => onFilterChange({ types: [], statuses: [] }); return ( - + +
+

Filters

+ {activeCount > 0 && ( + {activeCount} active + )} +
+
-

Document Type

+

Document Type

- {['correspondence', 'rfa', 'drawing'].map((type) => ( -
+ {DOC_TYPES.map(({ value, label }) => ( +
handleTypeChange(type, checked as boolean)} + id={`type-${value}`} + checked={filters.types?.includes(value)} + onCheckedChange={(checked) => handleTypeChange(value, checked as boolean)} /> -
))} @@ -62,26 +71,28 @@ export function SearchFilters({ onFilterChange }: SearchFiltersProps) {
-

Status

+

Status

- {['DRAFT', 'PENDING', 'APPROVED', 'REJECTED', 'IN_REVIEW'].map((status) => ( -
+ {STATUSES.map(({ value, label }) => ( +
handleStatusChange(status, checked as boolean)} + id={`status-${value}`} + checked={filters.statuses?.includes(value)} + onCheckedChange={(checked) => handleStatusChange(value, checked as boolean)} /> -
))}
- + {activeCount > 0 && ( + + )} ); } diff --git a/frontend/components/search/results.tsx b/frontend/components/search/results.tsx index d04702e..8f0bf86 100644 --- a/frontend/components/search/results.tsx +++ b/frontend/components/search/results.tsx @@ -13,6 +13,33 @@ interface SearchResultsProps { loading: boolean; } +const TYPE_META: Record = { + correspondence: { icon: FileText, label: 'Correspondence', color: 'text-blue-600' }, + rfa: { icon: Clipboard, label: 'RFA', color: 'text-purple-600' }, + drawing: { icon: Image, label: 'Drawing', color: 'text-green-600' }, +}; + +const STATUS_VARIANT: Record = { + DRAFT: 'bg-gray-100 text-gray-700', + SUBOWN: 'bg-yellow-100 text-yellow-700', + CLBOWN: 'bg-green-100 text-green-700', + CCBOWN: 'bg-red-100 text-red-700', + CANCELLED: 'bg-slate-100 text-slate-500 line-through', +}; + +const STATUS_LABEL: Record = { + DRAFT: 'Draft', + SUBOWN: 'Submitted', + CLBOWN: 'Approved', + CCBOWN: 'Rejected', + CANCELLED: 'Cancelled', +}; + +function getLink(result: SearchResult): string { + if (result.type === 'drawing') return `/drawings/${result.uuid}`; + return `/${result.type}s/${result.uuid}`; +} + export function SearchResults({ results, query, loading }: SearchResultsProps) { if (loading) { return ( @@ -30,57 +57,55 @@ export function SearchResults({ results, query, loading }: SearchResultsProps) { ); } - const getIcon = (type: string) => { - switch (type) { - case 'correspondence': - return FileText; - case 'rfa': - return Clipboard; - case 'drawing': - return Image; - default: - return FileText; - } - }; - - const getLink = (result: SearchResult) => { - return `/${result.type}s/${result.uuid}`; // ADR-019: Use UUID for public routes - }; - return ( -
+
{results.map((result, index) => { - const Icon = getIcon(result.type); + const meta = TYPE_META[result.type] ?? TYPE_META.correspondence; + const Icon = meta.icon; + const statusClass = STATUS_VARIANT[result.status] ?? 'bg-gray-100 text-gray-700'; + const statusLabel = STATUS_LABEL[result.status] ?? result.status; return ( - +
-
- +
+
-
-
-

- - {result.type} +
+
+ + {result.documentNumber} + + + {statusLabel} + + + {meta.label} - {result.status}
-

{result.description}

+

-
- {result.documentNumber} - - {format(new Date(result.createdAt), 'dd MMM yyyy')} -
+ {result.description && ( +

+ {result.description} +

+ )} + +

+ {format(new Date(result.createdAt), 'dd MMM yyyy')} +

diff --git a/frontend/hooks/__tests__/use-circulation.test.ts b/frontend/hooks/__tests__/use-circulation.test.ts new file mode 100644 index 0000000..281c4f6 --- /dev/null +++ b/frontend/hooks/__tests__/use-circulation.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { createTestQueryClient } from '@/lib/test-utils'; +import { + useCirculationsByCorrespondence, + circulationKeys, +} from '../use-circulation'; +import { circulationService } from '@/lib/services/circulation.service'; + +vi.mock('@/lib/services/circulation.service', () => ({ + circulationService: { + getByCorrespondenceUuid: vi.fn(), + }, +})); + +describe('use-circulation hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('circulationKeys', () => { + it('should generate correct cache keys', () => { + expect(circulationKeys.all).toEqual(['circulations']); + expect(circulationKeys.byCorrespondence('uuid-abc')).toEqual([ + 'circulations', + 'byCorrespondence', + 'uuid-abc', + ]); + }); + }); + + describe('useCirculationsByCorrespondence', () => { + it('should fetch circulations for a correspondence UUID', async () => { + const mockData = { + data: [ + { + uuid: 'circ-uuid-1', + circulationNo: 'CIR-001', + subject: 'Review Document', + statusCode: 'OPEN', + routings: [], + }, + ], + meta: { total: 1, page: 1, limit: 50 }, + }; + + vi.mocked(circulationService.getByCorrespondenceUuid).mockResolvedValue(mockData); + + const { wrapper } = createTestQueryClient(); + const { result } = renderHook( + () => useCirculationsByCorrespondence('corr-uuid-1'), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(circulationService.getByCorrespondenceUuid).toHaveBeenCalledWith('corr-uuid-1'); + expect(result.current.data).toEqual(mockData); + }); + + it('should not fetch when correspondenceUuid is empty', () => { + const { wrapper } = createTestQueryClient(); + const { result } = renderHook( + () => useCirculationsByCorrespondence(''), + { wrapper } + ); + + expect(result.current.fetchStatus).toBe('idle'); + expect(circulationService.getByCorrespondenceUuid).not.toHaveBeenCalled(); + }); + + it('should handle fetch error gracefully', async () => { + vi.mocked(circulationService.getByCorrespondenceUuid).mockRejectedValue( + new Error('Network error') + ); + + const { wrapper } = createTestQueryClient(); + const { result } = renderHook( + () => useCirculationsByCorrespondence('corr-uuid-error'), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); + + it('should use the correct query key', () => { + vi.mocked(circulationService.getByCorrespondenceUuid).mockResolvedValue([]); + + const { wrapper } = createTestQueryClient(); + const { result } = renderHook( + () => useCirculationsByCorrespondence('test-uuid'), + { wrapper } + ); + + expect(result.current.status).toBeDefined(); + expect(circulationKeys.byCorrespondence('test-uuid')).toEqual([ + 'circulations', + 'byCorrespondence', + 'test-uuid', + ]); + }); + }); +}); diff --git a/frontend/hooks/use-circulation.ts b/frontend/hooks/use-circulation.ts new file mode 100644 index 0000000..7445e8d --- /dev/null +++ b/frontend/hooks/use-circulation.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { circulationService } from '@/lib/services/circulation.service'; + +export const circulationKeys = { + all: ['circulations'] as const, + byCorrespondence: (uuid: string) => ['circulations', 'byCorrespondence', uuid] as const, +}; + +export function useCirculationsByCorrespondence(correspondenceUuid: string) { + return useQuery({ + queryKey: circulationKeys.byCorrespondence(correspondenceUuid), + queryFn: () => circulationService.getByCorrespondenceUuid(correspondenceUuid), + enabled: !!correspondenceUuid, + }); +} diff --git a/frontend/hooks/use-correspondence.ts b/frontend/hooks/use-correspondence.ts index af5538a..cd4adff 100644 --- a/frontend/hooks/use-correspondence.ts +++ b/frontend/hooks/use-correspondence.ts @@ -91,6 +91,25 @@ export function useDeleteCorrespondence() { }); } +export function useCancelCorrespondence() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uuid, reason }: { uuid: string; reason: string }) => + correspondenceService.cancel(uuid, reason), + onSuccess: (_, { uuid }) => { + toast.success('Correspondence cancelled successfully'); + queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(uuid) }); + queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() }); + }, + onError: (error: ApiError) => { + toast.error('Failed to cancel correspondence', { + description: error.response?.data?.message || 'Something went wrong', + }); + }, + }); +} + export function useSubmitCorrespondence() { const queryClient = useQueryClient(); @@ -110,6 +129,94 @@ export function useSubmitCorrespondence() { }); } +export function useCorrespondenceTags(uuid: string) { + return useQuery({ + queryKey: [...correspondenceKeys.detail(uuid), 'tags'] as const, + queryFn: () => correspondenceService.getTags(uuid), + enabled: !!uuid, + }); +} + +export function useAddTag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uuid, tagId }: { uuid: string; tagId: number }) => + correspondenceService.addTag(uuid, tagId), + onSuccess: (_, { uuid }) => { + toast.success('Tag added'); + queryClient.invalidateQueries({ queryKey: [...correspondenceKeys.detail(uuid), 'tags'] }); + }, + onError: (error: ApiError) => { + toast.error('Failed to add tag', { + description: error.response?.data?.message || 'Something went wrong', + }); + }, + }); +} + +export function useRemoveTag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uuid, tagId }: { uuid: string; tagId: number }) => + correspondenceService.removeTag(uuid, tagId), + onSuccess: (_, { uuid }) => { + toast.success('Tag removed'); + queryClient.invalidateQueries({ queryKey: [...correspondenceKeys.detail(uuid), 'tags'] }); + }, + onError: (error: ApiError) => { + toast.error('Failed to remove tag', { + description: error.response?.data?.message || 'Something went wrong', + }); + }, + }); +} + +export function useReferences(uuid: string) { + return useQuery({ + queryKey: [...correspondenceKeys.detail(uuid), 'references'] as const, + queryFn: () => correspondenceService.getReferences(uuid), + enabled: !!uuid, + }); +} + +export function useAddReference() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uuid, targetUuid }: { uuid: string; targetUuid: string }) => + correspondenceService.addReference(uuid, { targetUuid }), + onSuccess: (_, { uuid }) => { + toast.success('Reference added successfully'); + queryClient.invalidateQueries({ queryKey: [...correspondenceKeys.detail(uuid), 'references'] }); + }, + onError: (error: ApiError) => { + toast.error('Failed to add reference', { + description: error.response?.data?.message || 'Something went wrong', + }); + }, + }); +} + +export function useRemoveReference() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uuid, targetUuid }: { uuid: string; targetUuid: string }) => + correspondenceService.removeReference(uuid, targetUuid), + onSuccess: (_, { uuid }) => { + toast.success('Reference removed'); + queryClient.invalidateQueries({ queryKey: [...correspondenceKeys.detail(uuid), 'references'] }); + }, + onError: (error: ApiError) => { + toast.error('Failed to remove reference', { + description: error.response?.data?.message || 'Something went wrong', + }); + }, + }); +} + export function useProcessWorkflow() { const queryClient = useQueryClient(); diff --git a/frontend/lib/api/files.ts b/frontend/lib/api/files.ts new file mode 100644 index 0000000..37c017e --- /dev/null +++ b/frontend/lib/api/files.ts @@ -0,0 +1,26 @@ +import apiClient from '@/lib/api/client'; + +export interface UploadedAttachment { + id: number; + uuid: string; + tempId: string; + originalFilename: string; + mimeType: string; + fileSize: number; + isTemporary: boolean; +} + +export const filesApi = { + upload: async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const response = await apiClient.post('/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + }, + + uploadMany: async (files: File[]): Promise => { + return Promise.all(files.map((f) => filesApi.upload(f))); + }, +}; diff --git a/frontend/lib/services/circulation.service.ts b/frontend/lib/services/circulation.service.ts index c42f2af..cca8019 100644 --- a/frontend/lib/services/circulation.service.ts +++ b/frontend/lib/services/circulation.service.ts @@ -44,6 +44,16 @@ export const circulationService = { return response.data; }, + /** + * ดึงรายการใบเวียนของ correspondence (by correspondence UUID) + */ + getByCorrespondenceUuid: async (correspondenceUuid: string) => { + const response = await apiClient.get('/circulations', { + params: { correspondenceUuid, limit: 50 }, + }); + return response.data; + }, + /** * ลบ/ยกเลิกใบเวียน */ diff --git a/frontend/lib/services/correspondence.service.ts b/frontend/lib/services/correspondence.service.ts index 609b16c..ff70db5 100644 --- a/frontend/lib/services/correspondence.service.ts +++ b/frontend/lib/services/correspondence.service.ts @@ -5,7 +5,7 @@ import { CreateCorrespondenceDto } from '@/types/dto/correspondence/create-corre // Import DTO ใหม่ import { SubmitCorrespondenceDto } from '@/types/dto/correspondence/submit-correspondence.dto'; import { WorkflowActionDto } from '@/types/dto/correspondence/workflow-action.dto'; -import { AddReferenceDto, RemoveReferenceDto } from '@/types/dto/correspondence/add-reference.dto'; +import { AddReferenceDto } from '@/types/dto/correspondence/add-reference.dto'; export const correspondenceService = { // ... (getAll, getById, create, update, delete เดิมคงไว้) ... @@ -35,6 +35,13 @@ export const correspondenceService = { return response.data; }, + cancel: async (uuid: string, reason: string) => { + const response = await apiClient.delete(`/correspondences/${uuid}`, { + data: { reason }, + }); + return response.data; + }, + // --- 🔥 New Methods --- /** @@ -53,6 +60,14 @@ export const correspondenceService = { return response.data; }, + /** + * ดึงรายการเอกสารอ้างอิง + */ + getReferences: async (uuid: string) => { + const response = await apiClient.get(`/correspondences/${uuid}/references`); + return response.data; + }, + /** * เพิ่มเอกสารอ้างอิง */ @@ -62,15 +77,36 @@ export const correspondenceService = { }, /** - * ลบเอกสารอ้างอิง + * ลบเอกสารอ้างอิง (ใช้ path param ตาม backend: DELETE /:uuid/references/:targetUuid) */ - removeReference: async (uuid: string, data: RemoveReferenceDto) => { - // ใช้ DELETE method โดยส่ง body ไปด้วย (axios รองรับผ่าน config.data) - const response = await apiClient.delete(`/correspondences/${uuid}/references`, { - data: data, - }); + removeReference: async (uuid: string, targetUuid: string) => { + const response = await apiClient.delete(`/correspondences/${uuid}/references/${targetUuid}`); return response.data; }, + /** + * ดึง Tags ของ correspondence + */ + getTags: async (uuid: string) => { + const response = await apiClient.get(`/correspondences/${uuid}/tags`); + return response.data; + }, + + /** + * เพิ่ม Tag ให้ correspondence + */ + addTag: async (uuid: string, tagId: number) => { + const response = await apiClient.post(`/correspondences/${uuid}/tags/${tagId}`); + return response.data; + }, + + /** + * ลบ Tag ออกจาก correspondence + */ + removeTag: async (uuid: string, tagId: number) => { + const response = await apiClient.delete(`/correspondences/${uuid}/tags/${tagId}`); + return response.data; + }, + /** * Preview Document Number */ diff --git a/frontend/types/correspondence.ts b/frontend/types/correspondence.ts index b85f9dc..c9037bf 100644 --- a/frontend/types/correspondence.ts +++ b/frontend/types/correspondence.ts @@ -25,6 +25,9 @@ export interface CorrespondenceRevision { body?: string; remarks?: string; dueDate?: string; + documentDate?: string; + issuedDate?: string; + receivedDate?: string; schemaVersion?: number; description?: string; isCurrent: boolean; diff --git a/frontend/types/dto/correspondence/add-reference.dto.ts b/frontend/types/dto/correspondence/add-reference.dto.ts index cc1ef33..03e3674 100644 --- a/frontend/types/dto/correspondence/add-reference.dto.ts +++ b/frontend/types/dto/correspondence/add-reference.dto.ts @@ -1,11 +1,11 @@ // File: src/types/dto/correspondence/add-reference.dto.ts export interface AddReferenceDto { - /** ID ของเอกสารที่ต้องการอ้างอิงถึง */ - targetId: number; + /** UUID ของเอกสารที่ต้องการอ้างอิงถึง (ADR-019) */ + targetUuid: string; } export interface RemoveReferenceDto { - /** ID ของเอกสารที่ต้องการลบการอ้างอิง */ - targetId: number; + /** UUID ของเอกสารที่ต้องการลบการอ้างอิง (ADR-019) */ + targetUuid: string; } diff --git a/frontend/types/dto/correspondence/create-correspondence.dto.ts b/frontend/types/dto/correspondence/create-correspondence.dto.ts index cc677d5..1d10821 100644 --- a/frontend/types/dto/correspondence/create-correspondence.dto.ts +++ b/frontend/types/dto/correspondence/create-correspondence.dto.ts @@ -48,6 +48,9 @@ export interface CreateCorrespondenceDto { */ originatorId?: number | string; + /** Temp IDs from two-phase file upload (POST /files/upload → tempId) */ + attachmentTempIds?: string[]; + /** รายชื่อผู้รับ */ recipients?: { organizationId: number | string; type: 'TO' | 'CC' }[]; } diff --git a/frontend/types/dto/correspondence/search-correspondence.dto.ts b/frontend/types/dto/correspondence/search-correspondence.dto.ts index 53345b8..251d5a0 100644 --- a/frontend/types/dto/correspondence/search-correspondence.dto.ts +++ b/frontend/types/dto/correspondence/search-correspondence.dto.ts @@ -5,6 +5,7 @@ export interface SearchCorrespondenceDto { typeId?: number; // กรองตามประเภทเอกสาร projectId?: number; // กรองตามโครงการ statusId?: number; // กรองตามสถานะ (จาก Revision ปัจจุบัน) + status?: string; // กรองตามสถานะ code string (เช่น DRAFT, IN_REVIEW) revisionStatus?: 'CURRENT' | 'ALL' | 'OLD'; // กรองตามสถานะ Revision // เพิ่มเติมสำหรับการแบ่งหน้า (Pagination) diff --git a/frontend/types/dto/search/search-query.dto.ts b/frontend/types/dto/search/search-query.dto.ts index 98e779a..3ded5f7 100644 --- a/frontend/types/dto/search/search-query.dto.ts +++ b/frontend/types/dto/search/search-query.dto.ts @@ -7,6 +7,9 @@ export interface SearchQueryDto { /** กรองประเภท: 'rfa', 'correspondence', 'drawing' */ type?: string; + /** กรองสถานะเอกสาร */ + status?: string; + /** ID ของโครงการ */ projectId?: number; diff --git a/specs/01-requirements/01-01-objectives.md b/specs/01-requirements/01-01-objectives.md index 2346eec..9d236a8 100644 --- a/specs/01-requirements/01-01-objectives.md +++ b/specs/01-requirements/01-01-objectives.md @@ -3,19 +3,119 @@ --- title: 'Objectives' -version: 1.8.0 -status: first-draft +version: 1.8.1 +status: updated owner: Nattanin Peancharoen -last_updated: 2026-02-23 -related: - +last_updated: 2026-03-24 +related: + +- specs/00-Overview/00-03-product-vision.md +- specs/01-Requirements/01-04-user-stories.md +- specs/01-Requirements/01-05-acceptance-criteria.md +- specs/01-Requirements/01-03-modules/01-03-00-index.md +- specs/06-Decision-Records/ADR-005-technology-stack.md --- -สร้างเว็บแอปพลิเคชันสำหรับระบบบริหารจัดการเอกสารโครงการ (Document Management System - DMS) แบบครบวงจร ที่เน้นความปลอดภัยสูงสุด ความถูกต้องของข้อมูล (Data Integrity) และรองรับการขยายตัวในอนาคต (Scalability) โดยแก้ไขปัญหา Race Condition และเพิ่มความเสถียรในการจัดการไฟล์ และใช้ Unified Workflow Engine ในการจัดการกระบวนการอนุมัติทั้งหมดเพื่อความยืดหยุ่น +> [!NOTE] +> เอกสารนี้กำหนด **วัตถุประสงค์และขอบเขตของ LCBP3-DMS** ซึ่งเป็นเอกสารอ้างอิงหลักสำหรับการตัดสินใจทางสถาปัตยกรรมและ Feature Prioritization -- มีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร -- ช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล -- เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์ -- ปรับปรุงความปลอดภัยของระบบด้วยมาตรการป้องกันที่ทันสมัย -- เพิ่มความทนทานของระบบด้วยกลไก resilience patterns -- สร้างระบบ monitoring และ observability ที่ครอบคลุม +--- + +## 1. 🎯 วัตถุประสงค์หลัก (Primary Objectives) + +สร้างระบบบริหารจัดการเอกสารโครงการ (Document Management System) สำหรับโครงการก่อสร้างท่าเรือแหลมฉบังระยะที่ 3 ที่รองรับองค์กรผู้มีส่วนได้ส่วนเสียหลายฝ่าย ได้แก่ กทท., สค., ผรม., คคง. โดยมีเป้าหมายดังนี้: + +| # | วัตถุประสงค์ | KPI ที่วัดได้ | สถานะ | +|---|------------|------------|------| +| O-01 | จัดการวงจรชีวิตเอกสาร (Correspondence, RFA, Transmittal) แบบครบวงจร | Document turnaround time ลดลง ≥ 50% จาก Manual | 🔄 UAT | +| O-02 | บังคับใช้ RBAC 4 ระดับ (Global/Org/Project/Contract) ผ่าน CASL | ไม่พบ Unauthorized Access ใน Penetration Test | ✅ Done | +| O-03 | ป้องกัน Race Condition ในการออกเลขเอกสาร ด้วย Redis Redlock | Duplicate document number = 0 ใน load test 50 concurrent | ✅ Done | +| O-04 | ตรวจสอบไวรัสทุกไฟล์ที่อัปโหลด ด้วย ClamAV | Malware detection rate = 100% จาก EICAR test | ✅ Done | +| O-05 | รองรับ Full-text Search ข้ามเอกสารทั้งหมด ด้วย Elasticsearch | Search response time < 500ms สำหรับ 10K documents | ✅ Done | +| O-06 | รองรับ ~20,000 เอกสารที่มีอยู่เดิม ผ่าน Migration Bot (n8n + Ollama) | Migration accuracy ≥ 95% | 🔄 Planned | +| O-07 | บันทึก Audit Log ทุก Action ที่สำคัญในระบบ | Audit trail ครบ 100% สำหรับ CRUD + Workflow events | ✅ Done | +| O-08 | ระบบพร้อมรองรับ 100 concurrent users โดยไม่มี degradation | API response time < 200ms (P95) ที่ 100 VUs | 🔄 UAT | + +--- + +## 2. 🧩 ขอบเขตระบบ (Scope) + +### ✅ In Scope — ฟีเจอร์ที่พัฒนาใน LCBP3-DMS + +| Module | คำอธิบาย | อ้างอิง | +|--------|---------|--------| +| **Correspondence** | จดหมาย/เอกสารระหว่างองค์กร พร้อม Workflow | US-001~005 | +| **RFA (Request for Approval)** | ขออนุมัติ Shop Drawing พร้อม Transmittal | US-006~012c | +| **Transmittal** | ส่งเอกสารจำนวนมากพร้อมกัน | US-013~015 | +| **Shop Drawing** | จัดการแบบก่อสร้างพร้อม Revision History | US-016~018 | +| **Contract Drawing** | จัดการแบบคู่สัญญา | US-019~020 | +| **As-built Drawing** | จัดการแบบ As-built พร้อม Revision | US-021~022 | +| **Circulation Sheet** | เวียนเอกสารภายในองค์กร | US-023~025 | +| **Document Numbering** | ออกเลขเอกสารอัตโนมัติ พร้อม Format Config | ADR-002 | +| **Workflow Engine** | State machine สำหรับกระบวนการอนุมัติ | ADR-001 | +| **RBAC** | สิทธิ์ 4 ระดับผ่าน CASL | ADR-016 | +| **Full-text Search** | ค้นหาข้ามเอกสารทั้งหมด | Elasticsearch | +| **Audit Log** | บันทึกทุก Action สำคัญ | — | +| **Notifications** | แจ้งเตือน In-App, Email, LINE | ADR-008 | +| **Admin Panel** | จัดการ Users, Orgs, Projects, Configs | SCR-022~026 | +| **Migration Bot** | นำเข้าเอกสารเดิม ~20K docs | ADR-017 | + +### ❌ Out of Scope — ไม่รวมในโครงการนี้ + +- ระบบบัญชี / ERP Integration +- Mobile Native App (iOS/Android) — รองรับเฉพาะ Responsive Web +- Electronic Signature (e-Signature) ตามกฎหมาย +- Video Conference / Real-time Collaboration Editor +- IoT / Sensor Data Integration + +--- + +## 3. 🏗️ บริบทธุรกิจ (Business Context) + +**โครงการ:** ท่าเรือแหลมฉบัง ระยะที่ 3 (Laem Chabang Port Phase 3) +**ผู้ใช้งาน:** 4 องค์กรหลัก — กทท. (PAT), สค. (PMC), ผรม. (Contractor), คคง. (Consultant) +**ปัญหาเดิมที่แก้ไข:** + +- เอกสารเดิมอยู่ในรูปแบบกระดาษและไฟล์กระจัดกระจาย (~20,000 รายการ) +- ไม่มีระบบ Version Control สำหรับแบบก่อสร้าง +- กระบวนการอนุมัติต้องส่งเอกสารผ่านอีเมลหรือมือ ใช้เวลานาน +- ไม่มีการตรวจสอบสิทธิ์ที่รัดกุม ทำให้ข้อมูลรั่วไหลได้ +- ไม่มี Audit Trail สำหรับการตรวจสอบย้อนหลัง + +--- + +## 4. 📐 Architectural Principles (หลักการออกแบบ) + +| หลักการ | รายละเอียด | ADR | +|--------|---------|-----| +| **Security-First** | RBAC ทุก Endpoint, JWT, Helmet.js, ClamAV | ADR-016 | +| **Data Integrity** | UUID-based routing (ป้องกัน IDOR), Optimistic Locking | ADR-019 | +| **No Magic Migrations** | แก้ไข Schema SQL โดยตรง ไม่ใช้ ORM Migration | ADR-009 | +| **Thin Controller** | Business Logic อยู่ใน Service Layer เท่านั้น | — | +| **Unified Workflow** | State Machine เดียวสำหรับทุก Workflow | ADR-001 | +| **Hybrid Identifier** | INT PK (internal) + UUIDv7 (public API) | ADR-019 | +| **Queue-based Notifications** | BullMQ → Email/LINE/In-App async | ADR-008 | +| **Observable System** | Prometheus + Loki + Grafana | ADR-010 | + +--- + +## 5. ✅ เกณฑ์ความสำเร็จ (Success Criteria) + +ระบบถือว่าพร้อม Go-Live เมื่อ: + +- [ ] UAT ผ่าน ≥ 95% ของ Test Cases ใน `01-05-acceptance-criteria.md` +- [ ] ไม่พบ Critical Bug (Severity 1) ที่ค้างอยู่ +- [ ] Performance Test ผ่านที่ 100 concurrent users (API < 200ms P95) +- [ ] Security Audit ผ่าน (0 High/Critical Vulnerabilities) +- [ ] Sign-off จากตัวแทนทั้ง 4 องค์กร ตาม `00-04-stakeholder-signoff-and-risk.md` +- [ ] Runbook และ Backup/Recovery Plan พร้อม ตาม `04-02-backup-recovery.md` + +--- + +## 📝 Document Control + +- **Version:** 1.8.1 | **Status:** updated +- **Created:** 2026-02-23 | **Updated:** 2026-03-24 | **Owner:** Nattanin Peancharoen +- **Changes:** Expanded from stub to full objectives document — added KPI table, scope, business context, architectural principles, success criteria +- **Classification:** Internal Use Only diff --git a/specs/01-requirements/01-03-modules/01-03-02-correspondence.md b/specs/01-requirements/01-03-modules/01-03-02-correspondence.md index 2ad09c4..b333aa5 100644 --- a/specs/01-requirements/01-03-modules/01-03-02-correspondence.md +++ b/specs/01-requirements/01-03-modules/01-03-02-correspondence.md @@ -3,8 +3,8 @@ --- title: 'Functional Requirements: Correspondence Management' -version: 1.8.1 -status: updated +version: 1.8.2 +status: implemented owner: Nattanin Peancharoen last_updated: 2026-03-24 related: @@ -168,3 +168,24 @@ Correspondence ใช้ Unified Workflow Engine — ดูรายละเอ | **EC-CORR-003** | Originator และ Recipient เป็นองค์กรเดียวกัน → Block (ใช้ Circulation แทน) | 🟡 Medium | ดูรายละเอียดครบที่ `01-06-edge-cases-and-rules.md` หมวด "Module 7: Correspondence Edge Cases" + +--- + +## 3.2.11. Implementation Status (v1.8.2) + +| Feature | Status | หมายเหตุ | +|---|---|---| +| Create/Update/List/Detail Correspondence | ✅ Done | Phase 1 | +| File Attachments (two-phase upload) | ✅ Done | Phase 2 | +| Recipients (TO/CC), Submit Workflow | ✅ Done | Phase 3 | +| Elasticsearch Indexing | ✅ Done | Phase 4 | +| Cancel + In-App Notifications (cancel/submit) | ✅ Done | Phase 4 | +| Reference Selector (outgoing/incoming) | ✅ Done | Phase 5 | +| Tag Manager (assign/remove tags) | ✅ Done | Phase 5–6 | +| Search `/search` — status filter + pagination | ✅ Done | Phase 6.1 | +| Circulation Status Card in detail sidebar | ✅ Done | Phase 6.2 | +| Revision History UI (timeline) | ✅ Done | Phase 6.3 | +| Due Date Reminder (`@Cron` daily at 08:00) | ✅ Done | Phase 6.4 | +| Email notification via Nodemailer/BullMQ | ✅ Done | Phase 7.1 — type `'EMAIL'` → NotificationProcessor sends Nodemailer | +| Unit tests (service layer ≥ 80%) | ✅ Done | Phase 7.2 — 8 backend + 5 frontend tests | +| Bulk operations (bulk cancel/export) | ✅ Done | Phase 7.3 — `POST /bulk-cancel`, `GET /export-csv`, CSV button |