feat(rfa): complete RFA Approval Refactor - all 9 phases (T001-T080)
Phase 1-2: Setup, SQL schema, enums, queue constants, base entities
Phase 3 (US1): ReviewTeam, ReviewTeamMember, ReviewTask, TaskCreationService
Phase 4 (US2): ResponseCode, ResponseCodeRule, ImplicationsService, NotificationTriggerService
Phase 5 (US3): Delegation entity, CircularDetectionService, DelegationService/Controller/Module
Phase 6 (US4): ReminderRule, SchedulerService, EscalationService, ReminderProcessor, ReminderModule
Phase 7 (US5): DistributionMatrix, DistributionRecipient, ApprovalListenerService (Strangler),
TransmittalCreatorService, DistributionProcessor, DistributionModule
Phase 8 (US6): MatrixManagementService, InheritanceService (global→project override)
Phase 9 (Polish): AggregateStatusService, ConsensusService, VetoOverrideService,
ParallelGatewayHandler, review-validators, optimistic locking in completeReview,
test stubs (unit/integration/e2e), jest.config.js updated for tests/ directory
Frontend: ReviewTaskInbox, ParallelProgress, VetoOverrideDialog, DelegationForm,
DelegatedBadge, MatrixEditor, ProjectOverrideManager, DistributionStatus,
ReminderHistory, ResponseCodeSelector, CodeImplications, CompleteReviewForm,
ReviewTeamForm, ReviewTeamSelector, TeamMemberManager
Closes #1
This commit is contained in:
+22
-18
@@ -14,10 +14,14 @@ module.exports = {
|
|||||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
|
|
||||||
// Root directory for tests
|
// Root directory for tests
|
||||||
rootDir: 'src',
|
rootDir: '.',
|
||||||
|
|
||||||
// Test file pattern
|
// Test file pattern — ครอบคลุมทั้ง src/ (unit) และ tests/ (integration/e2e)
|
||||||
testRegex: '.*\\.spec\\.ts$',
|
testMatch: [
|
||||||
|
'<rootDir>/src/**/*.spec.ts',
|
||||||
|
'<rootDir>/tests/**/*.spec.ts',
|
||||||
|
'<rootDir>/tests/**/*.e2e-spec.ts',
|
||||||
|
],
|
||||||
|
|
||||||
// TypeScript transformation
|
// TypeScript transformation
|
||||||
transform: {
|
transform: {
|
||||||
@@ -30,16 +34,16 @@ module.exports = {
|
|||||||
|
|
||||||
// Coverage configuration
|
// Coverage configuration
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'**/*.(t|j)s',
|
'src/**/*.(t|j)s',
|
||||||
'!**/*.d.ts',
|
'!src/**/*.d.ts',
|
||||||
'!**/index.ts',
|
'!src/**/index.ts',
|
||||||
'!**/database/seeds/**',
|
'!src/**/database/seeds/**',
|
||||||
'!**/database/migrations/**',
|
'!src/**/database/migrations/**',
|
||||||
'!**/config/**',
|
'!src/**/config/**',
|
||||||
'!**/scripts/**',
|
'!src/**/scripts/**',
|
||||||
'!**/*.module.ts',
|
'!src/**/*.module.ts',
|
||||||
],
|
],
|
||||||
coverageDirectory: '../coverage',
|
coverageDirectory: './coverage',
|
||||||
coveragePathIgnorePatterns: ['/node_modules/', '/test/', '/dist/'],
|
coveragePathIgnorePatterns: ['/node_modules/', '/test/', '/dist/'],
|
||||||
|
|
||||||
// Test environment
|
// Test environment
|
||||||
@@ -49,7 +53,7 @@ module.exports = {
|
|||||||
cacheDirectory: '.jest-cache',
|
cacheDirectory: '.jest-cache',
|
||||||
|
|
||||||
// Global setup after env
|
// Global setup after env
|
||||||
setupFilesAfterEnv: ['../test/jest.setup.ts'],
|
setupFilesAfterEnv: ['./test/jest.setup.ts'],
|
||||||
|
|
||||||
// Transform ignore patterns (ให้ Jest ประมวลผล ESM modules)
|
// Transform ignore patterns (ให้ Jest ประมวลผล ESM modules)
|
||||||
// รองรับ uuid และ @nestjs/elasticsearch ที่เป็น ESM
|
// รองรับ uuid และ @nestjs/elasticsearch ที่เป็น ESM
|
||||||
@@ -100,11 +104,11 @@ module.exports = {
|
|||||||
|
|
||||||
// Module name mapper for path aliases
|
// Module name mapper for path aliases
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
'^@common/(.*)$': '<rootDir>/common/$1',
|
'^@common/(.*)$': '<rootDir>/src/common/$1',
|
||||||
'^@modules/(.*)$': '<rootDir>/modules/$1',
|
'^@modules/(.*)$': '<rootDir>/src/modules/$1',
|
||||||
'^@config/(.*)$': '<rootDir>/config/$1',
|
'^@config/(.*)$': '<rootDir>/src/config/$1',
|
||||||
'^@database/(.*)$': '<rootDir>/database/$1',
|
'^@database/(.*)$': '<rootDir>/src/database/$1',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verbose output for debugging
|
// Verbose output for debugging
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
|||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
import { AiModule } from './modules/ai/ai.module';
|
import { AiModule } from './modules/ai/ai.module';
|
||||||
import { RagModule } from './modules/rag/rag.module';
|
import { RagModule } from './modules/rag/rag.module';
|
||||||
|
import { ReviewTeamModule } from './modules/review-team/review-team.module';
|
||||||
|
import { ResponseCodeModule } from './modules/response-code/response-code.module';
|
||||||
|
import { DelegationModule } from './modules/delegation/delegation.module';
|
||||||
|
import { ReminderModule } from './modules/reminder/reminder.module';
|
||||||
|
import { DistributionModule } from './modules/distribution/distribution.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -191,6 +196,11 @@ import { RagModule } from './modules/rag/rag.module';
|
|||||||
MigrationModule,
|
MigrationModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
RagModule,
|
RagModule,
|
||||||
|
ReviewTeamModule,
|
||||||
|
ResponseCodeModule,
|
||||||
|
DelegationModule,
|
||||||
|
ReminderModule,
|
||||||
|
DistributionModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// File: src/common/validators/review-validators.ts
|
||||||
|
// Edge case validators สำหรับ RFA Review workflow (T073)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบว่า due date ถูกต้อง (ต้องอยู่ในอนาคต)
|
||||||
|
*/
|
||||||
|
export function validateDueDate(dueDate: Date): void {
|
||||||
|
const now = new Date();
|
||||||
|
if (dueDate <= now) {
|
||||||
|
throw new Error('Due date must be in the future');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบ delegation date range ไม่เกิน 90 วัน
|
||||||
|
*/
|
||||||
|
export function validateDelegationDateRange(startDate: Date, endDate: Date): void {
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
throw new Error('End date must be after start date');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDays = 90;
|
||||||
|
const diffMs = endDate.getTime() - startDate.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||||
|
|
||||||
|
if (diffDays > maxDays) {
|
||||||
|
throw new Error(`Delegation period cannot exceed ${maxDays} days`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบ ReviewTask ว่าสามารถ complete ได้ (ต้องมี response code)
|
||||||
|
*/
|
||||||
|
export function validateTaskCompletionRequirements(
|
||||||
|
taskStatus: string,
|
||||||
|
responseCodeId: number | undefined | null,
|
||||||
|
requiresComments: boolean,
|
||||||
|
comments: string | undefined | null,
|
||||||
|
): void {
|
||||||
|
if (taskStatus === 'COMPLETED') {
|
||||||
|
if (!responseCodeId) {
|
||||||
|
throw new Error('Response code is required to complete a review task');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresComments && (!comments || comments.trim().length === 0)) {
|
||||||
|
throw new Error('Comments are required for this response code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบ version สำหรับ optimistic locking (ADR-002)
|
||||||
|
*/
|
||||||
|
export function validateVersion(
|
||||||
|
expectedVersion: number,
|
||||||
|
actualVersion: number,
|
||||||
|
entityName: string,
|
||||||
|
): void {
|
||||||
|
if (actualVersion !== expectedVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`Optimistic lock conflict on ${entityName}: expected version ${expectedVersion}, got ${actualVersion}. Please retry.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบว่า override reason มีความยาวเพียงพอ
|
||||||
|
*/
|
||||||
|
export function validateOverrideReason(reason: string, minLength = 10): void {
|
||||||
|
if (!reason || reason.trim().length < minLength) {
|
||||||
|
throw new Error(`Override reason must be at least ${minLength} characters`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// File: src/modules/common/constants/queue.constants.ts
|
||||||
|
// Queue name constants สำหรับ BullMQ (ADR-008)
|
||||||
|
// รวม queue ทั้งหมดของระบบไว้ที่เดียว
|
||||||
|
|
||||||
|
// ─── Existing Queues ───────────────────────────────────────────────────────
|
||||||
|
export const QUEUE_NOTIFICATIONS = 'notifications';
|
||||||
|
export const QUEUE_WORKFLOW_EVENTS = 'workflow-events';
|
||||||
|
|
||||||
|
// ─── New Queues (Feature: 1-rfa-approval-refactor) ────────────────────────
|
||||||
|
|
||||||
|
/** Queue สำหรับ Auto-Reminders และ Escalation (T043-T047) */
|
||||||
|
export const QUEUE_REMINDERS = 'reminders';
|
||||||
|
|
||||||
|
/** Queue สำหรับ Distribution Matrix — กระจายเอกสารหลังอนุมัติ (T054-T056) */
|
||||||
|
export const QUEUE_DISTRIBUTION = 'distribution';
|
||||||
|
|
||||||
|
/** Queue สำหรับ Veto Override Notifications (T068.5) */
|
||||||
|
export const QUEUE_VETO_NOTIFICATIONS = 'veto-notifications';
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// File: src/modules/common/enums/review.enums.ts
|
||||||
|
// Shared enums สำหรับ RFA Approval Refactor (Feature: 1-rfa-approval-refactor)
|
||||||
|
|
||||||
|
// ─── Review Task Status ────────────────────────────────────────────────────
|
||||||
|
export enum ReviewTaskStatus {
|
||||||
|
PENDING = 'PENDING', // รอดำเนินการ
|
||||||
|
IN_PROGRESS = 'IN_PROGRESS', // กำลังตรวจสอบ
|
||||||
|
COMPLETED = 'COMPLETED', // เสร็จสิ้น (มีผลลัพธ์)
|
||||||
|
DELEGATED = 'DELEGATED', // ถูกมอบหมายให้ผู้อื่น
|
||||||
|
EXPIRED = 'EXPIRED', // เกินกำหนด
|
||||||
|
CANCELLED = 'CANCELLED', // ยกเลิก
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Response Code Category ────────────────────────────────────────────────
|
||||||
|
export enum ResponseCodeCategory {
|
||||||
|
ENGINEERING = 'ENGINEERING', // Shop Drawing / Method Statement / As-Built
|
||||||
|
MATERIAL = 'MATERIAL', // Material / Procurement Submittal
|
||||||
|
CONTRACT = 'CONTRACT', // Contract / Cost / BOQ
|
||||||
|
TESTING = 'TESTING', // Testing / Handover / QA
|
||||||
|
ESG = 'ESG', // Environment / Social / Governance
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delegation Scope ──────────────────────────────────────────────────────
|
||||||
|
export enum DelegationScope {
|
||||||
|
ALL = 'ALL', // มอบหมายทุกงาน
|
||||||
|
RFA_ONLY = 'RFA_ONLY', // เฉพาะงาน RFA
|
||||||
|
CORRESPONDENCE_ONLY = 'CORRESPONDENCE_ONLY', // เฉพาะงาน Correspondence
|
||||||
|
SPECIFIC_TYPES = 'SPECIFIC_TYPES', // กำหนดประเภทเอกสารเอง
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reminder Type ─────────────────────────────────────────────────────────
|
||||||
|
export enum ReminderType {
|
||||||
|
DUE_SOON = 'DUE_SOON', // X วันก่อนครบกำหนด
|
||||||
|
ON_DUE = 'ON_DUE', // วันครบกำหนด
|
||||||
|
OVERDUE = 'OVERDUE', // หลังครบกำหนด (ส่งซ้ำทุกวัน)
|
||||||
|
ESCALATION_L1 = 'ESCALATION_L1', // Escalation ระดับ 1 (ถึง Manager)
|
||||||
|
ESCALATION_L2 = 'ESCALATION_L2', // Escalation ระดับ 2 (ถึง PM/Director)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Review Team Member Role ───────────────────────────────────────────────
|
||||||
|
export enum ReviewTeamMemberRole {
|
||||||
|
REVIEWER = 'REVIEWER', // ผู้ตรวจสอบ
|
||||||
|
LEAD = 'LEAD', // หัวหน้าทีม (Lead Reviewer)
|
||||||
|
MANAGER = 'MANAGER', // ผู้จัดการ (Escalation target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Distribution Recipient Type ──────────────────────────────────────────
|
||||||
|
export enum RecipientType {
|
||||||
|
USER = 'USER', // ผู้ใช้เฉพาะคน
|
||||||
|
ORGANIZATION = 'ORGANIZATION', // องค์กร
|
||||||
|
TEAM = 'TEAM', // ทีม
|
||||||
|
ROLE = 'ROLE', // บทบาท เช่น ALL_QS, ALL_SITE_ENG
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Distribution Delivery Method ─────────────────────────────────────────
|
||||||
|
export enum DeliveryMethod {
|
||||||
|
EMAIL = 'EMAIL', // ส่งอีเมล
|
||||||
|
IN_APP = 'IN_APP', // แจ้งเตือนในระบบ
|
||||||
|
BOTH = 'BOTH', // ทั้งสองช่องทาง
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Consensus Decision (Parallel Review) ─────────────────────────────────
|
||||||
|
export enum ConsensusDecision {
|
||||||
|
APPROVED = 'APPROVED', // ผ่าน (Majority approved)
|
||||||
|
REJECTED = 'REJECTED', // ไม่ผ่าน (Veto triggered by Code 3)
|
||||||
|
APPROVED_WITH_COMMENTS = 'APPROVED_WITH_COMMENTS', // ผ่านพร้อมหมายเหตุ
|
||||||
|
PENDING = 'PENDING', // รอผล (ยังไม่ครบทุก Discipline)
|
||||||
|
OVERRIDDEN = 'OVERRIDDEN', // PM Override — บังคับผ่าน
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// File: src/modules/delegation/delegation.controller.ts
|
||||||
|
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
import { DelegationService } from './delegation.service';
|
||||||
|
import { CreateDelegationDto } from './dto/create-delegation.dto';
|
||||||
|
|
||||||
|
@Controller('delegations')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class DelegationController {
|
||||||
|
constructor(private readonly delegationService: DelegationService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /delegations
|
||||||
|
* ดึง Delegations ของ User ที่ login อยู่
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
findMyDelegations(@CurrentUser() user: User) {
|
||||||
|
return this.delegationService.findByDelegator(user.publicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /delegations
|
||||||
|
* สร้าง Delegation ใหม่ (FR-011)
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: User, @Body() dto: CreateDelegationDto) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
return this.delegationService.create(user.publicId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /delegations/:publicId
|
||||||
|
* Revoke delegation
|
||||||
|
*/
|
||||||
|
@Delete(':publicId')
|
||||||
|
revoke(@Param('publicId') publicId: string, @CurrentUser() user: User) {
|
||||||
|
return this.delegationService.revoke(publicId, user.publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// File: src/modules/delegation/delegation.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Delegation } from './entities/delegation.entity';
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
import { DelegationService } from './delegation.service';
|
||||||
|
import { DelegationController } from './delegation.controller';
|
||||||
|
import { CircularDetectionService } from './services/circular-detection.service';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Delegation, User]),
|
||||||
|
UserModule,
|
||||||
|
],
|
||||||
|
providers: [DelegationService, CircularDetectionService],
|
||||||
|
controllers: [DelegationController],
|
||||||
|
exports: [DelegationService],
|
||||||
|
})
|
||||||
|
export class DelegationModule {}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// File: src/modules/delegation/delegation.service.ts
|
||||||
|
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Delegation } from './entities/delegation.entity';
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
import { CircularDetectionService } from './services/circular-detection.service';
|
||||||
|
import { CreateDelegationDto } from './dto/create-delegation.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DelegationService {
|
||||||
|
private readonly logger = new Logger(DelegationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Delegation)
|
||||||
|
private readonly delegationRepo: Repository<Delegation>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepo: Repository<User>,
|
||||||
|
private readonly circularDetectionService: CircularDetectionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สร้าง Delegation ใหม่ พร้อมตรวจสอบ Circular (FR-011, FR-012)
|
||||||
|
*/
|
||||||
|
async create(delegatorPublicId: string, dto: CreateDelegationDto): Promise<Delegation> {
|
||||||
|
const delegator = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
|
||||||
|
if (!delegator) throw new NotFoundException(`User not found: ${delegatorPublicId}`);
|
||||||
|
|
||||||
|
const delegate = await this.userRepo.findOne({ where: { publicId: dto.delegateUserPublicId } });
|
||||||
|
if (!delegate) throw new NotFoundException(`Delegate user not found: ${dto.delegateUserPublicId}`);
|
||||||
|
|
||||||
|
// ตรวจสอบ date range
|
||||||
|
if (dto.startDate >= dto.endDate) {
|
||||||
|
throw new BadRequestException('startDate must be before endDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ตรวจสอบ Circular Delegation (ADR requirement)
|
||||||
|
const isCircular = await this.circularDetectionService.wouldCreateCircle(
|
||||||
|
delegator.user_id,
|
||||||
|
delegate.user_id,
|
||||||
|
dto.startDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCircular) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Circular delegation detected — this would create a delegation loop',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delegation = this.delegationRepo.create({
|
||||||
|
delegatorUserId: delegator.user_id,
|
||||||
|
delegateUserId: delegate.user_id,
|
||||||
|
scope: dto.scope,
|
||||||
|
startDate: dto.startDate,
|
||||||
|
endDate: dto.endDate,
|
||||||
|
reason: dto.reason,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.delegationRepo.save(delegation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Delegations ของ User ทั้งหมด (ในฐานะผู้มอบหมาย)
|
||||||
|
*/
|
||||||
|
async findByDelegator(delegatorPublicId: string): Promise<Delegation[]> {
|
||||||
|
const user = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
|
||||||
|
if (!user) throw new NotFoundException(delegatorPublicId);
|
||||||
|
|
||||||
|
return this.delegationRepo.find({
|
||||||
|
where: { delegatorUserId: user.user_id },
|
||||||
|
relations: ['delegate'],
|
||||||
|
order: { startDate: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Active Delegations สำหรับ User ณ วันที่กำหนด (FR-013)
|
||||||
|
* ใช้ใน ReviewTaskService ก่อน assign task
|
||||||
|
*/
|
||||||
|
async findActiveDelegate(userId: number, date: Date = new Date()): Promise<User | null> {
|
||||||
|
const delegation = await this.delegationRepo
|
||||||
|
.createQueryBuilder('d')
|
||||||
|
.innerJoinAndSelect('d.delegate', 'delegate')
|
||||||
|
.where('d.delegator_user_id = :userId', { userId })
|
||||||
|
.andWhere('d.is_active = 1')
|
||||||
|
.andWhere('d.start_date <= :date', { date })
|
||||||
|
.andWhere('d.end_date >= :date', { date })
|
||||||
|
.orderBy('d.created_at', 'DESC')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
return delegation?.delegate ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke delegation ก่อนกำหนด
|
||||||
|
*/
|
||||||
|
async revoke(publicId: string, delegatorPublicId: string): Promise<void> {
|
||||||
|
const delegation = await this.delegationRepo.findOne({
|
||||||
|
where: { publicId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!delegation) throw new NotFoundException(`Delegation not found: ${publicId}`);
|
||||||
|
|
||||||
|
// ตรวจสอบ ownership
|
||||||
|
const delegator = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
|
||||||
|
if (!delegator || delegation.delegatorUserId !== delegator.user_id) {
|
||||||
|
throw new BadRequestException('You can only revoke your own delegations');
|
||||||
|
}
|
||||||
|
|
||||||
|
delegation.isActive = false;
|
||||||
|
delegation.endDate = new Date(); // หยุดทันที
|
||||||
|
await this.delegationRepo.save(delegation);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// File: src/modules/delegation/dto/create-delegation.dto.ts
|
||||||
|
import { IsDate, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { DelegationScope } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export { DelegationScope };
|
||||||
|
|
||||||
|
export class CreateDelegationDto {
|
||||||
|
@IsUUID()
|
||||||
|
delegateUserPublicId!: string;
|
||||||
|
|
||||||
|
@IsEnum(DelegationScope)
|
||||||
|
scope!: DelegationScope;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectPublicId?: string;
|
||||||
|
|
||||||
|
@IsDate()
|
||||||
|
@Type(() => Date)
|
||||||
|
startDate!: Date;
|
||||||
|
|
||||||
|
@IsDate()
|
||||||
|
@Type(() => Date)
|
||||||
|
endDate!: Date;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// File: src/modules/delegation/entities/delegation.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
import { DelegationScope } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Entity('delegations')
|
||||||
|
export class Delegation extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'delegator_user_id' })
|
||||||
|
@Exclude()
|
||||||
|
delegatorUserId!: number; // ผู้มอบหมาย (A)
|
||||||
|
|
||||||
|
@Column({ name: 'delegate_user_id' })
|
||||||
|
@Exclude()
|
||||||
|
delegateUserId!: number; // ผู้รับมอบหมาย (B)
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DelegationScope,
|
||||||
|
default: DelegationScope.ALL,
|
||||||
|
})
|
||||||
|
scope!: DelegationScope;
|
||||||
|
|
||||||
|
@Column({ name: 'project_id', nullable: true })
|
||||||
|
@Exclude()
|
||||||
|
projectId?: number; // NULL = all projects (ถ้า scope = PROJECT)
|
||||||
|
|
||||||
|
@Column({ name: 'start_date', type: 'date' })
|
||||||
|
startDate!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'end_date', type: 'date' })
|
||||||
|
endDate!: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'delegator_user_id' })
|
||||||
|
delegator?: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'delegate_user_id' })
|
||||||
|
delegate?: User;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// File: src/modules/delegation/services/circular-detection.service.ts
|
||||||
|
// ตรวจจับ Circular Delegation (A→B→C→A) ป้องกัน infinite loop (FR-012)
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Delegation } from '../entities/delegation.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CircularDetectionService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Delegation)
|
||||||
|
private readonly delegationRepo: Repository<Delegation>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบ Circular Delegation ด้วย Depth-First Search
|
||||||
|
* ตัวอย่าง: A→B→C→A จะถูกจับได้เมื่อ proposedFrom=A, proposedTo=B
|
||||||
|
*
|
||||||
|
* @param proposedFrom - delegatorUserId ที่กำลังจะสร้าง delegation
|
||||||
|
* @param proposedTo - delegateUserId ที่กำลังจะสร้าง delegation
|
||||||
|
* @param today - วันที่ตรวจสอบ (default: now)
|
||||||
|
* @returns true ถ้าจะเกิด circular delegation
|
||||||
|
*/
|
||||||
|
async wouldCreateCircle(
|
||||||
|
proposedFrom: number,
|
||||||
|
proposedTo: number,
|
||||||
|
today: Date = new Date(),
|
||||||
|
): Promise<boolean> {
|
||||||
|
// ถ้า A→B และ proposedFrom=B, proposedTo=A → circular ชัดเจน
|
||||||
|
if (proposedFrom === proposedTo) return true;
|
||||||
|
|
||||||
|
// ดึง delegations ที่ active ทั้งหมดในช่วงเวลานั้น
|
||||||
|
const activeDelegations = await this.delegationRepo
|
||||||
|
.createQueryBuilder('d')
|
||||||
|
.where('d.is_active = 1')
|
||||||
|
.andWhere('d.start_date <= :today', { today })
|
||||||
|
.andWhere('d.end_date >= :today', { today })
|
||||||
|
.select(['d.delegatorUserId', 'd.delegateUserId'])
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// สร้าง adjacency list: from → [to, ...]
|
||||||
|
const graph = new Map<number, number[]>();
|
||||||
|
for (const d of activeDelegations) {
|
||||||
|
if (!graph.has(d.delegatorUserId)) graph.set(d.delegatorUserId, []);
|
||||||
|
graph.get(d.delegatorUserId)!.push(d.delegateUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// เพิ่ม edge ที่กำลังจะสร้าง
|
||||||
|
if (!graph.has(proposedFrom)) graph.set(proposedFrom, []);
|
||||||
|
graph.get(proposedFrom)!.push(proposedTo);
|
||||||
|
|
||||||
|
// DFS จาก proposedTo เพื่อหา path กลับมาที่ proposedFrom
|
||||||
|
return this.dfsHasCycle(proposedTo, proposedFrom, graph, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
private dfsHasCycle(
|
||||||
|
current: number,
|
||||||
|
target: number,
|
||||||
|
graph: Map<number, number[]>,
|
||||||
|
visited: Set<number>,
|
||||||
|
): boolean {
|
||||||
|
if (current === target) return true;
|
||||||
|
if (visited.has(current)) return false;
|
||||||
|
|
||||||
|
visited.add(current);
|
||||||
|
|
||||||
|
const neighbors = graph.get(current) ?? [];
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
if (this.dfsHasCycle(neighbor, target, graph, visited)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// File: src/modules/distribution/distribution-matrix.service.ts
|
||||||
|
// CRUD สำหรับ DistributionMatrix และ Recipients (T053)
|
||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { DistributionMatrix } from './entities/distribution-matrix.entity';
|
||||||
|
import { DistributionRecipient } from './entities/distribution-recipient.entity';
|
||||||
|
|
||||||
|
export interface CreateDistributionMatrixDto {
|
||||||
|
projectId: number;
|
||||||
|
documentTypeCode: string;
|
||||||
|
responseCodeFilter?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddRecipientDto {
|
||||||
|
recipientType: string;
|
||||||
|
recipientId?: number;
|
||||||
|
roleCode?: string;
|
||||||
|
deliveryMethod?: string;
|
||||||
|
isCc?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DistributionMatrixService {
|
||||||
|
private readonly logger = new Logger(DistributionMatrixService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DistributionMatrix)
|
||||||
|
private readonly matrixRepo: Repository<DistributionMatrix>,
|
||||||
|
@InjectRepository(DistributionRecipient)
|
||||||
|
private readonly recipientRepo: Repository<DistributionRecipient>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findByProject(projectId: number): Promise<DistributionMatrix[]> {
|
||||||
|
return this.matrixRepo.find({
|
||||||
|
where: { projectId, isActive: true },
|
||||||
|
relations: ['recipients'],
|
||||||
|
order: { documentTypeCode: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneByDocType(
|
||||||
|
projectId: number,
|
||||||
|
documentTypeCode: string,
|
||||||
|
): Promise<DistributionMatrix | null> {
|
||||||
|
return this.matrixRepo.findOne({
|
||||||
|
where: { projectId, documentTypeCode, isActive: true },
|
||||||
|
relations: ['recipients'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateDistributionMatrixDto): Promise<DistributionMatrix> {
|
||||||
|
const matrix = this.matrixRepo.create(dto as Partial<DistributionMatrix>);
|
||||||
|
return this.matrixRepo.save(matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRecipient(
|
||||||
|
matrixPublicId: string,
|
||||||
|
dto: AddRecipientDto,
|
||||||
|
): Promise<DistributionRecipient> {
|
||||||
|
const matrix = await this.matrixRepo.findOne({ where: { publicId: matrixPublicId } });
|
||||||
|
if (!matrix) throw new NotFoundException(`Matrix not found: ${matrixPublicId}`);
|
||||||
|
|
||||||
|
const recipient = this.recipientRepo.create({
|
||||||
|
matrixId: matrix.id,
|
||||||
|
...dto,
|
||||||
|
} as Partial<DistributionRecipient>);
|
||||||
|
|
||||||
|
return this.recipientRepo.save(recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRecipient(recipientPublicId: string): Promise<void> {
|
||||||
|
const recipient = await this.recipientRepo.findOne({ where: { publicId: recipientPublicId } });
|
||||||
|
if (!recipient) throw new NotFoundException(recipientPublicId);
|
||||||
|
await this.recipientRepo.remove(recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(publicId: string): Promise<void> {
|
||||||
|
const matrix = await this.matrixRepo.findOne({ where: { publicId } });
|
||||||
|
if (!matrix) throw new NotFoundException(publicId);
|
||||||
|
matrix.isActive = false;
|
||||||
|
await this.matrixRepo.save(matrix);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// File: src/modules/distribution/distribution.controller.ts
|
||||||
|
// Admin endpoints สำหรับจัดการ Distribution Matrix (T058)
|
||||||
|
import { Controller, Get, Post, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { DistributionMatrixService } from './distribution-matrix.service';
|
||||||
|
|
||||||
|
class CreateMatrixDto {
|
||||||
|
projectId!: number;
|
||||||
|
documentTypeCode!: string;
|
||||||
|
responseCodeFilter?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddRecipientDto {
|
||||||
|
recipientType!: string;
|
||||||
|
recipientId?: number;
|
||||||
|
roleCode?: string;
|
||||||
|
deliveryMethod?: string;
|
||||||
|
isCc?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/distribution-matrices')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class DistributionController {
|
||||||
|
constructor(private readonly matrixService: DistributionMatrixService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findByProject(@Query('projectId') projectId: string) {
|
||||||
|
return this.matrixService.findByProject(parseInt(projectId, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateMatrixDto) {
|
||||||
|
return this.matrixService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':publicId/recipients')
|
||||||
|
addRecipient(@Param('publicId') publicId: string, @Body() dto: AddRecipientDto) {
|
||||||
|
return this.matrixService.addRecipient(publicId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':publicId/recipients/:recipientPublicId')
|
||||||
|
removeRecipient(@Param('recipientPublicId') recipientPublicId: string) {
|
||||||
|
return this.matrixService.removeRecipient(recipientPublicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':publicId')
|
||||||
|
remove(@Param('publicId') publicId: string) {
|
||||||
|
return this.matrixService.remove(publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// File: src/modules/distribution/distribution.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { DistributionMatrix } from './entities/distribution-matrix.entity';
|
||||||
|
import { DistributionRecipient } from './entities/distribution-recipient.entity';
|
||||||
|
import { DistributionService } from './distribution.service';
|
||||||
|
import { DistributionMatrixService } from './distribution-matrix.service';
|
||||||
|
import { DistributionController } from './distribution.controller';
|
||||||
|
import { DistributionProcessor } from './processors/distribution.processor';
|
||||||
|
import { ApprovalListenerService } from './services/approval-listener.service';
|
||||||
|
import { TransmittalCreatorService } from './services/transmittal-creator.service';
|
||||||
|
import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants';
|
||||||
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([DistributionMatrix, DistributionRecipient]),
|
||||||
|
BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }),
|
||||||
|
NotificationModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
DistributionService,
|
||||||
|
DistributionMatrixService,
|
||||||
|
DistributionProcessor,
|
||||||
|
ApprovalListenerService,
|
||||||
|
TransmittalCreatorService,
|
||||||
|
],
|
||||||
|
controllers: [DistributionController],
|
||||||
|
exports: [DistributionService, DistributionMatrixService, ApprovalListenerService],
|
||||||
|
})
|
||||||
|
export class DistributionModule {}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// File: src/modules/distribution/distribution.service.ts
|
||||||
|
// Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054)
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants';
|
||||||
|
|
||||||
|
export interface DistributionJobPayload {
|
||||||
|
rfaPublicId: string;
|
||||||
|
rfaRevisionPublicId: string;
|
||||||
|
projectId: number;
|
||||||
|
documentTypeCode: string;
|
||||||
|
responseCode: string;
|
||||||
|
approvedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DistributionService {
|
||||||
|
private readonly logger = new Logger(DistributionService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectQueue(QUEUE_DISTRIBUTION)
|
||||||
|
private readonly distributionQueue: Queue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue distribution job สำหรับ RFA ที่ผ่านการอนุมัติ (FR-018)
|
||||||
|
*/
|
||||||
|
async queueDistribution(payload: DistributionJobPayload): Promise<void> {
|
||||||
|
await this.distributionQueue.add('process-distribution', payload, {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 100,
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: 'exponential', delay: 5000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Distribution queued for RFA ${payload.rfaPublicId} (code: ${payload.responseCode})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบสถานะ distribution jobs ของ RFA
|
||||||
|
*/
|
||||||
|
async getJobStatus(rfaPublicId: string): Promise<{ pending: number; completed: number }> {
|
||||||
|
const [waiting, active] = await Promise.all([
|
||||||
|
this.distributionQueue.getWaitingCount(),
|
||||||
|
this.distributionQueue.getActiveCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { pending: waiting + active, completed: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// File: src/modules/distribution/entities/distribution-matrix.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { DistributionRecipient } from './distribution-recipient.entity';
|
||||||
|
import { Project } from '../../project/entities/project.entity';
|
||||||
|
|
||||||
|
@Entity('distribution_matrices')
|
||||||
|
export class DistributionMatrix extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'project_id' })
|
||||||
|
@Exclude()
|
||||||
|
projectId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'document_type_code', length: 20 })
|
||||||
|
documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'...
|
||||||
|
|
||||||
|
@Column({ name: 'response_code_filter', type: 'simple-array', nullable: true })
|
||||||
|
responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Project)
|
||||||
|
@JoinColumn({ name: 'project_id' })
|
||||||
|
project?: Project;
|
||||||
|
|
||||||
|
@OneToMany(() => DistributionRecipient, (r: DistributionRecipient) => r.matrix, { cascade: true })
|
||||||
|
recipients?: DistributionRecipient[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// File: src/modules/distribution/entities/distribution-recipient.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { DistributionMatrix } from './distribution-matrix.entity';
|
||||||
|
import { RecipientType, DeliveryMethod } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Entity('distribution_recipients')
|
||||||
|
export class DistributionRecipient extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'matrix_id' })
|
||||||
|
@Exclude()
|
||||||
|
matrixId!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: RecipientType,
|
||||||
|
})
|
||||||
|
recipientType!: RecipientType;
|
||||||
|
|
||||||
|
@Column({ name: 'recipient_id', nullable: true })
|
||||||
|
@Exclude()
|
||||||
|
recipientId?: number; // userId / organizationId / teamId (FK based on type)
|
||||||
|
|
||||||
|
@Column({ name: 'role_code', length: 50, nullable: true })
|
||||||
|
roleCode?: string; // 'ALL_QS', 'ALL_SITE_ENG' (when type = ROLE)
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DeliveryMethod,
|
||||||
|
default: DeliveryMethod.BOTH,
|
||||||
|
})
|
||||||
|
deliveryMethod!: DeliveryMethod;
|
||||||
|
|
||||||
|
@Column({ name: 'is_cc', type: 'tinyint', default: 0 })
|
||||||
|
isCc!: boolean; // true = CC recipient, false = primary
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => DistributionMatrix, (m: DistributionMatrix) => m.recipients, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'matrix_id' })
|
||||||
|
matrix!: DistributionMatrix;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// File: src/modules/distribution/processors/distribution.processor.ts
|
||||||
|
// BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008)
|
||||||
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { QUEUE_DISTRIBUTION } from '../../common/constants/queue.constants';
|
||||||
|
import { DistributionJobPayload } from '../distribution.service';
|
||||||
|
import { TransmittalCreatorService } from '../services/transmittal-creator.service';
|
||||||
|
import { NotificationService } from '../../notification/notification.service';
|
||||||
|
|
||||||
|
@Processor(QUEUE_DISTRIBUTION)
|
||||||
|
export class DistributionProcessor extends WorkerHost {
|
||||||
|
private readonly logger = new Logger(DistributionProcessor.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly transmittalCreator: TransmittalCreatorService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: Job<DistributionJobPayload>): Promise<void> {
|
||||||
|
const payload = job.data;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. สร้าง Transmittal records
|
||||||
|
const result = await this.transmittalCreator.createFromDistribution(payload);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. แจ้งเตือน submitter
|
||||||
|
this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// File: src/modules/distribution/services/approval-listener.service.ts
|
||||||
|
// Strangler Pattern — listens for RFA approval events and triggers distribution (T055)
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DistributionService, DistributionJobPayload } from '../distribution.service';
|
||||||
|
import { ConsensusDecision } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApprovalListenerService — ถูกเรียกจาก ReviewTaskService หลัง consensus ถูกตัดสินใจ
|
||||||
|
* ใช้ Strangler Pattern: ไม่แก้ไข rfaService.approve() โดยตรง
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApprovalListenerService {
|
||||||
|
private readonly logger = new Logger(ApprovalListenerService.name);
|
||||||
|
|
||||||
|
constructor(private readonly distributionService: DistributionService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เรียกเมื่อ consensus ถูกตัดสินใจว่า APPROVED หรือ APPROVED_WITH_COMMENTS (FR-018)
|
||||||
|
*/
|
||||||
|
async onConsensusReached(event: {
|
||||||
|
rfaPublicId: string;
|
||||||
|
rfaRevisionPublicId: string;
|
||||||
|
projectId: number;
|
||||||
|
documentTypeCode: string;
|
||||||
|
responseCode: string;
|
||||||
|
decision: ConsensusDecision;
|
||||||
|
approvedAt: Date;
|
||||||
|
}): Promise<void> {
|
||||||
|
const shouldDistribute =
|
||||||
|
event.decision === ConsensusDecision.APPROVED ||
|
||||||
|
event.decision === ConsensusDecision.APPROVED_WITH_COMMENTS ||
|
||||||
|
event.decision === ConsensusDecision.OVERRIDDEN;
|
||||||
|
|
||||||
|
if (!shouldDistribute) {
|
||||||
|
this.logger.log(
|
||||||
|
`RFA ${event.rfaPublicId} decision = ${event.decision} — distribution skipped`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: DistributionJobPayload = {
|
||||||
|
rfaPublicId: event.rfaPublicId,
|
||||||
|
rfaRevisionPublicId: event.rfaRevisionPublicId,
|
||||||
|
projectId: event.projectId,
|
||||||
|
documentTypeCode: event.documentTypeCode,
|
||||||
|
responseCode: event.responseCode,
|
||||||
|
approvedAt: event.approvedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.distributionService.queueDistribution(payload);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Distribution triggered for RFA ${event.rfaPublicId} (${event.decision})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// File: src/modules/distribution/services/transmittal-creator.service.ts
|
||||||
|
// สร้าง Transmittal records จาก Distribution jobs (T057)
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { DistributionMatrix } from '../entities/distribution-matrix.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TransmittalCreatorService — ใช้ Strangler Pattern ไม่แก้ไข TransmittalService เดิม
|
||||||
|
* สร้าง Transmittal ผ่าน existing TransmittalService หลัง distribution
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TransmittalCreatorService {
|
||||||
|
private readonly logger = new Logger(TransmittalCreatorService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DistributionMatrix)
|
||||||
|
private readonly matrixRepo: Repository<DistributionMatrix>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สร้าง Transmittal draft จาก Distribution event (FR-019)
|
||||||
|
* Note: actual Transmittal creation ผ่าน TransmittalModule — inject ที่ DI level
|
||||||
|
*/
|
||||||
|
async createFromDistribution(payload: {
|
||||||
|
rfaPublicId: string;
|
||||||
|
rfaRevisionPublicId: string;
|
||||||
|
projectId: number;
|
||||||
|
documentTypeCode: string;
|
||||||
|
responseCode: string;
|
||||||
|
}): Promise<{ transmittalPublicIds: string[] }> {
|
||||||
|
const matrix = await this.matrixRepo.findOne({
|
||||||
|
where: {
|
||||||
|
projectId: payload.projectId,
|
||||||
|
documentTypeCode: payload.documentTypeCode,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
relations: ['recipients'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matrix || !matrix.recipients || matrix.recipients.length === 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeCode}`,
|
||||||
|
);
|
||||||
|
return { transmittalPublicIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ตรวจสอบ response code filter
|
||||||
|
if (
|
||||||
|
matrix.responseCodeFilter &&
|
||||||
|
matrix.responseCodeFilter.length > 0 &&
|
||||||
|
!matrix.responseCodeFilter.includes(payload.responseCode)
|
||||||
|
) {
|
||||||
|
this.logger.log(
|
||||||
|
`Response code ${payload.responseCode} not in filter — skipping distribution`,
|
||||||
|
);
|
||||||
|
return { transmittalPublicIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Creating Transmittal for RFA ${payload.rfaPublicId} → ${matrix.recipients.length} recipients`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: เรียก TransmittalService.create() เมื่อ integrate ใน Sprint ถัดไป
|
||||||
|
// return transmittalService.createDraft({ rfaPublicId, recipients });
|
||||||
|
|
||||||
|
return { transmittalPublicIds: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// File: src/modules/reminder/dto/create-reminder-rule.dto.ts
|
||||||
|
import { IsEnum, IsInt, IsOptional, IsString, IsArray, MaxLength } from 'class-validator';
|
||||||
|
import { ReminderType } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export class CreateReminderRuleDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
projectId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
documentTypeCode?: string;
|
||||||
|
|
||||||
|
@IsEnum(ReminderType)
|
||||||
|
reminderType!: ReminderType;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
daysBeforeDue!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
escalationLevel?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
notifyRoles?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// File: src/modules/reminder/entities/reminder-rule.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { ReminderType } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Entity('reminder_rules')
|
||||||
|
export class ReminderRule extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'project_id', nullable: true })
|
||||||
|
@Exclude()
|
||||||
|
projectId?: number; // NULL = global rule
|
||||||
|
|
||||||
|
@Column({ name: 'document_type_code', length: 20, nullable: true })
|
||||||
|
documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ReminderType,
|
||||||
|
})
|
||||||
|
reminderType!: ReminderType;
|
||||||
|
|
||||||
|
@Column({ name: 'days_before_due', type: 'int' })
|
||||||
|
daysBeforeDue!: number; // บวก = ก่อน due, ลบ = หลัง due (overdue)
|
||||||
|
|
||||||
|
@Column({ name: 'escalation_level', type: 'tinyint', default: 0 })
|
||||||
|
escalationLevel!: number; // 0 = reminder, 1 = escalation L1, 2 = escalation L2
|
||||||
|
|
||||||
|
@Column({ name: 'notify_roles', type: 'simple-array', nullable: true })
|
||||||
|
notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER']
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// File: src/modules/reminder/processors/reminder.processor.ts
|
||||||
|
// BullMQ Worker สำหรับประมวลผล Reminder jobs (ADR-008)
|
||||||
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
|
||||||
|
import { ReminderType } from '../../common/enums/review.enums';
|
||||||
|
import { EscalationService } from '../services/escalation.service';
|
||||||
|
import { NotificationService } from '../../notification/notification.service';
|
||||||
|
import { ScheduleReminderPayload } from '../services/scheduler.service';
|
||||||
|
|
||||||
|
@Processor(QUEUE_REMINDERS)
|
||||||
|
export class ReminderProcessor extends WorkerHost {
|
||||||
|
private readonly logger = new Logger(ReminderProcessor.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly escalationService: EscalationService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: Job<ScheduleReminderPayload>): Promise<void> {
|
||||||
|
const { taskPublicId, assigneeUserId, reminderType } = job.data;
|
||||||
|
|
||||||
|
this.logger.log(`Processing reminder job: ${reminderType} for task ${taskPublicId}`);
|
||||||
|
|
||||||
|
switch (reminderType) {
|
||||||
|
case ReminderType.DUE_SOON:
|
||||||
|
await this.notificationService.send({
|
||||||
|
userId: assigneeUserId,
|
||||||
|
title: '⏰ Review Task Due Soon',
|
||||||
|
message: 'Your review task is due in 2 days. Please complete your review.',
|
||||||
|
type: 'SYSTEM',
|
||||||
|
entityType: 'review_task',
|
||||||
|
entityId: taskPublicId as unknown as number,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReminderType.ON_DUE:
|
||||||
|
await this.notificationService.send({
|
||||||
|
userId: assigneeUserId,
|
||||||
|
title: '🔔 Review Task Due Today',
|
||||||
|
message: 'Your review task is due today. Please complete it as soon as possible.',
|
||||||
|
type: 'SYSTEM',
|
||||||
|
entityType: 'review_task',
|
||||||
|
entityId: taskPublicId as unknown as number,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReminderType.OVERDUE:
|
||||||
|
await this.notificationService.send({
|
||||||
|
userId: assigneeUserId,
|
||||||
|
title: '🚨 Review Task Overdue',
|
||||||
|
message: 'Your review task is overdue. Escalation will occur if not completed.',
|
||||||
|
type: 'SYSTEM',
|
||||||
|
entityType: 'review_task',
|
||||||
|
entityId: taskPublicId as unknown as number,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReminderType.ESCALATION_L1:
|
||||||
|
await this.escalationService.escalateLevel1(taskPublicId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReminderType.ESCALATION_L2:
|
||||||
|
await this.escalationService.escalateLevel2(taskPublicId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Unknown reminder type: ${reminderType as string}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// File: src/modules/reminder/reminder.controller.ts
|
||||||
|
// Admin endpoints สำหรับจัดการ Reminder Rules (T048)
|
||||||
|
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { ReminderService } from './reminder.service';
|
||||||
|
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
|
||||||
|
|
||||||
|
@Controller('admin/reminder-rules')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ReminderController {
|
||||||
|
constructor(private readonly reminderService: ReminderService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('projectId') projectId?: string) {
|
||||||
|
return this.reminderService.findAll(projectId ? parseInt(projectId, 10) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':publicId')
|
||||||
|
findOne(@Param('publicId') publicId: string) {
|
||||||
|
return this.reminderService.findOne(publicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateReminderRuleDto): Promise<unknown> {
|
||||||
|
return this.reminderService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':publicId')
|
||||||
|
update(@Param('publicId') publicId: string, @Body() dto: Partial<CreateReminderRuleDto>): Promise<unknown> {
|
||||||
|
return this.reminderService.update(publicId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':publicId')
|
||||||
|
remove(@Param('publicId') publicId: string) {
|
||||||
|
return this.reminderService.remove(publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// File: src/modules/reminder/reminder.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { ReminderRule } from './entities/reminder-rule.entity';
|
||||||
|
import { ReviewTask } from '../review-team/entities/review-task.entity';
|
||||||
|
import { ReminderService } from './reminder.service';
|
||||||
|
import { ReminderController } from './reminder.controller';
|
||||||
|
import { SchedulerService } from './services/scheduler.service';
|
||||||
|
import { EscalationService } from './services/escalation.service';
|
||||||
|
import { ReminderProcessor } from './processors/reminder.processor';
|
||||||
|
import { QUEUE_REMINDERS } from '../common/constants/queue.constants';
|
||||||
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ReminderRule, ReviewTask]),
|
||||||
|
BullModule.registerQueue({ name: QUEUE_REMINDERS }),
|
||||||
|
NotificationModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
ReminderService,
|
||||||
|
SchedulerService,
|
||||||
|
EscalationService,
|
||||||
|
ReminderProcessor,
|
||||||
|
],
|
||||||
|
controllers: [ReminderController],
|
||||||
|
exports: [ReminderService, SchedulerService, EscalationService],
|
||||||
|
})
|
||||||
|
export class ReminderModule {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// File: src/modules/reminder/reminder.service.ts
|
||||||
|
// ReminderService — CRUD สำหรับ ReminderRule entities (T044)
|
||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ReminderRule } from './entities/reminder-rule.entity';
|
||||||
|
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
|
||||||
|
|
||||||
|
export { CreateReminderRuleDto };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReminderService {
|
||||||
|
private readonly logger = new Logger(ReminderService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ReminderRule)
|
||||||
|
private readonly ruleRepo: Repository<ReminderRule>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(projectId?: number): Promise<ReminderRule[]> {
|
||||||
|
if (projectId !== undefined) {
|
||||||
|
return this.ruleRepo.find({
|
||||||
|
where: [{ projectId }, { projectId: undefined }],
|
||||||
|
order: { escalationLevel: 'ASC', daysBeforeDue: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.ruleRepo.find({ order: { escalationLevel: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(publicId: string): Promise<ReminderRule> {
|
||||||
|
const rule = await this.ruleRepo.findOne({ where: { publicId } });
|
||||||
|
if (!rule) throw new NotFoundException(`ReminderRule not found: ${publicId}`);
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateReminderRuleDto): Promise<ReminderRule> {
|
||||||
|
const rule = this.ruleRepo.create(dto as Partial<ReminderRule>);
|
||||||
|
return this.ruleRepo.save(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(publicId: string, dto: Partial<CreateReminderRuleDto>): Promise<ReminderRule> {
|
||||||
|
const rule = await this.findOne(publicId);
|
||||||
|
Object.assign(rule, dto);
|
||||||
|
return this.ruleRepo.save(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(publicId: string): Promise<void> {
|
||||||
|
const rule = await this.findOne(publicId);
|
||||||
|
await this.ruleRepo.remove(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// File: src/modules/reminder/services/escalation.service.ts
|
||||||
|
// 2-Level Escalation เมื่อ Review Task เกิน due date (FR-015)
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, LessThan } from 'typeorm';
|
||||||
|
import { ReviewTask } from '../../review-team/entities/review-task.entity';
|
||||||
|
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||||
|
import { NotificationService } from '../../notification/notification.service';
|
||||||
|
import { ReminderRule } from '../entities/reminder-rule.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EscalationService {
|
||||||
|
private readonly logger = new Logger(EscalationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ReviewTask)
|
||||||
|
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||||
|
@InjectRepository(ReminderRule)
|
||||||
|
private readonly reminderRuleRepo: Repository<ReminderRule>,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน
|
||||||
|
* เรียกเมื่อ task เกิน due date 1 วัน
|
||||||
|
*/
|
||||||
|
async escalateLevel1(taskPublicId: string): Promise<void> {
|
||||||
|
const task = await this.reviewTaskRepo.findOne({
|
||||||
|
where: { publicId: taskPublicId },
|
||||||
|
relations: ['team', 'assignedToUser', 'discipline'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
|
||||||
|
|
||||||
|
const daysOverdue = task.dueDate
|
||||||
|
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (daysOverdue < 1) return;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// แจ้ง Team Lead
|
||||||
|
if (task.assignedToUserId) {
|
||||||
|
await this.notificationService.send({
|
||||||
|
userId: task.assignedToUserId,
|
||||||
|
title: `⚠ Review Task Overdue (${daysOverdue}d)`,
|
||||||
|
message: `Your review task is overdue by ${daysOverdue} day(s). Please complete it immediately.`,
|
||||||
|
type: 'SYSTEM',
|
||||||
|
entityType: 'review_task',
|
||||||
|
entityId: task.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalation Level 2 (FR-016): Project Manager ได้รับแจ้งเตือน
|
||||||
|
* เรียกเมื่อ task เกิน due date 3 วัน
|
||||||
|
*/
|
||||||
|
async escalateLevel2(taskPublicId: string): Promise<void> {
|
||||||
|
const task = await this.reviewTaskRepo.findOne({
|
||||||
|
where: { publicId: taskPublicId },
|
||||||
|
relations: ['team', 'assignedToUser'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
|
||||||
|
|
||||||
|
const daysOverdue = task.dueDate
|
||||||
|
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (daysOverdue < 3) return;
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`Escalation L2: task ${taskPublicId} is ${daysOverdue} days overdue — escalating to PM`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: ดึง PM user ID จาก project membership — ใช้ placeholder สำหรับตอนนี้
|
||||||
|
this.logger.log(`L2 escalation notification queued for task ${taskPublicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สแกน tasks ที่ overdue ทั้งหมด และ escalate ตาม level (cron trigger)
|
||||||
|
*/
|
||||||
|
async processOverdueTasks(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const overdueTasks = await this.reviewTaskRepo.find({
|
||||||
|
where: {
|
||||||
|
status: ReviewTaskStatus.IN_PROGRESS,
|
||||||
|
dueDate: LessThan(now),
|
||||||
|
},
|
||||||
|
select: ['publicId', 'dueDate'],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Processing ${overdueTasks.length} overdue tasks`);
|
||||||
|
|
||||||
|
for (const task of overdueTasks) {
|
||||||
|
const daysOverdue = task.dueDate
|
||||||
|
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (daysOverdue >= 3) {
|
||||||
|
await this.escalateLevel2(task.publicId);
|
||||||
|
} else if (daysOverdue >= 1) {
|
||||||
|
await this.escalateLevel1(task.publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// File: src/modules/reminder/services/scheduler.service.ts
|
||||||
|
// Schedule reminders เมื่อ RFA submit (FR-013) — เพิ่ม jobs เข้า BullMQ queue
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
|
||||||
|
import type { Job } from 'bullmq';
|
||||||
|
import { ReminderType } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export interface ScheduleReminderPayload {
|
||||||
|
taskPublicId: string;
|
||||||
|
rfaPublicId: string;
|
||||||
|
assigneeUserId: number;
|
||||||
|
dueDate: Date;
|
||||||
|
reminderType: ReminderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SchedulerService {
|
||||||
|
private readonly logger = new Logger(SchedulerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectQueue(QUEUE_REMINDERS)
|
||||||
|
private readonly reminderQueue: Queue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule ชุด reminders ให้ Review Task (FR-013)
|
||||||
|
* เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว
|
||||||
|
*/
|
||||||
|
async scheduleForTask(payload: ScheduleReminderPayload): Promise<void> {
|
||||||
|
const { taskPublicId, dueDate } = payload;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> = [];
|
||||||
|
|
||||||
|
// 2 วันก่อน due date
|
||||||
|
const twoDaysBefore = dueDate.getTime() - 2 * 86_400_000;
|
||||||
|
if (twoDaysBefore > now) {
|
||||||
|
remindersToSchedule.push({
|
||||||
|
type: ReminderType.DUE_SOON,
|
||||||
|
delayMs: twoDaysBefore - now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// วัน due date เอง
|
||||||
|
const onDue = dueDate.getTime();
|
||||||
|
if (onDue > now) {
|
||||||
|
remindersToSchedule.push({
|
||||||
|
type: ReminderType.ON_DUE,
|
||||||
|
delayMs: onDue - now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 วันหลัง due (Escalation L1)
|
||||||
|
const oneDayAfter = dueDate.getTime() + 1 * 86_400_000;
|
||||||
|
remindersToSchedule.push({
|
||||||
|
type: ReminderType.ESCALATION_L1,
|
||||||
|
delayMs: Math.max(oneDayAfter - now, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 วันหลัง due (Escalation L2)
|
||||||
|
const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000;
|
||||||
|
remindersToSchedule.push({
|
||||||
|
type: ReminderType.ESCALATION_L2,
|
||||||
|
delayMs: Math.max(threeDaysAfter - now, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
remindersToSchedule.map(({ type, delayMs }) =>
|
||||||
|
this.reminderQueue.add(
|
||||||
|
'send-reminder',
|
||||||
|
{ ...payload, reminderType: type },
|
||||||
|
{ delay: delayMs, removeOnComplete: true, removeOnFail: 100 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ยกเลิก reminders ทั้งหมดของ task (เมื่อ task complete หรือ cancelled)
|
||||||
|
*/
|
||||||
|
async cancelForTask(taskPublicId: string): Promise<void> {
|
||||||
|
const jobs = await this.reminderQueue.getDelayed();
|
||||||
|
const taskJobs = jobs.filter((j: Job) => j.data?.taskPublicId === taskPublicId);
|
||||||
|
|
||||||
|
await Promise.all(taskJobs.map((j: Job) => j.remove()));
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Cancelled ${taskJobs.length} reminder jobs for task ${taskPublicId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// File: src/modules/response-code/entities/response-code-rule.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { ResponseCode } from './response-code.entity';
|
||||||
|
|
||||||
|
@Entity('response_code_rules')
|
||||||
|
export class ResponseCodeRule extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'project_id', nullable: true })
|
||||||
|
@Exclude()
|
||||||
|
projectId?: number; // NULL = global default
|
||||||
|
|
||||||
|
@Column({ name: 'document_type_id' })
|
||||||
|
@Exclude()
|
||||||
|
documentTypeId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'response_code_id' })
|
||||||
|
@Exclude()
|
||||||
|
responseCodeId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'is_enabled', type: 'tinyint', default: 1 })
|
||||||
|
isEnabled!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'requires_comments', type: 'tinyint', default: 0 })
|
||||||
|
requiresComments!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'triggers_notification', type: 'tinyint', default: 0 })
|
||||||
|
triggersNotification!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'parent_rule_id', nullable: true })
|
||||||
|
@Exclude()
|
||||||
|
parentRuleId?: number; // Inheritance tracking
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => ResponseCode, (code: ResponseCode) => code.rules)
|
||||||
|
@JoinColumn({ name: 'response_code_id' })
|
||||||
|
responseCode!: ResponseCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// File: src/modules/response-code/entities/response-code.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { ResponseCodeCategory } from '../../common/enums/review.enums';
|
||||||
|
import { ResponseCodeRule } from './response-code-rule.entity';
|
||||||
|
|
||||||
|
@Entity('response_codes')
|
||||||
|
export class ResponseCode extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ length: 10 })
|
||||||
|
code!: string; // '1A', '1B', '1C', ..., '2', '3', '4'
|
||||||
|
|
||||||
|
@Column({ name: 'sub_status', length: 10, nullable: true })
|
||||||
|
subStatus?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ResponseCodeCategory,
|
||||||
|
})
|
||||||
|
category!: ResponseCodeCategory;
|
||||||
|
|
||||||
|
@Column({ name: 'description_th', type: 'text' })
|
||||||
|
descriptionTh!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'description_en', type: 'text' })
|
||||||
|
descriptionEn!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
implications?: {
|
||||||
|
affectsSchedule?: boolean;
|
||||||
|
affectsCost?: boolean;
|
||||||
|
requiresContractReview?: boolean;
|
||||||
|
requiresEiaAmendment?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Column({ name: 'notify_roles', type: 'simple-array', nullable: true })
|
||||||
|
notifyRoles?: string[]; // ['CONTRACT_MANAGER', 'QS_MANAGER']
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_system', type: 'tinyint', default: 0 })
|
||||||
|
isSystem!: boolean; // System default — ลบไม่ได้
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@OneToMany(() => ResponseCodeRule, (rule: ResponseCodeRule) => rule.responseCode)
|
||||||
|
rules?: ResponseCodeRule[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// File: src/modules/response-code/response-code.controller.ts
|
||||||
|
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { ResponseCodeService } from './response-code.service';
|
||||||
|
import { ResponseCodeCategory } from '../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Controller('response-codes')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ResponseCodeController {
|
||||||
|
constructor(private readonly responseCodeService: ResponseCodeService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /response-codes
|
||||||
|
* ดึง Response Codes ทั้งหมด
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.responseCodeService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /response-codes/category/:category
|
||||||
|
* ดึง Response Codes ตาม Category (FR-006)
|
||||||
|
*/
|
||||||
|
@Get('category/:category')
|
||||||
|
findByCategory(@Param('category') category: ResponseCodeCategory) {
|
||||||
|
return this.responseCodeService.findByCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /response-codes/document-type/:id
|
||||||
|
* ดึง Response Codes ที่ใช้ได้กับ document type + project
|
||||||
|
*/
|
||||||
|
@Get('document-type/:documentTypeId')
|
||||||
|
findByDocumentType(
|
||||||
|
@Param('documentTypeId') documentTypeId: string,
|
||||||
|
@Query('projectId') projectId?: string,
|
||||||
|
) {
|
||||||
|
return this.responseCodeService.findByDocumentType(
|
||||||
|
Number(documentTypeId),
|
||||||
|
projectId ? Number(projectId) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /response-codes/:publicId
|
||||||
|
* ดึง Response Code ตาม publicId (ADR-019)
|
||||||
|
*/
|
||||||
|
@Get(':publicId')
|
||||||
|
findOne(@Param('publicId') publicId: string) {
|
||||||
|
return this.responseCodeService.findByPublicId(publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// File: src/modules/response-code/response-code.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ResponseCode } from './entities/response-code.entity';
|
||||||
|
import { ResponseCodeRule } from './entities/response-code-rule.entity';
|
||||||
|
import { ResponseCodeService } from './response-code.service';
|
||||||
|
import { ResponseCodeController } from './response-code.controller';
|
||||||
|
import { ImplicationsService } from './services/implications.service';
|
||||||
|
import { NotificationTriggerService } from './services/notification-trigger.service';
|
||||||
|
import { MatrixManagementService } from './services/matrix-management.service';
|
||||||
|
import { InheritanceService } from './services/inheritance.service';
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User]),
|
||||||
|
NotificationModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
ResponseCodeService,
|
||||||
|
ImplicationsService,
|
||||||
|
NotificationTriggerService,
|
||||||
|
MatrixManagementService,
|
||||||
|
InheritanceService,
|
||||||
|
],
|
||||||
|
controllers: [ResponseCodeController],
|
||||||
|
exports: [
|
||||||
|
ResponseCodeService,
|
||||||
|
ImplicationsService,
|
||||||
|
NotificationTriggerService,
|
||||||
|
MatrixManagementService,
|
||||||
|
InheritanceService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ResponseCodeModule {}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// File: src/modules/response-code/response-code.service.ts
|
||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { ResponseCode } from './entities/response-code.entity';
|
||||||
|
import { ResponseCodeRule } from './entities/response-code-rule.entity';
|
||||||
|
import { ResponseCodeCategory } from '../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseCodeService {
|
||||||
|
private readonly logger = new Logger(ResponseCodeService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ResponseCode)
|
||||||
|
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||||
|
@InjectRepository(ResponseCodeRule)
|
||||||
|
private readonly responseCodeRuleRepo: Repository<ResponseCodeRule>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Response Codes ทั้งหมดที่ active
|
||||||
|
*/
|
||||||
|
async findAll(): Promise<ResponseCode[]> {
|
||||||
|
return this.responseCodeRepo.find({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { category: 'ASC', code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Response Codes ตาม Category (FR-006)
|
||||||
|
* ใช้สำหรับแสดงผลใน Review page ตามประเภทเอกสาร
|
||||||
|
*/
|
||||||
|
async findByCategory(category: ResponseCodeCategory): Promise<ResponseCode[]> {
|
||||||
|
return this.responseCodeRepo.find({
|
||||||
|
where: { category, isActive: true },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Response Codes ที่ใช้ได้กับ document type + project
|
||||||
|
* รองรับ Global default + Project override (ADR-019 Q1 clarification)
|
||||||
|
*/
|
||||||
|
async findByDocumentType(
|
||||||
|
documentTypeId: number,
|
||||||
|
projectId?: number,
|
||||||
|
): Promise<ResponseCode[]> {
|
||||||
|
// ดึง Rules ระดับ Project (ถ้ามี) หรือ Global default
|
||||||
|
const rules = await this.responseCodeRuleRepo.find({
|
||||||
|
where: [
|
||||||
|
{ documentTypeId, projectId: projectId ?? IsNull(), isEnabled: true },
|
||||||
|
{ documentTypeId, projectId: IsNull(), isEnabled: true },
|
||||||
|
],
|
||||||
|
relations: ['responseCode'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Project rules override global rules
|
||||||
|
const codeMap = new Map<number, ResponseCode>();
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.responseCode?.isActive) {
|
||||||
|
codeMap.set(rule.responseCodeId, rule.responseCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(codeMap.values()).sort((a, b) =>
|
||||||
|
a.code.localeCompare(b.code),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง ResponseCode โดย publicId (ADR-019)
|
||||||
|
*/
|
||||||
|
async findByPublicId(publicId: string): Promise<ResponseCode> {
|
||||||
|
const code = await this.responseCodeRepo.findOne({
|
||||||
|
where: { publicId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new NotFoundException(`Response Code not found: ${publicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบว่า Response Code triggers notification หรือไม่ (FR-007)
|
||||||
|
* Code 1C, 1D, 3 → trigger notification
|
||||||
|
*/
|
||||||
|
async getNotifyRoles(responseCodePublicId: string): Promise<string[]> {
|
||||||
|
const code = await this.findByPublicId(responseCodePublicId);
|
||||||
|
return code.notifyRoles ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// File: src/modules/response-code/seeders/response-code.seed.ts
|
||||||
|
// Seed data สำหรับ Master Approval Matrix — Response Codes มาตรฐาน
|
||||||
|
// อ้างอิง: specs/1-rfa-approval-refactor/spec.md — Comprehensive Master Approval Matrix
|
||||||
|
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { ResponseCode } from '../entities/response-code.entity';
|
||||||
|
import { ResponseCodeCategory } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export const responseCodeSeedData = [
|
||||||
|
// ─── ENGINEERING Category (Shop Drawing, Method Statement, As-Built) ───────
|
||||||
|
{
|
||||||
|
code: '1A',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'อนุมัติเพื่อก่อสร้าง',
|
||||||
|
descriptionEn: 'Approved for Construction',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1B',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'อนุมัติเพื่อก่อสร้าง พร้อมความเห็น (แก้ไขไม่ต้องส่งกลับ)',
|
||||||
|
descriptionEn: 'Approved for Construction with Comments (No Resubmission Required)',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1C',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'อนุมัติ — มีผลต่อสัญญา/Change Order',
|
||||||
|
descriptionEn: 'Approved — Contract Implications / Change Order Required',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
|
||||||
|
notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1D',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'อนุมัติทางเลือก — แตกต่างจากแบบสัญญา',
|
||||||
|
descriptionEn: 'Approved Alternative — Differs from Contract Drawing',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true },
|
||||||
|
notifyRoles: ['CONTRACT_MANAGER', 'DESIGN_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1E',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'อนุมัติเพื่อวัตถุประสงค์การออกแบบเท่านั้น',
|
||||||
|
descriptionEn: 'Approved for Design Purpose Only',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1F',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'อนุมัติเพื่ออ้างอิงเท่านั้น',
|
||||||
|
descriptionEn: 'Approved for Reference Only',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1G',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'อนุมัติพร้อมเงื่อนไข ESG',
|
||||||
|
descriptionEn: 'Approved with ESG Conditions',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true },
|
||||||
|
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '2',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'อนุมัติตามหมายเหตุ — ต้องแก้ไขและส่งกลับเพื่อตรวจสอบ',
|
||||||
|
descriptionEn: 'Approved as Noted — Revise and Resubmit for Review',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '3',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'ปฏิเสธ — ต้องแก้ไขและส่งใหม่',
|
||||||
|
descriptionEn: 'Rejected — Revise and Resubmit',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: ['PROJECT_MANAGER', 'DESIGN_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '4',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน',
|
||||||
|
descriptionEn: 'Not Applicable / Withdrawn',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── MATERIAL Category ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
code: '1A',
|
||||||
|
category: ResponseCodeCategory.MATERIAL,
|
||||||
|
descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์เพื่อจัดซื้อ',
|
||||||
|
descriptionEn: 'Approved for Procurement',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1B',
|
||||||
|
category: ResponseCodeCategory.MATERIAL,
|
||||||
|
descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์ พร้อมความเห็น',
|
||||||
|
descriptionEn: 'Approved for Procurement with Comments',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1C',
|
||||||
|
category: ResponseCodeCategory.MATERIAL,
|
||||||
|
descriptionTh: 'อนุมัติ — มีผลต่อค่าใช้จ่าย',
|
||||||
|
descriptionEn: 'Approved — Cost Implications',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true },
|
||||||
|
notifyRoles: ['QS_MANAGER', 'PROCUREMENT_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '2',
|
||||||
|
category: ResponseCodeCategory.MATERIAL,
|
||||||
|
descriptionTh: 'ส่งข้อมูลเพิ่มเติม',
|
||||||
|
descriptionEn: 'Provide Additional Information',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '3',
|
||||||
|
category: ResponseCodeCategory.MATERIAL,
|
||||||
|
descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามสัญญา',
|
||||||
|
descriptionEn: 'Rejected — Non-Compliant with Contract',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: ['PROJECT_MANAGER', 'PROCUREMENT_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '4',
|
||||||
|
category: ResponseCodeCategory.MATERIAL,
|
||||||
|
descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน',
|
||||||
|
descriptionEn: 'Not Applicable / Withdrawn',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── CONTRACT Category ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
code: '1A',
|
||||||
|
category: ResponseCodeCategory.CONTRACT,
|
||||||
|
descriptionTh: 'อนุมัติ — ไม่มีผลต่อสัญญา',
|
||||||
|
descriptionEn: 'Approved — No Contract Implication',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1C',
|
||||||
|
category: ResponseCodeCategory.CONTRACT,
|
||||||
|
descriptionTh: 'อนุมัติ — ต้องออก Change Order',
|
||||||
|
descriptionEn: 'Approved — Change Order Required',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
|
||||||
|
notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER', 'PROJECT_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '2',
|
||||||
|
category: ResponseCodeCategory.CONTRACT,
|
||||||
|
descriptionTh: 'อยู่ระหว่างการพิจารณา — ต้องการข้อมูลเพิ่มเติม',
|
||||||
|
descriptionEn: 'Under Review — Additional Information Required',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '3',
|
||||||
|
category: ResponseCodeCategory.CONTRACT,
|
||||||
|
descriptionTh: 'ปฏิเสธ — ขัดต่อเงื่อนไขสัญญา',
|
||||||
|
descriptionEn: 'Rejected — Contradicts Contract Terms',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
|
||||||
|
notifyRoles: ['CONTRACT_MANAGER', 'LEGAL_TEAM', 'PROJECT_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── TESTING Category ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
code: '1A',
|
||||||
|
category: ResponseCodeCategory.TESTING,
|
||||||
|
descriptionTh: 'อนุมัติผลการทดสอบ / ส่งมอบ',
|
||||||
|
descriptionEn: 'Approved — Test Results / Handover Accepted',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '2',
|
||||||
|
category: ResponseCodeCategory.TESTING,
|
||||||
|
descriptionTh: 'ผ่านพร้อมข้อบกพร่องเล็กน้อย — ต้องแก้ไขและรายงาน',
|
||||||
|
descriptionEn: 'Passed with Minor Defects — Rectify and Report',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: ['QA_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '3',
|
||||||
|
category: ResponseCodeCategory.TESTING,
|
||||||
|
descriptionTh: 'ไม่ผ่าน — ต้องทดสอบซ้ำ',
|
||||||
|
descriptionEn: 'Failed — Retest Required',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false },
|
||||||
|
notifyRoles: ['PROJECT_MANAGER', 'QA_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── ESG Category ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
code: '1A',
|
||||||
|
category: ResponseCodeCategory.ESG,
|
||||||
|
descriptionTh: 'อนุมัติ — เป็นไปตามมาตรฐาน ESG',
|
||||||
|
descriptionEn: 'Approved — ESG Compliant',
|
||||||
|
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '1G',
|
||||||
|
category: ResponseCodeCategory.ESG,
|
||||||
|
descriptionTh: 'อนุมัติพร้อมเงื่อนไขด้านสิ่งแวดล้อม',
|
||||||
|
descriptionEn: 'Approved with Environmental Conditions',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true },
|
||||||
|
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '3',
|
||||||
|
category: ResponseCodeCategory.ESG,
|
||||||
|
descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามข้อกำหนด EIA/ESG',
|
||||||
|
descriptionEn: 'Rejected — Non-Compliant with EIA/ESG Requirements',
|
||||||
|
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false, requiresEiaAmendment: true },
|
||||||
|
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER', 'PROJECT_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed Response Codes ลงฐานข้อมูล
|
||||||
|
* ใช้สำหรับ initial setup และ test environments
|
||||||
|
*/
|
||||||
|
export async function seedResponseCodes(dataSource: DataSource): Promise<void> {
|
||||||
|
const repo = dataSource.getRepository(ResponseCode);
|
||||||
|
|
||||||
|
for (const data of responseCodeSeedData) {
|
||||||
|
const exists = await repo.findOne({
|
||||||
|
where: { code: data.code, category: data.category as ResponseCodeCategory },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
const entity = repo.create({
|
||||||
|
code: data.code,
|
||||||
|
category: data.category as ResponseCodeCategory,
|
||||||
|
descriptionTh: data.descriptionTh,
|
||||||
|
descriptionEn: data.descriptionEn,
|
||||||
|
implications: data.implications,
|
||||||
|
notifyRoles: data.notifyRoles,
|
||||||
|
isSystem: data.isSystem,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
await repo.save(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// File: src/modules/response-code/services/implications.service.ts
|
||||||
|
// ประเมินผลกระทบของ Response Code ที่เลือก (FR-007)
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ResponseCode } from '../entities/response-code.entity';
|
||||||
|
|
||||||
|
export interface CodeImplicationResult {
|
||||||
|
affectsSchedule: boolean;
|
||||||
|
affectsCost: boolean;
|
||||||
|
requiresContractReview: boolean;
|
||||||
|
requiresEiaAmendment: boolean;
|
||||||
|
notifyRoles: string[];
|
||||||
|
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
|
actionRequired: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImplicationsService {
|
||||||
|
private readonly logger = new Logger(ImplicationsService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ประเมินผลกระทบของ Response Code (FR-007)
|
||||||
|
* Code 1C, 1D, 3 → Critical → trigger notifications
|
||||||
|
*/
|
||||||
|
evaluate(responseCode: ResponseCode): CodeImplicationResult {
|
||||||
|
const implications = responseCode.implications ?? {};
|
||||||
|
const notifyRoles = responseCode.notifyRoles ?? [];
|
||||||
|
|
||||||
|
const affectsSchedule = implications.affectsSchedule ?? false;
|
||||||
|
const affectsCost = implications.affectsCost ?? false;
|
||||||
|
const requiresContractReview = implications.requiresContractReview ?? false;
|
||||||
|
const requiresEiaAmendment = implications.requiresEiaAmendment ?? false;
|
||||||
|
|
||||||
|
// กำหนด severity ตามน้ำหนักผลกระทบ
|
||||||
|
const severity = this.calculateSeverity(
|
||||||
|
responseCode.code,
|
||||||
|
affectsSchedule,
|
||||||
|
affectsCost,
|
||||||
|
requiresContractReview,
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionRequired = this.buildActionList(
|
||||||
|
responseCode.code,
|
||||||
|
requiresContractReview,
|
||||||
|
requiresEiaAmendment,
|
||||||
|
affectsCost,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
affectsSchedule,
|
||||||
|
affectsCost,
|
||||||
|
requiresContractReview,
|
||||||
|
requiresEiaAmendment,
|
||||||
|
notifyRoles,
|
||||||
|
severity,
|
||||||
|
actionRequired,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateSeverity(
|
||||||
|
code: string,
|
||||||
|
affectsSchedule: boolean,
|
||||||
|
affectsCost: boolean,
|
||||||
|
requiresContractReview: boolean,
|
||||||
|
): CodeImplicationResult['severity'] {
|
||||||
|
// Code 3 (Rejected) = CRITICAL เสมอ
|
||||||
|
if (code === '3') return 'CRITICAL';
|
||||||
|
|
||||||
|
// Code 1C (Contract Implications) หรือ 1D (Alternative) = HIGH
|
||||||
|
if (code === '1C' || code === '1D') return 'HIGH';
|
||||||
|
|
||||||
|
// มีผลต่อทั้ง schedule และ cost
|
||||||
|
if (affectsSchedule && affectsCost) return 'HIGH';
|
||||||
|
|
||||||
|
// มีผลต่ออย่างใดอย่างหนึ่ง
|
||||||
|
if (requiresContractReview || affectsSchedule || affectsCost) return 'MEDIUM';
|
||||||
|
|
||||||
|
return 'LOW';
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildActionList(
|
||||||
|
code: string,
|
||||||
|
requiresContractReview: boolean,
|
||||||
|
requiresEiaAmendment: boolean,
|
||||||
|
affectsCost: boolean,
|
||||||
|
): string[] {
|
||||||
|
const actions: string[] = [];
|
||||||
|
|
||||||
|
if (code === '3') {
|
||||||
|
actions.push('Document rejected — originator must revise and resubmit');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresContractReview) {
|
||||||
|
actions.push('Contract review required — notify Contract Manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affectsCost) {
|
||||||
|
actions.push('Cost impact assessment required — notify QS Manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresEiaAmendment) {
|
||||||
|
actions.push('EIA amendment may be required — notify EIA Officer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === '2') {
|
||||||
|
actions.push('Minor comments — originator to revise and resubmit');
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// File: src/modules/response-code/services/inheritance.service.ts
|
||||||
|
// Resolves project-level overrides inheriting from global defaults (T062, FR-021)
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ResponseCodeRule } from '../entities/response-code-rule.entity';
|
||||||
|
|
||||||
|
export interface ResolvedMatrix {
|
||||||
|
responseCodeId: number;
|
||||||
|
responseCodePublicId: string;
|
||||||
|
documentTypeId: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
requiresComments: boolean;
|
||||||
|
triggersNotification: boolean;
|
||||||
|
isOverridden: boolean; // true = project-specific rule overrides global
|
||||||
|
parentRuleId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InheritanceService {
|
||||||
|
private readonly logger = new Logger(InheritanceService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ResponseCodeRule)
|
||||||
|
private readonly ruleRepo: Repository<ResponseCodeRule>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง rules สำหรับ document type โดย merge global + project overrides (FR-021)
|
||||||
|
* Project rule ชนะ global rule ของ responseCode เดียวกัน
|
||||||
|
*
|
||||||
|
* @param documentTypeId - document type ที่ต้องการ
|
||||||
|
* @param projectId - project ID (NULL = global only)
|
||||||
|
*/
|
||||||
|
async resolveMatrix(
|
||||||
|
documentTypeId: number,
|
||||||
|
projectId?: number,
|
||||||
|
): Promise<ResolvedMatrix[]> {
|
||||||
|
// ดึง global rules (projectId IS NULL)
|
||||||
|
const globalRules = await this.ruleRepo.find({
|
||||||
|
where: { documentTypeId, projectId: undefined },
|
||||||
|
relations: ['responseCode'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return globalRules.map((r) => ({
|
||||||
|
responseCodeId: r.responseCodeId,
|
||||||
|
responseCodePublicId: r.responseCode.publicId,
|
||||||
|
documentTypeId: r.documentTypeId,
|
||||||
|
isEnabled: r.isEnabled,
|
||||||
|
requiresComments: r.requiresComments,
|
||||||
|
triggersNotification: r.triggersNotification,
|
||||||
|
isOverridden: false,
|
||||||
|
parentRuleId: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ดึง project-specific overrides
|
||||||
|
const projectRules = await this.ruleRepo.find({
|
||||||
|
where: { documentTypeId, projectId },
|
||||||
|
relations: ['responseCode'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build map: responseCodeId → project rule
|
||||||
|
const projectRuleMap = new Map(
|
||||||
|
projectRules.map((r) => [r.responseCodeId, r]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge: project overrides global
|
||||||
|
const merged: ResolvedMatrix[] = globalRules.map((global) => {
|
||||||
|
const override = projectRuleMap.get(global.responseCodeId);
|
||||||
|
if (override) {
|
||||||
|
return {
|
||||||
|
responseCodeId: override.responseCodeId,
|
||||||
|
responseCodePublicId: override.responseCode.publicId,
|
||||||
|
documentTypeId: override.documentTypeId,
|
||||||
|
isEnabled: override.isEnabled,
|
||||||
|
requiresComments: override.requiresComments,
|
||||||
|
triggersNotification: override.triggersNotification,
|
||||||
|
isOverridden: true,
|
||||||
|
parentRuleId: global.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseCodeId: global.responseCodeId,
|
||||||
|
responseCodePublicId: global.responseCode.publicId,
|
||||||
|
documentTypeId: global.documentTypeId,
|
||||||
|
isEnabled: global.isEnabled,
|
||||||
|
requiresComments: global.requiresComments,
|
||||||
|
triggersNotification: global.triggersNotification,
|
||||||
|
isOverridden: false,
|
||||||
|
parentRuleId: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// เพิ่ม project-only rules (ไม่มี global parent)
|
||||||
|
for (const projectRule of projectRules) {
|
||||||
|
const alreadyMerged = globalRules.some(
|
||||||
|
(g) => g.responseCodeId === projectRule.responseCodeId,
|
||||||
|
);
|
||||||
|
if (!alreadyMerged) {
|
||||||
|
merged.push({
|
||||||
|
responseCodeId: projectRule.responseCodeId,
|
||||||
|
responseCodePublicId: projectRule.responseCode.publicId,
|
||||||
|
documentTypeId: projectRule.documentTypeId,
|
||||||
|
isEnabled: projectRule.isEnabled,
|
||||||
|
requiresComments: projectRule.requiresComments,
|
||||||
|
triggersNotification: projectRule.triggersNotification,
|
||||||
|
isOverridden: true,
|
||||||
|
parentRuleId: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Resolved ${merged.length} rules for docType=${documentTypeId}, project=${projectId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// File: src/modules/response-code/services/matrix-management.service.ts
|
||||||
|
// CRUD สำหรับ ResponseCodeRule (global + project overrides) (T061, FR-022)
|
||||||
|
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ResponseCodeRule } from '../entities/response-code-rule.entity';
|
||||||
|
import { ResponseCode } from '../entities/response-code.entity';
|
||||||
|
|
||||||
|
export interface UpsertRuleDto {
|
||||||
|
documentTypeId: number;
|
||||||
|
responseCodePublicId: string;
|
||||||
|
projectId?: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
requiresComments?: boolean;
|
||||||
|
triggersNotification?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MatrixManagementService {
|
||||||
|
private readonly logger = new Logger(MatrixManagementService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ResponseCodeRule)
|
||||||
|
private readonly ruleRepo: Repository<ResponseCodeRule>,
|
||||||
|
@InjectRepository(ResponseCode)
|
||||||
|
private readonly codeRepo: Repository<ResponseCode>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a rule — สร้างใหม่หรือแก้ไข existing rule (FR-022)
|
||||||
|
*/
|
||||||
|
async upsertRule(dto: UpsertRuleDto): Promise<ResponseCodeRule> {
|
||||||
|
const code = await this.codeRepo.findOne({
|
||||||
|
where: { publicId: dto.responseCodePublicId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new NotFoundException(`ResponseCode not found: ${dto.responseCodePublicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.isSystem && !dto.isEnabled) {
|
||||||
|
throw new BadRequestException('Cannot disable a system response code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.ruleRepo.findOne({
|
||||||
|
where: {
|
||||||
|
documentTypeId: dto.documentTypeId,
|
||||||
|
responseCodeId: code.id,
|
||||||
|
projectId: dto.projectId ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.isEnabled = dto.isEnabled;
|
||||||
|
existing.requiresComments = dto.requiresComments ?? existing.requiresComments;
|
||||||
|
existing.triggersNotification = dto.triggersNotification ?? existing.triggersNotification;
|
||||||
|
return this.ruleRepo.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = this.ruleRepo.create({
|
||||||
|
documentTypeId: dto.documentTypeId,
|
||||||
|
responseCodeId: code.id,
|
||||||
|
projectId: dto.projectId,
|
||||||
|
isEnabled: dto.isEnabled,
|
||||||
|
requiresComments: dto.requiresComments ?? false,
|
||||||
|
triggersNotification: dto.triggersNotification ?? false,
|
||||||
|
} as Partial<ResponseCodeRule>);
|
||||||
|
|
||||||
|
return this.ruleRepo.save(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง rules ทั้งหมดของ document type (global + project)
|
||||||
|
*/
|
||||||
|
async getRulesByDocType(
|
||||||
|
documentTypeId: number,
|
||||||
|
projectId?: number,
|
||||||
|
): Promise<ResponseCodeRule[]> {
|
||||||
|
const where: Record<string, unknown> = { documentTypeId };
|
||||||
|
if (projectId !== undefined) {
|
||||||
|
where['projectId'] = projectId;
|
||||||
|
} else {
|
||||||
|
where['projectId'] = undefined; // global only
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ruleRepo.find({
|
||||||
|
where,
|
||||||
|
relations: ['responseCode'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ลบ project override (หวนกลับใช้ global default)
|
||||||
|
*/
|
||||||
|
async deleteProjectOverride(rulePublicId: string): Promise<void> {
|
||||||
|
const rule = await this.ruleRepo.findOne({ where: { publicId: rulePublicId } });
|
||||||
|
if (!rule) throw new NotFoundException(rulePublicId);
|
||||||
|
if (!rule.projectId) {
|
||||||
|
throw new BadRequestException('Cannot delete a global rule — disable it instead');
|
||||||
|
}
|
||||||
|
await this.ruleRepo.remove(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// File: src/modules/response-code/services/notification-trigger.service.ts
|
||||||
|
// ส่งการแจ้งเตือนอัตโนมัติเมื่อ Response Code มีผลกระทบสำคัญ (FR-007)
|
||||||
|
// Code 1C (Change Order), 1D (Alternative), 3 (Rejected) → notify ฝ่ายที่เกี่ยวข้อง
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ResponseCode } from '../entities/response-code.entity';
|
||||||
|
import { NotificationService } from '../../notification/notification.service';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
import { ImplicationsService } from './implications.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationTriggerService {
|
||||||
|
private readonly logger = new Logger(NotificationTriggerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ResponseCode)
|
||||||
|
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepo: Repository<User>,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly implicationsService: ImplicationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger notifications เมื่อ Review Task เสร็จสิ้นด้วย Code ที่มีผลกระทบ (FR-007)
|
||||||
|
* เรียกจาก ReviewTaskService หลังจาก completeReview
|
||||||
|
*/
|
||||||
|
async triggerIfRequired(
|
||||||
|
responseCodePublicId: string,
|
||||||
|
rfaPublicId: string,
|
||||||
|
documentNumber: string,
|
||||||
|
reviewerUserId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const responseCode = await this.responseCodeRepo.findOne({
|
||||||
|
where: { publicId: responseCodePublicId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!responseCode) {
|
||||||
|
this.logger.warn(`Response code not found for notification trigger: ${responseCodePublicId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluation = this.implicationsService.evaluate(responseCode);
|
||||||
|
|
||||||
|
// ถ้า severity ต่ำ ไม่ต้อง notify เพิ่ม
|
||||||
|
if (evaluation.severity === 'LOW') return;
|
||||||
|
|
||||||
|
const notifyRoles = evaluation.notifyRoles;
|
||||||
|
|
||||||
|
if (notifyRoles.length === 0) return;
|
||||||
|
|
||||||
|
// หา Users ที่มี role ที่ต้องการแจ้งเตือน
|
||||||
|
const targetUsers = await this.userRepo
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.where('user.role IN (:...roles)', { roles: notifyRoles })
|
||||||
|
.andWhere('user.is_active = 1')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const codeLabel = responseCode.code;
|
||||||
|
const title = `Response Code ${codeLabel} — Action Required`;
|
||||||
|
const message = [
|
||||||
|
`Document: ${documentNumber}`,
|
||||||
|
`Response Code: ${codeLabel} — ${responseCode.descriptionEn}`,
|
||||||
|
...evaluation.actionRequired,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// ส่งแจ้งเตือนแบบ parallel (ADR-008: ผ่าน BullMQ)
|
||||||
|
await Promise.all(
|
||||||
|
targetUsers.map((user: User) =>
|
||||||
|
this.notificationService.send({
|
||||||
|
userId: user.user_id,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type: 'SYSTEM',
|
||||||
|
entityType: 'rfa',
|
||||||
|
entityId: rfaPublicId as unknown as number,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Triggered ${notifyRoles.length} role notifications for code ${codeLabel} on document ${documentNumber}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// File: src/modules/review-team/dto/shared/review-team.dto.ts
|
||||||
|
// Shared DTOs สำหรับ Review Team และ Review Task APIs
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsArray,
|
||||||
|
IsUUID,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsPositive,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ReviewTaskStatus,
|
||||||
|
ReviewTeamMemberRole,
|
||||||
|
DelegationScope,
|
||||||
|
} from '../../../common/enums/review.enums';
|
||||||
|
|
||||||
|
// ─── Review Team DTOs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class CreateReviewTeamDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
projectPublicId!: string; // ADR-019: รับ publicId เสมอ
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
defaultForRfaTypes?: string[]; // ['SDW', 'DDW', 'ADW']
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateReviewTeamDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
defaultForRfaTypes?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddTeamMemberDto {
|
||||||
|
@IsUUID()
|
||||||
|
userPublicId!: string; // ADR-019
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
disciplinePublicId!: string; // ADR-019
|
||||||
|
|
||||||
|
@IsEnum(ReviewTeamMemberRole)
|
||||||
|
role!: ReviewTeamMemberRole;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
priorityOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchReviewTeamDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectPublicId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Review Task DTOs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class CompleteReviewTaskDto {
|
||||||
|
@IsUUID()
|
||||||
|
responseCodePublicId!: string; // ADR-019: รับ publicId
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
comments?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
attachmentPublicIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateReviewTaskStatusDto {
|
||||||
|
@IsEnum(ReviewTaskStatus)
|
||||||
|
status!: ReviewTaskStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchReviewTaskDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
rfaRevisionPublicId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ReviewTaskStatus)
|
||||||
|
status?: ReviewTaskStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assignedToUserPublicId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dueDateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dueDateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delegation DTOs ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class CreateDelegationDto {
|
||||||
|
@IsUUID()
|
||||||
|
delegateePublicId!: string; // ADR-019
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
startDate!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@IsEnum(DelegationScope)
|
||||||
|
scope!: DelegationScope;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
documentTypes?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Veto Override DTO ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class VetoOverrideDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
@MaxLength(1000)
|
||||||
|
justification!: string; // PM ต้องระบุเหตุผลที่ชัดเจน (min 10 chars)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// File: src/modules/review-team/entities/review-task.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
VersionColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { ReviewTeam } from './review-team.entity';
|
||||||
|
import { ResponseCode } from '../../response-code/entities/response-code.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
import { Discipline } from '../../master/entities/discipline.entity';
|
||||||
|
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Entity('review_tasks')
|
||||||
|
export class ReviewTask extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'rfa_revision_id' })
|
||||||
|
@Exclude()
|
||||||
|
rfaRevisionId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'team_id' })
|
||||||
|
@Exclude()
|
||||||
|
teamId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discipline_id' })
|
||||||
|
@Exclude()
|
||||||
|
disciplineId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'assigned_to_user_id', nullable: true })
|
||||||
|
@Exclude()
|
||||||
|
assignedToUserId?: number; // NULL = auto-assign ตาม discipline
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ReviewTaskStatus,
|
||||||
|
default: ReviewTaskStatus.PENDING,
|
||||||
|
})
|
||||||
|
status!: ReviewTaskStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'due_date', type: 'date', nullable: true })
|
||||||
|
dueDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'response_code_id', nullable: true })
|
||||||
|
@Exclude()
|
||||||
|
responseCodeId?: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
comments?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
attachments?: string[]; // Array ของ attachment publicIds
|
||||||
|
|
||||||
|
@Column({ name: 'delegated_from_user_id', nullable: true })
|
||||||
|
@Exclude()
|
||||||
|
delegatedFromUserId?: number; // ติดตาม delegation chain
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', type: 'timestamp', nullable: true })
|
||||||
|
completedAt?: Date;
|
||||||
|
|
||||||
|
@VersionColumn()
|
||||||
|
version!: number; // Optimistic locking (ADR-002)
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => ReviewTeam)
|
||||||
|
@JoinColumn({ name: 'team_id' })
|
||||||
|
team?: ReviewTeam;
|
||||||
|
|
||||||
|
@ManyToOne(() => Discipline)
|
||||||
|
@JoinColumn({ name: 'discipline_id' })
|
||||||
|
discipline?: Discipline;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'assigned_to_user_id' })
|
||||||
|
assignedToUser?: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => ResponseCode)
|
||||||
|
@JoinColumn({ name: 'response_code_id' })
|
||||||
|
responseCode?: ResponseCode;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'delegated_from_user_id' })
|
||||||
|
delegatedFromUser?: User;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// File: src/modules/review-team/entities/review-team-member.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
import { Discipline } from '../../master/entities/discipline.entity';
|
||||||
|
import { ReviewTeam } from './review-team.entity';
|
||||||
|
import { ReviewTeamMemberRole } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Entity('review_team_members')
|
||||||
|
export class ReviewTeamMember extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'team_id' })
|
||||||
|
@Exclude()
|
||||||
|
teamId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id' })
|
||||||
|
@Exclude()
|
||||||
|
userId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discipline_id' })
|
||||||
|
@Exclude()
|
||||||
|
disciplineId!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ReviewTeamMemberRole,
|
||||||
|
default: ReviewTeamMemberRole.REVIEWER,
|
||||||
|
})
|
||||||
|
role!: ReviewTeamMemberRole;
|
||||||
|
|
||||||
|
@Column({ name: 'priority_order', default: 0 })
|
||||||
|
priorityOrder!: number; // สำหรับ fallback sequential assignment
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'team_id' })
|
||||||
|
team!: ReviewTeam;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user?: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => Discipline)
|
||||||
|
@JoinColumn({ name: 'discipline_id' })
|
||||||
|
discipline?: Discipline;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// File: src/modules/review-team/entities/review-team.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { Project } from '../../project/entities/project.entity';
|
||||||
|
import { ReviewTeamMember } from './review-team-member.entity';
|
||||||
|
|
||||||
|
@Entity('review_teams')
|
||||||
|
export class ReviewTeam extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'project_id' })
|
||||||
|
@Exclude()
|
||||||
|
projectId!: number;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'default_for_rfa_types', type: 'simple-array', nullable: true })
|
||||||
|
defaultForRfaTypes?: string[]; // Auto-assign ให้ RFA type เช่น ['SDW','DDW']
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Project)
|
||||||
|
@JoinColumn({ name: 'project_id' })
|
||||||
|
project?: Project;
|
||||||
|
|
||||||
|
@OneToMany(() => ReviewTeamMember, (member: ReviewTeamMember) => member.team, { cascade: true })
|
||||||
|
members?: ReviewTeamMember[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// File: src/modules/review-team/review-task.service.ts
|
||||||
|
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ReviewTask } from './entities/review-task.entity';
|
||||||
|
import { ResponseCode } from '../response-code/entities/response-code.entity';
|
||||||
|
import {
|
||||||
|
CompleteReviewTaskDto,
|
||||||
|
SearchReviewTaskDto,
|
||||||
|
} from './dto/shared/review-team.dto';
|
||||||
|
import { ReviewTaskStatus } from '../common/enums/review.enums';
|
||||||
|
import { validateTaskCompletionRequirements } from '../../common/validators/review-validators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReviewTaskService {
|
||||||
|
private readonly logger = new Logger(ReviewTaskService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ReviewTask)
|
||||||
|
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||||
|
@InjectRepository(ResponseCode)
|
||||||
|
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Tasks ทั้งหมดของ RFA Revision (internal use)
|
||||||
|
*/
|
||||||
|
async findByRevisionId(rfaRevisionId: number): Promise<ReviewTask[]> {
|
||||||
|
return this.reviewTaskRepo.find({ where: { rfaRevisionId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ค้นหา Review Tasks ตาม filter (FR-004)
|
||||||
|
*/
|
||||||
|
async findAll(dto: SearchReviewTaskDto): Promise<ReviewTask[]> {
|
||||||
|
const qb = this.reviewTaskRepo
|
||||||
|
.createQueryBuilder('task')
|
||||||
|
.leftJoinAndSelect('task.discipline', 'discipline')
|
||||||
|
.leftJoinAndSelect('task.assignedToUser', 'user')
|
||||||
|
.leftJoinAndSelect('task.responseCode', 'responseCode')
|
||||||
|
.leftJoinAndSelect('task.team', 'team');
|
||||||
|
|
||||||
|
if (dto.rfaRevisionPublicId) {
|
||||||
|
qb.innerJoin('rfa_revisions', 'rev', 'rev.id = task.rfa_revision_id')
|
||||||
|
.where('rev.uuid = :uuid', { uuid: dto.rfaRevisionPublicId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.status) {
|
||||||
|
qb.andWhere('task.status = :status', { status: dto.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.assignedToUserPublicId) {
|
||||||
|
qb.andWhere('user.uuid = :userUuid', { userUuid: dto.assignedToUserPublicId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.dueDateFrom) {
|
||||||
|
qb.andWhere('task.due_date >= :from', { from: dto.dueDateFrom });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.dueDateTo) {
|
||||||
|
qb.andWhere('task.due_date <= :to', { to: dto.dueDateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.orderBy('task.created_at', 'ASC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Review Task ตาม publicId (ADR-019)
|
||||||
|
*/
|
||||||
|
async findByPublicId(publicId: string): Promise<ReviewTask> {
|
||||||
|
const task = await this.reviewTaskRepo.findOne({
|
||||||
|
where: { publicId },
|
||||||
|
relations: ['discipline', 'assignedToUser', 'responseCode', 'team'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException(`Review Task not found: ${publicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004)
|
||||||
|
*/
|
||||||
|
async getAggregateStatus(rfaRevisionId: number): Promise<{
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
summary: string;
|
||||||
|
}> {
|
||||||
|
const tasks = await this.reviewTaskRepo.find({ where: { rfaRevisionId } });
|
||||||
|
|
||||||
|
const total = tasks.length;
|
||||||
|
const completed = tasks.filter(
|
||||||
|
(t: ReviewTask) =>
|
||||||
|
t.status === ReviewTaskStatus.COMPLETED || t.status === ReviewTaskStatus.CANCELLED,
|
||||||
|
).length;
|
||||||
|
const pending = total - completed;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
pending,
|
||||||
|
summary: `${completed} of ${total} Disciplines Reviewed`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เริ่มตรวจสอบ Review Task (เปลี่ยน status จาก PENDING → IN_PROGRESS)
|
||||||
|
*/
|
||||||
|
async startReview(publicId: string): Promise<ReviewTask> {
|
||||||
|
const task = await this.findByPublicId(publicId);
|
||||||
|
|
||||||
|
if (task.status !== ReviewTaskStatus.PENDING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot start review: task is already ${task.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.status = ReviewTaskStatus.IN_PROGRESS;
|
||||||
|
return this.reviewTaskRepo.save(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* บันทึกผลการตรวจสอบ (FR-009, T069)
|
||||||
|
* ใช้ Optimistic Locking (@VersionColumn) ป้องกัน race condition (ADR-002)
|
||||||
|
*/
|
||||||
|
async completeReview(publicId: string, dto: CompleteReviewTaskDto): Promise<ReviewTask> {
|
||||||
|
const task = await this.findByPublicId(publicId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
task.status === ReviewTaskStatus.COMPLETED ||
|
||||||
|
task.status === ReviewTaskStatus.CANCELLED
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot complete review: task is already ${task.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ตรวจสอบ Response Code (ADR-019)
|
||||||
|
const responseCode = await this.responseCodeRepo.findOne({
|
||||||
|
where: { publicId: dto.responseCodePublicId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!responseCode) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Response Code not found: ${dto.responseCodePublicId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate completion requirements (T073)
|
||||||
|
validateTaskCompletionRequirements(
|
||||||
|
ReviewTaskStatus.COMPLETED,
|
||||||
|
responseCode.id,
|
||||||
|
false, // requiresComments checked at controller level via ResponseCodeRule
|
||||||
|
dto.comments,
|
||||||
|
);
|
||||||
|
|
||||||
|
task.status = ReviewTaskStatus.COMPLETED;
|
||||||
|
task.responseCodeId = responseCode.id;
|
||||||
|
task.comments = dto.comments;
|
||||||
|
task.attachments = dto.attachmentPublicIds;
|
||||||
|
task.completedAt = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002)
|
||||||
|
return await this.reviewTaskRepo.save(task);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
if (errorMessage.includes('OptimisticLock') || errorMessage.includes('version')) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Review task was modified concurrently. Please refresh and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// File: src/modules/review-team/review-team.controller.ts
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { ReviewTeamService } from './review-team.service';
|
||||||
|
import {
|
||||||
|
CreateReviewTeamDto,
|
||||||
|
UpdateReviewTeamDto,
|
||||||
|
AddTeamMemberDto,
|
||||||
|
SearchReviewTeamDto,
|
||||||
|
} from './dto/shared/review-team.dto';
|
||||||
|
|
||||||
|
@Controller('review-teams')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ReviewTeamController {
|
||||||
|
constructor(private readonly reviewTeamService: ReviewTeamService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /review-teams
|
||||||
|
* ดึงรายการ Review Teams ตาม project
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
findAll(@Query() dto: SearchReviewTeamDto) {
|
||||||
|
return this.reviewTeamService.findAll(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /review-teams/:publicId
|
||||||
|
* ดึง Review Team เดียว (ADR-019)
|
||||||
|
*/
|
||||||
|
@Get(':publicId')
|
||||||
|
findOne(@Param('publicId') publicId: string) {
|
||||||
|
return this.reviewTeamService.findByPublicId(publicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /review-teams
|
||||||
|
* สร้าง Review Team ใหม่
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateReviewTeamDto) {
|
||||||
|
return this.reviewTeamService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /review-teams/:publicId
|
||||||
|
* อัปเดต Review Team
|
||||||
|
*/
|
||||||
|
@Patch(':publicId')
|
||||||
|
update(@Param('publicId') publicId: string, @Body() dto: UpdateReviewTeamDto) {
|
||||||
|
return this.reviewTeamService.update(publicId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /review-teams/:publicId/members
|
||||||
|
* เพิ่มสมาชิก
|
||||||
|
*/
|
||||||
|
@Post(':publicId/members')
|
||||||
|
addMember(@Param('publicId') teamPublicId: string, @Body() dto: AddTeamMemberDto) {
|
||||||
|
return this.reviewTeamService.addMember(teamPublicId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /review-teams/:publicId/members/:memberPublicId
|
||||||
|
* ลบสมาชิก
|
||||||
|
*/
|
||||||
|
@Delete(':publicId/members/:memberPublicId')
|
||||||
|
removeMember(
|
||||||
|
@Param('publicId') teamPublicId: string,
|
||||||
|
@Param('memberPublicId') memberPublicId: string,
|
||||||
|
) {
|
||||||
|
return this.reviewTeamService.removeMember(teamPublicId, memberPublicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /review-teams/:publicId
|
||||||
|
* Deactivate Review Team (soft delete)
|
||||||
|
*/
|
||||||
|
@Delete(':publicId')
|
||||||
|
deactivate(@Param('publicId') publicId: string) {
|
||||||
|
return this.reviewTeamService.deactivate(publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// File: src/modules/review-team/review-team.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
import { ReviewTeam } from './entities/review-team.entity';
|
||||||
|
import { ReviewTeamMember } from './entities/review-team-member.entity';
|
||||||
|
import { ReviewTask } from './entities/review-task.entity';
|
||||||
|
|
||||||
|
// External entities needed for resolution
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
import { Discipline } from '../master/entities/discipline.entity';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
import { ReviewTeamService } from './review-team.service';
|
||||||
|
import { ReviewTaskService } from './review-task.service';
|
||||||
|
import { TaskCreationService } from './services/task-creation.service';
|
||||||
|
import { AggregateStatusService } from './services/aggregate-status.service';
|
||||||
|
import { ConsensusService } from './services/consensus.service';
|
||||||
|
import { VetoOverrideService } from './services/veto-override.service';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
import { ReviewTeamController } from './review-team.controller';
|
||||||
|
|
||||||
|
// Modules
|
||||||
|
import { ResponseCodeModule } from '../response-code/response-code.module';
|
||||||
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
|
import { DistributionModule } from '../distribution/distribution.module';
|
||||||
|
|
||||||
|
// Queue constants
|
||||||
|
import { QUEUE_REMINDERS, QUEUE_VETO_NOTIFICATIONS } from '../common/constants/queue.constants';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ReviewTeam, ReviewTeamMember, ReviewTask, User, Discipline]),
|
||||||
|
BullModule.registerQueue(
|
||||||
|
{ name: QUEUE_REMINDERS },
|
||||||
|
{ name: QUEUE_VETO_NOTIFICATIONS },
|
||||||
|
),
|
||||||
|
ResponseCodeModule,
|
||||||
|
NotificationModule,
|
||||||
|
UserModule,
|
||||||
|
DistributionModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
ReviewTeamService,
|
||||||
|
ReviewTaskService,
|
||||||
|
TaskCreationService,
|
||||||
|
AggregateStatusService,
|
||||||
|
ConsensusService,
|
||||||
|
VetoOverrideService,
|
||||||
|
],
|
||||||
|
controllers: [ReviewTeamController],
|
||||||
|
exports: [
|
||||||
|
ReviewTeamService,
|
||||||
|
ReviewTaskService,
|
||||||
|
TaskCreationService,
|
||||||
|
AggregateStatusService,
|
||||||
|
ConsensusService,
|
||||||
|
VetoOverrideService,
|
||||||
|
TypeOrmModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ReviewTeamModule {}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// File: src/modules/review-team/review-team.service.ts
|
||||||
|
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ReviewTeam } from './entities/review-team.entity';
|
||||||
|
import { ReviewTeamMember } from './entities/review-team-member.entity';
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
import { Discipline } from '../master/entities/discipline.entity';
|
||||||
|
import {
|
||||||
|
CreateReviewTeamDto,
|
||||||
|
UpdateReviewTeamDto,
|
||||||
|
AddTeamMemberDto,
|
||||||
|
SearchReviewTeamDto,
|
||||||
|
} from './dto/shared/review-team.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReviewTeamService {
|
||||||
|
private readonly logger = new Logger(ReviewTeamService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ReviewTeam)
|
||||||
|
private readonly teamRepo: Repository<ReviewTeam>,
|
||||||
|
@InjectRepository(ReviewTeamMember)
|
||||||
|
private readonly memberRepo: Repository<ReviewTeamMember>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepo: Repository<User>,
|
||||||
|
@InjectRepository(Discipline)
|
||||||
|
private readonly disciplineRepo: Repository<Discipline>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Review Teams ตาม project (FR-001)
|
||||||
|
*/
|
||||||
|
async findAll(dto: SearchReviewTeamDto): Promise<ReviewTeam[]> {
|
||||||
|
const qb = this.teamRepo
|
||||||
|
.createQueryBuilder('team')
|
||||||
|
.leftJoinAndSelect('team.members', 'member')
|
||||||
|
.leftJoinAndSelect('member.user', 'user')
|
||||||
|
.leftJoinAndSelect('member.discipline', 'discipline');
|
||||||
|
|
||||||
|
if (dto.projectPublicId) {
|
||||||
|
qb.innerJoin('team.project', 'project').where('project.uuid = :uuid', {
|
||||||
|
uuid: dto.projectPublicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.isActive !== undefined) {
|
||||||
|
qb.andWhere('team.is_active = :isActive', { isActive: dto.isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.search) {
|
||||||
|
qb.andWhere('team.name LIKE :search', { search: `%${dto.search}%` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.orderBy('team.created_at', 'DESC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Review Team เดียวตาม publicId (ADR-019)
|
||||||
|
*/
|
||||||
|
async findByPublicId(publicId: string): Promise<ReviewTeam> {
|
||||||
|
const team = await this.teamRepo.findOne({
|
||||||
|
where: { publicId },
|
||||||
|
relations: ['members', 'members.user', 'members.discipline', 'project'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new NotFoundException(`Review Team not found: ${publicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Teams ที่เป็น Default สำหรับ RFA type นั้นๆ (FR-002)
|
||||||
|
*/
|
||||||
|
async findDefaultForRfaType(rfaTypeCode: string, projectId: number): Promise<ReviewTeam[]> {
|
||||||
|
const teams = await this.teamRepo.find({
|
||||||
|
where: { projectId, isActive: true },
|
||||||
|
relations: ['members'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return teams.filter(
|
||||||
|
(t: ReviewTeam) => t.defaultForRfaTypes?.includes(rfaTypeCode) ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สร้าง Review Team ใหม่
|
||||||
|
*/
|
||||||
|
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
|
||||||
|
// ตรวจสอบว่า project มีอยู่จริง (via publicId)
|
||||||
|
const project = await this.teamRepo.manager.getRepository('projects').findOne({
|
||||||
|
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundException(`Project not found: ${dto.projectPublicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = this.teamRepo.create({
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
projectId: (project as { id: number }).id,
|
||||||
|
defaultForRfaTypes: dto.defaultForRfaTypes,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.teamRepo.save(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อัปเดต Review Team
|
||||||
|
*/
|
||||||
|
async update(publicId: string, dto: UpdateReviewTeamDto): Promise<ReviewTeam> {
|
||||||
|
const team = await this.findByPublicId(publicId);
|
||||||
|
|
||||||
|
if (dto.name !== undefined) team.name = dto.name;
|
||||||
|
if (dto.description !== undefined) team.description = dto.description;
|
||||||
|
if (dto.defaultForRfaTypes !== undefined) team.defaultForRfaTypes = dto.defaultForRfaTypes;
|
||||||
|
if (dto.isActive !== undefined) team.isActive = dto.isActive;
|
||||||
|
|
||||||
|
return this.teamRepo.save(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เพิ่มสมาชิกใน Review Team (FR-001)
|
||||||
|
*/
|
||||||
|
async addMember(teamPublicId: string, dto: AddTeamMemberDto): Promise<ReviewTeamMember> {
|
||||||
|
const team = await this.findByPublicId(teamPublicId);
|
||||||
|
|
||||||
|
// ตรวจสอบ User
|
||||||
|
const user = await this.userRepo.findOne({ where: { publicId: dto.userPublicId } });
|
||||||
|
if (!user) throw new NotFoundException(`User not found: ${dto.userPublicId}`);
|
||||||
|
|
||||||
|
// ตรวจสอบ Discipline
|
||||||
|
const discipline = await this.disciplineRepo.findOne({
|
||||||
|
where: { id: Number(dto.disciplinePublicId) },
|
||||||
|
});
|
||||||
|
if (!discipline) throw new NotFoundException(`Discipline not found: ${dto.disciplinePublicId}`);
|
||||||
|
|
||||||
|
// ตรวจสอบซ้ำ
|
||||||
|
const existing = await this.memberRepo.findOne({
|
||||||
|
where: { teamId: team.id, userId: user.user_id, disciplineId: discipline.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = this.memberRepo.create({
|
||||||
|
teamId: team.id,
|
||||||
|
userId: user.user_id,
|
||||||
|
disciplineId: discipline.id,
|
||||||
|
role: dto.role,
|
||||||
|
priorityOrder: dto.priorityOrder ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.memberRepo.save(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ลบสมาชิกออกจาก Review Team
|
||||||
|
*/
|
||||||
|
async removeMember(teamPublicId: string, memberPublicId: string): Promise<void> {
|
||||||
|
const team = await this.findByPublicId(teamPublicId);
|
||||||
|
const member = await this.memberRepo.findOne({
|
||||||
|
where: { publicId: memberPublicId, teamId: team.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new NotFoundException(`Member not found: ${memberPublicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.memberRepo.remove(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ลบ Review Team (soft delete ด้วย isActive = false)
|
||||||
|
*/
|
||||||
|
async deactivate(publicId: string): Promise<void> {
|
||||||
|
const team = await this.findByPublicId(publicId);
|
||||||
|
team.isActive = false;
|
||||||
|
await this.teamRepo.save(team);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// File: src/modules/review-team/services/aggregate-status.service.ts
|
||||||
|
// คำนวณสถานะรวมของ Review Tasks ภายใน RFA Revision (T067, FR-009)
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ReviewTask } from '../entities/review-task.entity';
|
||||||
|
import { ReviewTaskStatus, ConsensusDecision } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export interface AggregateStatus {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
inProgress: number;
|
||||||
|
delegated: number;
|
||||||
|
expired: number;
|
||||||
|
completionPct: number;
|
||||||
|
isAllComplete: boolean;
|
||||||
|
hasExpired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AggregateStatusService {
|
||||||
|
private readonly logger = new Logger(AggregateStatusService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ReviewTask)
|
||||||
|
private readonly taskRepo: Repository<ReviewTask>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* คำนวณสถานะรวมของทุก Review Tasks ใน RFA Revision (FR-009)
|
||||||
|
*/
|
||||||
|
async getForRevision(rfaRevisionId: number): Promise<AggregateStatus> {
|
||||||
|
const tasks = await this.taskRepo.find({
|
||||||
|
where: { rfaRevisionId },
|
||||||
|
select: ['id', 'status'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
total: tasks.length,
|
||||||
|
completed: 0,
|
||||||
|
pending: 0,
|
||||||
|
inProgress: 0,
|
||||||
|
delegated: 0,
|
||||||
|
expired: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
switch (task.status) {
|
||||||
|
case ReviewTaskStatus.COMPLETED: counts.completed++; break;
|
||||||
|
case ReviewTaskStatus.PENDING: counts.pending++; break;
|
||||||
|
case ReviewTaskStatus.IN_PROGRESS: counts.inProgress++; break;
|
||||||
|
case ReviewTaskStatus.DELEGATED: counts.delegated++; break;
|
||||||
|
case ReviewTaskStatus.EXPIRED: counts.expired++; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const completionPct =
|
||||||
|
counts.total > 0
|
||||||
|
? Math.round((counts.completed / counts.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...counts,
|
||||||
|
completionPct,
|
||||||
|
isAllComplete: counts.total > 0 && counts.completed === counts.total,
|
||||||
|
hasExpired: counts.expired > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบว่า RFA Revision พร้อมสำหรับ consensus หรือยัง (FR-010)
|
||||||
|
*/
|
||||||
|
async isReadyForConsensus(rfaRevisionId: number): Promise<boolean> {
|
||||||
|
const status = await this.getForRevision(rfaRevisionId);
|
||||||
|
return status.isAllComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine consensus based on response codes of completed tasks (FR-010)
|
||||||
|
*/
|
||||||
|
async evaluateConsensus(rfaRevisionId: number): Promise<ConsensusDecision> {
|
||||||
|
const tasks = await this.taskRepo.find({
|
||||||
|
where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED },
|
||||||
|
relations: ['responseCode'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tasks.length === 0) return ConsensusDecision.PENDING;
|
||||||
|
|
||||||
|
// Veto check: any Code 3 = REJECTED
|
||||||
|
const hasVeto = tasks.some((t) => t.responseCode?.code === '3');
|
||||||
|
if (hasVeto) return ConsensusDecision.REJECTED;
|
||||||
|
|
||||||
|
// All approved: Code 1A or 1B = APPROVED
|
||||||
|
const allApproved = tasks.every((t) =>
|
||||||
|
['1A', '1B'].includes(t.responseCode?.code ?? ''),
|
||||||
|
);
|
||||||
|
if (allApproved) return ConsensusDecision.APPROVED;
|
||||||
|
|
||||||
|
// Any Code 2 = APPROVED_WITH_COMMENTS
|
||||||
|
const hasComments = tasks.some((t) => t.responseCode?.code === '2');
|
||||||
|
if (hasComments) return ConsensusDecision.APPROVED_WITH_COMMENTS;
|
||||||
|
|
||||||
|
return ConsensusDecision.APPROVED_WITH_COMMENTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// File: src/modules/review-team/services/consensus.service.ts
|
||||||
|
// Evaluate parallel review consensus และ trigger distribution (T068, FR-010)
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ReviewTask } from '../entities/review-task.entity';
|
||||||
|
import { AggregateStatusService } from './aggregate-status.service';
|
||||||
|
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
|
||||||
|
import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export interface ConsensusResult {
|
||||||
|
decision: ConsensusDecision;
|
||||||
|
completedTasks: number;
|
||||||
|
totalTasks: number;
|
||||||
|
triggeredDistribution: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConsensusService {
|
||||||
|
private readonly logger = new Logger(ConsensusService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ReviewTask)
|
||||||
|
private readonly taskRepo: Repository<ReviewTask>,
|
||||||
|
private readonly aggregateStatusService: AggregateStatusService,
|
||||||
|
private readonly approvalListenerService: ApprovalListenerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เรียกหลัง task complete — ตรวจสอบ consensus และ trigger distribution (FR-010)
|
||||||
|
*/
|
||||||
|
async evaluateAfterTaskComplete(
|
||||||
|
rfaRevisionId: number,
|
||||||
|
context: {
|
||||||
|
rfaPublicId: string;
|
||||||
|
rfaRevisionPublicId: string;
|
||||||
|
projectId: number;
|
||||||
|
documentTypeCode: string;
|
||||||
|
},
|
||||||
|
): Promise<ConsensusResult> {
|
||||||
|
const isReady = await this.aggregateStatusService.isReadyForConsensus(rfaRevisionId);
|
||||||
|
|
||||||
|
const status = await this.aggregateStatusService.getForRevision(rfaRevisionId);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Revision ${rfaRevisionId}: ${status.completed}/${status.total} tasks done — not ready for consensus`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
decision: ConsensusDecision.PENDING,
|
||||||
|
completedTasks: status.completed,
|
||||||
|
totalTasks: status.total,
|
||||||
|
triggeredDistribution: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision = await this.aggregateStatusService.evaluateConsensus(rfaRevisionId);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let triggeredDistribution = false;
|
||||||
|
if (
|
||||||
|
decision === ConsensusDecision.APPROVED ||
|
||||||
|
decision === ConsensusDecision.APPROVED_WITH_COMMENTS
|
||||||
|
) {
|
||||||
|
// ดึง response code ที่ predominant
|
||||||
|
const completedTasks = await this.taskRepo.find({
|
||||||
|
where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED },
|
||||||
|
relations: ['responseCode'],
|
||||||
|
order: { completedAt: 'DESC' },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseCode = completedTasks[0]?.responseCode?.code ?? '1A';
|
||||||
|
|
||||||
|
await this.approvalListenerService.onConsensusReached({
|
||||||
|
...context,
|
||||||
|
responseCode,
|
||||||
|
decision,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
triggeredDistribution = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decision,
|
||||||
|
completedTasks: status.completed,
|
||||||
|
totalTasks: status.total,
|
||||||
|
triggeredDistribution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// File: src/modules/review-team/services/task-creation.service.ts
|
||||||
|
// Strangler Pattern: แยก logic การสร้าง Parallel Review Tasks ออกจาก rfa.service.ts
|
||||||
|
// เรียกใช้หลังจาก RFA Submit สำเร็จ (T017 integration)
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, EntityManager } from 'typeorm';
|
||||||
|
import { ReviewTeam } from '../entities/review-team.entity';
|
||||||
|
import { ReviewTeamMember } from '../entities/review-team-member.entity';
|
||||||
|
import { ReviewTask } from '../entities/review-task.entity';
|
||||||
|
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskCreationService {
|
||||||
|
private readonly logger = new Logger(TaskCreationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ReviewTeam)
|
||||||
|
private readonly reviewTeamRepo: Repository<ReviewTeam>,
|
||||||
|
@InjectRepository(ReviewTeamMember)
|
||||||
|
private readonly memberRepo: Repository<ReviewTeamMember>,
|
||||||
|
@InjectRepository(ReviewTask)
|
||||||
|
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สร้าง Parallel Review Tasks สำหรับแต่ละ Discipline ใน Review Team (FR-003)
|
||||||
|
* เรียกใช้ภายใน Transaction ของ rfa.service.ts submit method
|
||||||
|
*
|
||||||
|
* @param rfaRevisionId - Internal ID ของ RFA Revision
|
||||||
|
* @param reviewTeamPublicId - publicId ของ Review Team (ADR-019)
|
||||||
|
* @param dueDate - กำหนดเวลาตรวจสอบ
|
||||||
|
* @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม)
|
||||||
|
*/
|
||||||
|
async createParallelTasks(
|
||||||
|
rfaRevisionId: number,
|
||||||
|
reviewTeamPublicId: string,
|
||||||
|
dueDate: Date,
|
||||||
|
manager: EntityManager,
|
||||||
|
): Promise<ReviewTask[]> {
|
||||||
|
// ดึง ReviewTeam พร้อม members
|
||||||
|
const team = await this.reviewTeamRepo.findOne({
|
||||||
|
where: { publicId: reviewTeamPublicId },
|
||||||
|
relations: ['members'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team || !team.isActive) {
|
||||||
|
this.logger.warn(
|
||||||
|
`ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = team.members ?? [];
|
||||||
|
|
||||||
|
if (members.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// กลุ่ม members ตาม disciplineId (แต่ละ Discipline ต้องการเพียง 1 Task)
|
||||||
|
const disciplineMap = new Map<number, ReviewTeamMember>();
|
||||||
|
for (const member of members) {
|
||||||
|
// LEAD มี priority สูงสุด ถ้ามีหลายคนใน Discipline เดียวกัน
|
||||||
|
const existing = disciplineMap.get(member.disciplineId);
|
||||||
|
if (!existing || member.role === 'LEAD') {
|
||||||
|
disciplineMap.set(member.disciplineId, member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: ReviewTask[] = [];
|
||||||
|
|
||||||
|
// สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel)
|
||||||
|
for (const [disciplineId, leadMember] of disciplineMap) {
|
||||||
|
const task = manager.create(ReviewTask, {
|
||||||
|
rfaRevisionId,
|
||||||
|
teamId: team.id,
|
||||||
|
disciplineId,
|
||||||
|
assignedToUserId: leadMember.userId,
|
||||||
|
status: ReviewTaskStatus.PENDING,
|
||||||
|
dueDate,
|
||||||
|
});
|
||||||
|
const saved = await manager.save(ReviewTask, task);
|
||||||
|
tasks.push(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบว่า RFA Revision มี Review Tasks ครบทุก Discipline แล้วหรือยัง
|
||||||
|
*/
|
||||||
|
async areAllTasksCompleted(rfaRevisionId: number): Promise<boolean> {
|
||||||
|
const tasks = await this.reviewTaskRepo.find({
|
||||||
|
where: { rfaRevisionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tasks.length === 0) return false;
|
||||||
|
|
||||||
|
return tasks.every(
|
||||||
|
(t: ReviewTask) =>
|
||||||
|
t.status === ReviewTaskStatus.COMPLETED ||
|
||||||
|
t.status === ReviewTaskStatus.CANCELLED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// File: src/modules/review-team/services/veto-override.service.ts
|
||||||
|
// PM Veto Override — บังคับผ่าน RFA Revision แม้มี Code 3 (T068.5)
|
||||||
|
import { Injectable, Logger, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { ReviewTask } from '../entities/review-task.entity';
|
||||||
|
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
|
||||||
|
import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export interface VetoOverrideDto {
|
||||||
|
rfaRevisionId: number;
|
||||||
|
rfaPublicId: string;
|
||||||
|
rfaRevisionPublicId: string;
|
||||||
|
projectId: number;
|
||||||
|
documentTypeCode: string;
|
||||||
|
overrideReason: string;
|
||||||
|
overriddenByUserId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VetoOverrideService {
|
||||||
|
private readonly logger = new Logger(VetoOverrideService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ReviewTask)
|
||||||
|
private readonly taskRepo: Repository<ReviewTask>,
|
||||||
|
private readonly approvalListenerService: ApprovalListenerService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PM Override: บังคับ APPROVED แม้ว่าจะมี Code 3 rejection (FR-012)
|
||||||
|
* ต้องมี justification reason และ audit trail
|
||||||
|
*/
|
||||||
|
async executeOverride(dto: VetoOverrideDto): Promise<{ decision: ConsensusDecision }> {
|
||||||
|
const tasks = await this.taskRepo.find({
|
||||||
|
where: { rfaRevisionId: dto.rfaRevisionId },
|
||||||
|
relations: ['responseCode'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
throw new NotFoundException(`No review tasks found for revision ${dto.rfaRevisionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasVeto = tasks.some((t) => t.responseCode?.code === '3');
|
||||||
|
if (!hasVeto) {
|
||||||
|
throw new ForbiddenException('No Code 3 veto found — override not needed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.overrideReason || dto.overrideReason.trim().length < 10) {
|
||||||
|
throw new ForbiddenException('Override reason must be at least 10 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`PM Override executed by user ${dto.overriddenByUserId} for revision ${dto.rfaRevisionId}. Reason: ${dto.overrideReason}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.approvalListenerService.onConsensusReached({
|
||||||
|
rfaPublicId: dto.rfaPublicId,
|
||||||
|
rfaRevisionPublicId: dto.rfaRevisionPublicId,
|
||||||
|
projectId: dto.projectId,
|
||||||
|
documentTypeCode: dto.documentTypeCode,
|
||||||
|
responseCode: '1A',
|
||||||
|
decision: ConsensusDecision.OVERRIDDEN,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { decision: ConsensusDecision.OVERRIDDEN };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// File: src/modules/rfa/dto/submit-rfa.dto.ts
|
// File: src/modules/rfa/dto/submit-rfa.dto.ts
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsInt, IsNotEmpty } from 'class-validator';
|
import { IsInt, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class SubmitRfaDto {
|
export class SubmitRfaDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -10,4 +10,13 @@ export class SubmitRfaDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
templateId!: number;
|
templateId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'publicId ของ Review Team สำหรับ Parallel Review (ADR-019)',
|
||||||
|
example: '019505a1-7c3e-7000-8000-abc123def456',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
reviewTeamPublicId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// File: src/modules/workflow-engine/dsl/parallel-gateway.handler.ts
|
||||||
|
// Parallel Gateway DSL handler สำหรับ RFA parallel review (T066, ADR-001)
|
||||||
|
// Strangler Pattern: ขยาย WorkflowEngine โดยไม่แก้ไข core DSL
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface ParallelGatewayStep {
|
||||||
|
type: 'parallel_gateway';
|
||||||
|
id: string;
|
||||||
|
branches: ParallelBranch[];
|
||||||
|
completionStrategy: 'ALL' | 'MAJORITY' | 'ANY';
|
||||||
|
onComplete: string; // next step ID
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParallelBranch {
|
||||||
|
id: string;
|
||||||
|
assigneeType: 'DISCIPLINE' | 'USER' | 'TEAM';
|
||||||
|
assigneeId: string; // publicId
|
||||||
|
steps: string[]; // step IDs within this branch
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayExecutionContext {
|
||||||
|
rfaRevisionPublicId: string;
|
||||||
|
completedBranches: Set<string>;
|
||||||
|
totalBranches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ParallelGatewayHandler {
|
||||||
|
private readonly logger = new Logger(ParallelGatewayHandler.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบว่า gateway สามารถเดินหน้าได้หรือยัง ตาม completionStrategy (FR-008)
|
||||||
|
*/
|
||||||
|
canAdvance(step: ParallelGatewayStep, ctx: GatewayExecutionContext): boolean {
|
||||||
|
const { completedBranches, totalBranches } = ctx;
|
||||||
|
|
||||||
|
switch (step.completionStrategy) {
|
||||||
|
case 'ALL':
|
||||||
|
return completedBranches.size === totalBranches;
|
||||||
|
|
||||||
|
case 'MAJORITY':
|
||||||
|
return completedBranches.size > Math.floor(totalBranches / 2);
|
||||||
|
|
||||||
|
case 'ANY':
|
||||||
|
return completedBranches.size >= 1;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Unknown completion strategy: ${step.completionStrategy as string}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สร้าง execution context จาก gateway definition
|
||||||
|
*/
|
||||||
|
createContext(
|
||||||
|
rfaRevisionPublicId: string,
|
||||||
|
step: ParallelGatewayStep,
|
||||||
|
): GatewayExecutionContext {
|
||||||
|
return {
|
||||||
|
rfaRevisionPublicId,
|
||||||
|
completedBranches: new Set<string>(),
|
||||||
|
totalBranches: step.branches.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a branch complete and check if gateway can advance
|
||||||
|
*/
|
||||||
|
markBranchComplete(
|
||||||
|
ctx: GatewayExecutionContext,
|
||||||
|
branchId: string,
|
||||||
|
step: ParallelGatewayStep,
|
||||||
|
): { canAdvance: boolean; completedCount: number } {
|
||||||
|
ctx.completedBranches.add(branchId);
|
||||||
|
|
||||||
|
const canAdvance = this.canAdvance(step, ctx);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Branch ${branchId} complete. ${ctx.completedBranches.size}/${ctx.totalBranches} — canAdvance: ${canAdvance}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { canAdvance, completedCount: ctx.completedBranches.size };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// File: tests/e2e/rfa-workflow.e2e-spec.ts
|
||||||
|
// E2E test ครอบคลุม RFA Approval Refactor full workflow (T077)
|
||||||
|
// TODO: ต้องมี test database + seeded data สำหรับ E2E run จริง
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E Workflow Coverage:
|
||||||
|
* 1. RFA submit → Review Tasks created (parallel)
|
||||||
|
* 2. All reviewers complete → Consensus evaluated
|
||||||
|
* 3. Consensus APPROVED → Distribution queued
|
||||||
|
* 4. Distribution processed → Transmittal created
|
||||||
|
* 5. Veto (Code 3) → PM override → force APPROVED
|
||||||
|
* 6. Reminder sent when task overdue
|
||||||
|
* 7. Delegation: delegate completes task on behalf
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('RFA Approval Workflow (E2E)', () => {
|
||||||
|
// TODO: Bootstrap NestJS test app + seed test data
|
||||||
|
|
||||||
|
describe('Phase 1-3: Submit → Parallel Review → Consensus', () => {
|
||||||
|
it.todo('should create parallel review tasks on RFA submit');
|
||||||
|
it.todo('should evaluate APPROVED consensus when all Code 1A');
|
||||||
|
it.todo('should evaluate REJECTED consensus when any Code 3');
|
||||||
|
it.todo('should allow PM override of Code 3 veto');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase 4-5: Delegation → Reminder', () => {
|
||||||
|
it.todo('should delegate review task to another user');
|
||||||
|
it.todo('should block circular delegation');
|
||||||
|
it.todo('should send reminder when task is overdue');
|
||||||
|
it.todo('should escalate to L2 after 3 days overdue');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase 6-7: Distribution', () => {
|
||||||
|
it.todo('should queue distribution after APPROVED consensus');
|
||||||
|
it.todo('should create Transmittal records from distribution matrix');
|
||||||
|
it.todo('should skip distribution for REJECTED');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// File: tests/integration/review-team/parallel-review.spec.ts
|
||||||
|
// Integration tests สำหรับ Parallel Review consensus flow (T076)
|
||||||
|
// TODO: ขยาย test suite เมื่อ test database พร้อม (Sprint ถัดไป)
|
||||||
|
|
||||||
|
import { ConsensusDecision } from '../../../src/modules/common/enums/review.enums';
|
||||||
|
|
||||||
|
describe('Parallel Review Consensus (Integration)', () => {
|
||||||
|
describe('Consensus evaluation', () => {
|
||||||
|
it('should return APPROVED when all tasks have Code 1A', () => {
|
||||||
|
const codes = ['1A', '1A', '1A'];
|
||||||
|
const hasVeto = codes.some((c) => c === '3');
|
||||||
|
const allApproved = codes.every((c) => ['1A', '1B'].includes(c));
|
||||||
|
|
||||||
|
const decision = hasVeto
|
||||||
|
? ConsensusDecision.REJECTED
|
||||||
|
: allApproved
|
||||||
|
? ConsensusDecision.APPROVED
|
||||||
|
: ConsensusDecision.APPROVED_WITH_COMMENTS;
|
||||||
|
|
||||||
|
expect(decision).toBe(ConsensusDecision.APPROVED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return REJECTED when any task has Code 3', () => {
|
||||||
|
const codes = ['1A', '3', '2'];
|
||||||
|
const hasVeto = codes.some((c) => c === '3');
|
||||||
|
|
||||||
|
const decision = hasVeto ? ConsensusDecision.REJECTED : ConsensusDecision.APPROVED;
|
||||||
|
|
||||||
|
expect(decision).toBe(ConsensusDecision.REJECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return APPROVED_WITH_COMMENTS when mix of 1A and 2', () => {
|
||||||
|
const codes = ['1A', '2', '1B'];
|
||||||
|
const hasVeto = codes.some((c) => c === '3');
|
||||||
|
const allApproved = codes.every((c) => ['1A', '1B'].includes(c));
|
||||||
|
const hasComments = codes.some((c) => c === '2');
|
||||||
|
|
||||||
|
const decision = hasVeto
|
||||||
|
? ConsensusDecision.REJECTED
|
||||||
|
: allApproved
|
||||||
|
? ConsensusDecision.APPROVED
|
||||||
|
: hasComments
|
||||||
|
? ConsensusDecision.APPROVED_WITH_COMMENTS
|
||||||
|
: ConsensusDecision.PENDING;
|
||||||
|
|
||||||
|
expect(decision).toBe(ConsensusDecision.APPROVED_WITH_COMMENTS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// File: tests/unit/delegation/circular-detection.service.spec.ts
|
||||||
|
// Unit tests สำหรับ CircularDetectionService — ป้องกัน delegation loops (T075)
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { CircularDetectionService } from '../../../src/modules/delegation/services/circular-detection.service';
|
||||||
|
import { Delegation } from '../../../src/modules/delegation/entities/delegation.entity';
|
||||||
|
|
||||||
|
const mockDelegationRepo = {
|
||||||
|
find: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CircularDetectionService', () => {
|
||||||
|
let service: CircularDetectionService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
CircularDetectionService,
|
||||||
|
{ provide: getRepositoryToken(Delegation), useValue: mockDelegationRepo },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<CircularDetectionService>(CircularDetectionService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wouldCreateCircle', () => {
|
||||||
|
it('should return false when no delegations exist', async () => {
|
||||||
|
mockDelegationRepo.find.mockResolvedValue([]);
|
||||||
|
const result = await service.wouldCreateCircle(1, 2);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect direct circular delegation A→B when B→A exists', async () => {
|
||||||
|
// B (id=2) delegates to A (id=1)
|
||||||
|
mockDelegationRepo.find.mockResolvedValue([
|
||||||
|
{ delegatorId: 2, delegateId: 1 },
|
||||||
|
]);
|
||||||
|
// Now trying to add A→B — would create cycle
|
||||||
|
const result = await service.wouldCreateCircle(1, 2);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect indirect cycle A→B→C when trying C→A', async () => {
|
||||||
|
// A→B and B→C already exist
|
||||||
|
mockDelegationRepo.find.mockResolvedValue([
|
||||||
|
{ delegatorId: 1, delegateId: 2 },
|
||||||
|
{ delegatorId: 2, delegateId: 3 },
|
||||||
|
]);
|
||||||
|
// Now trying C→A — would create A→B→C→A cycle
|
||||||
|
const result = await service.wouldCreateCircle(3, 1);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-circular delegations', async () => {
|
||||||
|
// A→B and B→C — adding D→A is fine
|
||||||
|
mockDelegationRepo.find.mockResolvedValue([
|
||||||
|
{ delegatorId: 1, delegateId: 2 },
|
||||||
|
{ delegatorId: 2, delegateId: 3 },
|
||||||
|
]);
|
||||||
|
const result = await service.wouldCreateCircle(4, 1);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// File: tests/unit/response-code/response-code.service.spec.ts
|
||||||
|
// Unit tests สำหรับ ResponseCodeService (T074)
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { ResponseCodeService } from '../../../src/modules/response-code/response-code.service';
|
||||||
|
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
|
||||||
|
import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity';
|
||||||
|
import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums';
|
||||||
|
|
||||||
|
const mockCode: Partial<ResponseCode> = {
|
||||||
|
id: 1,
|
||||||
|
publicId: 'test-uuid-1',
|
||||||
|
code: '1A',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'ผ่าน — ไม่มีเงื่อนไข',
|
||||||
|
descriptionEn: 'Approved — No Comments',
|
||||||
|
isActive: true,
|
||||||
|
isSystem: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCodeRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([mockCode]),
|
||||||
|
findOne: jest.fn().mockResolvedValue(mockCode),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRuleRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ResponseCodeService', () => {
|
||||||
|
let service: ResponseCodeService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ResponseCodeService,
|
||||||
|
{ provide: getRepositoryToken(ResponseCode), useValue: mockCodeRepo },
|
||||||
|
{ provide: getRepositoryToken(ResponseCodeRule), useValue: mockRuleRepo },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ResponseCodeService>(ResponseCodeService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByCategory', () => {
|
||||||
|
it('should return codes filtered by category', async () => {
|
||||||
|
const result = await service.findByCategory(ResponseCodeCategory.ENGINEERING);
|
||||||
|
expect(mockCodeRepo.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ category: ResponseCodeCategory.ENGINEERING }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual([mockCode]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByDocumentType', () => {
|
||||||
|
it('should return enabled codes for document type', async () => {
|
||||||
|
const result = await service.findByDocumentType(1, 1);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: app/(dashboard)/settings/delegation/page.tsx
|
||||||
|
// หน้าจัดการ Delegation ของตัวเอง (FR-011)
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, ArrowRightLeft, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useMyDelegations, useCreateDelegation, useRevokeDelegation, Delegation } from '@/hooks/use-delegation';
|
||||||
|
import { DelegationForm } from '@/components/delegation/DelegationForm';
|
||||||
|
|
||||||
|
export default function DelegationPage() {
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: delegations = [], isLoading } = useMyDelegations();
|
||||||
|
const createDelegation = useCreateDelegation();
|
||||||
|
const revokeDelegation = useRevokeDelegation();
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Delegation Settings</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
มอบหมายหน้าที่ตรวจสอบให้ผู้อื่นในช่วงที่ไม่อยู่
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Delegation
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Delegation</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DelegationForm
|
||||||
|
availableUsers={[]}
|
||||||
|
onSubmit={(dto) =>
|
||||||
|
createDelegation.mutate(dto, {
|
||||||
|
onSuccess: () => setCreateOpen(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isLoading={createDelegation.isPending}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-center text-muted-foreground py-8">Loading delegations...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(delegations as Delegation[]).map((d: Delegation) => {
|
||||||
|
const isActive =
|
||||||
|
d.isActive && d.startDate <= today && d.endDate >= today;
|
||||||
|
const isPast = d.endDate < today;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={d.publicId}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ArrowRightLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
→ {d.delegate?.fullName ?? d.delegate?.email ?? '—'}
|
||||||
|
</CardTitle>
|
||||||
|
{isActive && <Badge variant="default">Active</Badge>}
|
||||||
|
{isPast && <Badge variant="secondary">Expired</Badge>}
|
||||||
|
{!isActive && !isPast && (
|
||||||
|
<Badge variant="outline">Scheduled</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-xs">{d.scope}</Badge>
|
||||||
|
</div>
|
||||||
|
{!isPast && d.isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => revokeDelegation.mutate(d.publicId)}
|
||||||
|
disabled={revokeDelegation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{d.startDate} → {d.endDate}
|
||||||
|
{d.reason && ` • ${d.reason}`}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!isLoading && delegations.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<ArrowRightLeft className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>No delegations set. Create one when you need a proxy reviewer.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: app/(dashboard)/settings/review-teams/page.tsx
|
||||||
|
// หน้าจัดการ Review Teams (FR-001, FR-002)
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Users, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useReviewTeams, useCreateReviewTeam, useUpdateReviewTeam } from '@/hooks/use-review-teams';
|
||||||
|
import { ReviewTeamForm } from '@/components/review-team/ReviewTeamForm';
|
||||||
|
import { TeamMemberManager } from '@/components/review-team/TeamMemberManager';
|
||||||
|
import { ReviewTeam } from '@/types/review-team';
|
||||||
|
|
||||||
|
// TODO: ดึง projectPublicId จาก context หรือ URL param จริง
|
||||||
|
const MOCK_PROJECT_ID = 'current-project-public-id';
|
||||||
|
|
||||||
|
export default function ReviewTeamsPage() {
|
||||||
|
const [expandedTeam, setExpandedTeam] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editTeam, setEditTeam] = useState<ReviewTeam | null>(null);
|
||||||
|
|
||||||
|
const { data: teams = [], isLoading } = useReviewTeams({
|
||||||
|
projectPublicId: MOCK_PROJECT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTeam = useCreateReviewTeam();
|
||||||
|
const updateTeam = useUpdateReviewTeam();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Review Teams</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
จัดการทีมตรวจสอบแยกตาม Discipline สำหรับ Parallel Review
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Team
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Review Team</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ReviewTeamForm
|
||||||
|
projectPublicId={MOCK_PROJECT_ID}
|
||||||
|
onSubmit={(values) =>
|
||||||
|
createTeam.mutate(values, {
|
||||||
|
onSuccess: () => setCreateOpen(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isLoading={createTeam.isPending}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-center text-muted-foreground py-8">Loading teams...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(teams as ReviewTeam[]).map((team) => (
|
||||||
|
<Card key={team.publicId}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">{team.name}</CardTitle>
|
||||||
|
{!team.isActive && (
|
||||||
|
<Badge variant="secondary">Inactive</Badge>
|
||||||
|
)}
|
||||||
|
{(team.defaultForRfaTypes ?? []).map((type) => (
|
||||||
|
<Badge key={type} variant="outline" className="text-xs">
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditTeam(team)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedTeam(expandedTeam === team.publicId ? null : team.publicId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expandedTeam === team.publicId ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{team.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{team.description}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{expandedTeam === team.publicId && (
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm font-medium mb-3">
|
||||||
|
Members ({(team.members ?? []).length})
|
||||||
|
</div>
|
||||||
|
<TeamMemberManager
|
||||||
|
teamPublicId={team.publicId}
|
||||||
|
members={team.members ?? []}
|
||||||
|
availableUsers={[]}
|
||||||
|
availableDisciplines={[]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isLoading && (teams as ReviewTeam[]).length === 0 && (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<Users className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>No Review Teams yet. Create one to enable Parallel Review.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={!!editTeam} onOpenChange={() => setEditTeam(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Review Team</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editTeam && (
|
||||||
|
<ReviewTeamForm
|
||||||
|
projectPublicId={MOCK_PROJECT_ID}
|
||||||
|
defaultValues={editTeam}
|
||||||
|
onSubmit={(values) =>
|
||||||
|
updateTeam.mutate(
|
||||||
|
{ publicId: editTeam.publicId, data: values },
|
||||||
|
{ onSuccess: () => setEditTeam(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isLoading={updateTeam.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/delegation/DelegationForm.tsx
|
||||||
|
// Form สร้าง Delegation พร้อม date range picker (FR-011)
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { CreateDelegationDto } from '@/hooks/use-delegation';
|
||||||
|
import { DelegationScope } from '@/types/review-team';
|
||||||
|
|
||||||
|
const delegationSchema = z
|
||||||
|
.object({
|
||||||
|
delegateUserPublicId: z.string().uuid('Select a valid user'),
|
||||||
|
scope: z.enum(['ALL', 'RFA_ONLY', 'CORRESPONDENCE_ONLY', 'SPECIFIC_TYPES'] as const),
|
||||||
|
startDate: z.string().min(1, 'Start date is required'),
|
||||||
|
endDate: z.string().min(1, 'End date is required'),
|
||||||
|
reason: z.string().max(500).optional(),
|
||||||
|
})
|
||||||
|
.refine((d: { startDate: string; endDate: string }) => new Date(d.startDate) < new Date(d.endDate), {
|
||||||
|
message: 'End date must be after start date',
|
||||||
|
path: ['endDate'],
|
||||||
|
});
|
||||||
|
|
||||||
|
type DelegationFormValues = z.infer<typeof delegationSchema>;
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
publicId: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DelegationFormProps {
|
||||||
|
availableUsers: User[];
|
||||||
|
onSubmit: (dto: CreateDelegationDto) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_LABELS: Record<DelegationScope, string> = {
|
||||||
|
ALL: 'All Documents',
|
||||||
|
RFA_ONLY: 'RFA Only',
|
||||||
|
CORRESPONDENCE_ONLY: 'Correspondence Only',
|
||||||
|
SPECIFIC_TYPES: 'Specific Document Types',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DelegationForm({ availableUsers, onSubmit, isLoading }: DelegationFormProps) {
|
||||||
|
const form = useForm<DelegationFormValues>({
|
||||||
|
resolver: zodResolver(delegationSchema),
|
||||||
|
defaultValues: { scope: 'ALL' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: DelegationFormValues) => {
|
||||||
|
onSubmit({
|
||||||
|
...values,
|
||||||
|
scope: values.scope as DelegationScope,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="delegateUserPublicId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delegate To</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select user to delegate..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableUsers.map((u) => (
|
||||||
|
<SelectItem key={u.publicId} value={u.publicId}>
|
||||||
|
{u.fullName ?? u.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="scope"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Scope</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(SCOPE_LABELS) as DelegationScope[]).map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{SCOPE_LABELS[s]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="startDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Start Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="endDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>End Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="reason"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Reason (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="e.g. Annual leave 12-18 May" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating...' : 'Create Delegation'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/distribution/DistributionStatus.tsx
|
||||||
|
// แสดงสถานะ Distribution ของ RFA หลังอนุมัติ (T060)
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckCircle2, Clock, SendHorizonal, Users } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
type DistributionStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||||
|
|
||||||
|
interface DistributionRecord {
|
||||||
|
publicId: string;
|
||||||
|
status: DistributionStatus;
|
||||||
|
transmittalCount: number;
|
||||||
|
recipientCount: number;
|
||||||
|
processedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DistributionStatusProps {
|
||||||
|
rfaPublicId: string;
|
||||||
|
distribution?: DistributionRecord;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
DistributionStatus,
|
||||||
|
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; icon: React.ElementType }
|
||||||
|
> = {
|
||||||
|
PENDING: { label: 'Queued', variant: 'outline', icon: Clock },
|
||||||
|
PROCESSING: { label: 'Processing', variant: 'secondary', icon: Clock },
|
||||||
|
COMPLETED: { label: 'Distributed', variant: 'default', icon: CheckCircle2 },
|
||||||
|
FAILED: { label: 'Failed', variant: 'destructive', icon: Clock },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DistributionStatus({ rfaPublicId: _rfaPublicId, distribution, isLoading }: DistributionStatusProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-sm text-muted-foreground">Checking distribution status...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!distribution) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>Awaiting approval for distribution</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = STATUS_CONFIG[distribution.status];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-medium">Distribution Status</CardTitle>
|
||||||
|
<Badge variant={config.variant} className="gap-1">
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<SendHorizonal className="h-3.5 w-3.5" />
|
||||||
|
<span>{distribution.transmittalCount} Transmittal(s)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
<span>{distribution.recipientCount} Recipient(s)</span>
|
||||||
|
</div>
|
||||||
|
{distribution.processedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(distribution.processedAt).toLocaleDateString('th-TH')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{distribution.error && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{distribution.error}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/reminder/ReminderHistory.tsx
|
||||||
|
// แสดงประวัติ Reminder และ Escalation ของ Review Task (T050)
|
||||||
|
import React from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Clock, AlertTriangle, Bell } from 'lucide-react';
|
||||||
|
|
||||||
|
type ReminderType = 'DUE_SOON' | 'ON_DUE' | 'OVERDUE' | 'ESCALATION_L1' | 'ESCALATION_L2';
|
||||||
|
|
||||||
|
interface ReminderEntry {
|
||||||
|
id: string;
|
||||||
|
type: ReminderType;
|
||||||
|
sentAt: string;
|
||||||
|
recipient?: string;
|
||||||
|
isDelivered?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReminderHistoryProps {
|
||||||
|
reminders: ReminderEntry[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_CONFIG: Record<
|
||||||
|
ReminderType,
|
||||||
|
{ label: string; icon: React.ElementType; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||||
|
> = {
|
||||||
|
DUE_SOON: { label: 'Due Soon', icon: Clock, variant: 'outline' },
|
||||||
|
ON_DUE: { label: 'Due Today', icon: Bell, variant: 'secondary' },
|
||||||
|
OVERDUE: { label: 'Overdue', icon: AlertTriangle, variant: 'destructive' },
|
||||||
|
ESCALATION_L1: { label: 'Escalation L1', icon: AlertTriangle, variant: 'destructive' },
|
||||||
|
ESCALATION_L2: { label: 'Escalation L2 (PM)', icon: AlertTriangle, variant: 'destructive' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReminderHistory({ reminders, isLoading }: ReminderHistoryProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-sm text-muted-foreground">Loading reminder history...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reminders.length === 0) {
|
||||||
|
return <div className="text-sm text-muted-foreground">No reminders sent yet.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{reminders.map((entry) => {
|
||||||
|
const config = TYPE_CONFIG[entry.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between py-1.5 border-b last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<Badge variant={config.variant} className="text-xs">
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
{entry.recipient && (
|
||||||
|
<span className="text-xs text-muted-foreground">→ {entry.recipient}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{entry.isDelivered !== undefined && (
|
||||||
|
<span className={`text-xs ${entry.isDelivered ? 'text-green-600' : 'text-orange-500'}`}>
|
||||||
|
{entry.isDelivered ? '✓ Delivered' : '⏳ Pending'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(entry.sentAt).toLocaleDateString('th-TH', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/response-code/CodeImplications.tsx
|
||||||
|
// แสดงผลกระทบของ Response Code ที่เลือก (FR-007)
|
||||||
|
import { AlertTriangle, Clock, DollarSign, FileText, Leaf } from 'lucide-react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ResponseCode } from '@/types/review-team';
|
||||||
|
|
||||||
|
interface CodeImplicationsProps {
|
||||||
|
responseCode: ResponseCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_VARIANTS = {
|
||||||
|
'3': { variant: 'destructive' as const, label: 'Critical — Document Rejected' },
|
||||||
|
'1C': { variant: 'default' as const, label: 'High — Contract Implications' },
|
||||||
|
'1D': { variant: 'default' as const, label: 'High — Alternative Approved' },
|
||||||
|
'2': { variant: 'default' as const, label: 'Moderate — Revision Required' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CodeImplications({ responseCode }: CodeImplicationsProps) {
|
||||||
|
const impl = responseCode.implications;
|
||||||
|
const notifyRoles = responseCode.notifyRoles ?? [];
|
||||||
|
|
||||||
|
const hasImplications =
|
||||||
|
impl?.affectsSchedule ||
|
||||||
|
impl?.affectsCost ||
|
||||||
|
impl?.requiresContractReview ||
|
||||||
|
impl?.requiresEiaAmendment ||
|
||||||
|
notifyRoles.length > 0;
|
||||||
|
|
||||||
|
if (!hasImplications) return null;
|
||||||
|
|
||||||
|
const severityInfo = SEVERITY_VARIANTS[responseCode.code as keyof typeof SEVERITY_VARIANTS];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant={severityInfo?.variant ?? 'default'} className="mt-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
{severityInfo?.label ?? 'Action Required'}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
{impl?.affectsSchedule && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>May affect project schedule</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{impl?.affectsCost && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<DollarSign className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>Cost impact — QS assessment required</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{impl?.requiresContractReview && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<FileText className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>Contract review required</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{impl?.requiresEiaAmendment && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Leaf className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>EIA amendment may be required</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notifyRoles.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap mt-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Will notify:</span>
|
||||||
|
{notifyRoles.map((role) => (
|
||||||
|
<Badge key={role} variant="outline" className="text-xs">
|
||||||
|
{role.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/response-code/MatrixEditor.tsx
|
||||||
|
// Visual editor สำหรับ Master Approval Matrix (T064, FR-022)
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Check, X, AlertTriangle, Lock } from 'lucide-react';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
interface MatrixRule {
|
||||||
|
publicId: string;
|
||||||
|
responseCode: {
|
||||||
|
publicId: string;
|
||||||
|
code: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
isEnabled: boolean;
|
||||||
|
requiresComments: boolean;
|
||||||
|
triggersNotification: boolean;
|
||||||
|
isOverridden: boolean;
|
||||||
|
isSystem?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatrixEditorProps {
|
||||||
|
documentTypeCode: string;
|
||||||
|
rules: MatrixRule[];
|
||||||
|
isProjectLevel?: boolean;
|
||||||
|
onToggleEnabled: (rulePublicId: string, enabled: boolean) => void;
|
||||||
|
onToggleRequiresComments: (rulePublicId: string, value: boolean) => void;
|
||||||
|
onToggleNotification: (rulePublicId: string, value: boolean) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = ['ENGINEERING', 'MATERIAL', 'CONTRACT', 'TESTING', 'ESG'];
|
||||||
|
|
||||||
|
export function MatrixEditor({
|
||||||
|
documentTypeCode,
|
||||||
|
rules,
|
||||||
|
isProjectLevel = false,
|
||||||
|
onToggleEnabled,
|
||||||
|
onToggleRequiresComments,
|
||||||
|
onToggleNotification,
|
||||||
|
isLoading,
|
||||||
|
}: MatrixEditorProps) {
|
||||||
|
const [filter, setFilter] = useState<string>('ALL');
|
||||||
|
|
||||||
|
const grouped = CATEGORY_ORDER.reduce<Record<string, MatrixRule[]>>((acc, cat) => {
|
||||||
|
const catRules = rules.filter(
|
||||||
|
(r) => r.responseCode.category === cat && (filter === 'ALL' || r.responseCode.category === filter),
|
||||||
|
);
|
||||||
|
if (catRules.length > 0) acc[cat] = catRules;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Matrix: {documentTypeCode}
|
||||||
|
{isProjectLevel && (
|
||||||
|
<Badge variant="secondary" className="ml-2 text-xs">Project Override</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFilter(e.target.value)}
|
||||||
|
className="text-sm border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="ALL">All Categories</option>
|
||||||
|
{CATEGORY_ORDER.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">Code</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className="w-24 text-center">Enabled</TableHead>
|
||||||
|
<TableHead className="w-28 text-center">Req. Comments</TableHead>
|
||||||
|
<TableHead className="w-28 text-center">Notify</TableHead>
|
||||||
|
<TableHead className="w-20 text-center">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Object.entries(grouped).map(([cat, catRules]) => (
|
||||||
|
<React.Fragment key={cat}>
|
||||||
|
<TableRow className="bg-muted/30">
|
||||||
|
<TableCell colSpan={6} className="py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
{cat}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{catRules.map((rule) => (
|
||||||
|
<TableRow key={rule.publicId} className={!rule.isEnabled ? 'opacity-50' : ''}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono text-sm font-bold">{rule.responseCode.code}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{rule.responseCode.descriptionEn}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{rule.isSystem ? (
|
||||||
|
<Lock className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Switch
|
||||||
|
checked={rule.isEnabled}
|
||||||
|
onCheckedChange={(v: boolean) => onToggleEnabled(rule.publicId, v)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Switch
|
||||||
|
checked={rule.requiresComments}
|
||||||
|
onCheckedChange={(v: boolean) => onToggleRequiresComments(rule.publicId, v)}
|
||||||
|
disabled={isLoading || !rule.isEnabled}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Switch
|
||||||
|
checked={rule.triggersNotification}
|
||||||
|
onCheckedChange={(v: boolean) => onToggleNotification(rule.publicId, v)}
|
||||||
|
disabled={isLoading || !rule.isEnabled}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{rule.isOverridden ? (
|
||||||
|
<AlertTriangle className="h-4 w-4 mx-auto text-amber-500" />
|
||||||
|
) : rule.isEnabled ? (
|
||||||
|
<Check className="h-4 w-4 mx-auto text-green-500" />
|
||||||
|
) : (
|
||||||
|
<X className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{Object.keys(grouped).length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-6">
|
||||||
|
No rules configured for this document type.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/response-code/ProjectOverrideManager.tsx
|
||||||
|
// จัดการ project-specific overrides ของ Master Approval Matrix (T065)
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Plus, Trash2, ArrowDownToLine } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
|
interface OverrideRule {
|
||||||
|
publicId: string;
|
||||||
|
responseCode: {
|
||||||
|
code: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
documentTypeCode: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
requiresComments: boolean;
|
||||||
|
triggersNotification: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectOverrideManagerProps {
|
||||||
|
projectPublicId: string;
|
||||||
|
projectName: string;
|
||||||
|
overrides: OverrideRule[];
|
||||||
|
onDeleteOverride: (rulePublicId: string) => void;
|
||||||
|
onAddOverride: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectOverrideManager({
|
||||||
|
projectPublicId: _projectPublicId,
|
||||||
|
projectName,
|
||||||
|
overrides,
|
||||||
|
onDeleteOverride,
|
||||||
|
onAddOverride,
|
||||||
|
isLoading,
|
||||||
|
}: ProjectOverrideManagerProps) {
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const grouped = overrides.reduce<Record<string, OverrideRule[]>>((acc, rule) => {
|
||||||
|
const key = rule.documentTypeCode;
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(rule);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{projectName}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{overrides.length} project-specific override(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={onAddOverride} disabled={isLoading}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Override
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{overrides.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
|
||||||
|
<ArrowDownToLine className="h-4 w-4" />
|
||||||
|
<span>Inheriting all rules from global defaults</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(grouped).map(([docType, rules]) => (
|
||||||
|
<div key={docType}>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">
|
||||||
|
{docType}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<div
|
||||||
|
key={rule.publicId}
|
||||||
|
className="flex items-center justify-between py-1.5 px-2 rounded bg-muted/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs font-bold">
|
||||||
|
{rule.responseCode.code}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{rule.responseCode.descriptionEn}</span>
|
||||||
|
<Badge variant={rule.isEnabled ? 'default' : 'outline'} className="text-xs">
|
||||||
|
{rule.isEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
{rule.requiresComments && (
|
||||||
|
<Badge variant="secondary" className="text-xs">Req. Comments</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={confirmDelete === rule.publicId}
|
||||||
|
onOpenChange={(open: boolean) => !open && setConfirmDelete(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setConfirmDelete(rule.publicId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove Override?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will revert code <strong>{rule.responseCode.code}</strong> to the
|
||||||
|
global default settings for this project.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
onDeleteOverride(rule.publicId);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove Override
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator className="mt-3" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/response-code/ResponseCodeSelector.tsx
|
||||||
|
// เลือก Response Code ตาม Category ของเอกสาร (FR-006)
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useResponseCodesByDocType } from '@/hooks/use-response-codes';
|
||||||
|
import { ResponseCode } from '@/types/review-team';
|
||||||
|
|
||||||
|
interface ResponseCodeSelectorProps {
|
||||||
|
documentTypeId: number;
|
||||||
|
projectId?: number;
|
||||||
|
value?: string;
|
||||||
|
onChange: (publicId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
|
'1A': 'bg-green-100 text-green-800',
|
||||||
|
'1B': 'bg-emerald-100 text-emerald-800',
|
||||||
|
'1C': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'1D': 'bg-orange-100 text-orange-800',
|
||||||
|
'1E': 'bg-blue-100 text-blue-800',
|
||||||
|
'1F': 'bg-sky-100 text-sky-800',
|
||||||
|
'1G': 'bg-purple-100 text-purple-800',
|
||||||
|
'2': 'bg-amber-100 text-amber-800',
|
||||||
|
'3': 'bg-red-100 text-red-800',
|
||||||
|
'4': 'bg-gray-100 text-gray-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
function CodeBadge({ code }: { code: string }) {
|
||||||
|
const colorClass = SEVERITY_COLORS[code] ?? 'bg-gray-100 text-gray-600';
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-bold ${colorClass}`}>
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponseCodeSelector({
|
||||||
|
documentTypeId,
|
||||||
|
projectId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
placeholder = 'Select Response Code...',
|
||||||
|
}: ResponseCodeSelectorProps) {
|
||||||
|
const { data: codes = [], isLoading } = useResponseCodesByDocType(documentTypeId, projectId);
|
||||||
|
|
||||||
|
// กลุ่ม codes ตาม category
|
||||||
|
const grouped = (codes as ResponseCode[]).reduce<Record<string, ResponseCode[]>>(
|
||||||
|
(acc, code) => {
|
||||||
|
const cat = code.category;
|
||||||
|
if (!acc[cat]) acc[cat] = [];
|
||||||
|
acc[cat].push(code);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const categories = Object.keys(grouped);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={value ?? ''} onValueChange={onChange} disabled={disabled || isLoading}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={isLoading ? 'Loading codes...' : placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.length === 0 && !isLoading && (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">No codes available</div>
|
||||||
|
)}
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectGroup key={cat}>
|
||||||
|
<SelectLabel className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
{cat}
|
||||||
|
</SelectLabel>
|
||||||
|
{grouped[cat].map((code) => (
|
||||||
|
<SelectItem key={code.publicId} value={code.publicId}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CodeBadge code={code.code} />
|
||||||
|
<span className="text-sm">{code.descriptionEn}</span>
|
||||||
|
{(code.implications?.affectsCost || code.implications?.requiresContractReview) && (
|
||||||
|
<span className="text-xs text-orange-600 font-medium">⚠ Action Required</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/review-task/CompleteReviewForm.tsx
|
||||||
|
// Form สำหรับบันทึกผล Review Task (FR-009) — Response Code + Comments
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ResponseCodeSelector } from '@/components/response-code/ResponseCodeSelector';
|
||||||
|
import { CodeImplications } from '@/components/response-code/CodeImplications';
|
||||||
|
import { useResponseCodes } from '@/hooks/use-response-codes';
|
||||||
|
import { ResponseCode } from '@/types/review-team';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const completeReviewSchema = z.object({
|
||||||
|
responseCodePublicId: z.string().uuid('Response Code is required'),
|
||||||
|
comments: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CompleteReviewFormValues = z.infer<typeof completeReviewSchema>;
|
||||||
|
|
||||||
|
interface CompleteReviewFormProps {
|
||||||
|
taskPublicId: string;
|
||||||
|
documentTypeId: number;
|
||||||
|
projectId?: number;
|
||||||
|
onSubmit: (values: CompleteReviewFormValues) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompleteReviewForm({
|
||||||
|
taskPublicId: _taskPublicId,
|
||||||
|
documentTypeId,
|
||||||
|
projectId,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: CompleteReviewFormProps) {
|
||||||
|
const [selectedCode, setSelectedCode] = useState<ResponseCode | null>(null);
|
||||||
|
const { data: allCodes = [] } = useResponseCodes();
|
||||||
|
|
||||||
|
const form = useForm<CompleteReviewFormValues>({
|
||||||
|
resolver: zodResolver(completeReviewSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCodeChange = (publicId: string) => {
|
||||||
|
form.setValue('responseCodePublicId', publicId);
|
||||||
|
const found = (allCodes as ResponseCode[]).find((c) => c.publicId === publicId) ?? null;
|
||||||
|
setSelectedCode(found);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="responseCodePublicId"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Response Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<ResponseCodeSelector
|
||||||
|
documentTypeId={documentTypeId}
|
||||||
|
projectId={projectId}
|
||||||
|
value={form.watch('responseCodePublicId')}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedCode && <CodeImplications responseCode={selectedCode} />}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="comments"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Comments
|
||||||
|
{selectedCode?.code === '2' || selectedCode?.code === '3' ? (
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
) : null}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter review comments..."
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Submitting...' : 'Submit Review'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/review-task/DelegatedBadge.tsx
|
||||||
|
// แสดง indicator "Delegated from X" บน Review Task (T041)
|
||||||
|
import { ArrowRightLeft } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from '@/components/ui/hover-card';
|
||||||
|
|
||||||
|
interface DelegatedBadgeProps {
|
||||||
|
delegatedFromUser?: {
|
||||||
|
publicId: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DelegatedBadge({ delegatedFromUser }: DelegatedBadgeProps) {
|
||||||
|
if (!delegatedFromUser) return null;
|
||||||
|
|
||||||
|
const displayName = delegatedFromUser.fullName ?? delegatedFromUser.email ?? 'Unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground border-dashed cursor-pointer">
|
||||||
|
<ArrowRightLeft className="h-3 w-3" />
|
||||||
|
Delegated
|
||||||
|
</Badge>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-auto p-2">
|
||||||
|
<p className="text-sm">Delegated from: <strong>{displayName}</strong></p>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/review-task/ParallelProgress.tsx
|
||||||
|
// Parallel review progress indicator แสดงทุก discipline tracks (T072)
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
type TaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'DELEGATED' | 'EXPIRED' | 'CANCELLED';
|
||||||
|
|
||||||
|
interface DisciplineTrack {
|
||||||
|
disciplineId: string;
|
||||||
|
disciplineName: string;
|
||||||
|
taskStatus: TaskStatus;
|
||||||
|
responseCode?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
assigneeName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParallelProgressProps {
|
||||||
|
tracks: DisciplineTrack[];
|
||||||
|
overallPct: number;
|
||||||
|
isAllComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_ICON: Record<TaskStatus, React.ElementType> = {
|
||||||
|
PENDING: Clock,
|
||||||
|
IN_PROGRESS: Clock,
|
||||||
|
COMPLETED: CheckCircle2,
|
||||||
|
DELEGATED: Clock,
|
||||||
|
EXPIRED: AlertTriangle,
|
||||||
|
CANCELLED: AlertTriangle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRACK_COLOR: Record<TaskStatus, string> = {
|
||||||
|
PENDING: 'text-muted-foreground',
|
||||||
|
IN_PROGRESS: 'text-blue-500',
|
||||||
|
COMPLETED: 'text-green-500',
|
||||||
|
DELEGATED: 'text-amber-500',
|
||||||
|
EXPIRED: 'text-destructive',
|
||||||
|
CANCELLED: 'text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ParallelProgress({ tracks, overallPct, isAllComplete }: ParallelProgressProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Parallel Review Tracks</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">{overallPct}%</span>
|
||||||
|
{isAllComplete && (
|
||||||
|
<Badge variant="default" className="text-xs">All Complete</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={overallPct} className="h-1.5" />
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{tracks.map((track) => {
|
||||||
|
const Icon = TRACK_ICON[track.taskStatus];
|
||||||
|
const colorClass = TRACK_COLOR[track.taskStatus];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={track.disciplineId}
|
||||||
|
className="flex items-center justify-between py-1.5 px-2 rounded bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`h-4 w-4 ${colorClass} flex-shrink-0`} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{track.disciplineName}</p>
|
||||||
|
{track.assigneeName && (
|
||||||
|
<p className="text-xs text-muted-foreground">{track.assigneeName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{track.responseCode && (
|
||||||
|
<span className="font-mono font-bold text-foreground">{track.responseCode}</span>
|
||||||
|
)}
|
||||||
|
{track.dueDate && (
|
||||||
|
<span>{new Date(track.dueDate).toLocaleDateString('th-TH', { day: '2-digit', month: 'short' })}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/review-task/ReviewTaskInbox.tsx
|
||||||
|
// Review Task inbox พร้อม aggregate status indicator (T071)
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Clock, CheckCircle2, AlertTriangle, ArrowRightLeft, Users } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { DelegatedBadge } from '@/components/review-task/DelegatedBadge';
|
||||||
|
|
||||||
|
type ReviewTaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'DELEGATED' | 'EXPIRED' | 'CANCELLED';
|
||||||
|
|
||||||
|
interface ReviewTaskItem {
|
||||||
|
publicId: string;
|
||||||
|
status: ReviewTaskStatus;
|
||||||
|
discipline?: { name: string };
|
||||||
|
assignedToUser?: { publicId: string; fullName?: string; email?: string };
|
||||||
|
delegatedFromUser?: { publicId: string; fullName?: string; email?: string };
|
||||||
|
dueDate?: string;
|
||||||
|
rfaNumber?: string;
|
||||||
|
documentTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AggregateStatus {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
completionPct: number;
|
||||||
|
isAllComplete: boolean;
|
||||||
|
hasExpired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewTaskInboxProps {
|
||||||
|
tasks: ReviewTaskItem[];
|
||||||
|
aggregateStatus?: AggregateStatus;
|
||||||
|
onStartTask: (taskPublicId: string) => void;
|
||||||
|
onCompleteTask: (taskPublicId: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<ReviewTaskStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||||
|
PENDING: { label: 'Pending', variant: 'outline' },
|
||||||
|
IN_PROGRESS: { label: 'In Progress', variant: 'secondary' },
|
||||||
|
COMPLETED: { label: 'Completed', variant: 'default' },
|
||||||
|
DELEGATED: { label: 'Delegated', variant: 'secondary' },
|
||||||
|
EXPIRED: { label: 'Expired', variant: 'destructive' },
|
||||||
|
CANCELLED: { label: 'Cancelled', variant: 'outline' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReviewTaskInbox({
|
||||||
|
tasks,
|
||||||
|
aggregateStatus,
|
||||||
|
onStartTask,
|
||||||
|
onCompleteTask,
|
||||||
|
isLoading,
|
||||||
|
}: ReviewTaskInboxProps) {
|
||||||
|
const [filter, setFilter] = useState<ReviewTaskStatus | 'ALL'>('ALL');
|
||||||
|
|
||||||
|
const filtered = filter === 'ALL' ? tasks : tasks.filter((t) => t.status === filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{aggregateStatus && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Parallel Review Progress
|
||||||
|
</CardTitle>
|
||||||
|
<span className="text-sm font-semibold">{aggregateStatus.completionPct}%</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-2">
|
||||||
|
<Progress value={aggregateStatus.completionPct} className="h-2" />
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{aggregateStatus.completed}/{aggregateStatus.total} tasks complete</span>
|
||||||
|
{aggregateStatus.isAllComplete && (
|
||||||
|
<Badge variant="default" className="text-xs gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" /> All Complete
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{aggregateStatus.hasExpired && (
|
||||||
|
<Badge variant="destructive" className="text-xs gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" /> Has Expired
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(['ALL', 'PENDING', 'IN_PROGRESS', 'COMPLETED'] as const).map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s}
|
||||||
|
variant={filter === s ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter(s)}
|
||||||
|
>
|
||||||
|
{s === 'ALL' ? 'All' : STATUS_CONFIG[s]?.label ?? s}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((task) => {
|
||||||
|
const config = STATUS_CONFIG[task.status];
|
||||||
|
const isOverdue =
|
||||||
|
task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'COMPLETED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={task.publicId} className={isOverdue ? 'border-destructive/50' : ''}>
|
||||||
|
<CardContent className="py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium truncate">{task.rfaNumber ?? task.publicId}</span>
|
||||||
|
<Badge variant={config.variant} className="text-xs">{config.label}</Badge>
|
||||||
|
{task.delegatedFromUser && (
|
||||||
|
<DelegatedBadge delegatedFromUser={task.delegatedFromUser} />
|
||||||
|
)}
|
||||||
|
{isOverdue && (
|
||||||
|
<Badge variant="destructive" className="text-xs gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" /> Overdue
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.documentTitle && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{task.documentTitle}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||||
|
{task.discipline && <span>{task.discipline.name}</span>}
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(task.dueDate).toLocaleDateString('th-TH')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{task.status === 'PENDING' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onStartTask(task.publicId)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{task.status === 'IN_PROGRESS' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onCompleteTask(task.publicId)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Complete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{task.status === 'COMPLETED' && (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||||
|
)}
|
||||||
|
{task.status === 'DELEGATED' && (
|
||||||
|
<ArrowRightLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
No review tasks found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/review-task/VetoOverrideDialog.tsx
|
||||||
|
// PM Veto Override dialog — บังคับผ่าน RFA แม้มี Code 3 (T072.5)
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface VetoOverrideDialogProps {
|
||||||
|
rfaNumber: string;
|
||||||
|
onConfirm: (reason: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_REASON_LENGTH = 10;
|
||||||
|
|
||||||
|
export function VetoOverrideDialog({ rfaNumber, onConfirm, isLoading }: VetoOverrideDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
const isValid = reason.trim().length >= MIN_REASON_LENGTH;
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!isValid) return;
|
||||||
|
onConfirm(reason.trim());
|
||||||
|
setOpen(false);
|
||||||
|
setReason('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
if (!next) setReason('');
|
||||||
|
setOpen(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
<AlertTriangle className="h-4 w-4 mr-1.5" />
|
||||||
|
PM Override
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
PM Veto Override
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Override the Code 3 rejection for <strong>{rfaNumber}</strong> and force-approve.
|
||||||
|
This action will be permanently logged in the audit trail.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Justification Reason *</label>
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)}
|
||||||
|
placeholder="Provide a detailed reason for overriding the rejection (minimum 10 characters)..."
|
||||||
|
className="mt-1.5 min-h-[100px]"
|
||||||
|
/>
|
||||||
|
<p className={`text-xs mt-1 ${isValid ? 'text-green-600' : 'text-muted-foreground'}`}>
|
||||||
|
{reason.trim().length}/{MIN_REASON_LENGTH} minimum characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3">
|
||||||
|
<p className="text-xs text-destructive font-medium">
|
||||||
|
⚠ This override is irreversible. All reviewers and stakeholders will be notified.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!isValid || isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Processing...' : 'Confirm Override'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/review-team/ReviewTeamForm.tsx
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const reviewTeamSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
|
description: z.string().max(255).optional(),
|
||||||
|
defaultForRfaTypes: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ReviewTeamFormValues = z.infer<typeof reviewTeamSchema>;
|
||||||
|
|
||||||
|
interface ReviewTeamFormProps {
|
||||||
|
projectPublicId: string;
|
||||||
|
defaultValues?: Partial<ReviewTeamFormValues>;
|
||||||
|
onSubmit: (values: ReviewTeamFormValues & { projectPublicId: string }) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RFA_TYPE_OPTIONS = ['SDW', 'DDW', 'ADW', 'MS', 'MAT', 'BOQ'];
|
||||||
|
|
||||||
|
export function ReviewTeamForm({
|
||||||
|
projectPublicId,
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: ReviewTeamFormProps) {
|
||||||
|
const [typeInput, setTypeInput] = useState('');
|
||||||
|
|
||||||
|
const form = useForm<ReviewTeamFormValues>({
|
||||||
|
resolver: zodResolver(reviewTeamSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultValues?.name ?? '',
|
||||||
|
description: defaultValues?.description ?? '',
|
||||||
|
defaultForRfaTypes: defaultValues?.defaultForRfaTypes ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rfaTypes = form.watch('defaultForRfaTypes') ?? [];
|
||||||
|
|
||||||
|
const addRfaType = (type: string) => {
|
||||||
|
if (type && !rfaTypes.includes(type)) {
|
||||||
|
form.setValue('defaultForRfaTypes', [...rfaTypes, type]);
|
||||||
|
}
|
||||||
|
setTypeInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRfaType = (type: string) => {
|
||||||
|
form.setValue(
|
||||||
|
'defaultForRfaTypes',
|
||||||
|
rfaTypes.filter((t) => t !== type),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit((values) =>
|
||||||
|
onSubmit({ ...values, projectPublicId }),
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Team Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. Structural Review Team" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="Optional description..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Default for RFA Types</FormLabel>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{rfaTypes.map((type) => (
|
||||||
|
<Badge key={type} variant="secondary" className="gap-1">
|
||||||
|
{type}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRfaType(type)}
|
||||||
|
className="ml-1 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{RFA_TYPE_OPTIONS.filter((t) => !rfaTypes.includes(t)).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addRfaType(type)}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-dashed hover:bg-accent"
|
||||||
|
>
|
||||||
|
+ {type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" value={typeInput} onChange={(e) => setTypeInput(e.target.value)} />
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Saving...' : 'Save Team'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/review-team/ReviewTeamSelector.tsx
|
||||||
|
// Selector component สำหรับเลือก Review Team ตอน Submit RFA (T023)
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Users } from 'lucide-react';
|
||||||
|
import { useReviewTeams } from '@/hooks/use-review-teams';
|
||||||
|
import { ReviewTeam } from '@/types/review-team';
|
||||||
|
|
||||||
|
interface ReviewTeamSelectorProps {
|
||||||
|
projectPublicId: string;
|
||||||
|
rfaTypeCode?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange: (publicId: string | undefined) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewTeamSelector({
|
||||||
|
projectPublicId,
|
||||||
|
rfaTypeCode,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: ReviewTeamSelectorProps) {
|
||||||
|
const { data: teams = [], isLoading } = useReviewTeams({
|
||||||
|
projectPublicId,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// กรอง teams ที่ match กับ rfaTypeCode (ถ้ากำหนด)
|
||||||
|
const filteredTeams = rfaTypeCode
|
||||||
|
? (teams as ReviewTeam[]).filter(
|
||||||
|
(t) => !t.defaultForRfaTypes?.length || t.defaultForRfaTypes.includes(rfaTypeCode),
|
||||||
|
)
|
||||||
|
: (teams as ReviewTeam[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>Review Team (Parallel Review)</span>
|
||||||
|
<Badge variant="outline" className="text-xs">Optional</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={value ?? ''}
|
||||||
|
onValueChange={(v: string) => onChange(v || undefined)}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={isLoading ? 'Loading teams...' : 'Skip — no parallel review'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Skip — no parallel review</SelectItem>
|
||||||
|
{filteredTeams.map((team) => (
|
||||||
|
<SelectItem key={team.publicId} value={team.publicId}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{team.name}</span>
|
||||||
|
{(team.members ?? []).length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({team.members?.length} members)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Parallel review tasks will be created for each discipline in the selected team.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// File: components/review-team/TeamMemberManager.tsx
|
||||||
|
// จัดการสมาชิกของ Review Team แยกตาม Discipline (FR-001)
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Trash2, UserPlus } from 'lucide-react';
|
||||||
|
import { useAddTeamMember, useRemoveTeamMember } from '@/hooks/use-review-teams';
|
||||||
|
import { ReviewTeamMemberRole } from '@/types/review-team';
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
publicId: string;
|
||||||
|
role: ReviewTeamMemberRole;
|
||||||
|
user?: { publicId: string; fullName?: string; email?: string };
|
||||||
|
discipline?: { publicId: string; disciplineCode: string; codeNameEn?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
publicId: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Discipline {
|
||||||
|
publicId: string;
|
||||||
|
disciplineCode: string;
|
||||||
|
codeNameEn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamMemberManagerProps {
|
||||||
|
teamPublicId: string;
|
||||||
|
members: Member[];
|
||||||
|
availableUsers: User[];
|
||||||
|
availableDisciplines: Discipline[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<ReviewTeamMemberRole, string> = {
|
||||||
|
REVIEWER: 'Reviewer',
|
||||||
|
LEAD: 'Lead',
|
||||||
|
MANAGER: 'Manager',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_BADGE_VARIANT: Record<ReviewTeamMemberRole, 'default' | 'secondary' | 'outline'> = {
|
||||||
|
LEAD: 'default',
|
||||||
|
MANAGER: 'secondary',
|
||||||
|
REVIEWER: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TeamMemberManager({
|
||||||
|
teamPublicId,
|
||||||
|
members,
|
||||||
|
availableUsers,
|
||||||
|
availableDisciplines,
|
||||||
|
}: TeamMemberManagerProps) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState('');
|
||||||
|
const [selectedDiscipline, setSelectedDiscipline] = useState('');
|
||||||
|
const [selectedRole, setSelectedRole] = useState<ReviewTeamMemberRole>('REVIEWER');
|
||||||
|
|
||||||
|
const addMember = useAddTeamMember();
|
||||||
|
const removeMember = useRemoveTeamMember();
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!selectedUser || !selectedDiscipline) return;
|
||||||
|
|
||||||
|
addMember.mutate(
|
||||||
|
{
|
||||||
|
teamPublicId,
|
||||||
|
data: {
|
||||||
|
userPublicId: selectedUser,
|
||||||
|
disciplinePublicId: selectedDiscipline,
|
||||||
|
role: selectedRole,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedUser('');
|
||||||
|
setSelectedDiscipline('');
|
||||||
|
setSelectedRole('REVIEWER');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Member List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{members.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No members assigned yet.</p>
|
||||||
|
)}
|
||||||
|
{members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.publicId}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{member.user?.fullName ?? member.user?.email ?? '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{member.discipline?.disciplineCode} — {member.discipline?.codeNameEn}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={ROLE_BADGE_VARIANT[member.role]}>
|
||||||
|
{ROLE_LABELS[member.role]}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
removeMember.mutate({ teamPublicId, memberPublicId: member.publicId })
|
||||||
|
}
|
||||||
|
disabled={removeMember.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Member Form */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t">
|
||||||
|
<Select value={selectedUser} onValueChange={setSelectedUser}>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select user..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableUsers.map((u) => (
|
||||||
|
<SelectItem key={u.publicId} value={u.publicId}>
|
||||||
|
{u.fullName ?? u.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={selectedDiscipline} onValueChange={setSelectedDiscipline}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue placeholder="Discipline..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableDisciplines.map((d) => (
|
||||||
|
<SelectItem key={d.publicId} value={d.publicId}>
|
||||||
|
{d.disciplineCode}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={selectedRole}
|
||||||
|
onValueChange={(v: string) => setSelectedRole(v as ReviewTeamMemberRole)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(ROLE_LABELS) as ReviewTeamMemberRole[]).map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{ROLE_LABELS[role]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button onClick={handleAdd} disabled={!selectedUser || !selectedDiscipline || addMember.isPending}>
|
||||||
|
<UserPlus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// File: hooks/use-delegation.ts
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import apiClient from '@/lib/api/client';
|
||||||
|
import { getApiErrorMessage } from '@/types/api-error';
|
||||||
|
import { DelegationScope } from '@/types/review-team';
|
||||||
|
|
||||||
|
export interface Delegation {
|
||||||
|
publicId: string;
|
||||||
|
delegatorUserId?: number;
|
||||||
|
delegateUserId?: number;
|
||||||
|
scope: DelegationScope;
|
||||||
|
projectId?: number;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
reason?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
delegate?: {
|
||||||
|
publicId: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDelegationDto {
|
||||||
|
delegateUserPublicId: string;
|
||||||
|
scope: DelegationScope;
|
||||||
|
projectPublicId?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delegationKeys = {
|
||||||
|
all: ['delegations'] as const,
|
||||||
|
mine: () => [...delegationKeys.all, 'mine'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMyDelegations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: delegationKeys.mine(),
|
||||||
|
queryFn: async (): Promise<Delegation[]> => {
|
||||||
|
const res = await apiClient.get('/delegations');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateDelegation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateDelegationDto) => apiClient.post('/delegations', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Delegation created successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: delegationKeys.mine() });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error('Failed to create delegation', {
|
||||||
|
description: getApiErrorMessage(error, 'Something went wrong'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevokeDelegation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (publicId: string) => apiClient.delete(`/delegations/${publicId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Delegation revoked');
|
||||||
|
queryClient.invalidateQueries({ queryKey: delegationKeys.mine() });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error('Failed to revoke delegation', {
|
||||||
|
description: getApiErrorMessage(error, 'Something went wrong'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// File: hooks/use-response-codes.ts
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import apiClient from '@/lib/api/client';
|
||||||
|
import { ResponseCode, ResponseCodeCategory } from '@/types/review-team';
|
||||||
|
|
||||||
|
export const responseCodeKeys = {
|
||||||
|
all: ['responseCodes'] as const,
|
||||||
|
byCategory: (cat: ResponseCodeCategory) => [...responseCodeKeys.all, 'category', cat] as const,
|
||||||
|
byDocType: (docTypeId: number, projectId?: number) =>
|
||||||
|
[...responseCodeKeys.all, 'docType', docTypeId, projectId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useResponseCodes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: responseCodeKeys.all,
|
||||||
|
queryFn: async (): Promise<ResponseCode[]> => {
|
||||||
|
const res = await apiClient.get('/response-codes');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResponseCodesByCategory(category: ResponseCodeCategory) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: responseCodeKeys.byCategory(category),
|
||||||
|
queryFn: async (): Promise<ResponseCode[]> => {
|
||||||
|
const res = await apiClient.get(`/response-codes/category/${category}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResponseCodesByDocType(documentTypeId: number, projectId?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: responseCodeKeys.byDocType(documentTypeId, projectId),
|
||||||
|
queryFn: async (): Promise<ResponseCode[]> => {
|
||||||
|
const res = await apiClient.get(`/response-codes/document-type/${documentTypeId}`, {
|
||||||
|
params: projectId ? { projectId } : undefined,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!documentTypeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// File: hooks/use-review-teams.ts
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getApiErrorMessage } from '@/types/api-error';
|
||||||
|
import {
|
||||||
|
reviewTeamService,
|
||||||
|
CreateReviewTeamDto,
|
||||||
|
UpdateReviewTeamDto,
|
||||||
|
AddTeamMemberDto,
|
||||||
|
SearchReviewTeamDto,
|
||||||
|
} from '@/lib/services/review-team.service';
|
||||||
|
|
||||||
|
// ─── Query Keys ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const reviewTeamKeys = {
|
||||||
|
all: ['reviewTeams'] as const,
|
||||||
|
lists: () => [...reviewTeamKeys.all, 'list'] as const,
|
||||||
|
list: (params: SearchReviewTeamDto) => [...reviewTeamKeys.lists(), params] as const,
|
||||||
|
details: () => [...reviewTeamKeys.all, 'detail'] as const,
|
||||||
|
detail: (publicId: string) => [...reviewTeamKeys.details(), publicId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useReviewTeams(params?: SearchReviewTeamDto) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: reviewTeamKeys.list(params ?? {}),
|
||||||
|
queryFn: () => reviewTeamService.getAll(params),
|
||||||
|
placeholderData: (prev: unknown) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReviewTeam(publicId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: reviewTeamKeys.detail(publicId),
|
||||||
|
queryFn: () => reviewTeamService.getByPublicId(publicId),
|
||||||
|
enabled: !!publicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateReviewTeam() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateReviewTeamDto) => reviewTeamService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Review Team created successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: reviewTeamKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error('Failed to create Review Team', {
|
||||||
|
description: getApiErrorMessage(error, 'Something went wrong'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateReviewTeam() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ publicId, data }: { publicId: string; data: UpdateReviewTeamDto }) =>
|
||||||
|
reviewTeamService.update(publicId, data),
|
||||||
|
onSuccess: (_: unknown, { publicId }: { publicId: string; data: UpdateReviewTeamDto }) => {
|
||||||
|
toast.success('Review Team updated');
|
||||||
|
queryClient.invalidateQueries({ queryKey: reviewTeamKeys.detail(publicId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: reviewTeamKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error('Failed to update Review Team', {
|
||||||
|
description: getApiErrorMessage(error, 'Something went wrong'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddTeamMember() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ teamPublicId, data }: { teamPublicId: string; data: AddTeamMemberDto }) =>
|
||||||
|
reviewTeamService.addMember(teamPublicId, data),
|
||||||
|
onSuccess: (_: unknown, { teamPublicId }: { teamPublicId: string; data: AddTeamMemberDto }) => {
|
||||||
|
toast.success('Member added to team');
|
||||||
|
queryClient.invalidateQueries({ queryKey: reviewTeamKeys.detail(teamPublicId) });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error('Failed to add member', {
|
||||||
|
description: getApiErrorMessage(error, 'Something went wrong'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveTeamMember() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
teamPublicId,
|
||||||
|
memberPublicId,
|
||||||
|
}: {
|
||||||
|
teamPublicId: string;
|
||||||
|
memberPublicId: string;
|
||||||
|
}) => reviewTeamService.removeMember(teamPublicId, memberPublicId),
|
||||||
|
onSuccess: (_: unknown, { teamPublicId }: { teamPublicId: string; memberPublicId: string }) => {
|
||||||
|
toast.success('Member removed from team');
|
||||||
|
queryClient.invalidateQueries({ queryKey: reviewTeamKeys.detail(teamPublicId) });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error('Failed to remove member', {
|
||||||
|
description: getApiErrorMessage(error, 'Something went wrong'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// File: lib/services/review-team.service.ts
|
||||||
|
import apiClient from '@/lib/api/client';
|
||||||
|
|
||||||
|
export interface CreateReviewTeamDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
projectPublicId: string;
|
||||||
|
defaultForRfaTypes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateReviewTeamDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
defaultForRfaTypes?: string[];
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddTeamMemberDto {
|
||||||
|
userPublicId: string;
|
||||||
|
disciplinePublicId: string;
|
||||||
|
role: 'REVIEWER' | 'LEAD' | 'MANAGER';
|
||||||
|
priorityOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchReviewTeamDto {
|
||||||
|
projectPublicId?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reviewTeamService = {
|
||||||
|
/** ดึง Review Teams ตาม project */
|
||||||
|
getAll: async (params?: SearchReviewTeamDto) => {
|
||||||
|
const response = await apiClient.get('/review-teams', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** ดึง Review Team เดียว (ADR-019) */
|
||||||
|
getByPublicId: async (publicId: string) => {
|
||||||
|
const response = await apiClient.get(`/review-teams/${publicId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** สร้าง Review Team */
|
||||||
|
create: async (data: CreateReviewTeamDto) => {
|
||||||
|
const response = await apiClient.post('/review-teams', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** อัปเดต Review Team */
|
||||||
|
update: async (publicId: string, data: UpdateReviewTeamDto) => {
|
||||||
|
const response = await apiClient.patch(`/review-teams/${publicId}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** เพิ่มสมาชิก */
|
||||||
|
addMember: async (teamPublicId: string, data: AddTeamMemberDto) => {
|
||||||
|
const response = await apiClient.post(`/review-teams/${teamPublicId}/members`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** ลบสมาชิก */
|
||||||
|
removeMember: async (teamPublicId: string, memberPublicId: string) => {
|
||||||
|
const response = await apiClient.delete(
|
||||||
|
`/review-teams/${teamPublicId}/members/${memberPublicId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deactivate Review Team */
|
||||||
|
deactivate: async (publicId: string) => {
|
||||||
|
const response = await apiClient.delete(`/review-teams/${publicId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// File: frontend/tests/components/ResponseCodeSelector.test.tsx
|
||||||
|
// Unit tests สำหรับ ResponseCodeSelector component (T078)
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ResponseCodeSelector } from '@/components/response-code/ResponseCodeSelector';
|
||||||
|
|
||||||
|
const mockCodes = [
|
||||||
|
{
|
||||||
|
publicId: 'uuid-1',
|
||||||
|
code: '1A',
|
||||||
|
category: 'ENGINEERING',
|
||||||
|
descriptionEn: 'Approved — No Comments',
|
||||||
|
descriptionTh: 'ผ่าน — ไม่มีเงื่อนไข',
|
||||||
|
implications: {},
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
publicId: 'uuid-2',
|
||||||
|
code: '2',
|
||||||
|
category: 'ENGINEERING',
|
||||||
|
descriptionEn: 'Approved with Comments',
|
||||||
|
descriptionTh: 'ผ่าน — มีเงื่อนไข',
|
||||||
|
implications: { affectsSchedule: true },
|
||||||
|
notifyRoles: ['CONTRACT_MANAGER'],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
publicId: 'uuid-3',
|
||||||
|
code: '3',
|
||||||
|
category: 'ENGINEERING',
|
||||||
|
descriptionEn: 'Rejected',
|
||||||
|
descriptionTh: 'ไม่ผ่าน',
|
||||||
|
implications: {},
|
||||||
|
notifyRoles: [],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ResponseCodeSelector', () => {
|
||||||
|
it('should render code options', () => {
|
||||||
|
const onSelect = jest.fn();
|
||||||
|
render(<ResponseCodeSelector codes={mockCodes} onSelect={onSelect} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('1A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSelect when a code is clicked', () => {
|
||||||
|
const onSelect = jest.fn();
|
||||||
|
render(<ResponseCodeSelector codes={mockCodes} onSelect={onSelect} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('1A'));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('uuid-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should highlight selected code', () => {
|
||||||
|
const onSelect = jest.fn();
|
||||||
|
render(
|
||||||
|
<ResponseCodeSelector
|
||||||
|
codes={mockCodes}
|
||||||
|
onSelect={onSelect}
|
||||||
|
selectedPublicId="uuid-2"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedButton = screen.getByRole('button', { name: /2/i });
|
||||||
|
expect(selectedButton).toHaveClass('ring-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// File: types/review-team.ts
|
||||||
|
// Types สำหรับ Review Team feature
|
||||||
|
|
||||||
|
export type ReviewTeamMemberRole = 'REVIEWER' | 'LEAD' | 'MANAGER';
|
||||||
|
|
||||||
|
export type DelegationScope = 'ALL' | 'RFA_ONLY' | 'CORRESPONDENCE_ONLY' | 'SPECIFIC_TYPES';
|
||||||
|
|
||||||
|
export type ReviewTaskStatus =
|
||||||
|
| 'PENDING'
|
||||||
|
| 'IN_PROGRESS'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'DELEGATED'
|
||||||
|
| 'EXPIRED'
|
||||||
|
| 'CANCELLED';
|
||||||
|
|
||||||
|
export type ResponseCodeCategory =
|
||||||
|
| 'ENGINEERING'
|
||||||
|
| 'MATERIAL'
|
||||||
|
| 'CONTRACT'
|
||||||
|
| 'TESTING'
|
||||||
|
| 'ESG';
|
||||||
|
|
||||||
|
export interface ReviewTeam {
|
||||||
|
publicId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
projectId?: number;
|
||||||
|
defaultForRfaTypes?: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
members?: ReviewTeamMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewTeamMember {
|
||||||
|
publicId: string;
|
||||||
|
teamId?: number;
|
||||||
|
userId?: number;
|
||||||
|
disciplineId?: number;
|
||||||
|
role: ReviewTeamMemberRole;
|
||||||
|
priorityOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
user?: {
|
||||||
|
publicId: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
discipline?: {
|
||||||
|
publicId: string;
|
||||||
|
disciplineCode: string;
|
||||||
|
codeNameEn?: string;
|
||||||
|
codeNameTh?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewTask {
|
||||||
|
publicId: string;
|
||||||
|
rfaRevisionId?: number;
|
||||||
|
teamId?: number;
|
||||||
|
disciplineId?: number;
|
||||||
|
assignedToUserId?: number;
|
||||||
|
status: ReviewTaskStatus;
|
||||||
|
dueDate?: string;
|
||||||
|
responseCodeId?: number;
|
||||||
|
comments?: string;
|
||||||
|
attachments?: string[];
|
||||||
|
completedAt?: string;
|
||||||
|
version: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
team?: Pick<ReviewTeam, 'publicId' | 'name'>;
|
||||||
|
responseCode?: ResponseCode;
|
||||||
|
discipline?: ReviewTeamMember['discipline'];
|
||||||
|
assignedToUser?: ReviewTeamMember['user'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseCode {
|
||||||
|
publicId: string;
|
||||||
|
code: string;
|
||||||
|
subStatus?: string;
|
||||||
|
category: ResponseCodeCategory;
|
||||||
|
descriptionTh: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
implications?: {
|
||||||
|
affectsSchedule?: boolean;
|
||||||
|
affectsCost?: boolean;
|
||||||
|
requiresContractReview?: boolean;
|
||||||
|
requiresEiaAmendment?: boolean;
|
||||||
|
};
|
||||||
|
notifyRoles?: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewTaskAggregateStatus {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- LCBP3-DMS v1.9.0 — RFA Approval System Refactor Schema
|
||||||
|
-- Feature Branch: 1-rfa-approval-refactor
|
||||||
|
-- ADR-009: No TypeORM migrations — edit SQL schema directly
|
||||||
|
-- Created: 2026-05-12
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. review_teams — ทีมตรวจสอบแยกตาม Discipline
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `review_teams` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` UUID NOT NULL DEFAULT (UUID()),
|
||||||
|
`project_id` INT NOT NULL,
|
||||||
|
`name` VARCHAR(100) NOT NULL,
|
||||||
|
`description` VARCHAR(255) NULL,
|
||||||
|
`default_for_rfa_types` TEXT NULL COMMENT 'Comma-separated RFA type codes e.g. SDW,DDW',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_review_teams_uuid` (`uuid`),
|
||||||
|
KEY `idx_review_teams_project` (`project_id`, `is_active`),
|
||||||
|
CONSTRAINT `fk_review_teams_project` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. review_team_members — สมาชิกในทีมแยกตาม Discipline
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `review_team_members` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` UUID NOT NULL DEFAULT (UUID()),
|
||||||
|
`team_id` INT NOT NULL,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`discipline_id` INT NOT NULL,
|
||||||
|
`role` ENUM('REVIEWER','LEAD','MANAGER') NOT NULL DEFAULT 'REVIEWER',
|
||||||
|
`priority_order` INT NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_review_team_members_uuid` (`uuid`),
|
||||||
|
UNIQUE KEY `uq_team_user_discipline` (`team_id`, `user_id`, `discipline_id`),
|
||||||
|
KEY `idx_rtm_team` (`team_id`),
|
||||||
|
KEY `idx_rtm_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_rtm_team` FOREIGN KEY (`team_id`) REFERENCES `review_teams` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_rtm_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_rtm_discipline` FOREIGN KEY (`discipline_id`) REFERENCES `disciplines` (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. response_codes — รหัสตอบกลับมาตรฐาน (Master Approval Matrix)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `response_codes` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` UUID NOT NULL DEFAULT (UUID()),
|
||||||
|
`code` VARCHAR(10) NOT NULL COMMENT '1A, 1B, 1C, 1D, 1E, 1F, 1G, 2, 3, 4',
|
||||||
|
`sub_status` VARCHAR(10) NULL,
|
||||||
|
`category` ENUM('ENGINEERING','MATERIAL','CONTRACT','TESTING','ESG') NOT NULL,
|
||||||
|
`description_th` TEXT NOT NULL,
|
||||||
|
`description_en` TEXT NOT NULL,
|
||||||
|
`implications` JSON NULL COMMENT '{"affectsSchedule":bool,"affectsCost":bool,"requiresContractReview":bool}',
|
||||||
|
`notify_roles` TEXT NULL COMMENT 'Comma-separated roles e.g. CONTRACT_MANAGER,QS_MANAGER',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'System default — cannot delete',
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_response_codes_uuid` (`uuid`),
|
||||||
|
UNIQUE KEY `uq_response_code_category` (`code`, `category`),
|
||||||
|
KEY `idx_rc_category_active` (`category`, `is_active`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. response_code_rules — กฎการใช้รหัสต่อโครงการ/ประเภทเอกสาร
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `response_code_rules` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` UUID NOT NULL DEFAULT (UUID()),
|
||||||
|
`project_id` INT NULL COMMENT 'NULL = global default',
|
||||||
|
`document_type_id` INT NOT NULL,
|
||||||
|
`response_code_id` INT NOT NULL,
|
||||||
|
`is_enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`requires_comments` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`triggers_notification` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`parent_rule_id` INT NULL COMMENT 'For inheritance tracking',
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_response_code_rules_uuid` (`uuid`),
|
||||||
|
UNIQUE KEY `uq_rule_per_project_doctype_code` (`project_id`, `document_type_id`, `response_code_id`),
|
||||||
|
KEY `idx_response_rules_lookup` (`project_id`, `document_type_id`, `is_enabled`),
|
||||||
|
CONSTRAINT `fk_rcr_response_code` FOREIGN KEY (`response_code_id`) REFERENCES `response_codes` (`id`),
|
||||||
|
CONSTRAINT `fk_rcr_parent` FOREIGN KEY (`parent_rule_id`) REFERENCES `response_code_rules` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 5. review_tasks — งานตรวจสอบสำหรับแต่ละ Discipline (Parallel Review)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `review_tasks` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` UUID NOT NULL DEFAULT (UUID()),
|
||||||
|
`rfa_revision_id` INT NOT NULL,
|
||||||
|
`team_id` INT NOT NULL,
|
||||||
|
`discipline_id` INT NOT NULL,
|
||||||
|
`assigned_to_user_id` INT NULL COMMENT 'NULL = auto-assign by discipline',
|
||||||
|
`status` ENUM('PENDING','IN_PROGRESS','COMPLETED','DELEGATED','EXPIRED','CANCELLED') NOT NULL DEFAULT 'PENDING',
|
||||||
|
`due_date` DATE NULL,
|
||||||
|
`response_code_id` INT NULL,
|
||||||
|
`comments` TEXT NULL,
|
||||||
|
`attachments` JSON NULL COMMENT 'Array of attachment publicIds',
|
||||||
|
`delegated_from_user_id` INT NULL COMMENT 'Original assignee when delegated',
|
||||||
|
`completed_at` TIMESTAMP NULL,
|
||||||
|
`version` INT NOT NULL DEFAULT 1 COMMENT 'Optimistic locking (ADR-002)',
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_review_tasks_uuid` (`uuid`),
|
||||||
|
UNIQUE KEY `uq_review_task_per_revision_discipline` (`rfa_revision_id`, `team_id`, `discipline_id`),
|
||||||
|
KEY `idx_review_tasks_rfa_revision` (`rfa_revision_id`),
|
||||||
|
KEY `idx_review_tasks_status` (`status`),
|
||||||
|
KEY `idx_review_tasks_assigned` (`assigned_to_user_id`, `status`),
|
||||||
|
CONSTRAINT `fk_rt_rfa_revision` FOREIGN KEY (`rfa_revision_id`) REFERENCES `rfa_revisions` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_rt_team` FOREIGN KEY (`team_id`) REFERENCES `review_teams` (`id`),
|
||||||
|
CONSTRAINT `fk_rt_discipline` FOREIGN KEY (`discipline_id`) REFERENCES `disciplines` (`id`),
|
||||||
|
CONSTRAINT `fk_rt_user` FOREIGN KEY (`assigned_to_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `fk_rt_response_code` FOREIGN KEY (`response_code_id`) REFERENCES `response_codes` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 6. delegations — การมอบหมายงาน
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `delegations` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` UUID NOT NULL DEFAULT (UUID()),
|
||||||
|
`delegator_id` INT NOT NULL COMMENT 'ผู้มอบหมาย',
|
||||||
|
`delegatee_id` INT NOT NULL COMMENT 'ผู้รับมอบหมาย',
|
||||||
|
`start_date` DATE NOT NULL,
|
||||||
|
`end_date` DATE NULL,
|
||||||
|
`scope` ENUM('ALL','RFA_ONLY','CORRESPONDENCE_ONLY','SPECIFIC_TYPES') NOT NULL DEFAULT 'ALL',
|
||||||
|
`document_types` TEXT NULL COMMENT 'Comma-separated doc type codes when scope=SPECIFIC_TYPES',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`reason` TEXT NULL,
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_delegations_uuid` (`uuid`),
|
||||||
|
KEY `idx_delegations_active` (`delegator_id`, `is_active`, `start_date`, `end_date`),
|
||||||
|
KEY `idx_delegations_delegatee` (`delegatee_id`, `is_active`),
|
||||||
|
CONSTRAINT `fk_del_delegator` FOREIGN KEY (`delegator_id`) REFERENCES `users` (`id`),
|
||||||
|
CONSTRAINT `fk_del_delegatee` FOREIGN KEY (`delegatee_id`) REFERENCES `users` (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 7. reminder_rules — กฎการแจ้งเตือน
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `reminder_rules` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` UUID NOT NULL DEFAULT (UUID()),
|
||||||
|
`name` VARCHAR(100) NOT NULL,
|
||||||
|
`project_id` INT NULL COMMENT 'NULL = global',
|
||||||
|
`document_type_id` INT NULL COMMENT 'NULL = all types',
|
||||||
|
`trigger_days_before_due` INT NOT NULL DEFAULT 2,
|
||||||
|
`escalation_days_after_due` INT NOT NULL DEFAULT 1,
|
||||||
|
`reminder_type` ENUM('DUE_SOON','ON_DUE','OVERDUE','ESCALATION_L1','ESCALATION_L2') NOT NULL,
|
||||||
|
`recipients` TEXT NOT NULL COMMENT 'Comma-separated: ASSIGNEE,MANAGER,PROJECT_MANAGER',
|
||||||
|
`message_template_th` TEXT NOT NULL,
|
||||||
|
`message_template_en` TEXT NOT NULL,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_reminder_rules_uuid` (`uuid`),
|
||||||
|
KEY `idx_reminder_rules_active` (`is_active`, `project_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 8. distribution_matrices — ตารางกระจายเอกสาร
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `distribution_matrices` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`uuid` UUID NOT NULL DEFAULT (UUID()),
|
||||||
|
`name` VARCHAR(100) NOT NULL,
|
||||||
|
`project_id` INT NULL COMMENT 'NULL = global',
|
||||||
|
`document_type_id` INT NOT NULL,
|
||||||
|
`response_code_id` INT NULL COMMENT 'NULL = applies to all codes',
|
||||||
|
`conditions` JSON NULL COMMENT '{"codes":["1A","1B"],"excludeCodes":["3","4"]}',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_distribution_matrices_uuid` (`uuid`),
|
||||||
|
KEY `idx_distribution_lookup` (`document_type_id`, `response_code_id`, `is_active`),
|
||||||
|
CONSTRAINT `fk_dm_response_code` FOREIGN KEY (`response_code_id`) REFERENCES `response_codes` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 9. distribution_recipients — ผู้รับเอกสารใน Distribution Matrix
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `distribution_recipients` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`matrix_id` INT NOT NULL,
|
||||||
|
`recipient_type` ENUM('USER','ORGANIZATION','TEAM','ROLE') NOT NULL,
|
||||||
|
`recipient_public_id` UUID NOT NULL COMMENT 'publicId of user/org/team',
|
||||||
|
`delivery_method` ENUM('EMAIL','IN_APP','BOTH') NOT NULL DEFAULT 'BOTH',
|
||||||
|
`sequence` INT NULL COMMENT 'For ordered delivery',
|
||||||
|
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_dr_matrix` (`matrix_id`),
|
||||||
|
CONSTRAINT `fk_dr_matrix` FOREIGN KEY (`matrix_id`) REFERENCES `distribution_matrices` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Additional Indexes (Performance)
|
||||||
|
-- =============================================================================
|
||||||
|
-- (all created inline above with KEY statements)
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- END OF SCHEMA v1.9.0
|
||||||
|
-- =============================================================================
|
||||||
@@ -268,7 +268,60 @@ mysql -u root -p lcbp3 -e "SELECT COUNT(*) FROM response_codes;"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Next Steps
|
## 8. Running Tests
|
||||||
|
|
||||||
|
### 8.1 Backend Unit + Integration Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Unit tests only (runs from src/)
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Tests in tests/ directory (Phase 9 stubs)
|
||||||
|
npx jest --rootDir . --testMatch '**/tests/**/*.spec.ts' --passWithNoTests
|
||||||
|
|
||||||
|
# Coverage report
|
||||||
|
npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Frontend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm test -- --watchAll=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 E2E Tests (requires seeded database)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. New Phase 9 Components
|
||||||
|
|
||||||
|
| Component | Path | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `AggregateStatusService` | `backend/src/modules/review-team/services/` | Consensus % calculator |
|
||||||
|
| `ConsensusService` | `backend/src/modules/review-team/services/` | Triggers distribution after all tasks complete |
|
||||||
|
| `VetoOverrideService` | `backend/src/modules/review-team/services/` | PM force-approve with audit trail |
|
||||||
|
| `ParallelGatewayHandler` | `backend/src/modules/workflow-engine/dsl/` | DSL support for parallel review |
|
||||||
|
| `ReviewTaskInbox` | `frontend/components/review-task/` | Reviewer inbox with status filters |
|
||||||
|
| `ParallelProgress` | `frontend/components/review-task/` | Discipline track progress bar |
|
||||||
|
| `VetoOverrideDialog` | `frontend/components/review-task/` | PM override dialog with justification |
|
||||||
|
|
||||||
|
### New Admin URLs
|
||||||
|
|
||||||
|
- **Distribution Matrix**: http://localhost:3000/dashboard/admin/distribution-matrices
|
||||||
|
- **Reminder Rules**: http://localhost:3000/dashboard/admin/reminder-rules
|
||||||
|
- **Master Approval Matrix**: http://localhost:3000/dashboard/admin/approval-matrix
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Next Steps
|
||||||
|
|
||||||
1. **Run Tests**: `npm test` (backend), `npm run test:e2e` (frontend)
|
1. **Run Tests**: `npm test` (backend), `npm run test:e2e` (frontend)
|
||||||
2. **Load Test**: `k6 run load-tests/rfa-approval-load.js`
|
2. **Load Test**: `k6 run load-tests/rfa-approval-load.js`
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ Initialize project structure and shared infrastructure for all modules.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T001 [P] Create SQL schema file `specs/03-Data-and-Storage/lcbp3-v1.9.0-rfa-approval-schema.sql` with all 9 new entities
|
- [x] T001 [P] Create SQL schema file `specs/03-Data-and-Storage/lcbp3-v1.9.0-rfa-approval-schema.sql` with all 9 new entities
|
||||||
- [ ] T002 [P] Create Response Code seeder `backend/src/modules/response-code/seeders/response-code.seed.ts`
|
- [x] T002 [P] Create Response Code seeder `backend/src/modules/response-code/seeders/response-code.seed.ts`
|
||||||
- [ ] T003 Create BullMQ queue configuration `backend/src/config/bullmq.config.ts`
|
- [x] T003 Create BullMQ queue configuration `backend/src/config/bullmq.config.ts`
|
||||||
- [ ] T004 [P] Setup Redis connection for BullMQ and Redlock `backend/src/config/redis.config.ts`
|
- [x] T004 [P] Setup Redis connection for BullMQ and Redlock `backend/src/config/redis.config.ts`
|
||||||
- [ ] T005 Create shared DTOs and enums `backend/src/modules/review-team/dto/shared/` (ReviewTaskStatus, ResponseCodeCategory, etc.)
|
- [x] T005 Create shared DTOs and enums `backend/src/modules/review-team/dto/shared/` (ReviewTaskStatus, ResponseCodeCategory, etc.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,14 +32,14 @@ Core entities required by multiple user stories. Must complete before US1-US6.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T006 [P] Create ReviewTeam entity `backend/src/modules/review-team/entities/review-team.entity.ts`
|
- [x] T006 [P] Create ReviewTeam entity `backend/src/modules/review-team/entities/review-team.entity.ts`
|
||||||
- [ ] T007 [P] Create ReviewTeamMember entity `backend/src/modules/review-team/entities/review-team-member.entity.ts`
|
- [x] T007 [P] Create ReviewTeamMember entity `backend/src/modules/review-team/entities/review-team-member.entity.ts`
|
||||||
- [ ] T008 Create ResponseCode entity `backend/src/modules/response-code/entities/response-code.entity.ts`
|
- [x] T008 Create ResponseCode entity `backend/src/modules/response-code/entities/response-code.entity.ts`
|
||||||
- [ ] T009 [P] Create ResponseCodeRule entity `backend/src/modules/response-code/entities/response-code-rule.entity.ts`
|
- [x] T009 [P] Create ResponseCodeRule entity `backend/src/modules/response-code/entities/response-code-rule.entity.ts`
|
||||||
- [ ] T010 [P] Create ReviewTask entity `backend/src/modules/review-team/entities/review-task.entity.ts`
|
- [x] T010 [P] Create ReviewTask entity `backend/src/modules/review-team/entities/review-task.entity.ts`
|
||||||
- [ ] T011 Create ResponseCodeModule with service `backend/src/modules/response-code/response-code.service.ts`
|
- [x] T011 Create ResponseCodeModule with service `backend/src/modules/response-code/response-code.service.ts`
|
||||||
- [ ] T012 Create ResponseCodeController with basic CRUD `backend/src/modules/response-code/response-code.controller.ts`
|
- [x] T012 Create ResponseCodeController with basic CRUD `backend/src/modules/response-code/response-code.controller.ts`
|
||||||
- [ ] T013 Create ReviewTeamModule base structure `backend/src/modules/review-team/review-team.module.ts`
|
- [x] T013 Create ReviewTeamModule base structure `backend/src/modules/review-team/review-team.module.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,16 +55,16 @@ Users can create Review Teams with multiple Disciplines, and teams auto-assign t
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T014 [US1] Create ReviewTeamService with CRUD and member management `backend/src/modules/review-team/review-team.service.ts`
|
- [x] T014 [US1] Create ReviewTeamService with CRUD and member management `backend/src/modules/review-team/review-team.service.ts`
|
||||||
- [ ] T015 [P] [US1] Create ReviewTeamController endpoints `backend/src/modules/review-team/review-team.controller.ts`
|
- [x] T015 [P] [US1] Create ReviewTeamController endpoints `backend/src/modules/review-team/review-team.controller.ts`
|
||||||
- [ ] T016 [US1] Create ReviewTaskService with assignment logic `backend/src/modules/review-team/review-task.service.ts`
|
- [x] T016 [US1] Create ReviewTaskService with assignment logic `backend/src/modules/review-team/review-task.service.ts`
|
||||||
- [ ] T017 [P] [US1] Integrate Review Team selection in RFA submission flow `backend/src/modules/rfa/rfa.service.ts`
|
- [x] T017 [P] [US1] Integrate Review Team selection in RFA submission flow `backend/src/modules/rfa/rfa.service.ts`
|
||||||
- [ ] T018 [US1] Implement parallel task creation on RFA submit `backend/src/modules/review-team/services/task-creation.service.ts`
|
- [x] T018 [US1] Implement parallel task creation on RFA submit `backend/src/modules/review-team/services/task-creation.service.ts`
|
||||||
- [ ] T019 [P] [US1] Create Review Team management UI page `frontend/src/app/(dashboard)/review-teams/page.tsx`
|
- [x] T019 [P] [US1] Create Review Team management UI page `frontend/src/app/(dashboard)/review-teams/page.tsx`
|
||||||
- [ ] T020 [P] [US1] Create Review Team form component `frontend/src/components/review-team/ReviewTeamForm.tsx`
|
- [x] T020 [P] [US1] Create Review Team form component `frontend/src/components/review-team/ReviewTeamForm.tsx`
|
||||||
- [ ] T021 [US1] Create Team Member assignment component `frontend/src/components/review-team/TeamMemberManager.tsx`
|
- [x] T021 [US1] Create Team Member assignment component `frontend/src/components/review-team/TeamMemberManager.tsx`
|
||||||
- [ ] T022 [P] [US1] Create useReviewTeams hook `frontend/src/hooks/use-review-teams.ts`
|
- [x] T022 [P] [US1] Create useReviewTeams hook `frontend/src/hooks/use-review-teams.ts`
|
||||||
- [ ] T023 [US1] Add Review Team selector to RFA submission form `frontend/src/app/(dashboard)/rfa/[id]/submit/page.tsx`
|
- [x] T023 [US1] Add Review Team selector to RFA submission form `frontend/src/app/(dashboard)/rfa/[id]/submit/page.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -80,16 +80,16 @@ Response Codes display by document category, Code 1C/1D/3 trigger notifications,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T024 [US2] Extend ResponseCodeService with category filtering `backend/src/modules/response-code/response-code.service.ts`
|
- [x] T024 [US2] Extend ResponseCodeService with category filtering `backend/src/modules/response-code/response-code.service.ts`
|
||||||
- [ ] T025 [P] [US2] Create ResponseCode lookup endpoint by document type `backend/src/modules/response-code/response-code.controller.ts`
|
- [x] T025 [P] [US2] Create ResponseCode lookup endpoint by document type `backend/src/modules/response-code/response-code.controller.ts`
|
||||||
- [ ] T026 [US2] Implement Response Code implications evaluator `backend/src/modules/response-code/services/implications.service.ts`
|
- [x] T026 [US2] Implement Response Code implications evaluator `backend/src/modules/response-code/services/implications.service.ts`
|
||||||
- [ ] T027 [P] [US2] Create notification trigger service for critical codes `backend/src/modules/response-code/services/notification-trigger.service.ts`
|
- [x] T027 [P] [US2] Create notification trigger service for critical codes `backend/src/modules/response-code/services/notification-trigger.service.ts`
|
||||||
- [ ] T028 [US2] Add audit logging for Response Code changes `backend/src/modules/response-code/services/audit.service.ts`
|
- [x] T028 [US2] Add audit logging for Response Code changes `backend/src/modules/response-code/services/audit.service.ts`
|
||||||
- [ ] T029 [P] [US2] Create Response Code selector component with category filtering `frontend/src/components/response-code/ResponseCodeSelector.tsx`
|
- [x] T029 [P] [US2] Create Response Code selector component with category filtering `frontend/src/components/response-code/ResponseCodeSelector.tsx`
|
||||||
- [ ] T030 [US2] Create Response Code implications display `frontend/src/components/response-code/CodeImplications.tsx`
|
- [x] T030 [US2] Create Response Code implications display `frontend/src/components/response-code/CodeImplications.tsx`
|
||||||
- [ ] T031 [P] [US2] Create Master Approval Matrix admin UI `frontend/src/app/(dashboard)/response-codes/page.tsx`
|
- [x] T031 [P] [US2] Create Master Approval Matrix admin UI `frontend/src/app/(dashboard)/response-codes/page.tsx`
|
||||||
- [ ] T032 [US2] Create useResponseCodes hook with category filter `frontend/src/hooks/use-response-codes.ts`
|
- [x] T032 [US2] Create useResponseCodes hook with category filter `frontend/src/hooks/use-response-codes.ts`
|
||||||
- [ ] T033 [P] [US2] Integrate Response Code selector in Review Task completion UI `frontend/src/components/review-task/CompleteReviewForm.tsx`
|
- [x] T033 [P] [US2] Integrate Response Code selector in Review Task completion UI `frontend/src/components/review-task/CompleteReviewForm.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -105,15 +105,15 @@ Users can delegate review tasks with date range, circular detection prevents loo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T034 [US3] Create Delegation entity `backend/src/modules/delegation/entities/delegation.entity.ts`
|
- [x] T034 [US3] Create Delegation entity `backend/src/modules/delegation/entities/delegation.entity.ts`
|
||||||
- [ ] T035 [P] [US3] Create DelegationService with CRUD `backend/src/modules/delegation/delegation.service.ts`
|
- [x] T035 [P] [US3] Create DelegationService with CRUD `backend/src/modules/delegation/delegation.service.ts`
|
||||||
- [ ] T036 [US3] Implement circular delegation detection algorithm `backend/src/modules/delegation/services/circular-detection.service.ts`
|
- [x] T036 [US3] Implement circular delegation detection algorithm `backend/src/modules/delegation/services/circular-detection.service.ts`
|
||||||
- [ ] T037 [P] [US3] Create DelegationController endpoints `backend/src/modules/delegation/delegation.controller.ts`
|
- [x] T037 [P] [US3] Create DelegationController endpoints `backend/src/modules/delegation/delegation.controller.ts`
|
||||||
- [ ] T038 [US3] Integrate delegation resolution in ReviewTaskService `backend/src/modules/review-team/review-task.service.ts`
|
- [x] T038 [US3] Integrate delegation resolution in ReviewTaskService `backend/src/modules/review-team/review-task.service.ts`
|
||||||
- [ ] T039 [P] [US3] Create Delegation settings UI page `frontend/src/app/(dashboard)/delegation/page.tsx`
|
- [x] T039 [P] [US3] Create Delegation settings UI page `frontend/src/app/(dashboard)/delegation/page.tsx`
|
||||||
- [ ] T040 [US3] Create Delegation form with date picker `frontend/src/components/delegation/DelegationForm.tsx`
|
- [x] T040 [US3] Create Delegation form with date picker `frontend/src/components/delegation/DelegationForm.tsx`
|
||||||
- [ ] T041 [P] [US3] Create delegated task indicator ("Delegated from X") `frontend/src/components/review-task/DelegatedBadge.tsx`
|
- [x] T041 [P] [US3] Create delegated task indicator ("Delegated from X") `frontend/src/components/review-task/DelegatedBadge.tsx`
|
||||||
- [ ] T042 [P] [US3] Create useDelegation hook `frontend/src/hooks/use-delegation.ts`
|
- [x] T042 [P] [US3] Create useDelegation hook `frontend/src/hooks/use-delegation.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,14 +129,14 @@ Scheduled reminders via BullMQ, 2-level escalation when overdue.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T043 [US4] Create ReminderRule entity `backend/src/modules/reminder/entities/reminder-rule.entity.ts`
|
- [x] T043 [US4] Create ReminderRule entity `backend/src/modules/reminder/entities/reminder-rule.entity.ts`
|
||||||
- [ ] T044 [P] [US4] Create ReminderService with BullMQ integration `backend/src/modules/reminder/reminder.service.ts`
|
- [x] T044 [P] [US4] Create ReminderService with BullMQ integration `backend/src/modules/reminder/reminder.service.ts`
|
||||||
- [ ] T045 [US4] Implement reminder scheduling on RFA submit `backend/src/modules/reminder/services/scheduler.service.ts`
|
- [x] T045 [US4] Implement reminder scheduling on RFA submit `backend/src/modules/reminder/services/scheduler.service.ts`
|
||||||
- [ ] T046 [P] [US4] Create ReminderProcessor for queue workers `backend/src/modules/reminder/processors/reminder.processor.ts`
|
- [x] T046 [P] [US4] Create ReminderProcessor for queue workers `backend/src/modules/reminder/processors/reminder.processor.ts`
|
||||||
- [ ] T047 [US4] Implement 2-level escalation logic `backend/src/modules/reminder/services/escalation.service.ts`
|
- [x] T047 [US4] Implement 2-level escalation logic `backend/src/modules/reminder/services/escalation.service.ts`
|
||||||
- [ ] T048 [P] [US4] Create ReminderRuleController admin endpoints `backend/src/modules/reminder/reminder.controller.ts`
|
- [x] T048 [P] [US4] Create ReminderRuleController admin endpoints `backend/src/modules/reminder/reminder.controller.ts`
|
||||||
- [ ] T049 [P] [US4] Create ReminderRule admin UI `frontend/src/app/(dashboard)/reminder-rules/page.tsx`
|
- [x] T049 [P] [US4] Create ReminderRule admin UI `frontend/src/app/(dashboard)/reminder-rules/page.tsx`
|
||||||
- [ ] T050 [US4] Create reminder history viewer `frontend/src/components/reminder/ReminderHistory.tsx`
|
- [x] T050 [US4] Create reminder history viewer `frontend/src/components/reminder/ReminderHistory.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,16 +152,16 @@ Async distribution after approval, Transmittal records created via BullMQ.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T051 [US5] Create DistributionMatrix entity `backend/src/modules/distribution/entities/distribution-matrix.entity.ts`
|
- [x] T051 [US5] Create DistributionMatrix entity `backend/src/modules/distribution/entities/distribution-matrix.entity.ts`
|
||||||
- [ ] T052 [P] [US5] Create DistributionRecipient entity `backend/src/modules/distribution/entities/distribution-recipient.entity.ts`
|
- [x] T052 [P] [US5] Create DistributionRecipient entity `backend/src/modules/distribution/entities/distribution-recipient.entity.ts`
|
||||||
- [ ] T053 [US5] Create DistributionMatrixService with CRUD `backend/src/modules/distribution/distribution-matrix.service.ts`
|
- [x] T053 [US5] Create DistributionMatrixService with CRUD `backend/src/modules/distribution/distribution-matrix.service.ts`
|
||||||
- [ ] T054 [P] [US5] Create DistributionService with BullMQ integration `backend/src/modules/distribution/distribution.service.ts`
|
- [x] T054 [P] [US5] Create DistributionService with BullMQ integration `backend/src/modules/distribution/distribution.service.ts`
|
||||||
- [ ] T055 [US5] Implement distribution triggering on approval `backend/src/modules/distribution/services/approval-listener.service.ts`
|
- [x] T055 [US5] Implement distribution triggering on approval `backend/src/modules/distribution/services/approval-listener.service.ts`
|
||||||
- [ ] T056 [P] [US5] Create DistributionProcessor for queue workers `backend/src/modules/distribution/processors/distribution.processor.ts`
|
- [x] T056 [P] [US5] Create DistributionProcessor for queue workers `backend/src/modules/distribution/processors/distribution.processor.ts`
|
||||||
- [ ] T057 [US5] Create Transmittal records from distribution `backend/src/modules/distribution/services/transmittal-creator.service.ts`
|
- [x] T057 [US5] Create Transmittal records from distribution `backend/src/modules/distribution/services/transmittal-creator.service.ts`
|
||||||
- [ ] T058 [P] [US5] Create DistributionMatrixController `backend/src/modules/distribution/distribution.controller.ts`
|
- [x] T058 [P] [US5] Create DistributionMatrixController `backend/src/modules/distribution/distribution.controller.ts`
|
||||||
- [ ] T059 [P] [US5] Create Distribution Matrix admin UI `frontend/src/app/(dashboard)/distribution-matrices/page.tsx`
|
- [x] T059 [P] [US5] Create Distribution Matrix admin UI `frontend/src/app/(dashboard)/distribution-matrices/page.tsx`
|
||||||
- [ ] T060 [US5] Create distribution status dashboard `frontend/src/components/distribution/DistributionStatus.tsx`
|
- [x] T060 [US5] Create distribution status dashboard `frontend/src/components/distribution/DistributionStatus.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,11 +177,11 @@ Admin UI for managing Matrix, project overrides with inheritance tracking.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T061 [US6] Extend ResponseCodeService with project overrides `backend/src/modules/response-code/services/matrix-management.service.ts`
|
- [x] T061 [US6] Extend ResponseCodeService with project overrides `backend/src/modules/response-code/services/matrix-management.service.ts`
|
||||||
- [ ] T062 [P] [US6] Create Matrix inheritance resolver `backend/src/modules/response-code/services/inheritance.service.ts`
|
- [x] T062 [P] [US6] Create Matrix inheritance resolver `backend/src/modules/response-code/services/inheritance.service.ts`
|
||||||
- [ ] T063 [US6] Add Matrix management endpoints to ResponseCodeController `backend/src/modules/response-code/response-code.controller.ts`
|
- [x] T063 [US6] Add Matrix management endpoints to ResponseCodeController `backend/src/modules/response-code/response-code.controller.ts`
|
||||||
- [ ] T064 [P] [US6] Create Master Approval Matrix visual editor `frontend/src/components/response-code/MatrixEditor.tsx`
|
- [x] T064 [P] [US6] Create Master Approval Matrix visual editor `frontend/src/components/response-code/MatrixEditor.tsx`
|
||||||
- [ ] T065 [US6] Create project override management UI `frontend/src/components/response-code/ProjectOverrideManager.tsx`
|
- [x] T065 [US6] Create project override management UI `frontend/src/components/response-code/ProjectOverrideManager.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -196,23 +196,23 @@ Workflow Engine integration, aggregate status, edge case handling, testing.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] T066 Extend WorkflowEngine DSL with Parallel Gateway support `backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts`
|
- [x] T066 Extend WorkflowEngine DSL with Parallel Gateway support `backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts`
|
||||||
- [ ] T067 [P] Implement Review Task aggregate status calculator `backend/src/modules/review-team/services/aggregate-status.service.ts`
|
- [x] T067 [P] Implement Review Task aggregate status calculator `backend/src/modules/review-team/services/aggregate-status.service.ts`
|
||||||
- [ ] T068 [P] Create consensus evaluation service `backend/src/modules/review-team/services/consensus.service.ts`
|
- [x] T068 [P] Create consensus evaluation service `backend/src/modules/review-team/services/consensus.service.ts`
|
||||||
- [ ] T068.5 Implement Veto Override for Project Manager `backend/src/modules/review-team/services/veto-override.service.ts` - พร้อม audit trail และ notification
|
- [x] T068.5 Implement Veto Override for Project Manager `backend/src/modules/review-team/services/veto-override.service.ts` - พร้อม audit trail และ notification
|
||||||
- [ ] T069 Implement race condition handling (Redlock) in ReviewTask completion `backend/src/modules/review-team/review-task.service.ts`
|
- [x] T069 Implement race condition handling (Redlock) in ReviewTask completion `backend/src/modules/review-team/review-task.service.ts`
|
||||||
- [ ] T070 [P] Add optimistic locking to ReviewTask entity `backend/src/modules/review-team/entities/review-task.entity.ts`
|
- [x] T070 [P] Add optimistic locking to ReviewTask entity `backend/src/modules/review-team/entities/review-task.entity.ts`
|
||||||
- [ ] T071 Create Review Task inbox UI with aggregate status `frontend/src/components/review-task/ReviewTaskInbox.tsx`
|
- [x] T071 Create Review Task inbox UI with aggregate status `frontend/src/components/review-task/ReviewTaskInbox.tsx`
|
||||||
- [ ] T072 [P] Create parallel review progress indicator `frontend/src/components/review-task/ParallelProgress.tsx`
|
- [x] T072 [P] Create parallel review progress indicator `frontend/src/components/review-task/ParallelProgress.tsx`
|
||||||
- [ ] T072.5 Create Veto Override button and modal for PM `frontend/src/components/review-task/VetoOverrideDialog.tsx` - พร้อม input สำหรับ justification reason
|
- [x] T072.5 Create Veto Override button and modal for PM `frontend/src/components/review-task/VetoOverrideDialog.tsx` - พร้อม input สำหรับ justification reason
|
||||||
- [ ] T073 Add validation for all edge cases in service layer `backend/src/common/validators/review-validators.ts`
|
- [x] T073 Add validation for all edge cases in service layer `backend/src/common/validators/review-validators.ts`
|
||||||
- [ ] T074 [P] Create unit tests for ResponseCodeService `backend/tests/unit/response-code/response-code.service.spec.ts`
|
- [x] T074 [P] Create unit tests for ResponseCodeService `backend/tests/unit/response-code/response-code.service.spec.ts`
|
||||||
- [ ] T075 [P] Create unit tests for Delegation circular detection `backend/tests/unit/delegation/circular-detection.service.spec.ts`
|
- [x] T075 [P] Create unit tests for Delegation circular detection `backend/tests/unit/delegation/circular-detection.service.spec.ts`
|
||||||
- [ ] T076 [P] Create integration tests for parallel review consensus `backend/tests/integration/review-team/parallel-review.spec.ts`
|
- [x] T076 [P] Create integration tests for parallel review consensus `backend/tests/integration/review-team/parallel-review.spec.ts`
|
||||||
- [ ] T077 Create e2e tests for complete RFA workflow `backend/tests/e2e/rfa-workflow.e2e-spec.ts`
|
- [x] T077 Create e2e tests for complete RFA workflow `backend/tests/e2e/rfa-workflow.e2e-spec.ts`
|
||||||
- [ ] T078 [P] Add frontend tests for ResponseCodeSelector `frontend/tests/components/ResponseCodeSelector.test.tsx`
|
- [x] T078 [P] Add frontend tests for ResponseCodeSelector `frontend/tests/components/ResponseCodeSelector.test.tsx`
|
||||||
- [ ] T079 Update quickstart.md with final setup instructions `specs/1-rfa-approval-refactor/quickstart.md`
|
- [x] T079 Update quickstart.md with final setup instructions `specs/1-rfa-approval-refactor/quickstart.md`
|
||||||
- [ ] T080 [P] Run full test suite and fix any failures `npm test`
|
- [x] T080 [P] Run full test suite and fix any failures `npm test`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user