251210:1709 Frontend: reeactor organization and run build
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-10 17:09:11 +07:00
parent aa96cd90e3
commit c8a0f281ef
140 changed files with 3780 additions and 1473 deletions

View File

@@ -0,0 +1,43 @@
# Work Summary - 2025-12-10
## ✅ Organizations Page Refactoring (Admin Console)
Refactored the Organizations management page in the Admin Console following established patterns.
### New Files Created
| File | Description |
| ------------------------------------------ | ------------------------------------------------------------ |
| `components/admin/organization-dialog.tsx` | Extracted dialog component with form validation (~212 lines) |
| `types/dto/organization.dto.ts` | Typed DTOs matching backend (`Create`, `Update`, `Search`) |
### Modified Files
| File | Changes |
| ------------------------------------------ | ------------------------------------------------- |
| `app/(admin)/admin/organizations/page.tsx` | Reduced from 300 → 153 lines by extracting dialog |
| `hooks/use-master-data.ts` | Replaced `any` with proper DTO types |
| `lib/services/master-data.service.ts` | Added typed organization methods |
### Pattern Improvements
- **Component Extraction**: Followed `UserDialog` pattern for consistency
- **Type Safety**: Removed `any` types from organization hooks and service
- **Code Reduction**: Page reduced by ~50% (300 → 153 lines)
### Bug Fixes (Discovered)
- Fixed Zod v4 compatibility issue in `organization-dialog.tsx`
- Fixed Zod v4 compatibility issue in `projects/page.tsx`
> **Note**: Pre-existing TypeScript errors in `disciplines/page.tsx`, `rfa-types/page.tsx`, and `user-dialog.tsx` still require Zod v4 fixes.
## 🧪 Verification
- ✅ Organizations files compile without TypeScript errors
- ⚠️ Full build blocked by pre-existing issues in other admin pages
## 📋 Next Steps
1. Fix remaining Zod v4 compatibility issues in other admin pages
2. Manual testing of Organizations CRUD operations

View File

@@ -0,0 +1,263 @@
# Task: Database Setup & Migrations
**Status:** Not Started
**Priority:** P0 (Critical - Foundation)
**Estimated Effort:** 2-3 days
**Dependencies:** None
**Owner:** Backend Team
---
## 📋 Overview
ตั้งค่า Database schema สำหรับ LCBP3-DMS โดยใช้ TypeORM Migrations และ Seeding data
---
## 🎯 Objectives
- ✅ สร้าง Initial Database Schema
- ✅ Setup TypeORM Configuration
- ✅ Create Migration System
- ✅ Setup Seed Data
- ✅ Verify Database Structure
---
## 📝 Acceptance Criteria
1. **Database Schema:**
- ✅ ทุกตารางถูกสร้างตาม Data Dictionary v1.5.1
- ✅ Foreign Keys ถูกต้องครบถ้วน
- ✅ Indexes ครบตาม Specification
- ✅ Virtual Columns สำหรับ JSON fields
2. **Migrations:**
- ✅ Migration files เรียงลำดับถูกต้อง
- ✅ สามารถ `migrate:up` และ `migrate:down` ได้
- ✅ ไม่มี Data loss เมื่อ rollback
3. **Seed Data:**
- ✅ Master data (Organizations, Project, Roles, Permissions)
- ✅ Test users สำหรับแต่ละ Role
- ✅ Sample data สำหรับ Development
---
## 🛠️ Implementation Steps
### 1. TypeORM Configuration
```typescript
// File: backend/src/config/database.config.ts
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = {
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../database/migrations/*{.ts,.js}'],
migrationsRun: false, // Manual migration
synchronize: false, // ห้ามใช้ใน Production
logging: process.env.NODE_ENV === 'development',
};
```
### 2. Create Entity Classes
**Core Entities:**
- `Organization` (organizations)
- `Project` (projects)
- `Contract` (contracts)
- `User` (users)
- `Role` (roles)
- `Permission` (permissions)
- `UserAssignment` (user_assignments)
**Document Entities:**
- `Correspondence` (correspondences)
- `CorrespondenceRevision` (correspondence_revisions)
- `Rfa` (rfas)
- `RfaRevision` (rfa_revisions)
- `ShopDrawing` (shop_drawings)
- `ShopDrawingRevision` (shop_drawing_revisions)
**Supporting Entities:**
- `WorkflowDefinition` (workflow_definitions)
- `WorkflowInstance` (workflow_instances)
- `WorkflowHistory` (workflow_history)
- `DocumentNumberFormat` (document_number_formats)
- `DocumentNumberCounter` (document_number_counters)
- `Attachment` (attachments)
- `AuditLog` (audit_logs)
### 3. Create Initial Migration
```bash
npm run migration:generate -- -n InitialSchema
```
**Migration File Structure:**
```typescript
// File: backend/src/database/migrations/1701234567890-InitialSchema.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1701234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create organizations table
await queryRunner.query(`
CREATE TABLE organizations (
id INT PRIMARY KEY AUTO_INCREMENT,
organization_code VARCHAR(20) NOT NULL UNIQUE,
organization_name VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
INDEX idx_org_code (organization_code),
INDEX idx_org_active (is_active, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Continue with other tables...
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS organizations;`);
// Rollback other tables...
}
}
```
### 4. Create Seed Script
```typescript
// File: backend/src/database/seeds/run-seed.ts
import { DataSource } from 'typeorm';
import { seedOrganizations } from './organization.seed';
import { seedRoles } from './role.seed';
import { seedUsers } from './user.seed';
async function runSeeds() {
const dataSource = new DataSource(databaseConfig);
await dataSource.initialize();
try {
console.log('🌱 Seeding database...');
await seedOrganizations(dataSource);
await seedRoles(dataSource);
await seedUsers(dataSource);
console.log('✅ Seeding completed!');
} catch (error) {
console.error('❌ Seeding failed:', error);
} finally {
await dataSource.destroy();
}
}
runSeeds();
```
---
## ✅ Testing & Verification
### 1. Migration Testing
```bash
# Run migrations
npm run migration:run
# Verify tables created
mysql -u root -p lcbp3_dev -e "SHOW TABLES;"
# Rollback one migration
npm run migration:revert
# Re-run migrations
npm run migration:run
```
### 2. Seed Data Verification
```bash
# Run seed
npm run seed
# Verify data
mysql -u root -p lcbp3_dev -e "SELECT * FROM organizations;"
mysql -u root -p lcbp3_dev -e "SELECT * FROM roles;"
mysql -u root -p lcbp3_dev -e "SELECT * FROM users;"
```
### 3. Schema Validation
```sql
-- Check Foreign Keys
SELECT
TABLE_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME
FROM
INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE
TABLE_SCHEMA = 'lcbp3_dev'
AND REFERENCED_TABLE_NAME IS NOT NULL;
-- Check Indexes
SELECT
TABLE_NAME, INDEX_NAME, COLUMN_NAME
FROM
INFORMATION_SCHEMA.STATISTICS
WHERE
TABLE_SCHEMA = 'lcbp3_dev'
ORDER BY
TABLE_NAME, INDEX_NAME;
```
---
## 📚 Related Documents
- [Data Dictionary v1.4.5](../../docs/4_Data_Dictionary_V1_4_5.md)
- [SQL Schema](../../docs/8_lcbp3_v1_4_5.sql)
- [Data Model](../02-architecture/data-model.md)
---
## 📦 Deliverables
- [ ] TypeORM configuration file
- [ ] Entity classes (50+ entities)
- [ ] Initial migration file
- [ ] Seed scripts (organizations, roles, users)
- [ ] Migration test script
- [ ] Documentation: How to run migrations
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------- | ------ | ------------------------------------------- |
| Migration errors | High | Test on dev DB first, backup before migrate |
| Missing indexes | Medium | Review Data Dictionary carefully |
| Seed data conflicts | Low | Use `INSERT IGNORE` or check existing |
---
## 📌 Notes
- ใช้ `utf8mb4_unicode_ci` สำหรับ Thai language support
- ตรวจสอบ Virtual Columns สำหรับ JSON indexing
- ใช้ `@VersionColumn()` สำหรับ Optimistic Locking tables

View File

@@ -0,0 +1,427 @@
# Task: Common Module - Auth & Security
**Status:** Not Started
**Priority:** P0 (Critical - Foundation)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-BE-001 (Database)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Common Module ที่รวม Authentication, Authorization, Guards, Interceptors, และ Utility Services
---
## 🎯 Objectives
- ✅ JWT Authentication System
- ✅ 4-Level RBAC with CASL
- ✅ Custom Guards และ Decorators
- ✅ Idempotency Interceptor
- ✅ Rate Limiting
- ✅ Input Validation Framework
---
## 📝 Acceptance Criteria
1. **Authentication:**
- ✅ Login with username/password returns JWT
- ✅ Token refresh mechanism works
- ✅ Token revocation supported
- ✅ Password hashing with bcrypt
2. **Authorization:**
- ✅ RBAC Guards ตรวจสอบ 4 levels (Global/Org/Project/Contract)
- ✅ Permission cache ใน Redis (TTL: 30min)
- ✅ CASL Ability Factory working
3. **Security:**
- ✅ Rate limiting per user/IP
- ✅ Idempotency-Key validation
- ✅ Input sanitization
- ✅ CORS configuration
---
## 🛠️ Implementation Steps
### 1. Auth Module
```typescript
// File: backend/src/common/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '8h' },
}),
],
providers: [AuthService, JwtStrategy, LocalStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
```
```typescript
// File: backend/src/common/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
private redis: Redis
) {}
async login(loginDto: LoginDto): Promise<AuthResponse> {
const user = await this.validateUser(loginDto.username, loginDto.password);
const payload = {
sub: user.user_id,
username: user.username,
organization_id: user.organization_id,
};
const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
// Store refresh token in Redis
await this.redis.set(
`refresh_token:${user.user_id}`,
refreshToken,
'EX',
7 * 24 * 3600
);
return {
access_token: accessToken,
refresh_token: refreshToken,
user: this.sanitizeUser(user),
};
}
async validateUser(username: string, password: string): Promise<User> {
const user = await this.userService.findByUsername(username);
if (!user || !user.is_active) {
throw new UnauthorizedException('Invalid credentials');
}
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
async refreshToken(refreshToken: string): Promise<AuthResponse> {
// Verify and refresh token
}
async logout(userId: number): Promise<void> {
// Revoke tokens
await this.redis.del(`refresh_token:${userId}`);
await this.redis.del(`user:${userId}:permissions`);
}
}
```
### 2. RBAC Guards
```typescript
// File: backend/src/common/guards/permission.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory } from '../ability/ability.factory';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: AbilityFactory,
private redis: Redis
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const permission = this.reflector.get<string>(
'permission',
context.getHandler()
);
if (!permission) {
return true; // No permission required
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check cache first
let ability = await this.getCachedAbility(user.sub);
if (!ability) {
ability = await this.abilityFactory.createForUser(user);
await this.cacheAbility(user.sub, ability);
}
const [action, subject] = permission.split('.');
const resource = this.getResource(request);
return ability.can(action, subject, resource);
}
private async getCachedAbility(userId: number): Promise<any> {
const cached = await this.redis.get(`user:${userId}:permissions`);
return cached ? JSON.parse(cached) : null;
}
private async cacheAbility(userId: number, ability: any): Promise<void> {
await this.redis.set(
`user:${userId}:permissions`,
JSON.stringify(ability.rules),
'EX',
1800 // 30 minutes
);
}
}
```
### 3. Custom Decorators
```typescript
// File: backend/src/common/decorators/require-permission.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const RequirePermission = (permission: string) =>
SetMetadata('permission', permission);
// Usage:
// @RequirePermission('correspondence.create')
```
```typescript
// File: backend/src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);
// Usage:
// async create(@CurrentUser() user: User) {}
```
### 4. Idempotency Interceptor
```typescript
// File: backend/src/common/interceptors/idempotency.interceptor.ts
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(private redis: Redis) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const idempotencyKey = request.headers['idempotency-key'];
// Only apply to POST/PUT/DELETE
if (!['POST', 'PUT', 'DELETE'].includes(request.method)) {
return next.handle();
}
if (!idempotencyKey) {
throw new BadRequestException('Idempotency-Key header required');
}
// Check for cached result
const cacheKey = `idempotency:${idempotencyKey}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return of(JSON.parse(cached)); // Return previous result
}
// Execute and cache result
return next.handle().pipe(
tap(async (response) => {
await this.redis.set(
cacheKey,
JSON.stringify(response),
'EX',
86400 // 24 hours
);
})
);
}
}
```
### 5. Rate Limiting
```typescript
// File: backend/src/common/guards/rate-limit.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ThrottlerGuard } from '@nestjs/throttler';
@Injectable()
export class RateLimitGuard extends ThrottlerGuard {
protected async getTracker(req: any): Promise<string> {
// Use user ID if authenticated, otherwise IP
return req.user?.sub || req.ip;
}
protected async getLimit(context: ExecutionContext): Promise<number> {
// Different limits per role
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) return 100; // Anonymous
switch (user.role) {
case 'admin':
return 5000;
case 'document_control':
return 2000;
case 'editor':
return 1000;
default:
return 500;
}
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
// File: backend/src/common/auth/auth.service.spec.ts
describe('AuthService', () => {
it('should login with valid credentials', async () => {
const result = await service.login({
username: 'testuser',
password: 'password123',
});
expect(result.access_token).toBeDefined();
expect(result.refresh_token).toBeDefined();
});
it('should throw error with invalid credentials', async () => {
await expect(
service.login({
username: 'testuser',
password: 'wrongpassword',
})
).rejects.toThrow(UnauthorizedException);
});
});
```
### 2. Integration Tests
```bash
# Test login endpoint
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "password123"}'
# Test protected endpoint
curl http://localhost:3000/projects \
-H "Authorization: Bearer <access_token>"
# Test permission guard
curl -X POST http://localhost:3000/correspondences \
-H "Authorization: Bearer <viewer_token>" \
-d '{}' # Should return 403
```
### 3. RBAC Testing
```typescript
describe('PermissionGuard', () => {
it('should allow global admin to access everything', async () => {
const canAccess = await guard.canActivate(
mockContext({
user: globalAdmin,
permission: 'correspondence.create',
})
);
expect(canAccess).toBe(true);
});
it('should deny viewer from creating', async () => {
const canAccess = await guard.canActivate(
mockContext({
user: viewer,
permission: 'correspondence.create',
})
);
expect(canAccess).toBe(false);
});
});
```
---
## 📚 Related Documents
- [Backend Guidelines - Security](../03-implementation/backend-guidelines.md#security)
- [ADR-004: RBAC Implementation](../05-decisions/ADR-004-rbac-implementation.md)
- [ADR-006: Redis Caching Strategy](../05-decisions/ADR-006-redis-caching-strategy.md)
---
## 📦 Deliverables
- [ ] AuthModule (login, refresh, logout)
- [ ] JWT Strategy
- [ ] Permission Guard with CASL
- [ ] Custom Decorators (@RequirePermission, @CurrentUser)
- [ ] Idempotency Interceptor
- [ ] Rate Limiting Guard
- [ ] Unit Tests (80% coverage)
- [ ] Integration Tests
- [ ] Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------- | -------- | -------------------------------------- |
| JWT secret exposure | Critical | Use strong secret, rotate periodically |
| Redis cache miss | Medium | Fallback to DB query |
| Rate limit bypass | Medium | Multiple tracking (IP + User) |
| RBAC complexity | High | Comprehensive testing |
---
## 📌 Notes
- JWT secret must be 32+ characters
- Refresh tokens expire after 7 days
- Permission cache expires after 30 minutes
- Rate limits differ by role (see RateLimitGuard)

View File

@@ -0,0 +1,470 @@
# Task: File Storage Service (Two-Phase)
**Status:** Not Started
**Priority:** P1 (High)
**Estimated Effort:** 4-5 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง FileStorageService ที่ใช้ Two-Phase Storage Pattern (Temp → Permanent) พร้อม Virus Scanning และ File Validation
---
## 🎯 Objectives
- ✅ Two-Phase Upload System
- ✅ Virus Scanning Integration (ClamAV)
- ✅ File Type Validation
- ✅ Automated Cleanup Job
- ✅ File Metadata Management
---
## 📝 Acceptance Criteria
1. **Phase 1 - Temp Upload:**
- ✅ Upload file → Scan virus → Save to temp/
- ✅ Generate temp_id and return to client
- ✅ Set expiration (24 hours)
- ✅ Calculate SHA-256 checksum
2. **Phase 2 - Commit:**
- ✅ Move temp file → permanent/{YYYY}/{MM}/
- ✅ Update attachment record (is_temporary=false)
- ✅ Link to parent entity (correspondence, rfa, etc.)
- ✅ Transaction-safe (rollback on error)
3. **Cleanup:**
- ✅ Cron job runs every 6 hours
- ✅ Delete expired temp files
- ✅ Delete orphan files (no DB record)
---
## 🛠️ Implementation Steps
### 1. File Storage Service
```typescript
// File: backend/src/common/file-storage/file-storage.service.ts
import { Injectable } from '@nestjs/common';
import * as fs from 'fs-extra';
import * as path from 'path';
import { createHash } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class FileStorageService {
private readonly TEMP_DIR: string;
private readonly PERMANENT_DIR: string;
private readonly MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
constructor(
private config: ConfigService,
private virusScanner: VirusScannerService,
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>
) {
this.TEMP_DIR = path.join(config.get('STORAGE_PATH'), 'temp');
this.PERMANENT_DIR = path.join(config.get('STORAGE_PATH'), 'permanent');
this.ensureDirectories();
}
async uploadToTemp(
file: Express.Multer.File,
userId: number
): Promise<UploadResult> {
// 1. Validate file
this.validateFile(file);
// 2. Virus scan
const scanResult = await this.virusScanner.scan(file.buffer);
if (scanResult.isInfected) {
throw new BadRequestException(`Virus detected: ${scanResult.virusName}`);
}
// 3. Generate identifiers
const tempId = uuidv4();
const storedFilename = `${tempId}_${this.sanitizeFilename(
file.originalname
)}`;
const tempPath = path.join(this.TEMP_DIR, storedFilename);
// 4. Calculate checksum
const checksum = this.calculateChecksum(file.buffer);
// 5. Save to temp directory
await fs.writeFile(tempPath, file.buffer);
// 6. Create attachment record
const attachment = await this.attachmentRepo.save({
original_filename: file.originalname,
stored_filename: storedFilename,
file_path: tempPath,
mime_type: file.mimetype,
file_size: file.size,
checksum,
is_temporary: true,
temp_id: tempId,
expires_at: new Date(Date.now() + 24 * 3600 * 1000), // 24h
uploaded_by_user_id: userId,
});
return {
temp_id: tempId,
filename: file.originalname,
size: file.size,
mime_type: file.mimetype,
expires_at: attachment.expires_at,
};
}
async commitFiles(
tempIds: string[],
entityId: number,
entityType: string,
manager: EntityManager
): Promise<Attachment[]> {
const commitedAttachments = [];
for (const tempId of tempIds) {
// 1. Get temp attachment
const tempAttachment = await manager.findOne(Attachment, {
where: { temp_id: tempId, is_temporary: true },
});
if (!tempAttachment) {
throw new NotFoundException(`Temp file not found: ${tempId}`);
}
if (tempAttachment.expires_at < new Date()) {
throw new BadRequestException(`Temp file expired: ${tempId}`);
}
// 2. Generate permanent path
const now = new Date();
const year = now.getFullYear().toString();
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const permanentDir = path.join(this.PERMANENT_DIR, year, month);
await fs.ensureDir(permanentDir);
const permanentFilename = `${uuidv4()}_${
tempAttachment.original_filename
}`;
const permanentPath = path.join(permanentDir, permanentFilename);
// 3. Move file (atomic operation)
await fs.move(tempAttachment.file_path, permanentPath, {
overwrite: false,
});
// 4. Update attachment record
await manager.update(
Attachment,
{ id: tempAttachment.id },
{
file_path: permanentPath,
stored_filename: permanentFilename,
is_temporary: false,
temp_id: null,
expires_at: null,
}
);
commitedAttachments.push({ ...tempAttachment, file_path: permanentPath });
}
return commitedAttachments;
}
private validateFile(file: Express.Multer.File): void {
// File type validation
const allowedTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'image/png',
'image/jpeg',
'application/zip',
];
if (!allowedTypes.includes(file.mimetype)) {
throw new BadRequestException('Invalid file type');
}
// Size validation
if (file.size > this.MAX_FILE_SIZE) {
throw new BadRequestException('File too large (max 50MB)');
}
// Magic number validation
this.validateMagicNumber(file.buffer, file.mimetype);
}
private validateMagicNumber(buffer: Buffer, mimetype: string): void {
const signatures = {
'application/pdf': [0x25, 0x50, 0x44, 0x46], // %PDF
'image/png': [0x89, 0x50, 0x4e, 0x47], // PNG
'image/jpeg': [0xff, 0xd8, 0xff], // JPEG
};
const signature = signatures[mimetype];
if (signature) {
for (let i = 0; i < signature.length; i++) {
if (buffer[i] !== signature[i]) {
throw new BadRequestException('File content does not match type');
}
}
}
}
private calculateChecksum(buffer: Buffer): string {
return createHash('sha256').update(buffer).digest('hex');
}
private sanitizeFilename(filename: string): string {
return filename.replace(/[^a-zA-Z0-9._-]/g, '_');
}
private async ensureDirectories(): Promise<void> {
await fs.ensureDir(this.TEMP_DIR);
await fs.ensureDir(this.PERMANENT_DIR);
}
}
```
### 2. Virus Scanner Service
```typescript
// File: backend/src/common/file-storage/virus-scanner.service.ts
import { Injectable } from '@nestjs/common';
import NodeClam from 'clamscan';
@Injectable()
export class VirusScannerService {
private clamScan: NodeClam;
async onModuleInit() {
this.clamScan = await new NodeClam().init({
clamdscan: {
host: process.env.CLAMAV_HOST || 'localhost',
port: process.env.CLAMAV_PORT || 3310,
},
});
}
async scan(buffer: Buffer): Promise<ScanResult> {
const { isInfected, viruses } = await this.clamScan.scanStream(buffer);
return {
isInfected,
virusName: viruses.length > 0 ? viruses[0] : null,
};
}
}
```
### 3. Cleanup Job
```typescript
// File: backend/src/common/file-storage/file-cleanup.service.ts
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class FileCleanupService {
constructor(
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>,
private logger: Logger
) {}
@Cron('0 */6 * * *') // Every 6 hours
async cleanupExpiredFiles(): Promise<void> {
this.logger.log('Starting expired file cleanup...');
const expiredFiles = await this.attachmentRepo.find({
where: {
is_temporary: true,
expires_at: LessThan(new Date()),
},
});
let deleted = 0;
for (const file of expiredFiles) {
try {
// Delete physical file
await fs.remove(file.file_path);
// Delete DB record
await this.attachmentRepo.remove(file);
deleted++;
} catch (error) {
this.logger.error(`Failed to delete file ${file.temp_id}:`, error);
}
}
this.logger.log(`Cleaned up ${deleted} expired files`);
}
@Cron(CronExpression.EVERY_DAY_AT_2AM)
async cleanupOrphanFiles(): Promise<void> {
// Find files in filesystem without DB records
this.logger.log('Starting orphan file cleanup...');
// Implementation...
}
}
```
### 4. Controller
```typescript
// File: backend/src/common/file-storage/file-storage.controller.ts
@Controller('attachments')
@UseGuards(JwtAuthGuard)
export class FileStorageController {
constructor(private fileStorage: FileStorageService) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async upload(
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: User
): Promise<UploadResult> {
return this.fileStorage.uploadToTemp(file, user.user_id);
}
@Get('temp/:tempId/download')
async downloadTemp(@Param('tempId') tempId: string, @Res() res: Response) {
const attachment = await this.attachmentRepo.findOne({
where: { temp_id: tempId, is_temporary: true },
});
if (!attachment) {
throw new NotFoundException('File not found');
}
res.download(attachment.file_path, attachment.original_filename);
}
@Delete('temp/:tempId')
async deleteTempFile(@Param('tempId') tempId: string): Promise<void> {
// Delete temp file
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('FileStorageService', () => {
it('should upload file to temp successfully', async () => {
const mockFile = createMockFile('test.pdf', 'application/pdf');
const result = await service.uploadToTemp(mockFile, 1);
expect(result.temp_id).toBeDefined();
expect(result.expires_at).toBeDefined();
});
it('should reject infected files', async () => {
virusScanner.scan = jest.fn().mockResolvedValue({
isInfected: true,
virusName: 'EICAR-Test-File',
});
const mockFile = createMockFile('virus.exe', 'application/octet-stream');
await expect(service.uploadToTemp(mockFile, 1)).rejects.toThrow(
'Virus detected'
);
});
it('should commit temp files to permanent', async () => {
const tempIds = ['temp-id-1', 'temp-id-2'];
const committed = await service.commitFiles(
tempIds,
1,
'correspondence',
manager
);
expect(committed).toHaveLength(2);
expect(committed[0].is_temporary).toBe(false);
});
});
```
### 2. Integration Tests
```bash
# Upload file
curl -X POST http://localhost:3000/attachments/upload \
-H "Authorization: Bearer <token>" \
-F "file=@test.pdf"
# Response: { "temp_id": "...", "expires_at": "..." }
# Create correspondence with temp file
curl -X POST http://localhost:3000/correspondences \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"title": "Test",
"project_id": 1,
"temp_file_ids": ["<temp_id>"]
}'
```
---
## 📚 Related Documents
- [ADR-003: Two-Phase File Storage](../05-decisions/ADR-003-file-storage-approach.md)
- [Backend Guidelines - File Storage](../03-implementation/backend-guidelines.md#file-storage)
---
## 📦 Deliverables
- [ ] FileStorageService
- [ ] VirusScannerService (ClamAV integration)
- [ ] FileCleanupService (Cron jobs)
- [ ] FileStorageController
- [ ] AttachmentEntity
- [ ] Unit Tests (85% coverage)
- [ ] Integration Tests
- [ ] Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------- | -------- | -------------------------------- |
| ClamAV service down | High | Queue scans, allow bypass in dev |
| Disk space full | Critical | Monitoring + alerts |
| File move failure | Medium | Atomic operations + retry logic |
| Orphan files | Low | Cleanup job + monitoring |
---
## 📌 Notes
- ClamAV requires separate Docker container
- Temp files expire after 24 hours
- Cleanup job runs every 6 hours
- Maximum file size: 50MB
- Supported types: PDF, DOCX, XLSX, PNG, JPEG, ZIP

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,521 @@
# Task: Correspondence Module
**Status:** Not Started
**Priority:** P1 (High - Core Business Module)
**Estimated Effort:** 7-10 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Correspondence Module สำหรับจัดการเอกสารโต้ตอบด้วย Master-Revision Pattern พร้อม Workflow Integration
---
## 🎯 Objectives
- ✅ CRUD Operations (Correspondences + Revisions)
- ✅ Master-Revision Pattern Implementation
- ✅ Attachment Management
- ✅ Workflow Integration (Routing)
- ✅ Document Number Generation
- ✅ Search & Filter
---
## 📝 Acceptance Criteria
1. **Basic Operations:**
- ✅ Create correspondence (auto-generate number)
- ✅ Create revision
- ✅ Update correspondence/revision
- ✅ Soft delete correspondence
- ✅ Get correspondence with latest revision
- ✅ Get all revisions history
2. **Attachments:**
- ✅ Upload via two-phase storage
- ✅ Link attachments to revision
- ✅ Download attachments
- ✅ Delete attachments
3. **Workflow:**
- ✅ Submit correspondence → Create workflow instance
- ✅ Execute workflow transitions
- ✅ Track workflow status
4. **Search & Filter:**
- ✅ Search by title, number, project
- ✅ Filter by status, type, date range
- ✅ Pagination support
---
## 🛠️ Implementation Steps
### 1. Entities
```typescript
// File: backend/src/modules/correspondence/entities/correspondence.entity.ts
@Entity('correspondences')
export class Correspondence extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
correspondence_number: string;
@Column({ length: 500 })
title: string;
@Column()
project_id: number;
@Column()
originator_organization_id: number;
@Column()
recipient_organization_id: number;
@Column()
correspondence_type_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ default: 'draft' })
status: string;
@Column()
created_by_user_id: number;
@DeleteDateColumn()
deleted_at: Date;
// Relationships
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'originator_organization_id' })
originatorOrganization: Organization;
@OneToMany(() => CorrespondenceRevision, (rev) => rev.correspondence)
revisions: CorrespondenceRevision[];
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by_user_id' })
createdBy: User;
}
```
```typescript
// File: backend/src/modules/correspondence/entities/correspondence-revision.entity.ts
@Entity('correspondence_revisions')
export class CorrespondenceRevision {
@PrimaryGeneratedColumn()
id: number;
@Column()
correspondence_id: number;
@Column({ default: 1 })
revision_number: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'json', nullable: true })
details: any; // Dynamic JSON field
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
// Relationships
@ManyToOne(() => Correspondence, (corr) => corr.revisions)
@JoinColumn({ name: 'correspondence_id' })
correspondence: Correspondence;
@ManyToMany(() => Attachment)
@JoinTable({
name: 'correspondence_attachments',
joinColumn: { name: 'correspondence_revision_id' },
inverseJoinColumn: { name: 'attachment_id' },
})
attachments: Attachment[];
}
```
### 2. Service
```typescript
// File: backend/src/modules/correspondence/correspondence.service.ts
@Injectable()
export class CorrespondenceService {
constructor(
@InjectRepository(Correspondence)
private corrRepo: Repository<Correspondence>,
@InjectRepository(CorrespondenceRevision)
private revisionRepo: Repository<CorrespondenceRevision>,
private fileStorage: FileStorageService,
private docNumbering: DocumentNumberingService,
private workflowEngine: WorkflowEngineService,
private dataSource: DataSource
) {}
async create(
dto: CreateCorrespondenceDto,
userId: number
): Promise<Correspondence> {
return this.dataSource.transaction(async (manager) => {
// 1. Generate document number
const docNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.originator_organization_id,
typeId: dto.correspondence_type_id,
disciplineId: dto.discipline_id,
});
// 2. Create correspondence master
const correspondence = manager.create(Correspondence, {
correspondence_number: docNumber,
title: dto.title,
project_id: dto.project_id,
originator_organization_id: dto.originator_organization_id,
recipient_organization_id: dto.recipient_organization_id,
correspondence_type_id: dto.correspondence_type_id,
discipline_id: dto.discipline_id,
status: 'draft',
created_by_user_id: userId,
});
await manager.save(correspondence);
// 3. Create initial revision
const revision = manager.create(CorrespondenceRevision, {
correspondence_id: correspondence.id,
revision_number: 1,
description: dto.description,
details: dto.details,
created_by_user_id: userId,
});
await manager.save(revision);
// 4. Commit temp files (if any)
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
correspondence.id,
'correspondence',
manager
);
// Link attachments to revision
revision.attachments = attachments;
await manager.save(revision);
}
// 5. Create workflow instance
const workflowInstance = await this.workflowEngine.createInstance(
'CORRESPONDENCE_ROUTING',
'correspondence',
correspondence.id,
manager
);
return correspondence;
});
}
async createRevision(
correspondenceId: number,
dto: CreateRevisionDto,
userId: number
): Promise<CorrespondenceRevision> {
return this.dataSource.transaction(async (manager) => {
// Get latest revision number
const latestRevision = await manager.findOne(CorrespondenceRevision, {
where: { correspondence_id: correspondenceId },
order: { revision_number: 'DESC' },
});
const nextRevisionNumber = (latestRevision?.revision_number || 0) + 1;
// Create new revision
const revision = manager.create(CorrespondenceRevision, {
correspondence_id: correspondenceId,
revision_number: nextRevisionNumber,
description: dto.description,
details: dto.details,
created_by_user_id: userId,
});
await manager.save(revision);
// Commit temp files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
correspondenceId,
'correspondence',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
return revision;
});
}
async findAll(
query: SearchCorrespondenceDto
): Promise<PaginatedResult<Correspondence>> {
const queryBuilder = this.corrRepo
.createQueryBuilder('corr')
.leftJoinAndSelect('corr.project', 'project')
.leftJoinAndSelect('corr.originatorOrganization', 'org')
.leftJoinAndSelect('corr.revisions', 'revision')
.where('corr.deleted_at IS NULL');
// Apply filters
if (query.project_id) {
queryBuilder.andWhere('corr.project_id = :projectId', {
projectId: query.project_id,
});
}
if (query.status) {
queryBuilder.andWhere('corr.status = :status', { status: query.status });
}
if (query.search) {
queryBuilder.andWhere(
'(corr.title LIKE :search OR corr.correspondence_number LIKE :search)',
{ search: `%${query.search}%` }
);
}
// Pagination
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('corr.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: number): Promise<Correspondence> {
const correspondence = await this.corrRepo.findOne({
where: { id, deleted_at: IsNull() },
relations: [
'revisions',
'revisions.attachments',
'project',
'originatorOrganization',
],
order: { revisions: { revision_number: 'DESC' } },
});
if (!correspondence) {
throw new NotFoundException(`Correspondence #${id} not found`);
}
return correspondence;
}
async submitForRouting(id: number, userId: number): Promise<void> {
const correspondence = await this.findOne(id);
if (correspondence.status !== 'draft') {
throw new BadRequestException('Can only submit draft correspondences');
}
// Execute workflow transition
await this.workflowEngine.executeTransition(
correspondence.id,
'SUBMIT',
userId
);
// Update status
await this.corrRepo.update(id, { status: 'submitted' });
}
async softDelete(id: number, userId: number): Promise<void> {
const correspondence = await this.findOne(id);
if (correspondence.status !== 'draft') {
throw new BadRequestException('Can only delete draft correspondences');
}
await this.corrRepo.softDelete(id);
}
}
```
### 3. Controller
```typescript
// File: backend/src/modules/correspondence/correspondence.controller.ts
@Controller('correspondences')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Correspondences')
export class CorrespondenceController {
constructor(private service: CorrespondenceService) {}
@Post()
@RequirePermission('correspondence.create')
@UseInterceptors(IdempotencyInterceptor)
async create(
@Body() dto: CreateCorrespondenceDto,
@CurrentUser() user: User
): Promise<Correspondence> {
return this.service.create(dto, user.user_id);
}
@Post(':id/revisions')
@RequirePermission('correspondence.edit')
async createRevision(
@Param('id', ParseIntPipe) id: number,
@Body() dto: CreateRevisionDto,
@CurrentUser() user: User
): Promise<CorrespondenceRevision> {
return this.service.createRevision(id, dto, user.user_id);
}
@Get()
@RequirePermission('correspondence.view')
async findAll(@Query() query: SearchCorrespondenceDto) {
return this.service.findAll(query);
}
@Get(':id')
@RequirePermission('correspondence.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
@Post(':id/submit')
@RequirePermission('correspondence.submit')
async submit(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
): Promise<void> {
return this.service.submitForRouting(id, user.user_id);
}
@Delete(':id')
@RequirePermission('correspondence.delete')
@HttpCode(204)
async delete(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
): Promise<void> {
return this.service.softDelete(id, user.user_id);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('CorrespondenceService', () => {
it('should create correspondence with document number', async () => {
const dto = {
title: 'Test Correspondence',
project_id: 1,
originator_organization_id: 3,
recipient_organization_id: 1,
correspondence_type_id: 1,
};
const result = await service.create(dto, 1);
expect(result.correspondence_number).toMatch(/^TEAM-RFA-\d{4}-\d{4}$/);
expect(result.revisions).toHaveLength(1);
});
});
```
### 2. Integration Tests
```bash
# Create correspondence
curl -X POST http://localhost:3000/correspondences \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"title": "Test Correspondence",
"project_id": 1,
"originator_organization_id": 3,
"recipient_organization_id": 1,
"correspondence_type_id": 1,
"temp_file_ids": ["temp-id-123"]
}'
```
---
## 📚 Related Documents
- [Data Model - Correspondences](../02-architecture/data-model.md#correspondences)
- [Functional Requirements - Correspondence](../01-requirements/03.2-correspondence.md)
---
## 📦 Deliverables
- [ ] Correspondence Entity
- [ ] CorrespondenceRevision Entity
- [ ] CorrespondenceService (CRUD + Workflow)
- [ ] CorrespondenceController
- [ ] DTOs (Create, Update, Search)
- [ ] Unit Tests (85% coverage)
- [ ] Integration Tests
- [ ] API Documentation (Swagger)
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------------- | -------- | ------------------------------ |
| Document number collision | Critical | Double-lock mechanism |
| File orphans | Medium | Two-phase storage |
| Workflow state mismatch | High | Transaction-safe state updates |
---
## 📌 Notes
- Use Master-Revision pattern (separate tables)
- Auto-generate document number on create
- Workflow integration required for submit
- Soft delete only drafts
- Pagination default: 20 items per page

View File

@@ -0,0 +1,540 @@
# Task: Workflow Engine Module
**Status:** Completed
**Priority:** P0 (Critical - Core Infrastructure)
**Estimated Effort:** 10-14 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Unified Workflow Engine ที่ใช้ DSL-based configuration สำหรับจัดการ Workflow ของ Correspondences, RFAs, และ Circulations
---
## Objectives
- ✅ DSL Parser และ Validator
- ✅ State Machine Management
- ✅ Workflow Instance Lifecycle
- ✅ Transition Execution
- ✅ History Tracking
- ✅ Notification Integration
---
## 📝 Acceptance Criteria
1. **Definition Management:**
- ✅ Create/Update workflow from JSON DSL
- ✅ Validate DSL syntax และ Logic
- ✅ Version management
- ✅ Activate/Deactivate definitions
2. **Instance Management:**
- ✅ Create instance from definition
- ✅ Execute transitions
- ✅ Check guards (permissions, validations)
- ✅ Trigger effects (notifications, updates)
- ✅ Track history
3. **Integration:**
- ✅ Used by Correspondence module
- ✅ Used by RFA module
- ✅ Used by Circulation module
- ✅ Notification service integration
---
## 🛠️ Implementation Steps
### 1. Entities
```typescript
// File: backend/src/modules/workflow-engine/entities/workflow-definition.entity.ts
@Entity('workflow_definitions')
export class WorkflowDefinition {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column()
version: number;
@Column({ length: 50 })
entity_type: string; // 'correspondence', 'rfa', 'circulation'
@Column({ type: 'json' })
definition: WorkflowDSL; // JSON DSL
@Column({ default: true })
is_active: boolean;
@CreateDateColumn()
created_at: Date;
@Index(['name', 'version'], { unique: true })
_nameVersionIndex: void;
}
```
```typescript
// File: backend/src/modules/workflow-engine/entities/workflow-instance.entity.ts
@Entity('workflow_instances')
export class WorkflowInstance {
@PrimaryGeneratedColumn()
id: number;
@Column()
definition_id: number;
@Column({ length: 50 })
entity_type: string;
@Column()
entity_id: number;
@Column({ length: 50 })
current_state: string;
@Column({ type: 'json', nullable: true })
context: any; // Runtime data
@CreateDateColumn()
started_at: Date;
@Column({ type: 'timestamp', nullable: true })
completed_at: Date;
@ManyToOne(() => WorkflowDefinition)
@JoinColumn({ name: 'definition_id' })
definition: WorkflowDefinition;
@OneToMany(() => WorkflowHistory, (history) => history.instance)
history: WorkflowHistory[];
}
```
```typescript
// File: backend/src/modules/workflow-engine/entities/workflow-history.entity.ts
@Entity('workflow_history')
export class WorkflowHistory {
@PrimaryGeneratedColumn()
id: number;
@Column()
instance_id: number;
@Column({ length: 50, nullable: true })
from_state: string;
@Column({ length: 50 })
to_state: string;
@Column({ length: 50 })
action: string;
@Column()
actor_id: number;
@Column({ type: 'json', nullable: true })
metadata: any;
@CreateDateColumn()
transitioned_at: Date;
@ManyToOne(() => WorkflowInstance, (instance) => instance.history)
@JoinColumn({ name: 'instance_id' })
instance: WorkflowInstance;
@ManyToOne(() => User)
@JoinColumn({ name: 'actor_id' })
actor: User;
}
```
### 2. DSL Types
```typescript
// File: backend/src/modules/workflow-engine/types/workflow-dsl.type.ts
export interface WorkflowDSL {
name: string;
version: number;
entity_type: string;
states: WorkflowState[];
transitions: WorkflowTransition[];
}
export interface WorkflowState {
name: string;
type: 'initial' | 'intermediate' | 'final';
allowed_transitions: string[];
}
export interface WorkflowTransition {
action: string;
from: string;
to: string;
guards?: Guard[];
effects?: Effect[];
}
export interface Guard {
type: 'permission' | 'validation' | 'condition';
permission?: string;
rules?: string[];
condition?: string;
}
export interface Effect {
type: 'notification' | 'update_entity' | 'create_log';
template?: string;
recipients?: string[];
field?: string;
value?: any;
}
```
### 3. DSL Parser
```typescript
// File: backend/src/modules/workflow-engine/services/dsl-parser.service.ts
@Injectable()
export class DslParserService {
parseDefinition(dsl: WorkflowDSL): ParsedWorkflow {
this.validateStructure(dsl);
this.validateStates(dsl);
this.validateTransitions(dsl);
return {
states: this.parseStates(dsl.states),
transitions: this.parseTransitions(dsl.transitions),
stateMap: this.buildStateMap(dsl.states),
};
}
private validateStructure(dsl: WorkflowDSL): void {
if (!dsl.name || !dsl.states || !dsl.transitions) {
throw new BadRequestException('Invalid DSL structure');
}
}
private validateStates(dsl: WorkflowDSL): void {
const initialStates = dsl.states.filter((s) => s.type === 'initial');
if (initialStates.length !== 1) {
throw new BadRequestException('Must have exactly one initial state');
}
const finalStates = dsl.states.filter((s) => s.type === 'final');
if (finalStates.length === 0) {
throw new BadRequestException('Must have at least one final state');
}
}
private validateTransitions(dsl: WorkflowDSL): void {
const stateNames = new Set(dsl.states.map((s) => s.name));
for (const transition of dsl.transitions) {
if (!stateNames.has(transition.from)) {
throw new BadRequestException(`Unknown state: ${transition.from}`);
}
if (!stateNames.has(transition.to)) {
throw new BadRequestException(`Unknown state: ${transition.to}`);
}
}
}
getInitialState(dsl: WorkflowDSL): string {
const initialState = dsl.states.find((s) => s.type === 'initial');
return initialState.name;
}
buildStateMap(states: WorkflowState[]): Map<string, WorkflowState> {
return new Map(states.map((s) => [s.name, s]));
}
}
```
### 4. Workflow Engine Service
```typescript
// File: backend/src/modules/workflow-engine/services/workflow-engine.service.ts
@Injectable()
export class WorkflowEngineService {
constructor(
@InjectRepository(WorkflowDefinition)
private defRepo: Repository<WorkflowDefinition>,
@InjectRepository(WorkflowInstance)
private instanceRepo: Repository<WorkflowInstance>,
@InjectRepository(WorkflowHistory)
private historyRepo: Repository<WorkflowHistory>,
private dslParser: DslParserService,
private guardExecutor: GuardExecutorService,
private effectExecutor: EffectExecutorService,
private dataSource: DataSource
) {}
async createInstance(
definitionName: string,
entityType: string,
entityId: number,
manager?: EntityManager
): Promise<WorkflowInstance> {
const repo = manager || this.instanceRepo;
//Get active definition
const definition = await this.defRepo.findOne({
where: { name: definitionName, entity_type: entityType, is_active: true },
order: { version: 'DESC' },
});
if (!definition) {
throw new NotFoundException(
`Workflow definition not found: ${definitionName}`
);
}
// Get initial state
const initialState = this.dslParser.getInitialState(definition.definition);
// Create instance
const instance = repo.create({
definition_id: definition.id,
entity_type: entityType,
entity_id: entityId,
current_state: initialState,
context: {},
});
return repo.save(instance);
}
async executeTransition(
instanceId: number,
action: string,
actorId: number
): Promise<void> {
return this.dataSource.transaction(async (manager) => {
// 1. Get instance
const instance = await manager.findOne(WorkflowInstance, {
where: { id: instanceId },
relations: ['definition'],
});
if (!instance) {
throw new NotFoundException(
`Workflow instance not found: ${instanceId}`
);
}
// 2. Find transition
const dsl = instance.definition.definition;
const transition = dsl.transitions.find(
(t) => t.action === action && t.from === instance.current_state
);
if (!transition) {
throw new BadRequestException(
`Invalid transition: ${action} from ${instance.current_state}`
);
}
// 3. Execute guards
await this.guardExecutor.checkGuards(transition.guards, {
actorId,
instance,
});
// 4. Update state
const fromState = instance.current_state;
instance.current_state = transition.to;
// Check if reached final state
const toStateConfig = dsl.states.find((s) => s.name === transition.to);
if (toStateConfig.type === 'final') {
instance.completed_at = new Date();
}
await manager.save(instance);
// 5. Record history
await manager.save(WorkflowHistory, {
instance_id: instanceId,
from_state: fromState,
to_state: transition.to,
action,
actor_id: actorId,
metadata: {},
});
// 6. Execute effects
await this.effectExecutor.executeEffects(transition.effects, {
instance,
actorId,
manager,
});
});
}
async getInstanceHistory(instanceId: number): Promise<WorkflowHistory[]> {
return this.historyRepo.find({
where: { instance_id: instanceId },
relations: ['actor'],
order: { transitioned_at: 'ASC' },
});
}
async getCurrentState(entityType: string, entityId: number): Promise<string> {
const instance = await this.instanceRepo.findOne({
where: { entity_type: entityType, entity_id: entityId },
order: { started_at: 'DESC' },
});
return instance?.current_state || null;
}
}
```
### 5. Guard Executor
```typescript
// File: backend/src/modules/workflow-engine/services/guard-executor.service.ts
@Injectable()
export class GuardExecutorService {
constructor(private abilityFactory: AbilityFactory) {}
async checkGuards(guards: Guard[], context: any): Promise<void> {
if (!guards || guards.length === 0) {
return;
}
for (const guard of guards) {
await this.checkGuard(guard, context);
}
}
private async checkGuard(guard: Guard, context: any): Promise<void> {
switch (guard.type) {
case 'permission':
await this.checkPermission(guard.permission, context);
break;
case 'validation':
await this.checkValidation(guard.rules, context);
break;
case 'condition':
await this.checkCondition(guard.condition, context);
break;
default:
throw new BadRequestException(`Unknown guard type: ${guard.type}`);
}
}
private async checkPermission(
permission: string,
context: any
): Promise<void> {
const ability = await this.abilityFactory.createForUser({
user_id: context.actorId,
});
const [action, subject] = permission.split('.');
if (!ability.can(action, subject)) {
throw new ForbiddenException(`Permission denied: ${permission}`);
}
}
private async checkValidation(rules: string[], context: any): Promise<void> {
// Implement validation rules
// e.g., "hasAttachment", "hasRecipient"
}
private async checkCondition(condition: string, context: any): Promise<void> {
// Evaluate condition expression
// e.g., "entity.status === 'draft'"
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('WorkflowEngineService', () => {
it('should create instance with initial state', async () => {
const instance = await service.createInstance(
'CORRESPONDENCE_ROUTING',
'correspondence',
1
);
expect(instance.current_state).toBe('DRAFT');
});
it('should execute valid transition', async () => {
await service.executeTransition(instance.id, 'SUBMIT', userId);
const updated = await instanceRepo.findOne(instance.id);
expect(updated.current_state).toBe('SUBMITTED');
});
it('should reject invalid transition', async () => {
await expect(
service.executeTransition(instance.id, 'INVALID_ACTION', userId)
).rejects.toThrow('Invalid transition');
});
});
```
---
## 📚 Related Documents
- [ADR-001: Unified Workflow Engine](../05-decisions/ADR-001-unified-workflow-engine.md)
- [Unified Workflow Requirements](../01-requirements/03.6-unified-workflow.md)
---
## 📦 Deliverables
- [ ] Workflow Entities (Definition, Instance, History)
- [ ] DSL Parser และ Validator
- [ ] WorkflowEngineService
- [ ] Guard Executor
- [ ] Effect Executor
- [ ] Example Workflow Definitions
- [ ] Unit Tests (90% coverage)
- [ ] Integration Tests
- [ ] Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------ | -------- | --------------------------------------- |
| DSL parsing errors | High | Comprehensive validation |
| Guard failures | Medium | Clear error messages |
| State corruption | Critical | Transaction-safe updates |
| Performance issues | Medium | Optimize DSL parsing, cache definitions |
---
## 📌 Notes
- DSL structure validated on save
- Workflow definitions versioned
- Guard checks before state changes
- History tracked for audit trail
- Effects executed after state update

View File

@@ -0,0 +1,587 @@
# Task: RFA Module
**Status:** In Progress
**Priority:** P1 (High - Core Business Module)
**Estimated Effort:** 8-12 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004, TASK-BE-006
**Owner:** Backend Team
---
## 📋 Overview
สร้าง RFA (Request for Approval) Module สำหรับจัดการเอกสารขออนุมัติด้วย Master-Revision Pattern พร้อม Approval Workflow
---
## 🎯 Objectives
- ✅ CRUD Operations (RFAs + Revisions + Items)
- ✅ Master-Revision Pattern
- ✅ RFA Items Management
- ✅ Approval Workflow Integration
- ✅ Response/Approve Actions
- ✅ Status Tracking
---
## 📝 Acceptance Criteria
1. **Basic Operations:**
- ✅ Create RFA with auto-generated number
- ✅ Add/Update/Delete RFA items
- ✅ Create revision
- ✅ Get RFA with all items and attachments
2. **Approval Workflow:**
- ✅ Submit RFA → Start approval workflow
- ✅ Review RFA (Approve/Reject/Revise)
- ✅ Respond to RFA
- ✅ Track approval status
3. **RFA Items:**
- ✅ Add multiple items to RFA
- ✅ Link items to drawings (optional)
- ✅ Item-level approval tracking
---
## 🛠️ Implementation Steps
### 1. Entities
```typescript
// File: backend/src/modules/rfa/entities/rfa.entity.ts
@Entity('rfas')
export class Rfa extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
rfa_number: string;
@Column({ length: 500 })
subject: string;
@Column()
project_id: number;
@Column()
contractor_organization_id: number;
@Column()
consultant_organization_id: number;
@Column()
rfa_type_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ default: 'draft' })
status: string;
@Column({ nullable: true })
approved_code_id: number; // Final approval result
@Column()
created_by_user_id: number;
@DeleteDateColumn()
deleted_at: Date;
// Relationships
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => RfaRevision, (rev) => rev.rfa)
revisions: RfaRevision[];
@OneToMany(() => RfaItem, (item) => item.rfa)
items: RfaItem[];
@ManyToOne(() => RfaApproveCode)
@JoinColumn({ name: 'approved_code_id' })
approvedCode: RfaApproveCode;
}
```
```typescript
// File: backend/src/modules/rfa/entities/rfa-revision.entity.ts
@Entity('rfa_revisions')
export class RfaRevision {
@PrimaryGeneratedColumn()
id: number;
@Column()
rfa_id: number;
@Column({ default: 1 })
revision_number: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'json', nullable: true })
details: any;
@Column({ type: 'date', nullable: true })
required_date: Date;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Rfa, (rfa) => rfa.revisions)
@JoinColumn({ name: 'rfa_id' })
rfa: Rfa;
@ManyToMany(() => Attachment)
@JoinTable({
name: 'rfa_attachments',
joinColumn: { name: 'rfa_revision_id' },
inverseJoinColumn: { name: 'attachment_id' },
})
attachments: Attachment[];
}
```
```typescript
// File: backend/src/modules/rfa/entities/rfa-item.entity.ts
@Entity('rfa_items')
export class RfaItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
rfa_id: number;
@Column({ length: 500 })
item_description: string;
@Column({ nullable: true })
drawing_id: number;
@Column({ nullable: true })
location: string;
@Column({ nullable: true })
quantity: number;
@Column({ length: 50, nullable: true })
unit: string;
@Column({ type: 'text', nullable: true })
remarks: string;
@ManyToOne(() => Rfa, (rfa) => rfa.items)
@JoinColumn({ name: 'rfa_id' })
rfa: Rfa;
@ManyToOne(() => ShopDrawing)
@JoinColumn({ name: 'drawing_id' })
drawing: ShopDrawing;
}
```
### 2. Service
```typescript
// File: backend/src/modules/rfa/rfa.service.ts
@Injectable()
export class RfaService {
constructor(
@InjectRepository(Rfa)
private rfaRepo: Repository<Rfa>,
@InjectRepository(RfaRevision)
private revisionRepo: Repository<RfaRevision>,
@InjectRepository(RfaItem)
private itemRepo: Repository<RfaItem>,
private fileStorage: FileStorageService,
private docNumbering: DocumentNumberingService,
private workflowEngine: WorkflowEngineService,
private dataSource: DataSource
) {}
async create(dto: CreateRfaDto, userId: number): Promise<Rfa> {
return this.dataSource.transaction(async (manager) => {
// 1. Generate RFA number
const rfaNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.contractor_organization_id,
typeId: dto.rfa_type_id,
disciplineId: dto.discipline_id,
});
// 2. Create RFA master
const rfa = manager.create(Rfa, {
rfa_number: rfaNumber,
subject: dto.subject,
project_id: dto.project_id,
contractor_organization_id: dto.contractor_organization_id,
consultant_organization_id: dto.consultant_organization_id,
rfa_type_id: dto.rfa_type_id,
discipline_id: dto.discipline_id,
status: 'draft',
created_by_user_id: userId,
});
await manager.save(rfa);
// 3. Create initial revision
const revision = manager.create(RfaRevision, {
rfa_id: rfa.id,
revision_number: 1,
description: dto.description,
details: dto.details,
required_date: dto.required_date,
created_by_user_id: userId,
});
await manager.save(revision);
// 4. Create RFA items
if (dto.items?.length > 0) {
const items = dto.items.map((item) =>
manager.create(RfaItem, {
rfa_id: rfa.id,
...item,
})
);
await manager.save(items);
}
// 5. Commit temp files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
rfa.id,
'rfa',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
// 6. Create workflow instance
await this.workflowEngine.createInstance(
'RFA_APPROVAL',
'rfa',
rfa.id,
manager
);
return rfa;
});
}
async submitForApproval(id: number, userId: number): Promise<void> {
const rfa = await this.findOne(id);
if (rfa.status !== 'draft') {
throw new BadRequestException('Can only submit draft RFAs');
}
// Validate items exist
if (!rfa.items || rfa.items.length === 0) {
throw new BadRequestException('RFA must have at least one item');
}
// Execute workflow transition
await this.workflowEngine.executeTransition(rfa.id, 'SUBMIT', userId);
// Update status
await this.rfaRepo.update(id, { status: 'submitted' });
}
async reviewRfa(
id: number,
action: 'approve' | 'reject' | 'revise',
dto: ReviewRfaDto,
userId: number
): Promise<void> {
const rfa = await this.findOne(id);
if (rfa.status !== 'submitted' && rfa.status !== 'under_review') {
throw new BadRequestException('Invalid RFA status for review');
}
// Execute workflow transition
const workflowAction = action.toUpperCase();
await this.workflowEngine.executeTransition(rfa.id, workflowAction, userId);
// Update RFA status and approval code
const updates: any = {
status:
action === 'approve'
? 'approved'
: action === 'reject'
? 'rejected'
: 'revising',
};
if (action === 'approve' && dto.approve_code_id) {
updates.approved_code_id = dto.approve_code_id;
}
await this.rfaRepo.update(id, updates);
}
async respondToRfa(
id: number,
dto: RespondRfaDto,
userId: number
): Promise<void> {
return this.dataSource.transaction(async (manager) => {
const rfa = await this.findOne(id);
if (rfa.status !== 'approved' && rfa.status !== 'rejected') {
throw new BadRequestException('RFA must be reviewed first');
}
// Create response revision
const latestRevision = await manager.findOne(RfaRevision, {
where: { rfa_id: id },
order: { revision_number: 'DESC' },
});
const responseRevision = manager.create(RfaRevision, {
rfa_id: id,
revision_number: (latestRevision?.revision_number || 0) + 1,
description: dto.response_description,
details: dto.response_details,
created_by_user_id: userId,
});
await manager.save(responseRevision);
// Commit response files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
id,
'rfa',
manager
);
responseRevision.attachments = attachments;
await manager.save(responseRevision);
}
// Update status
await manager.update(Rfa, id, { status: 'responded' });
// Execute workflow
await this.workflowEngine.executeTransition(id, 'RESPOND', userId);
});
}
async findAll(query: SearchRfaDto): Promise<PaginatedResult<Rfa>> {
const queryBuilder = this.rfaRepo
.createQueryBuilder('rfa')
.leftJoinAndSelect('rfa.project', 'project')
.leftJoinAndSelect('rfa.items', 'items')
.leftJoinAndSelect('rfa.approvedCode', 'approvedCode')
.where('rfa.deleted_at IS NULL');
// Apply filters
if (query.project_id) {
queryBuilder.andWhere('rfa.project_id = :projectId', {
projectId: query.project_id,
});
}
if (query.status) {
queryBuilder.andWhere('rfa.status = :status', { status: query.status });
}
if (query.search) {
queryBuilder.andWhere(
'(rfa.subject LIKE :search OR rfa.rfa_number LIKE :search)',
{ search: `%${query.search}%` }
);
}
// Pagination
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('rfa.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
async findOne(id: number): Promise<Rfa> {
const rfa = await this.rfaRepo.findOne({
where: { id, deleted_at: IsNull() },
relations: [
'revisions',
'revisions.attachments',
'items',
'items.drawing',
'project',
'approvedCode',
],
order: { revisions: { revision_number: 'DESC' } },
});
if (!rfa) {
throw new NotFoundException(`RFA #${id} not found`);
}
return rfa;
}
}
```
### 3. Controller
```typescript
// File: backend/src/modules/rfa/rfa.controller.ts
@Controller('rfas')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('RFAs')
export class RfaController {
constructor(private service: RfaService) {}
@Post()
@RequirePermission('rfa.create')
@UseInterceptors(IdempotencyInterceptor)
async create(
@Body() dto: CreateRfaDto,
@CurrentUser() user: User
): Promise<Rfa> {
return this.service.create(dto, user.user_id);
}
@Post(':id/submit')
@RequirePermission('rfa.submit')
async submit(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
) {
return this.service.submitForApproval(id, user.user_id);
}
@Post(':id/review')
@RequirePermission('rfa.review')
async review(
@Param('id', ParseIntPipe) id: number,
@Body() dto: ReviewRfaDto,
@CurrentUser() user: User
) {
return this.service.reviewRfa(id, dto.action, dto, user.user_id);
}
@Post(':id/respond')
@RequirePermission('rfa.respond')
async respond(
@Param('id', ParseIntPipe) id: number,
@Body() dto: RespondRfaDto,
@CurrentUser() user: User
) {
return this.service.respondToRfa(id, dto, user.user_id);
}
@Get()
@RequirePermission('rfa.view')
async findAll(@Query() query: SearchRfaDto) {
return this.service.findAll(query);
}
@Get(':id')
@RequirePermission('rfa.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('RfaService', () => {
it('should create RFA with items', async () => {
const dto = {
subject: 'Test RFA',
project_id: 1,
contractor_organization_id: 3,
consultant_organization_id: 1,
rfa_type_id: 1,
items: [
{ item_description: 'Item 1', quantity: 10, unit: 'pcs' },
{ item_description: 'Item 2', quantity: 5, unit: 'm' },
],
};
const result = await service.create(dto, 1);
expect(result.rfa_number).toMatch(/^TEAM-RFA-\d{4}-\d{4}$/);
expect(result.items).toHaveLength(2);
});
it('should execute approval workflow', async () => {
await service.submitForApproval(rfa.id, userId);
await service.reviewRfa(
rfa.id,
'approve',
{ approve_code_id: 1 },
reviewerId
);
const updated = await service.findOne(rfa.id);
expect(updated.status).toBe('approved');
expect(updated.approved_code_id).toBe(1);
});
});
```
---
## 📚 Related Documents
- [Data Model - RFAs](../02-architecture/data-model.md#rfas)
- [Functional Requirements - RFA](../01-requirements/03.3-rfa.md)
---
## 📦 Deliverables
- [ ] Rfa, RfaRevision, RfaItem Entities
- [ ] RfaService (CRUD + Approval Workflow)
- [ ] RfaController
- [ ] DTOs (Create, Review, Respond, Search)
- [ ] Unit Tests (85% coverage)
- [ ] Integration Tests
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| -------------------------- | ------ | ------------------------------ |
| Complex approval workflow | High | Clear state machine definition |
| Item management complexity | Medium | Transaction-safe CRUD |
| Response/revision tracking | Medium | Clear revision numbering |
---
## 📌 Notes
- RFA Items required before submit
- Approval codes from master data table
- Support multi-level approval workflow
- Response creates new revision
- Link items to drawings (optional)

View File

@@ -0,0 +1,584 @@
# Task: Drawing Module (Shop & Contract Drawings)
**Status:** In Progress
**Priority:** P2 (Medium - Supporting Module)
**Estimated Effort:** 6-8 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Drawing Module สำหรับจัดการ Shop Drawings (แบบก่อสร้าง) และ Contract Drawings (แบบคู่สัญญา)
---
## 🎯 Objectives
- ✅ Contract Drawing Management
- ✅ Shop Drawing with Master-Revision Pattern
- ✅ Drawing Categories
- ✅ Drawing References/Links
- ✅ Version Control
- ✅ Search & Filter
---
## 📝 Acceptance Criteria
1. **Contract Drawings:**
- ✅ Upload contract drawings
- ✅ Categorize by discipline
- ✅ Link to project/contract
- ✅ Search by drawing number
2. **Shop Drawings:**
- ✅ Create shop drawing with auto-number
- ✅ Create revisions
- ✅ Link to contract drawings
- ✅ Track submission status
3. **Drawing Management:**
- ✅ Version tracking
- ✅ Drawing categories
- ✅ Cross-references
- ✅ Attachment management
---
## 🛠️ Implementation Steps
### 1. Entities
```typescript
// File: backend/src/modules/drawing/entities/contract-drawing.entity.ts
@Entity('contract_drawings')
export class ContractDrawing {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
drawing_number: string;
@Column({ length: 500 })
drawing_title: string;
@Column()
contract_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ nullable: true })
category_id: number;
@Column({ type: 'date', nullable: true })
issue_date: Date;
@Column({ length: 50, nullable: true })
revision: string;
@Column({ nullable: true })
attachment_id: number; // PDF file
@CreateDateColumn()
created_at: Date;
@DeleteDateColumn()
deleted_at: Date;
@ManyToOne(() => Contract)
@JoinColumn({ name: 'contract_id' })
contract: Contract;
@ManyToOne(() => Discipline)
@JoinColumn({ name: 'discipline_id' })
discipline: Discipline;
@ManyToOne(() => Attachment)
@JoinColumn({ name: 'attachment_id' })
attachment: Attachment;
@Index(['contract_id', 'drawing_number'], { unique: true })
_contractDrawingIndex: void;
}
```
```typescript
// File: backend/src/modules/drawing/entities/shop-drawing.entity.ts
@Entity('shop_drawings')
export class ShopDrawing extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100, unique: true })
drawing_number: string;
@Column({ length: 500 })
drawing_title: string;
@Column()
project_id: number;
@Column()
contractor_organization_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ nullable: true })
category_id: number;
@Column({ default: 'draft' })
status: string;
@Column()
created_by_user_id: number;
@DeleteDateColumn()
deleted_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => ShopDrawingRevision, (rev) => rev.shopDrawing)
revisions: ShopDrawingRevision[];
@ManyToMany(() => ContractDrawing)
@JoinTable({
name: 'shop_drawing_references',
joinColumn: { name: 'shop_drawing_id' },
inverseJoinColumn: { name: 'contract_drawing_id' },
})
contractDrawingReferences: ContractDrawing[];
}
```
```typescript
// File: backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts
@Entity('shop_drawing_revisions')
export class ShopDrawingRevision {
@PrimaryGeneratedColumn()
id: number;
@Column()
shop_drawing_id: number;
@Column({ default: 1 })
revision_number: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'json', nullable: true })
details: any;
@Column({ type: 'date', nullable: true })
submission_date: Date;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => ShopDrawing, (sd) => sd.revisions)
@JoinColumn({ name: 'shop_drawing_id' })
shopDrawing: ShopDrawing;
@ManyToMany(() => Attachment)
@JoinTable({
name: 'shop_drawing_attachments',
joinColumn: { name: 'shop_drawing_revision_id' },
inverseJoinColumn: { name: 'attachment_id' },
})
attachments: Attachment[];
}
```
### 2. Service
```typescript
// File: backend/src/modules/drawing/drawing.service.ts
@Injectable()
export class DrawingService {
constructor(
@InjectRepository(ContractDrawing)
private contractDrawingRepo: Repository<ContractDrawing>,
@InjectRepository(ShopDrawing)
private shopDrawingRepo: Repository<ShopDrawing>,
@InjectRepository(ShopDrawingRevision)
private shopRevisionRepo: Repository<ShopDrawingRevision>,
private fileStorage: FileStorageService,
private docNumbering: DocumentNumberingService,
private dataSource: DataSource
) {}
// Contract Drawing Methods
async createContractDrawing(
dto: CreateContractDrawingDto,
userId: number
): Promise<ContractDrawing> {
return this.dataSource.transaction(async (manager) => {
// Commit drawing file
const attachments = await this.fileStorage.commitFiles(
[dto.temp_file_id],
null,
'contract_drawing',
manager
);
const contractDrawing = manager.create(ContractDrawing, {
drawing_number: dto.drawing_number,
drawing_title: dto.drawing_title,
contract_id: dto.contract_id,
discipline_id: dto.discipline_id,
category_id: dto.category_id,
issue_date: dto.issue_date,
revision: dto.revision || 'A',
attachment_id: attachments[0].id,
});
return manager.save(contractDrawing);
});
}
async findAllContractDrawings(
query: SearchDrawingDto
): Promise<PaginatedResult<ContractDrawing>> {
const queryBuilder = this.contractDrawingRepo
.createQueryBuilder('cd')
.leftJoinAndSelect('cd.contract', 'contract')
.leftJoinAndSelect('cd.discipline', 'discipline')
.leftJoinAndSelect('cd.attachment', 'attachment')
.where('cd.deleted_at IS NULL');
if (query.contract_id) {
queryBuilder.andWhere('cd.contract_id = :contractId', {
contractId: query.contract_id,
});
}
if (query.discipline_id) {
queryBuilder.andWhere('cd.discipline_id = :disciplineId', {
disciplineId: query.discipline_id,
});
}
if (query.search) {
queryBuilder.andWhere(
'(cd.drawing_number LIKE :search OR cd.drawing_title LIKE :search)',
{ search: `%${query.search}%` }
);
}
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('cd.drawing_number', 'ASC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
// Shop Drawing Methods
async createShopDrawing(
dto: CreateShopDrawingDto,
userId: number
): Promise<ShopDrawing> {
return this.dataSource.transaction(async (manager) => {
// Generate drawing number
const drawingNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.contractor_organization_id,
typeId: 999, // Shop Drawing type
disciplineId: dto.discipline_id,
});
// Create shop drawing master
const shopDrawing = manager.create(ShopDrawing, {
drawing_number: drawingNumber,
drawing_title: dto.drawing_title,
project_id: dto.project_id,
contractor_organization_id: dto.contractor_organization_id,
discipline_id: dto.discipline_id,
category_id: dto.category_id,
status: 'draft',
created_by_user_id: userId,
});
await manager.save(shopDrawing);
// Create initial revision
const revision = manager.create(ShopDrawingRevision, {
shop_drawing_id: shopDrawing.id,
revision_number: 1,
description: dto.description,
details: dto.details,
submission_date: dto.submission_date,
created_by_user_id: userId,
});
await manager.save(revision);
// Commit files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
shopDrawing.id,
'shop_drawing',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
// Link contract drawing references
if (dto.contract_drawing_ids?.length > 0) {
const contractDrawings = await manager.findByIds(
ContractDrawing,
dto.contract_drawing_ids
);
shopDrawing.contractDrawingReferences = contractDrawings;
await manager.save(shopDrawing);
}
return shopDrawing;
});
}
async createShopDrawingRevision(
shopDrawingId: number,
dto: CreateShopDrawingRevisionDto,
userId: number
): Promise<ShopDrawingRevision> {
return this.dataSource.transaction(async (manager) => {
const latestRevision = await manager.findOne(ShopDrawingRevision, {
where: { shop_drawing_id: shopDrawingId },
order: { revision_number: 'DESC' },
});
const nextRevisionNumber = (latestRevision?.revision_number || 0) + 1;
const revision = manager.create(ShopDrawingRevision, {
shop_drawing_id: shopDrawingId,
revision_number: nextRevisionNumber,
description: dto.description,
details: dto.details,
submission_date: dto.submission_date,
created_by_user_id: userId,
});
await manager.save(revision);
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
shopDrawingId,
'shop_drawing',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
return revision;
});
}
async findAllShopDrawings(
query: SearchDrawingDto
): Promise<PaginatedResult<ShopDrawing>> {
const queryBuilder = this.shopDrawingRepo
.createQueryBuilder('sd')
.leftJoinAndSelect('sd.project', 'project')
.leftJoinAndSelect('sd.revisions', 'revisions')
.leftJoinAndSelect('sd.contractDrawingReferences', 'refs')
.where('sd.deleted_at IS NULL');
if (query.project_id) {
queryBuilder.andWhere('sd.project_id = :projectId', {
projectId: query.project_id,
});
}
if (query.search) {
queryBuilder.andWhere(
'(sd.drawing_number LIKE :search OR sd.drawing_title LIKE :search)',
{ search: `%${query.search}%` }
);
}
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('sd.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
async findOneShopDrawing(id: number): Promise<ShopDrawing> {
const shopDrawing = await this.shopDrawingRepo.findOne({
where: { id, deleted_at: IsNull() },
relations: [
'revisions',
'revisions.attachments',
'contractDrawingReferences',
'project',
],
order: { revisions: { revision_number: 'DESC' } },
});
if (!shopDrawing) {
throw new NotFoundException(`Shop Drawing #${id} not found`);
}
return shopDrawing;
}
}
```
### 3. Controller
```typescript
// File: backend/src/modules/drawing/drawing.controller.ts
@Controller('drawings')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Drawings')
export class DrawingController {
constructor(private service: DrawingService) {}
// Contract Drawings
@Post('contract')
@RequirePermission('drawing.create')
async createContractDrawing(
@Body() dto: CreateContractDrawingDto,
@CurrentUser() user: User
) {
return this.service.createContractDrawing(dto, user.user_id);
}
@Get('contract')
@RequirePermission('drawing.view')
async findAllContractDrawings(@Query() query: SearchDrawingDto) {
return this.service.findAllContractDrawings(query);
}
// Shop Drawings
@Post('shop')
@RequirePermission('drawing.create')
@UseInterceptors(IdempotencyInterceptor)
async createShopDrawing(
@Body() dto: CreateShopDrawingDto,
@CurrentUser() user: User
) {
return this.service.createShopDrawing(dto, user.user_id);
}
@Post('shop/:id/revisions')
@RequirePermission('drawing.edit')
async createShopDrawingRevision(
@Param('id', ParseIntPipe) id: number,
@Body() dto: CreateShopDrawingRevisionDto,
@CurrentUser() user: User
) {
return this.service.createShopDrawingRevision(id, dto, user.user_id);
}
@Get('shop')
@RequirePermission('drawing.view')
async findAllShopDrawings(@Query() query: SearchDrawingDto) {
return this.service.findAllShopDrawings(query);
}
@Get('shop/:id')
@RequirePermission('drawing.view')
async findOneShopDrawing(@Param('id', ParseIntPipe) id: number) {
return this.service.findOneShopDrawing(id);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('DrawingService', () => {
it('should create contract drawing with PDF', async () => {
const dto = {
drawing_number: 'A-001',
drawing_title: 'Floor Plan',
contract_id: 1,
temp_file_id: 'temp-pdf-id',
};
const result = await service.createContractDrawing(dto, 1);
expect(result.attachment_id).toBeDefined();
});
it('should create shop drawing with auto number', async () => {
const dto = {
drawing_title: 'Shop Drawing Test',
project_id: 1,
contractor_organization_id: 3,
contract_drawing_ids: [1, 2],
};
const result = await service.createShopDrawing(dto, 1);
expect(result.drawing_number).toMatch(/^TEAM-SD-\d{4}-\d{4}$/);
expect(result.contractDrawingReferences).toHaveLength(2);
});
});
```
---
## 📚 Related Documents
- [Data Model - Drawings](../02-architecture/data-model.md#drawings)
- [Functional Requirements - Drawings](../01-requirements/03.4-contract-drawing.md)
---
## 📦 Deliverables
- [ ] ContractDrawing Entity
- [ ] ShopDrawing & ShopDrawingRevision Entities
- [ ] DrawingService (Both types)
- [ ] DrawingController
- [ ] DTOs
- [ ] Unit Tests (80% coverage)
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| -------------------------- | ------ | --------------------------------- |
| Large drawing files | Medium | File size validation, compression |
| Drawing reference tracking | Medium | Junction table management |
| Version confusion | Low | Clear revision numbering |
---
## 📌 Notes
- Contract drawings: PDF uploads only
- Shop drawings: Auto-numbered with revisions
- Cross-references tracked in junction table
- Categories and disciplines from master data

View File

@@ -0,0 +1,578 @@
# Task: Circulation & Transmittal Modules
**Status:** In Progress
**Priority:** P2 (Medium)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-006
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Circulation Module (ใบเวียนภายใน) และ Transmittal Module (เอกสารนำส่ง) สำหรับจัดการการส่งเอกสารภายในและภายนอก
---
## 🎯 Objectives
- ✅ Circulation Sheet Management
- ✅ Transmittal Management
- ✅ Assignee Tracking
- ✅ Workflow Integration
- ✅ Document Linking
---
## 📝 Acceptance Criteria
1. **Circulation:**
- ✅ Create circulation sheet
- ✅ Add assignees (multiple users)
- ✅ Link documents (correspondences, RFAs)
- ✅ Track completion status
2. **Transmittal:**
- ✅ Create transmittal
- ✅ Add documents
- ✅ Generate transmittal number
- ✅ Print/Export transmittal letter
---
## 🛠️ Implementation Steps
### 1. Circulation Entities
```typescript
// File: backend/src/modules/circulation/entities/circulation.entity.ts
@Entity('circulations')
export class Circulation {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
circulation_number: string;
@Column({ length: 500 })
subject: string;
@Column()
project_id: number;
@Column()
organization_id: number;
@Column({ default: 'active' })
status: string;
@Column({ type: 'date', nullable: true })
due_date: Date;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => CirculationAssignee, (assignee) => assignee.circulation)
assignees: CirculationAssignee[];
@ManyToMany(() => Correspondence)
@JoinTable({ name: 'circulation_correspondences' })
correspondences: Correspondence[];
}
```
```typescript
// File: backend/src/modules/circulation/entities/circulation-assignee.entity.ts
@Entity('circulation_assignees')
export class CirculationAssignee {
@PrimaryGeneratedColumn()
id: number;
@Column()
circulation_id: number;
@Column()
user_id: number;
@Column({ default: 'pending' })
status: string;
@Column({ type: 'text', nullable: true })
remarks: string;
@Column({ type: 'timestamp', nullable: true })
completed_at: Date;
@ManyToOne(() => Circulation, (circ) => circ.assignees)
@JoinColumn({ name: 'circulation_id' })
circulation: Circulation;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}
```
### 2. Transmittal Entities
```typescript
// File: backend/src/modules/transmittal/entities/transmittal.entity.ts
@Entity('transmittals')
export class Transmittal {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
transmittal_number: string;
@Column({ length: 500 })
attention_to: string;
@Column()
project_id: number;
@Column()
from_organization_id: number;
@Column()
to_organization_id: number;
@Column({ type: 'date' })
transmittal_date: Date;
@Column({ type: 'text', nullable: true })
remarks: string;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => TransmittalItem, (item) => item.transmittal)
items: TransmittalItem[];
}
```
```typescript
// File: backend/src/modules/transmittal/entities/transmittal-item.entity.ts
@Entity('transmittal_items')
export class TransmittalItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
transmittal_id: number;
@Column({ length: 50 })
document_type: string; // 'correspondence', 'rfa', 'drawing'
@Column()
document_id: number;
@Column({ length: 100 })
document_number: string;
@Column({ length: 500, nullable: true })
document_title: string;
@Column({ default: 1 })
number_of_copies: number;
@ManyToOne(() => Transmittal, (trans) => trans.items)
@JoinColumn({ name: 'transmittal_id' })
transmittal: Transmittal;
}
```
### 3. Services
```typescript
// File: backend/src/modules/circulation/circulation.service.ts
@Injectable()
export class CirculationService {
constructor(
@InjectRepository(Circulation)
private circulationRepo: Repository<Circulation>,
@InjectRepository(CirculationAssignee)
private assigneeRepo: Repository<CirculationAssignee>,
private docNumbering: DocumentNumberingService,
private workflowEngine: WorkflowEngineService,
private dataSource: DataSource
) {}
async create(
dto: CreateCirculationDto,
userId: number
): Promise<Circulation> {
return this.dataSource.transaction(async (manager) => {
// Generate circulation number
const circulationNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.organization_id,
typeId: 900, // Circulation type
});
// Create circulation
const circulation = manager.create(Circulation, {
circulation_number: circulationNumber,
subject: dto.subject,
project_id: dto.project_id,
organization_id: dto.organization_id,
due_date: dto.due_date,
status: 'active',
created_by_user_id: userId,
});
await manager.save(circulation);
// Add assignees
if (dto.assignee_user_ids?.length > 0) {
const assignees = dto.assignee_user_ids.map((userId) =>
manager.create(CirculationAssignee, {
circulation_id: circulation.id,
user_id: userId,
status: 'pending',
})
);
await manager.save(assignees);
}
// Link correspondences
if (dto.correspondence_ids?.length > 0) {
const correspondences = await manager.findByIds(
Correspondence,
dto.correspondence_ids
);
circulation.correspondences = correspondences;
await manager.save(circulation);
}
// Create workflow instance
await this.workflowEngine.createInstance(
'CIRCULATION_INTERNAL',
'circulation',
circulation.id,
manager
);
return circulation;
});
}
async completeAssignment(
circulationId: number,
assigneeId: number,
dto: CompleteAssignmentDto,
userId: number
): Promise<void> {
const assignee = await this.assigneeRepo.findOne({
where: { id: assigneeId, circulation_id: circulationId, user_id: userId },
});
if (!assignee) {
throw new NotFoundException('Assignment not found');
}
await this.assigneeRepo.update(assigneeId, {
status: 'completed',
remarks: dto.remarks,
completed_at: new Date(),
});
// Check if all assignees completed
const allAssignees = await this.assigneeRepo.find({
where: { circulation_id: circulationId },
});
const allCompleted = allAssignees.every((a) => a.status === 'completed');
if (allCompleted) {
await this.circulationRepo.update(circulationId, { status: 'completed' });
await this.workflowEngine.executeTransition(
circulationId,
'COMPLETE',
userId
);
}
}
}
```
```typescript
// File: backend/src/modules/transmittal/transmittal.service.ts
@Injectable()
export class TransmittalService {
constructor(
@InjectRepository(Transmittal)
private transmittalRepo: Repository<Transmittal>,
@InjectRepository(TransmittalItem)
private itemRepo: Repository<TransmittalItem>,
@InjectRepository(Correspondence)
private correspondenceRepo: Repository<Correspondence>,
@InjectRepository(Rfa)
private rfaRepo: Repository<Rfa>,
private docNumbering: DocumentNumberingService,
private dataSource: DataSource
) {}
async create(
dto: CreateTransmittalDto,
userId: number
): Promise<Transmittal> {
return this.dataSource.transaction(async (manager) => {
// Generate transmittal number
const transmittalNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.from_organization_id,
typeId: 901, // Transmittal type
});
// Create transmittal
const transmittal = manager.create(Transmittal, {
transmittal_number: transmittalNumber,
attention_to: dto.attention_to,
project_id: dto.project_id,
from_organization_id: dto.from_organization_id,
to_organization_id: dto.to_organization_id,
transmittal_date: dto.transmittal_date || new Date(),
remarks: dto.remarks,
created_by_user_id: userId,
});
await manager.save(transmittal);
// Add items
if (dto.items?.length > 0) {
for (const itemDto of dto.items) {
// Fetch document details
const docDetails = await this.getDocumentDetails(
itemDto.document_type,
itemDto.document_id,
manager
);
const item = manager.create(TransmittalItem, {
transmittal_id: transmittal.id,
document_type: itemDto.document_type,
document_id: itemDto.document_id,
document_number: docDetails.number,
document_title: docDetails.title,
number_of_copies: itemDto.number_of_copies || 1,
});
await manager.save(item);
}
}
return transmittal;
});
}
private async getDocumentDetails(
type: string,
id: number,
manager: EntityManager
): Promise<{ number: string; title: string }> {
switch (type) {
case 'correspondence':
const corr = await manager.findOne(Correspondence, { where: { id } });
return { number: corr.correspondence_number, title: corr.title };
case 'rfa':
const rfa = await manager.findOne(Rfa, { where: { id } });
return { number: rfa.rfa_number, title: rfa.subject };
default:
throw new BadRequestException(`Unknown document type: ${type}`);
}
}
async findOne(id: number): Promise<Transmittal> {
const transmittal = await this.transmittalRepo.findOne({
where: { id },
relations: ['items', 'project'],
});
if (!transmittal) {
throw new NotFoundException(`Transmittal #${id} not found`);
}
return transmittal;
}
async generatePDF(id: number): Promise<Buffer> {
const transmittal = await this.findOne(id);
// Generate PDF using template
// Implementation with library like pdfmake or puppeteer
return Buffer.from('PDF content');
}
}
```
### 4. Controllers
```typescript
// File: backend/src/modules/circulation/circulation.controller.ts
@Controller('circulations')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class CirculationController {
constructor(private service: CirculationService) {}
@Post()
@RequirePermission('circulation.create')
async create(@Body() dto: CreateCirculationDto, @CurrentUser() user: User) {
return this.service.create(dto, user.user_id);
}
@Post(':circulationId/assignees/:assigneeId/complete')
@RequirePermission('circulation.complete')
async completeAssignment(
@Param('circulationId', ParseIntPipe) circulationId: number,
@Param('assigneeId', ParseIntPipe) assigneeId: number,
@Body() dto: CompleteAssignmentDto,
@CurrentUser() user: User
) {
return this.service.completeAssignment(
circulationId,
assigneeId,
dto,
user.user_id
);
}
}
```
```typescript
// File: backend/src/modules/transmittal/transmittal.controller.ts
@Controller('transmittals')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class TransmittalController {
constructor(private service: TransmittalService) {}
@Post()
@RequirePermission('transmittal.create')
@UseInterceptors(IdempotencyInterceptor)
async create(@Body() dto: CreateTransmittalDto, @CurrentUser() user: User) {
return this.service.create(dto, user.user_id);
}
@Get(':id')
@RequirePermission('transmittal.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
@Get(':id/pdf')
@RequirePermission('transmittal.view')
async downloadPDF(
@Param('id', ParseIntPipe) id: number,
@Res() res: Response
) {
const pdf = await this.service.generatePDF(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename=transmittal-${id}.pdf`
);
res.send(pdf);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('CirculationService', () => {
it('should create circulation with assignees', async () => {
const dto = {
subject: 'Review Documents',
project_id: 1,
organization_id: 3,
assignee_user_ids: [1, 2, 3],
correspondence_ids: [10, 11],
};
const result = await service.create(dto, 1);
expect(result.assignees).toHaveLength(3);
expect(result.correspondences).toHaveLength(2);
});
});
describe('TransmittalService', () => {
it('should create transmittal with document items', async () => {
const dto = {
attention_to: 'Project Manager',
project_id: 1,
from_organization_id: 3,
to_organization_id: 1,
items: [
{ document_type: 'correspondence', document_id: 10 },
{ document_type: 'rfa', document_id: 5 },
],
};
const result = await service.create(dto, 1);
expect(result.items).toHaveLength(2);
});
});
```
---
## 📚 Related Documents
- [Functional Requirements - Circulation](../01-requirements/03.8-circulation-sheet.md)
- [Functional Requirements - Transmittal](../01-requirements/03.7-transmittals.md)
---
## 📦 Deliverables
- [ ] Circulation & CirculationAssignee Entities
- [ ] Transmittal & TransmittalItem Entities
- [ ] Services (Both modules)
- [ ] Controllers
- [ ] DTOs
- [ ] PDF Generation (Transmittal)
- [ ] Unit Tests (80% coverage)
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------------- | ------ | ---------------------------- |
| PDF generation complexity | Medium | Use proven library (pdfmake) |
| Multi-assignee tracking | Medium | Clear status management |
| Document linking | Low | Foreign key validation |
---
## 📌 Notes
- Circulation tracks multiple assignees
- All assignees must complete before circulation closes
- Transmittal can include multiple document types
- PDF template for transmittal letter
- Auto-numbering for both modules

View File

@@ -0,0 +1,524 @@
# Task: Notification & Audit Log Services
**Status:** Completed
**Priority:** P3 (Low - Supporting Services)
**Estimated Effort:** 3-5 days
**Dependencies:** TASK-BE-001, TASK-BE-002
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Notification Service สำหรับส่งการแจ้งเตือน และ Audit Log Service สำหรับบันทึกประวัติการใช้งานระบบ
---
## 🎯 Objectives
- ✅ Email Notification
- ✅ LINE Notify Integration
- ✅ In-App Notifications
- ✅ Audit Log Recording
- ✅ Audit Log Query & Export
---
## 📝 Acceptance Criteria
1. **Notifications:**
- ✅ Send email via queue
- ✅ Send LINE Notify
- ✅ Store in-app notifications
- ✅ Mark notifications as read
- ✅ Notification templates
2. **Audit Logs:**
- ✅ Auto-log CRUD operations
- ✅ Log workflow transitions
- ✅ Query audit logs by user/entity
- ✅ Export to CSV
---
## 🛠️ Implementation Steps
### 1. Notification Entity
```typescript
// File: backend/src/modules/notification/entities/notification.entity.ts
@Entity('notifications')
export class Notification {
@PrimaryGeneratedColumn()
id: number;
@Column()
user_id: number;
@Column({ length: 100 })
notification_type: string;
@Column({ length: 500 })
title: string;
@Column({ type: 'text' })
message: string;
@Column({ length: 255, nullable: true })
link: string;
@Column({ default: false })
is_read: boolean;
@Column({ type: 'timestamp', nullable: true })
read_at: Date;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}
```
### 2. Notification Service
```typescript
// File: backend/src/modules/notification/notification.service.ts
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
@Injectable()
export class NotificationService {
constructor(
@InjectRepository(Notification)
private notificationRepo: Repository<Notification>,
@InjectQueue('email') private emailQueue: Queue,
@InjectQueue('line-notify') private lineQueue: Queue
) {}
async createNotification(dto: CreateNotificationDto): Promise<Notification> {
const notification = this.notificationRepo.create({
user_id: dto.user_id,
notification_type: dto.type,
title: dto.title,
message: dto.message,
link: dto.link,
});
return this.notificationRepo.save(notification);
}
async sendEmail(dto: SendEmailDto): Promise<void> {
await this.emailQueue.add('send-email', {
to: dto.to,
subject: dto.subject,
template: dto.template,
context: dto.context,
});
}
async sendLineNotify(dto: SendLineNotifyDto): Promise<void> {
await this.lineQueue.add('send-line', {
token: dto.token,
message: dto.message,
});
}
async notifyWorkflowTransition(
workflowId: number,
action: string,
actorId: number
): Promise<void> {
// Get relevant users to notify
const users = await this.getRelevantUsers(workflowId);
for (const user of users) {
// Create in-app notification
await this.createNotification({
user_id: user.user_id,
type: 'workflow_transition',
title: `${action} completed`,
message: `Workflow ${workflowId} has been ${action}`,
link: `/workflows/${workflowId}`,
});
// Send email
if (user.email_notifications_enabled) {
await this.sendEmail({
to: user.email,
subject: `Workflow Update`,
template: 'workflow-transition',
context: { action, workflowId },
});
}
// Send LINE
if (user.line_notify_token) {
await this.sendLineNotify({
token: user.line_notify_token,
message: `Workflow ${workflowId}: ${action}`,
});
}
}
}
async getUserNotifications(
userId: number,
unreadOnly: boolean = false
): Promise<Notification[]> {
const query: any = { user_id: userId };
if (unreadOnly) {
query.is_read = false;
}
return this.notificationRepo.find({
where: query,
order: { created_at: 'DESC' },
take: 50,
});
}
async markAsRead(notificationId: number, userId: number): Promise<void> {
await this.notificationRepo.update(
{ id: notificationId, user_id: userId },
{ is_read: true, read_at: new Date() }
);
}
async markAllAsRead(userId: number): Promise<void> {
await this.notificationRepo.update(
{ user_id: userId, is_read: false },
{ is_read: true, read_at: new Date() }
);
}
}
```
### 3. Email Queue Processor
```typescript
// File: backend/src/modules/notification/processors/email.processor.ts
import { Processor, Process } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import * as nodemailer from 'nodemailer';
import * as handlebars from 'handlebars';
@Processor('email')
export class EmailProcessor {
private transporter: nodemailer.Transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
@Process('send-email')
async sendEmail(job: Job<any>) {
const { to, subject, template, context } = job.data;
// Load template
const templatePath = `./templates/emails/${template}.hbs`;
const templateSource = await fs.readFile(templatePath, 'utf-8');
const compiledTemplate = handlebars.compile(templateSource);
const html = compiledTemplate(context);
// Send email
await this.transporter.sendMail({
from: process.env.SMTP_FROM,
to,
subject,
html,
});
}
}
```
### 4. LINE Notify Processor
```typescript
// File: backend/src/modules/notification/processors/line-notify.processor.ts
@Processor('line-notify')
export class LineNotifyProcessor {
@Process('send-line')
async sendLineNotify(job: Job<any>) {
const { token, message } = job.data;
await axios.post(
'https://notify-api.line.me/api/notify',
`message=${encodeURIComponent(message)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
}
);
}
}
```
### 5. Audit Log Service
```typescript
// File: backend/src/modules/audit/audit.service.ts
@Injectable()
export class AuditService {
constructor(
@InjectRepository(AuditLog)
private auditRepo: Repository<AuditLog>
) {}
async log(dto: CreateAuditLogDto): Promise<void> {
const auditLog = this.auditRepo.create({
user_id: dto.user_id,
action: dto.action,
entity_type: dto.entity_type,
entity_id: dto.entity_id,
changes: dto.changes,
ip_address: dto.ip_address,
user_agent: dto.user_agent,
});
await this.auditRepo.save(auditLog);
}
async findByEntity(
entityType: string,
entityId: number
): Promise<AuditLog[]> {
return this.auditRepo.find({
where: { entity_type: entityType, entity_id: entityId },
relations: ['user'],
order: { created_at: 'DESC' },
});
}
async findByUser(userId: number, limit: number = 100): Promise<AuditLog[]> {
return this.auditRepo.find({
where: { user_id: userId },
order: { created_at: 'DESC' },
take: limit,
});
}
async exportToCsv(query: AuditQueryDto): Promise<string> {
const logs = await this.auditRepo.find({
where: this.buildWhereClause(query),
relations: ['user'],
order: { created_at: 'DESC' },
});
// Generate CSV
const csv = logs
.map((log) =>
[
log.created_at,
log.user.username,
log.action,
log.entity_type,
log.entity_id,
log.ip_address,
].join(',')
)
.join('\n');
return `Timestamp,User,Action,Entity Type,Entity ID,IP Address\n${csv}`;
}
}
```
### 6. Audit Interceptor
```typescript
// File: backend/src/common/interceptors/audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private auditService: AuditService) {}
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const { method, url, user, ip, headers } = request;
// Only audit write operations
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
return next.handle();
}
return next.handle().pipe(
tap(async (response) => {
// Extract entity info from URL
const match = url.match(/\/(\w+)\/(\d+)?/);
if (match) {
const [, entityType, entityId] = match;
await this.auditService.log({
user_id: user?.user_id,
action: `${method} ${entityType}`,
entity_type: entityType,
entity_id: entityId ? parseInt(entityId) : null,
changes: JSON.stringify(request.body),
ip_address: ip,
user_agent: headers['user-agent'],
});
}
})
);
}
}
```
### 7. Controllers
```typescript
// File: backend/src/modules/notification/notification.controller.ts
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationController {
constructor(private service: NotificationService) {}
@Get('my')
async getMyNotifications(
@CurrentUser() user: User,
@Query('unread_only') unreadOnly: boolean
) {
return this.service.getUserNotifications(user.user_id, unreadOnly);
}
@Post(':id/read')
async markAsRead(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
) {
return this.service.markAsRead(id, user.user_id);
}
@Post('read-all')
async markAllAsRead(@CurrentUser() user: User) {
return this.service.markAllAsRead(user.user_id);
}
}
```
```typescript
// File: backend/src/modules/audit/audit.controller.ts
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class AuditController {
constructor(private service: AuditService) {}
@Get('entity/:type/:id')
@RequirePermission('audit.view')
async getEntityAuditLogs(
@Param('type') type: string,
@Param('id', ParseIntPipe) id: number
) {
return this.service.findByEntity(type, id);
}
@Get('export')
@RequirePermission('audit.export')
async exportAuditLogs(@Query() query: AuditQueryDto, @Res() res: Response) {
const csv = await this.service.exportToCsv(query);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=audit-logs.csv');
res.send(csv);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('NotificationService', () => {
it('should create in-app notification', async () => {
const result = await service.createNotification({
user_id: 1,
type: 'info',
title: 'Test',
message: 'Test message',
});
expect(result.id).toBeDefined();
});
it('should queue email for sending', async () => {
await service.sendEmail({
to: 'test@example.com',
subject: 'Test',
template: 'test',
context: {},
});
expect(emailQueue.add).toHaveBeenCalled();
});
});
describe('AuditService', () => {
it('should log audit event', async () => {
await service.log({
user_id: 1,
action: 'CREATE correspondence',
entity_type: 'correspondence',
entity_id: 10,
});
const logs = await service.findByEntity('correspondence', 10);
expect(logs).toHaveLength(1);
});
});
```
---
## 📚 Related Documents
- [System Architecture - Notifications](../02-architecture/system-architecture.md#notifications)
---
## 📦 Deliverables
- [ ] NotificationService (Email, LINE, In-App)
- [ ] Email & LINE Queue Processors
- [ ] Email Templates (Handlebars)
- [ ] AuditService
- [ ] Audit Interceptor
- [ ] Controllers
- [ ] Unit Tests (75% coverage)
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| --------------------- | ------ | -------------------------- |
| Email service down | Low | Queue retry logic |
| LINE token expiration | Low | Token refresh mechanism |
| Audit log volume | Medium | Archive old logs, indexing |
---
## 📌 Notes
- Email sent via queue (async)
- LINE Notify requires user token setup
- In-app notifications stored in DB
- Audit logs auto-generated via interceptor
- Export audit logs to CSV
- Email templates use Handlebars

View File

@@ -0,0 +1,641 @@
# Task: Master Data Management Module
**Status:** Completed
**Priority:** P1 (High - Required for System Setup)
**Estimated Effort:** 6-8 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Master Data Management Module สำหรับจัดการข้อมูลหลักของระบบ ที่ใช้สำหรับ Configuration และ Dropdown Lists
---
## 🎯 Objectives
- ✅ Organization Management (CRUD)
- ✅ Project & Contract Management
- ✅ Type/Category Management
- ✅ Discipline Management
- ✅ Code Management (RFA Approve Codes, etc.)
- ✅ User Preferences
---
## 📝 Acceptance Criteria
1. **Organization Management:**
- ✅ Create/Update/Delete organizations
- ✅ Active/Inactive toggle
- ✅ Organization hierarchy (if needed)
- ✅ Unique organization codes
2. **Project & Contract Management:**
- ✅ Create/Update/Delete projects
- ✅ Link projects to organizations
- ✅ Create/Update/Delete contracts
- ✅ Link contracts to projects
3. **Type Management:**
- ✅ Correspondence Types CRUD
- ✅ RFA Types CRUD
- ✅ Drawing Categories CRUD
- ✅ Correspondence Sub Types CRUD
4. **Discipline Management:**
- ✅ Create/Update disciplines
- ✅ Discipline codes (GEN, STR, ARC, etc.)
- ✅ Active/Inactive status
5. **Code Management:**
- ✅ RFA Approve Codes CRUD
- ✅ Other lookup codes
---
## 🛠️ Implementation Steps
### 1. Organization Module
```typescript
// File: backend/src/modules/master-data/organization/organization.service.ts
@Injectable()
export class OrganizationService {
constructor(
@InjectRepository(Organization)
private orgRepo: Repository<Organization>
) {}
async create(dto: CreateOrganizationDto): Promise<Organization> {
// Check unique code
const existing = await this.orgRepo.findOne({
where: { organization_code: dto.organization_code },
});
if (existing) {
throw new ConflictException('Organization code already exists');
}
const organization = this.orgRepo.create({
organization_code: dto.organization_code,
organization_name: dto.organization_name,
organization_name_en: dto.organization_name_en,
address: dto.address,
phone: dto.phone,
email: dto.email,
is_active: true,
});
return this.orgRepo.save(organization);
}
async update(id: number, dto: UpdateOrganizationDto): Promise<Organization> {
const organization = await this.findOne(id);
// Check unique code if changed
if (
dto.organization_code &&
dto.organization_code !== organization.organization_code
) {
const existing = await this.orgRepo.findOne({
where: { organization_code: dto.organization_code },
});
if (existing) {
throw new ConflictException('Organization code already exists');
}
}
Object.assign(organization, dto);
return this.orgRepo.save(organization);
}
async findAll(includeInactive: boolean = false): Promise<Organization[]> {
const where: any = {};
if (!includeInactive) {
where.is_active = true;
}
return this.orgRepo.find({
where,
order: { organization_code: 'ASC' },
});
}
async findOne(id: number): Promise<Organization> {
const organization = await this.orgRepo.findOne({ where: { id } });
if (!organization) {
throw new NotFoundException(`Organization #${id} not found`);
}
return organization;
}
async toggleActive(id: number): Promise<Organization> {
const organization = await this.findOne(id);
organization.is_active = !organization.is_active;
return this.orgRepo.save(organization);
}
async delete(id: number): Promise<void> {
// Check if organization has any related data
const hasProjects = await this.hasRelatedProjects(id);
if (hasProjects) {
throw new BadRequestException(
'Cannot delete organization with related projects'
);
}
await this.orgRepo.softDelete(id);
}
private async hasRelatedProjects(organizationId: number): Promise<boolean> {
const count = await this.orgRepo
.createQueryBuilder('org')
.leftJoin(
'projects',
'p',
'p.client_organization_id = org.id OR p.consultant_organization_id = org.id'
)
.where('org.id = :id', { id: organizationId })
.getCount();
return count > 0;
}
}
```
### 2. Project & Contract Module
```typescript
// File: backend/src/modules/master-data/project/project.service.ts
@Injectable()
export class ProjectService {
constructor(
@InjectRepository(Project)
private projectRepo: Repository<Project>,
@InjectRepository(Contract)
private contractRepo: Repository<Contract>
) {}
async createProject(dto: CreateProjectDto): Promise<Project> {
const project = this.projectRepo.create({
project_code: dto.project_code,
project_name: dto.project_name,
project_name_en: dto.project_name_en,
client_organization_id: dto.client_organization_id,
consultant_organization_id: dto.consultant_organization_id,
start_date: dto.start_date,
end_date: dto.end_date,
is_active: true,
});
return this.projectRepo.save(project);
}
async createContract(dto: CreateContractDto): Promise<Contract> {
// Verify project exists
const project = await this.projectRepo.findOne({
where: { id: dto.project_id },
});
if (!project) {
throw new NotFoundException(`Project #${dto.project_id} not found`);
}
const contract = this.contractRepo.create({
contract_number: dto.contract_number,
contract_name: dto.contract_name,
project_id: dto.project_id,
contractor_organization_id: dto.contractor_organization_id,
start_date: dto.start_date,
end_date: dto.end_date,
contract_value: dto.contract_value,
is_active: true,
});
return this.contractRepo.save(contract);
}
async findAllProjects(): Promise<Project[]> {
return this.projectRepo.find({
where: { is_active: true },
relations: ['clientOrganization', 'consultantOrganization', 'contracts'],
order: { project_code: 'ASC' },
});
}
async findProjectContracts(projectId: number): Promise<Contract[]> {
return this.contractRepo.find({
where: { project_id: projectId, is_active: true },
relations: ['contractorOrganization'],
order: { contract_number: 'ASC' },
});
}
}
```
### 3. Type Management Service
```typescript
// File: backend/src/modules/master-data/type/type.service.ts
@Injectable()
export class TypeService {
constructor(
@InjectRepository(CorrespondenceType)
private corrTypeRepo: Repository<CorrespondenceType>,
@InjectRepository(RfaType)
private rfaTypeRepo: Repository<RfaType>,
@InjectRepository(DrawingCategory)
private drawingCategoryRepo: Repository<DrawingCategory>,
@InjectRepository(CorrespondenceSubType)
private corrSubTypeRepo: Repository<CorrespondenceSubType>
) {}
// Correspondence Types
async createCorrespondenceType(
dto: CreateTypeDto
): Promise<CorrespondenceType> {
const type = this.corrTypeRepo.create({
type_code: dto.type_code,
type_name: dto.type_name,
is_active: true,
});
return this.corrTypeRepo.save(type);
}
async findAllCorrespondenceTypes(): Promise<CorrespondenceType[]> {
return this.corrTypeRepo.find({
where: { is_active: true },
order: { type_code: 'ASC' },
});
}
// RFA Types
async createRfaType(dto: CreateTypeDto): Promise<RfaType> {
const type = this.rfaTypeRepo.create({
type_code: dto.type_code,
type_name: dto.type_name,
is_active: true,
});
return this.rfaTypeRepo.save(type);
}
async findAllRfaTypes(): Promise<RfaType[]> {
return this.rfaTypeRepo.find({
where: { is_active: true },
order: { type_code: 'ASC' },
});
}
// Drawing Categories
async createDrawingCategory(dto: CreateTypeDto): Promise<DrawingCategory> {
const category = this.drawingCategoryRepo.create({
category_code: dto.type_code,
category_name: dto.type_name,
is_active: true,
});
return this.drawingCategoryRepo.save(category);
}
async findAllDrawingCategories(): Promise<DrawingCategory[]> {
return this.drawingCategoryRepo.find({
where: { is_active: true },
order: { category_code: 'ASC' },
});
}
// Correspondence Sub Types
async createCorrespondenceSubType(
dto: CreateSubTypeDto
): Promise<CorrespondenceSubType> {
const subType = this.corrSubTypeRepo.create({
correspondence_type_id: dto.correspondence_type_id,
sub_type_code: dto.sub_type_code,
sub_type_name: dto.sub_type_name,
is_active: true,
});
return this.corrSubTypeRepo.save(subType);
}
async findCorrespondenceSubTypes(
typeId: number
): Promise<CorrespondenceSubType[]> {
return this.corrSubTypeRepo.find({
where: { correspondence_type_id: typeId, is_active: true },
order: { sub_type_code: 'ASC' },
});
}
}
```
### 4. Discipline Management
```typescript
// File: backend/src/modules/master-data/discipline/discipline.service.ts
@Injectable()
export class DisciplineService {
constructor(
@InjectRepository(Discipline)
private disciplineRepo: Repository<Discipline>
) {}
async create(dto: CreateDisciplineDto): Promise<Discipline> {
const existing = await this.disciplineRepo.findOne({
where: { discipline_code: dto.discipline_code },
});
if (existing) {
throw new ConflictException('Discipline code already exists');
}
const discipline = this.disciplineRepo.create({
discipline_code: dto.discipline_code,
discipline_name: dto.discipline_name,
is_active: true,
});
return this.disciplineRepo.save(discipline);
}
async findAll(): Promise<Discipline[]> {
return this.disciplineRepo.find({
where: { is_active: true },
order: { discipline_code: 'ASC' },
});
}
async update(id: number, dto: UpdateDisciplineDto): Promise<Discipline> {
const discipline = await this.disciplineRepo.findOne({ where: { id } });
if (!discipline) {
throw new NotFoundException(`Discipline #${id} not found`);
}
Object.assign(discipline, dto);
return this.disciplineRepo.save(discipline);
}
}
```
### 5. RFA Approve Codes
```typescript
// File: backend/src/modules/master-data/code/code.service.ts
@Injectable()
export class CodeService {
constructor(
@InjectRepository(RfaApproveCode)
private rfaApproveCodeRepo: Repository<RfaApproveCode>
) {}
async createRfaApproveCode(
dto: CreateApproveCodeDto
): Promise<RfaApproveCode> {
const code = this.rfaApproveCodeRepo.create({
code: dto.code,
description: dto.description,
is_active: true,
});
return this.rfaApproveCodeRepo.save(code);
}
async findAllRfaApproveCodes(): Promise<RfaApproveCode[]> {
return this.rfaApproveCodeRepo.find({
where: { is_active: true },
order: { code: 'ASC' },
});
}
}
```
### 6. Master Data Controller
```typescript
// File: backend/src/modules/master-data/master-data.controller.ts
@Controller('master-data')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Master Data')
export class MasterDataController {
constructor(
private organizationService: OrganizationService,
private projectService: ProjectService,
private typeService: TypeService,
private disciplineService: DisciplineService,
private codeService: CodeService
) {}
// Organizations
@Get('organizations')
async getOrganizations() {
return this.organizationService.findAll();
}
@Post('organizations')
@RequirePermission('master_data.manage')
async createOrganization(@Body() dto: CreateOrganizationDto) {
return this.organizationService.create(dto);
}
@Put('organizations/:id')
@RequirePermission('master_data.manage')
async updateOrganization(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateOrganizationDto
) {
return this.organizationService.update(id, dto);
}
// Projects
@Get('projects')
async getProjects() {
return this.projectService.findAllProjects();
}
@Post('projects')
@RequirePermission('master_data.manage')
async createProject(@Body() dto: CreateProjectDto) {
return this.projectService.createProject(dto);
}
// Contracts
@Get('projects/:projectId/contracts')
async getProjectContracts(
@Param('projectId', ParseIntPipe) projectId: number
) {
return this.projectService.findProjectContracts(projectId);
}
@Post('contracts')
@RequirePermission('master_data.manage')
async createContract(@Body() dto: CreateContractDto) {
return this.projectService.createContract(dto);
}
// Correspondence Types
@Get('correspondence-types')
async getCorrespondenceTypes() {
return this.typeService.findAllCorrespondenceTypes();
}
@Post('correspondence-types')
@RequirePermission('master_data.manage')
async createCorrespondenceType(@Body() dto: CreateTypeDto) {
return this.typeService.createCorrespondenceType(dto);
}
// RFA Types
@Get('rfa-types')
async getRfaTypes() {
return this.typeService.findAllRfaTypes();
}
// Disciplines
@Get('disciplines')
async getDisciplines() {
return this.disciplineService.findAll();
}
@Post('disciplines')
@RequirePermission('master_data.manage')
async createDiscipline(@Body() dto: CreateDisciplineDto) {
return this.disciplineService.create(dto);
}
// RFA Approve Codes
@Get('rfa-approve-codes')
async getRfaApproveCodes() {
return this.codeService.findAllRfaApproveCodes();
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('OrganizationService', () => {
it('should create organization with unique code', async () => {
const dto = {
organization_code: 'TEST',
organization_name: 'Test Organization',
};
const result = await service.create(dto);
expect(result.organization_code).toBe('TEST');
expect(result.is_active).toBe(true);
});
it('should throw error when creating duplicate code', async () => {
await expect(
service.create({
organization_code: 'TEAM',
organization_name: 'Duplicate',
})
).rejects.toThrow(ConflictException);
});
it('should prevent deletion of organization with projects', async () => {
await expect(service.delete(1)).rejects.toThrow(BadRequestException);
});
});
describe('ProjectService', () => {
it('should create project with contracts', async () => {
const project = await service.createProject({
project_code: 'LCBP3',
project_name: 'Laem Chabang Phase 3',
client_organization_id: 1,
consultant_organization_id: 2,
});
expect(project.project_code).toBe('LCBP3');
});
});
```
### 2. Integration Tests
```bash
# Get all organizations
curl http://localhost:3000/master-data/organizations
# Create organization
curl -X POST http://localhost:3000/master-data/organizations \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"organization_code": "ABC",
"organization_name": "ABC Company"
}'
# Get projects
curl http://localhost:3000/master-data/projects
# Get disciplines
curl http://localhost:3000/master-data/disciplines
```
---
## 📚 Related Documents
- [Data Model - Master Data](../02-architecture/data-model.md#core--master-data)
- [Data Dictionary v1.4.5](../../docs/4_Data_Dictionary_V1_4_5.md)
---
## 📦 Deliverables
- [ ] OrganizationService (CRUD)
- [ ] ProjectService & ContractService
- [ ] TypeService (Correspondence, RFA, Drawing)
- [ ] DisciplineService
- [ ] CodeService (RFA Approve Codes)
- [ ] MasterDataController (unified endpoints)
- [ ] DTOs for all entities
- [ ] Unit Tests (80% coverage)
- [ ] Integration Tests
- [ ] API Documentation (Swagger)
- [ ] Seed data scripts
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ----------------------- | ------ | --------------------------------- |
| Duplicate codes | Medium | Unique constraints + validation |
| Circular dependencies | Low | Proper foreign key design |
| Deletion with relations | High | Check relations before delete |
| Data integrity | High | Use transactions for related data |
---
## 📌 Notes
- All master data tables have `is_active` flag
- Soft delete for organizations and projects
- Unique codes enforced at database level
- Organization deletion checks for related projects
- Seed data required for initial setup
- Admin-only access for create/update/delete
- Public read access for dropdown lists
- Cache frequently accessed master data (Redis)

View File

@@ -0,0 +1,738 @@
# Task: User Management Module
**Status:** Completed
**Priority:** P1 (High - Core User Features)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth & RBAC)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง User Management Module สำหรับจัดการ Users, User Profiles, Password Management, และ User Preferences
---
## 🎯 Objectives
- ✅ User CRUD Operations
- ✅ User Profile Management
- ✅ Password Change & Reset
- ✅ User Preferences (Settings)
- ✅ User Avatar Upload
- ✅ User Search & Filter
---
## 📝 Acceptance Criteria
1. **User Management:**
- ✅ Create user with default password
- ✅ Update user information
- ✅ Activate/Deactivate users
- ✅ Soft delete users
- ✅ Search users by name/email/username
2. **Profile Management:**
- ✅ User can view own profile
- ✅ User can update own profile
- ✅ Upload avatar/profile picture
- ✅ Change display name
3. **Password Management:**
- ✅ Change password (authenticated)
- ✅ Reset password (forgot password flow)
- ✅ Password strength validation
- ✅ Password history (prevent reuse)
4. **User Preferences:**
- ✅ Email notification settings
- ✅ LINE Notify token
- ✅ Language preference (TH/EN)
- ✅ Timezone settings
---
## 🛠️ Implementation Steps
### 1. User Service
```typescript
// File: backend/src/modules/user/user.service.ts
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
@InjectRepository(UserPreference)
private preferenceRepo: Repository<UserPreference>,
private fileStorage: FileStorageService
) {}
async create(dto: CreateUserDto): Promise<User> {
// Check unique username and email
const existingUsername = await this.userRepo.findOne({
where: { username: dto.username },
});
if (existingUsername) {
throw new ConflictException('Username already exists');
}
const existingEmail = await this.userRepo.findOne({
where: { email: dto.email },
});
if (existingEmail) {
throw new ConflictException('Email already exists');
}
// Hash default password
const defaultPassword = dto.password || this.generateRandomPassword();
const passwordHash = await bcrypt.hash(defaultPassword, 10);
// Create user
const user = this.userRepo.create({
username: dto.username,
email: dto.email,
first_name: dto.first_name,
last_name: dto.last_name,
organization_id: dto.organization_id,
password_hash: passwordHash,
is_active: true,
must_change_password: true, // Force password change on first login
});
await this.userRepo.save(user);
// Create default preferences
await this.createDefaultPreferences(user.user_id);
return user;
}
async update(id: number, dto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
// Check unique email if changed
if (dto.email && dto.email !== user.email) {
const existing = await this.userRepo.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException('Email already exists');
}
}
Object.assign(user, dto);
return this.userRepo.save(user);
}
async findAll(query: SearchUserDto): Promise<PaginatedResult<User>> {
const queryBuilder = this.userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.organization', 'org')
.where('user.deleted_at IS NULL');
// Search filters
if (query.search) {
queryBuilder.andWhere(
'(user.username LIKE :search OR user.email LIKE :search OR ' +
'user.first_name LIKE :search OR user.last_name LIKE :search)',
{ search: `%${query.search}%` }
);
}
if (query.organization_id) {
queryBuilder.andWhere('user.organization_id = :orgId', {
orgId: query.organization_id,
});
}
if (query.is_active !== undefined) {
queryBuilder.andWhere('user.is_active = :isActive', {
isActive: query.is_active,
});
}
// Pagination
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('user.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
// Remove sensitive data
items.forEach((user) => this.sanitizeUser(user));
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: number): Promise<User> {
const user = await this.userRepo.findOne({
where: { user_id: id, deleted_at: IsNull() },
relations: ['organization'],
});
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return this.sanitizeUser(user);
}
async toggleActive(id: number): Promise<User> {
const user = await this.userRepo.findOne({ where: { user_id: id } });
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
user.is_active = !user.is_active;
return this.userRepo.save(user);
}
async softDelete(id: number): Promise<void> {
const user = await this.findOne(id);
// Prevent deletion of users with active sessions or critical roles
const hasActiveSessions = await this.hasActiveSessions(id);
if (hasActiveSessions) {
throw new BadRequestException('Cannot delete user with active sessions');
}
await this.userRepo.softDelete(id);
}
private sanitizeUser(user: User): User {
delete user.password_hash;
return user;
}
private generateRandomPassword(): string {
return (
Math.random().toString(36).slice(-8) +
Math.random().toString(36).slice(-8)
);
}
private async createDefaultPreferences(userId: number): Promise<void> {
const preferences = this.preferenceRepo.create({
user_id: userId,
language: 'th',
timezone: 'Asia/Bangkok',
email_notifications_enabled: true,
line_notify_enabled: false,
});
await this.preferenceRepo.save(preferences);
}
private async hasActiveSessions(userId: number): Promise<boolean> {
// Check Redis for active sessions
// Implementation depends on session management strategy
return false;
}
}
```
### 2. Profile Service
```typescript
// File: backend/src/modules/user/profile.service.ts
@Injectable()
export class ProfileService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
@InjectRepository(UserPreference)
private preferenceRepo: Repository<UserPreference>,
private fileStorage: FileStorageService
) {}
async getProfile(userId: number): Promise<UserProfile> {
const user = await this.userRepo.findOne({
where: { user_id: userId },
relations: ['organization', 'preferences'],
});
if (!user) {
throw new NotFoundException('User not found');
}
const preferences = await this.preferenceRepo.findOne({
where: { user_id: userId },
});
return {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
display_name: user.display_name,
organization: user.organization,
avatar_url: user.avatar_url,
preferences,
};
}
async updateProfile(userId: number, dto: UpdateProfileDto): Promise<User> {
const user = await this.userRepo.findOne({ where: { user_id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
// Update allowed fields only
if (dto.first_name) user.first_name = dto.first_name;
if (dto.last_name) user.last_name = dto.last_name;
if (dto.display_name) user.display_name = dto.display_name;
if (dto.phone) user.phone = dto.phone;
return this.userRepo.save(user);
}
async uploadAvatar(
userId: number,
file: Express.Multer.File
): Promise<string> {
// Upload to temp storage
const uploadResult = await this.fileStorage.uploadToTemp(file, userId);
// Commit to permanent storage
const attachments = await this.fileStorage.commitFiles(
[uploadResult.temp_id],
userId,
'user_avatar',
this.userRepo.manager
);
const avatarUrl = `/attachments/${attachments[0].id}`;
// Update user avatar_url
await this.userRepo.update(userId, { avatar_url: avatarUrl });
return avatarUrl;
}
async updatePreferences(
userId: number,
dto: UpdatePreferencesDto
): Promise<UserPreference> {
let preferences = await this.preferenceRepo.findOne({
where: { user_id: userId },
});
if (!preferences) {
preferences = this.preferenceRepo.create({ user_id: userId });
}
Object.assign(preferences, dto);
return this.preferenceRepo.save(preferences);
}
}
```
### 3. Password Service
```typescript
// File: backend/src/modules/user/password.service.ts
@Injectable()
export class PasswordService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
@InjectRepository(PasswordHistory)
private passwordHistoryRepo: Repository<PasswordHistory>,
private redis: Redis,
private emailQueue: Queue
) {}
async changePassword(userId: number, dto: ChangePasswordDto): Promise<void> {
const user = await this.userRepo.findOne({ where: { user_id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
// Verify current password
const isValid = await bcrypt.compare(
dto.current_password,
user.password_hash
);
if (!isValid) {
throw new BadRequestException('Current password is incorrect');
}
// Validate new password strength
this.validatePasswordStrength(dto.new_password);
// Check password history (prevent reuse of last 5 passwords)
await this.checkPasswordHistory(userId, dto.new_password);
// Hash new password
const newPasswordHash = await bcrypt.hash(dto.new_password, 10);
// Update password
user.password_hash = newPasswordHash;
user.must_change_password = false;
user.password_changed_at = new Date();
await this.userRepo.save(user);
// Save to password history
await this.passwordHistoryRepo.save({
user_id: userId,
password_hash: newPasswordHash,
});
// Invalidate all existing sessions
await this.invalidateUserSessions(userId);
}
async requestPasswordReset(email: string): Promise<void> {
const user = await this.userRepo.findOne({ where: { email } });
if (!user) {
// Don't reveal if email exists
return;
}
// Generate reset token
const resetToken = this.generateResetToken();
const resetTokenHash = await bcrypt.hash(resetToken, 10);
// Store token in Redis (expires in 1 hour)
await this.redis.set(
`password_reset:${user.user_id}`,
resetTokenHash,
'EX',
3600
);
// Send reset email
await this.emailQueue.add('send-password-reset', {
to: user.email,
resetToken,
username: user.username,
});
}
async resetPassword(dto: ResetPasswordDto): Promise<void> {
const user = await this.userRepo.findOne({
where: { username: dto.username },
});
if (!user) {
throw new BadRequestException('Invalid reset token');
}
// Verify reset token
const storedTokenHash = await this.redis.get(
`password_reset:${user.user_id}`
);
if (!storedTokenHash) {
throw new BadRequestException('Reset token expired');
}
const isValid = await bcrypt.compare(dto.reset_token, storedTokenHash);
if (!isValid) {
throw new BadRequestException('Invalid reset token');
}
// Validate new password
this.validatePasswordStrength(dto.new_password);
// Hash and update password
const newPasswordHash = await bcrypt.hash(dto.new_password, 10);
user.password_hash = newPasswordHash;
user.password_changed_at = new Date();
await this.userRepo.save(user);
// Delete reset token
await this.redis.del(`password_reset:${user.user_id}`);
// Invalidate sessions
await this.invalidateUserSessions(user.user_id);
}
private validatePasswordStrength(password: string): void {
if (password.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
// Check for at least one uppercase, one lowercase, one number
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
if (!hasUpperCase || !hasLowerCase || !hasNumber) {
throw new BadRequestException(
'Password must contain uppercase, lowercase, and numbers'
);
}
}
private async checkPasswordHistory(
userId: number,
newPassword: string
): Promise<void> {
const history = await this.passwordHistoryRepo.find({
where: { user_id: userId },
order: { changed_at: 'DESC' },
take: 5,
});
for (const record of history) {
const isSame = await bcrypt.compare(newPassword, record.password_hash);
if (isSame) {
throw new BadRequestException('Cannot reuse recently used passwords');
}
}
}
private generateResetToken(): string {
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
);
}
private async invalidateUserSessions(userId: number): Promise<void> {
await this.redis.del(`user:${userId}:permissions`);
await this.redis.del(`refresh_token:${userId}`);
}
}
```
### 4. User Controller
```typescript
// File: backend/src/modules/user/user.controller.ts
@Controller('users')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Users')
export class UserController {
constructor(
private userService: UserService,
private profileService: ProfileService,
private passwordService: PasswordService
) {}
// User Management (Admin)
@Get()
@RequirePermission('user.view')
async findAll(@Query() query: SearchUserDto) {
return this.userService.findAll(query);
}
@Post()
@RequirePermission('user.create')
async create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
@Put(':id')
@RequirePermission('user.update')
async update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateUserDto
) {
return this.userService.update(id, dto);
}
@Post(':id/toggle-active')
@RequirePermission('user.update')
async toggleActive(@Param('id', ParseIntPipe) id: number) {
return this.userService.toggleActive(id);
}
@Delete(':id')
@RequirePermission('user.delete')
@HttpCode(204)
async delete(@Param('id', ParseIntPipe) id: number) {
return this.userService.softDelete(id);
}
// Profile Management (Self)
@Get('me/profile')
async getMyProfile(@CurrentUser() user: User) {
return this.profileService.getProfile(user.user_id);
}
@Put('me/profile')
async updateMyProfile(
@CurrentUser() user: User,
@Body() dto: UpdateProfileDto
) {
return this.profileService.updateProfile(user.user_id, dto);
}
@Post('me/avatar')
@UseInterceptors(FileInterceptor('avatar'))
async uploadAvatar(
@CurrentUser() user: User,
@UploadedFile() file: Express.Multer.File
) {
return this.profileService.uploadAvatar(user.user_id, file);
}
@Put('me/preferences')
async updatePreferences(
@CurrentUser() user: User,
@Body() dto: UpdatePreferencesDto
) {
return this.profileService.updatePreferences(user.user_id, dto);
}
// Password Management
@Post('me/change-password')
async changePassword(
@CurrentUser() user: User,
@Body() dto: ChangePasswordDto
) {
return this.passwordService.changePassword(user.user_id, dto);
}
@Post('request-password-reset')
@Public() // No auth required
async requestPasswordReset(@Body() dto: RequestPasswordResetDto) {
return this.passwordService.requestPasswordReset(dto.email);
}
@Post('reset-password')
@Public() // No auth required
async resetPassword(@Body() dto: ResetPasswordDto) {
return this.passwordService.resetPassword(dto);
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('UserService', () => {
it('should create user with hashed password', async () => {
const dto = {
username: 'testuser',
email: 'test@example.com',
first_name: 'Test',
last_name: 'User',
organization_id: 1,
};
const result = await service.create(dto);
expect(result.username).toBe('testuser');
expect(result.password_hash).toBeUndefined(); // Sanitized
expect(result.must_change_password).toBe(true);
});
it('should prevent duplicate username', async () => {
await expect(
service.create({ username: 'admin', email: 'new@example.com' })
).rejects.toThrow(ConflictException);
});
});
describe('PasswordService', () => {
it('should change password successfully', async () => {
await service.changePassword(1, {
current_password: 'oldPassword123',
new_password: 'NewPassword123',
});
// Verify password updated
});
it('should prevent password reuse', async () => {
await expect(
service.changePassword(1, {
current_password: 'current',
new_password: 'previouslyUsed',
})
).rejects.toThrow('Cannot reuse recently used passwords');
});
it('should validate password strength', async () => {
await expect(
service.changePassword(1, {
current_password: 'current',
new_password: 'weak',
})
).rejects.toThrow('Password must be at least 8 characters');
});
});
```
---
## 📚 Related Documents
- [Data Model - Users](../02-architecture/data-model.md#users--rbac)
- [ADR-004: RBAC Implementation](../05-decisions/ADR-004-rbac-implementation.md)
---
## 📦 Deliverables
- [ ] UserService (CRUD)
- [ ] ProfileService (Profile & Avatar)
- [ ] PasswordService (Change & Reset)
- [ ] UserController
- [ ] DTOs (Create, Update, Profile, Password)
- [ ] Password History tracking
- [ ] Unit Tests (85% coverage)
- [ ] Integration Tests
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| -------------------- | -------- | --------------------------------------- |
| Password reset abuse | High | Rate limiting, token expiration |
| Session hijacking | Critical | Session invalidation on password change |
| Weak passwords | High | Password strength validation |
| Email not delivered | Medium | Logging + retry mechanism |
---
## 📌 Notes
- Default password generated on user creation
- Force password change on first login
- Password history prevents reuse (last 5 passwords)
- Reset token expires in 1 hour
- All sessions invalidated on password change
- Avatar uploaded via two-phase storage
- User preferences stored separately
- Soft delete for users
- Admin permission required for user CRUD
- Users can manage own profile without admin permission

View File

@@ -0,0 +1,381 @@
# TASK-FE-001: Frontend Setup & Configuration
**ID:** TASK-FE-001
**Title:** Frontend Project Setup & Configuration
**Category:** Foundation
**Priority:** P0 (Critical)
**Effort:** 2-3 days
**Dependencies:** None
**Assigned To:** Frontend Lead
---
## 📋 Overview
Setup Next.js project with TypeScript, Tailwind CSS, Shadcn/UI, and all necessary tooling for LCBP3-DMS frontend development.
---
## 🎯 Objectives
1. Initialize Next.js 14+ project with App Router
2. Configure TypeScript with strict mode
3. Setup Tailwind CSS and Shadcn/UI
4. Configure ESLint, Prettier, and Husky
5. Setup environment variables
6. Configure API client and interceptors
---
## ✅ Acceptance Criteria
- [ ] Next.js project running on `http://localhost:3001`
- [ ] TypeScript strict mode enabled
- [ ] Shadcn/UI components installable with CLI
- [ ] ESLint and Prettier working
- [ ] Environment variables loaded correctly
- [ ] Axios configured with interceptors
- [ ] Health check endpoint accessible
---
## 🔧 Implementation Steps
### Step 1: Create Next.js Project
```bash
# Create Next.js project with TypeScript
npx create-next-app@latest frontend --typescript --tailwind --app --src-dir --import-alias "@/*"
cd frontend
# Install dependencies
npm install
```
### Step 2: Configure TypeScript
```json
// File: tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```
### Step 3: Setup Tailwind CSS
```javascript
// File: tailwind.config.js
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
// ... more colors
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;
```
### Step 4: Initialize Shadcn/UI
```bash
# Initialize shadcn/ui
npx shadcn-ui@latest init
# Answer prompts:
# - TypeScript: Yes
# - Style: Default
# - Base color: Slate
# - CSS variables: Yes
# - Tailwind config: tailwind.config.js
# - Components: @/components
# - Utils: @/lib/utils
# Install essential components
npx shadcn-ui@latest add button input label card dialog dropdown-menu table
```
### Step 5: Configure ESLint & Prettier
```bash
npm install -D prettier eslint-config-prettier
```
```json
// File: .eslintrc.json
{
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn"
}
}
```
```json
// File: .prettierrc
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100
}
```
### Step 6: Setup Git Hooks with Husky
```bash
npm install -D husky lint-staged
# Initialize husky
npx husky-init
```
```json
// File: package.json (add to scripts)
{
"scripts": {
"prepare": "husky install"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
}
```
```bash
# File: .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
```
### Step 7: Environment Variables
```bash
# File: .env.local (DO NOT commit)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_APP_NAME=LCBP3-DMS
NEXT_PUBLIC_APP_VERSION=1.5.0
```
```bash
# File: .env.example (commit this)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_APP_NAME=LCBP3-DMS
NEXT_PUBLIC_APP_VERSION=1.5.0
```
```typescript
// File: src/lib/env.ts
export const env = {
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
appName: process.env.NEXT_PUBLIC_APP_NAME!,
appVersion: process.env.NEXT_PUBLIC_APP_VERSION!,
};
// Validate at build time
if (!env.apiUrl) {
throw new Error('NEXT_PUBLIC_API_URL is required');
}
```
### Step 8: Configure API Client
```bash
npm install axios react-query zustand
```
```typescript
// File: src/lib/api/client.ts
import axios from 'axios';
import { env } from '@/lib/env';
export const apiClient = axios.create({
baseURL: env.apiUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Redirect to login
localStorage.removeItem('auth-token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
```
### Step 9: Project Structure
```
frontend/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (public)/ # Public routes
│ │ │ └── login/
│ │ ├── (dashboard)/ # Protected routes
│ │ │ ├── correspondences/
│ │ │ ├── rfas/
│ │ │ └── drawings/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ │
│ ├── components/ # React components
│ │ ├── ui/ # Shadcn/UI components
│ │ ├── layout/ # Layout components
│ │ ├── correspondences/ # Feature components
│ │ └── common/ # Shared components
│ │
│ ├── lib/ # Utilities
│ │ ├── api/ # API clients
│ │ ├── stores/ # Zustand stores
│ │ ├── utils.ts # Helpers
│ │ └── env.ts # Environment
│ │
│ ├── types/ # TypeScript types
│ │ └── index.ts
│ │
│ └── styles/ # Global styles
│ └── globals.css
├── public/ # Static files
├── .env.example
├── .eslintrc.json
├── .prettierrc
├── next.config.js
├── tailwind.config.ts
├── tsconfig.json
└── package.json
```
---
## 🧪 Testing & Verification
### Manual Testing
```bash
# Start dev server
npm run dev
# Check TypeScript
npm run type-check
# Run linter
npm run lint
# Format code
npm run format
```
### Verification Checklist
- [ ] Dev server starts without errors
- [ ] TypeScript compilation succeeds
- [ ] ESLint passes with no errors
- [ ] Tailwind CSS classes working
- [ ] Shadcn/UI components render correctly
- [ ] Environment variables accessible
- [ ] API client configured (test with mock endpoint)
---
## 📦 Deliverables
- [ ] Next.js project initialized
- [ ] TypeScript configured (strict mode)
- [ ] Tailwind CSS working
- [ ] Shadcn/UI installed
- [ ] ESLint & Prettier configured
- [ ] Husky git hooks working
- [ ] Environment variables setup
- [ ] API client configured
- [ ] Project structure documented
---
## 🔗 Related Documents
- [ADR-011: Next.js App Router](../../05-decisions/ADR-011-nextjs-app-router.md)
- [ADR-012: UI Component Library](../../05-decisions/ADR-012-ui-component-library.md)
- [ADR-014: State Management](../../05-decisions/ADR-014-state-management.md)
- [Frontend Guidelines](../../03-implementation/frontend-guidelines.md)
---
## 📝 Notes
- Use App Router (not Pages Router)
- Enable TypeScript strict mode
- Follow Shadcn/UI patterns for components
- Keep bundle size small
---
**Created:** 2025-12-01
**Updated:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,438 @@
# TASK-FE-002: Authentication & Authorization UI
**ID:** TASK-FE-002
**Title:** Login, Session Management & RBAC UI
**Category:** Foundation
**Priority:** P0 (Critical)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-001, TASK-BE-002
**Assigned To:** Frontend Developer
---
## 📋 Overview
Implement authentication UI including login form, session management with Zustand, and permission-based UI rendering.
---
## 🎯 Objectives
1. Create login page with form validation
2. Implement JWT token management
3. Setup Zustand auth store
4. Create protected route middleware
5. Implement permission-based UI components
6. Add logout functionality
---
## ✅ Acceptance Criteria
- [ ] User can login with username/password
- [ ] JWT token stored securely
- [ ] Unauthorized users redirected to login
- [ ] UI elements hidden based on permissions
- [ ] Session persists after page reload
- [ ] Logout clears session
---
## 🔧 Implementation Steps
### Step 1: Create Auth Store (Zustand)
```typescript
// File: src/lib/stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
user_id: number;
username: string;
email: string;
first_name: string;
last_name: string;
roles: Array<{
role_name: string;
scope: string;
scope_id: number;
}>;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
setAuth: (user: User, token: string) => void;
logout: () => void;
hasPermission: (permission: string, scope?: string) => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
setAuth: (user, token) => {
set({ user, token, isAuthenticated: true });
localStorage.setItem('auth-token', token);
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false });
localStorage.removeItem('auth-token');
},
hasPermission: (permission, scope) => {
const { user } = get();
if (!user) return false;
// Check user roles for permission
return user.roles.some((role) => {
// Permission logic based on RBAC
return true; // Implement actual logic
});
},
}),
{
name: 'auth-storage',
}
)
);
```
### Step 2: Login API Client
```typescript
// File: src/lib/api/auth.ts
import { apiClient } from './client';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
user: {
user_id: number;
username: string;
email: string;
first_name: string;
last_name: string;
roles: any[];
};
}
export const authApi = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post('/auth/login', credentials);
return response.data;
},
logout: async (): Promise<void> => {
await apiClient.post('/auth/logout');
},
getCurrentUser: async () => {
const response = await apiClient.get('/auth/me');
return response.data;
},
};
```
### Step 3: Login Page
```typescript
// File: src/app/(public)/login/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { authApi } from '@/lib/api/auth';
import { useAuthStore } from '@/lib/stores/auth-store';
const loginSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth);
const [error, setError] = useState('');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
setError('');
const response = await authApi.login(data);
setAuth(response.user, response.access_token);
router.push('/');
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl">LCBP3-DMS Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">
{error}
</div>
)}
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
{...register('username')}
placeholder="Enter username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">
{errors.username.message}
</p>
)}
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...register('password')}
placeholder="Enter password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
```
### Step 4: Protected Route Middleware
```typescript
// File: src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token');
const isPublicPage = request.nextUrl.pathname.startsWith('/login');
if (!token && !isPublicPage) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (token && isPublicPage) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
```
### Step 5: Permission-Based UI Components
```typescript
// File: src/components/common/can.tsx
'use client';
import { useAuthStore } from '@/lib/stores/auth-store';
import { ReactNode } from 'react';
interface CanProps {
permission: string;
scope?: string;
children: ReactNode;
fallback?: ReactNode;
}
export function Can({
permission,
scope,
children,
fallback = null,
}: CanProps) {
const hasPermission = useAuthStore((state) => state.hasPermission);
if (!hasPermission(permission, scope)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
```
```typescript
// Usage example
import { Can } from '@/components/common/can';
<Can permission="correspondence:create">
<Button>Create Correspondence</Button>
</Can>;
```
### Step 6: User Menu Component
```typescript
// File: src/components/layout/user-menu.tsx
'use client';
import { useAuthStore } from '@/lib/stores/auth-store';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useRouter } from 'next/navigation';
export function UserMenu() {
const router = useRouter();
const { user, logout } = useAuthStore();
if (!user) return null;
const handleLogout = () => {
logout();
router.push('/login');
};
const initials = `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">
{user.first_name} {user.last_name}
</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push('/settings')}>
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
```
---
## 🧪 Testing & Verification
### Test Cases
1. **Login Success**
- Enter valid credentials
- User redirected to dashboard
- Token stored
2. **Login Failure**
- Enter invalid credentials
- Error message displayed
- User stays on login page
3. **Protected Routes**
- Access protected route without login → Redirect to login
- Login → Access protected route successfully
4. **Session Persistence**
- Login → Refresh page → Still logged in
5. **Logout**
- Click logout → Token cleared → Redirected to login
6. **Permission-Based UI**
- User with permission sees button
- User without permission doesn't see button
---
## 📦 Deliverables
- [ ] Login page with validation
- [ ] Zustand auth store
- [ ] Auth API client
- [ ] Protected route middleware
- [ ] Permission-based UI components
- [ ] User menu with logout
- [ ] Session persistence
---
## 🔗 Related Documents
- [ADR-014: State Management](../../05-decisions/ADR-014-state-management.md)
- [ADR-013: Form Handling](../../05-decisions/ADR-013-form-handling-validation.md)
- [TASK-BE-002: Auth & RBAC](./TASK-BE-002-auth-rbac.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,346 @@
# TASK-FE-003: Layout & Navigation System
**ID:** TASK-FE-003
**Title:** Dashboard Layout, Sidebar & Navigation
**Category:** Foundation
**Priority:** P0 (Critical)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-001, TASK-FE-002
**Assigned To:** Frontend Developer
---
## 📋 Overview
Create responsive dashboard layout with sidebar navigation, header, and optimized nested layouts using Next.js App Router.
---
## 🎯 Objectives
1. Create responsive dashboard layout
2. Implement sidebar with navigation menu
3. Create header with user menu and breadcrumbs
4. Setup route groups for layout organization
5. Implement mobile-responsive design
6. Add dark mode support (optional)
---
## ✅ Acceptance Criteria
- [ ] Dashboard layout responsive (desktop/tablet/mobile)
- [ ] Sidebar collapsible on mobile
- [ ] Navigation highlights active route
- [ ] Breadcrumbs show current location
- [ ] User menu functional
- [ ] Layout persists across page navigation
---
## 🔧 Implementation Steps
### Step 1: Dashboard Layout
```typescript
// File: src/app/(dashboard)/layout.tsx
import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// Server-side auth check
const cookieStore = cookies();
const token = cookieStore.get('auth-token');
if (!token) {
redirect('/login');
}
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6 bg-gray-50">
{children}
</main>
</div>
</div>
);
}
```
### Step 2: Sidebar Component
```typescript
// File: src/components/layout/sidebar.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
FileText,
Clipboard,
Image,
Send,
Users,
Settings,
Home,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/lib/stores/ui-store';
const menuItems = [
{ href: '/', label: 'Dashboard', icon: Home },
{ href: '/correspondences', label: 'Correspondences', icon: FileText },
{ href: '/rfas', label: 'RFAs', icon: Clipboard },
{ href: '/drawings', label: 'Drawings', icon: Image },
{ href: '/transmittals', label: 'Transmittals', icon: Send },
{ href: '/users', label: 'Users', icon: Users },
{ href: '/settings', label: 'Settings', icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
const { sidebarCollapsed, toggleSidebar } = useUIStore();
return (
<aside
className={cn(
'flex flex-col border-r bg-white transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64'
)}
>
{/* Logo */}
<div className="flex h-16 items-center justify-between px-4 border-b">
{!sidebarCollapsed && <h1 className="text-lg font-bold">LCBP3-DMS</h1>}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="ml-auto"
>
<MenuIcon />
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 p-2">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-gray-700 hover:bg-gray-100'
)}
>
<Icon className="h-5 w-5 flex-shrink-0" />
{!sidebarCollapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
{/* Footer */}
{!sidebarCollapsed && (
<div className="border-t p-4 text-xs text-gray-500">Version 1.5.0</div>
)}
</aside>
);
}
```
### Step 3: Header Component
```typescript
// File: src/components/layout/header.tsx
'use client';
import { Breadcrumbs } from './breadcrumbs';
import { UserMenu } from './user-menu';
import { Button } from '@/components/ui/button';
import { Bell } from 'lucide-react';
export function Header() {
return (
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<Breadcrumbs />
<div className="flex items-center gap-4">
{/* Notifications */}
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500" />
</Button>
<UserMenu />
</div>
</header>
);
}
```
### Step 4: Breadcrumbs Component
```typescript
// File: src/components/layout/breadcrumbs.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronRight } from 'lucide-react';
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split('/').filter(Boolean);
return (
<nav className="flex items-center space-x-2 text-sm">
<Link href="/" className="text-gray-600 hover:text-gray-900">
Home
</Link>
{segments.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join('/')}`;
const label = segment.charAt(0).toUpperCase() + segment.slice(1);
const isLast = index === segments.length - 1;
return (
<div key={href} className="flex items-center space-x-2">
<ChevronRight className="h-4 w-4 text-gray-400" />
{isLast ? (
<span className="font-medium text-gray-900">{label}</span>
) : (
<Link href={href} className="text-gray-600 hover:text-gray-900">
{label}
</Link>
)}
</div>
);
})}
</nav>
);
}
```
### Step 5: UI Store (Sidebar State)
```typescript
// File: src/lib/stores/ui-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
}),
{
name: 'ui-preferences',
}
)
);
```
### Step 6: Mobile Responsive
```typescript
// File: src/components/layout/mobile-sidebar.tsx
'use client';
import { useState } from 'react';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Menu } from 'lucide-react';
import { Sidebar } from './sidebar';
export function MobileSidebar() {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-64">
<Sidebar />
</SheetContent>
</Sheet>
);
}
```
---
## 🧪 Testing & Verification
### Manual Testing
1. **Desktop Layout**
- Sidebar visible and functional
- Toggle sidebar collapse/expand
- Active route highlighted
2. **Mobile Layout**
- Sidebar hidden by default
- Hamburger menu opens sidebar
- Sidebar slides from left
3. **Navigation**
- Click menu items → Navigate correctly
- Breadcrumbs update on navigation
- Active state persists on reload
4. **User Menu**
- Display user info
- Logout functional
---
## 📦 Deliverables
- [ ] Dashboard layout for (dashboard) route group
- [ ] Responsive sidebar with navigation
- [ ] Header with breadcrumbs and user menu
- [ ] UI store for sidebar state
- [ ] Mobile-responsive design
- [ ] Icon library (lucide-react)
---
## 🔗 Related Documents
- [ADR-011: Next.js App Router](../../05-decisions/ADR-011-nextjs-app-router.md)
- [ADR-014: State Management](../../05-decisions/ADR-014-state-management.md)
- [TASK-FE-002: Auth UI](./TASK-FE-002-auth-ui.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,406 @@
# TASK-FE-004: Correspondence Management UI
**ID:** TASK-FE-004
**Title:** Correspondence List, Create, View & Edit UI
**Category:** Business Modules
**Priority:** P1 (High)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-003, TASK-BE-005
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build complete UI for Correspondence Management including list view with filters, create/edit forms, detail view, and status workflows.
---
## 🎯 Objectives
1. Create correspondence list with pagination and filters
2. Implement create/edit forms with validation
3. Build detail view with attachments
4. Add status workflow actions (Submit, Approve, Reject)
5. Implement file upload for attachments
6. Add search and filtering
---
## ✅ Acceptance Criteria
- [ ] List displays correspondences with pagination
- [ ] Filter by status, date range, organization
- [ ] Create form validates all required fields
- [ ] File attachments upload successfully
- [ ] Detail view shows complete information
- [ ] Workflow actions work (Submit, Approve, Reject)
- [ ] Real-time status updates
---
## 🔧 Implementation Steps
### Step 1: Correspondence List Page
```typescript
// File: src/app/(dashboard)/correspondences/page.tsx
import { CorrespondenceList } from '@/components/correspondences/list';
import { CorrespondenceFilters } from '@/components/correspondences/filters';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { getCorrespondences } from '@/lib/api/correspondences';
export default async function CorrespondencesPage({
searchParams,
}: {
searchParams: { page?: string; status?: string; search?: string };
}) {
const page = parseInt(searchParams.page || '1');
const data = await getCorrespondences({
page,
status: searchParams.status,
search: searchParams.search,
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Correspondences</h1>
<p className="text-gray-600 mt-1">
Manage official letters and communications
</p>
</div>
<Link href="/correspondences/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Correspondence
</Button>
</Link>
</div>
<CorrespondenceFilters />
<CorrespondenceList data={data} />
</div>
);
}
```
### Step 2: Correspondence List Component
```typescript
// File: src/components/correspondences/list.tsx
'use client';
import { useState } from 'react';
import { Correspondence } from '@/types';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { format } from 'date-fns';
import { Eye, Edit } from 'lucide-react';
import { Pagination } from '@/components/common/pagination';
interface CorrespondenceListProps {
data: {
items: Correspondence[];
total: number;
page: number;
totalPages: number;
};
}
export function CorrespondenceList({ data }: CorrespondenceListProps) {
const getStatusColor = (status: string) => {
const colors = {
DRAFT: 'gray',
PENDING: 'yellow',
IN_REVIEW: 'blue',
APPROVED: 'green',
REJECTED: 'red',
};
return colors[status] || 'gray';
};
return (
<div className="space-y-4">
{data.items.map((item) => (
<Card
key={item.correspondence_id}
className="p-6 hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">{item.subject}</h3>
<Badge variant={getStatusColor(item.status)}>
{item.status}
</Badge>
</div>
<p className="text-sm text-gray-600 mb-3">
{item.description || 'No description'}
</p>
<div className="flex gap-6 text-sm text-gray-500">
<span>
<strong>From:</strong> {item.from_organization?.org_name}
</span>
<span>
<strong>To:</strong> {item.to_organization?.org_name}
</span>
<span>
<strong>Date:</strong>{' '}
{format(new Date(item.created_at), 'dd MMM yyyy')}
</span>
</div>
</div>
<div className="flex gap-2">
<Link href={`/correspondences/${item.correspondence_id}`}>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
View
</Button>
</Link>
{item.status === 'DRAFT' && (
<Link href={`/correspondences/${item.correspondence_id}/edit`}>
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
)}
</div>
</div>
</Card>
))}
<Pagination
currentPage={data.page}
totalPages={data.totalPages}
total={data.total}
/>
</div>
);
}
```
### Step 3: Create/Edit Form
```typescript
// File: src/app/(dashboard)/correspondences/new/page.tsx
import { CorrespondenceForm } from '@/components/correspondences/form';
export default function NewCorrespondencePage() {
return (
<div>
<h1 className="text-3xl font-bold mb-6">New Correspondence</h1>
<CorrespondenceForm />
</div>
);
}
```
```typescript
// File: src/components/correspondences/form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FileUpload } from '@/components/common/file-upload';
import { useRouter } from 'next/navigation';
import { correspondenceApi } from '@/lib/api/correspondences';
const correspondenceSchema = z.object({
subject: z.string().min(5, 'Subject must be at least 5 characters'),
description: z.string().optional(),
document_type_id: z.number(),
from_organization_id: z.number(),
to_organization_id: z.number(),
importance: z.enum(['NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
attachments: z.array(z.instanceof(File)).optional(),
});
type FormData = z.infer<typeof correspondenceSchema>;
export function CorrespondenceForm() {
const router = useRouter();
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(correspondenceSchema),
});
const onSubmit = async (data: FormData) => {
try {
await correspondenceApi.create(data);
router.push('/correspondences');
} catch (error) {
console.error(error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
{/* Subject */}
<div>
<Label htmlFor="subject">Subject *</Label>
<Input id="subject" {...register('subject')} />
{errors.subject && (
<p className="text-sm text-red-600 mt-1">{errors.subject.message}</p>
)}
</div>
{/* Description */}
<div>
<Label htmlFor="description">Description</Label>
<Textarea id="description" {...register('description')} rows={4} />
</div>
{/* From/To Organizations */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label>From Organization *</Label>
<Select
onValueChange={(v) => setValue('from_organization_id', parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{/* Populate from API */}
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>To Organization *</Label>
<Select
onValueChange={(v) => setValue('to_organization_id', parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Importance */}
<div>
<Label>Importance</Label>
<div className="flex gap-4 mt-2">
<label className="flex items-center">
<input
type="radio"
value="NORMAL"
{...register('importance')}
defaultChecked
/>
<span className="ml-2">Normal</span>
</label>
<label className="flex items-center">
<input type="radio" value="HIGH" {...register('importance')} />
<span className="ml-2">High</span>
</label>
<label className="flex items-center">
<input type="radio" value="URGENT" {...register('importance')} />
<span className="ml-2">Urgent</span>
</label>
</div>
</div>
{/* File Attachments */}
<div>
<Label>Attachments</Label>
<FileUpload
onFilesSelected={(files) => setValue('attachments', files)}
maxFiles={10}
accept=".pdf,.doc,.docx,.xls,.xlsx"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Correspondence'}
</Button>
</div>
</form>
);
}
```
### Step 4: Detail View
```typescript
// File: src/app/(dashboard)/correspondences/[id]/page.tsx
import { getCorrespondenceById } from '@/lib/api/correspondences';
import { CorrespondenceDetail } from '@/components/correspondences/detail';
import { notFound } from 'next/navigation';
export default async function CorrespondenceDetailPage({
params,
}: {
params: { id: string };
}) {
const correspondence = await getCorrespondenceById(parseInt(params.id));
if (!correspondence) {
notFound();
}
return <CorrespondenceDetail data={correspondence} />;
}
```
---
## 📦 Deliverables
- [ ] List page with filters and pagination
- [ ] Create/Edit forms with validation
- [ ] Detail view with complete information
- [ ] File upload component
- [ ] Status workflow actions
- [ ] API client functions
---
## 🔗 Related Documents
- [ADR-013: Form Handling](../../05-decisions/ADR-013-form-handling-validation.md)
- [TASK-BE-005: Correspondence Module](./TASK-BE-005-correspondence-module.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,454 @@
# TASK-FE-005: Common Components & Reusable UI
**ID:** TASK-FE-005
**Title:** Build Reusable UI Components Library
**Category:** Foundation
**Priority:** P1 (High)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-001
**Assigned To:** Frontend Developer
---
## 📋 Overview
Create reusable components including Data Table, File Upload, Date Picker, Pagination, Status Badges, and other common UI elements used across the application.
---
## 🎯 Objectives
1. Build DataTable component with sorting, filtering
2. Create File Upload component with drag-and-drop
3. Implement Date Range Picker
4. Create Pagination component
5. Build Status Badge components
6. Create Confirmation Dialog
7. Implement Toast Notifications
---
## 📦 Deliverables
### 1. Data Table Component
```typescript
// File: src/components/common/data-table.tsx
'use client';
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getSortedRowModel,
SortingState,
} from '@tanstack/react-table';
import { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
sorting,
},
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
```
### 2. File Upload Component
```typescript
// File: src/components/common/file-upload.tsx
'use client';
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, X, File } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
maxFiles?: number;
accept?: string;
maxSize?: number; // bytes
}
export function FileUpload({
onFilesSelected,
maxFiles = 5,
accept = '.pdf,.doc,.docx',
maxSize = 10485760, // 10MB
}: FileUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const newFiles = [...prev, ...acceptedFiles].slice(0, maxFiles);
onFilesSelected(newFiles);
return newFiles;
});
},
[maxFiles, onFilesSelected]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxFiles,
accept: accept.split(',').reduce((acc, ext) => ({ ...acc, [ext]: [] }), {}),
maxSize,
});
const removeFile = (index: number) => {
setFiles((prev) => {
const newFiles = prev.filter((_, i) => i !== index);
onFilesSelected(newFiles);
return newFiles;
});
};
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
isDragActive
? 'border-primary bg-primary/5'
: 'border-gray-300 hover:border-gray-400'
)}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">
{isDragActive
? 'Drop files here'
: 'Drag & drop files or click to browse'}
</p>
<p className="mt-1 text-xs text-gray-500">
Maximum {maxFiles} files, {(maxSize / 1024 / 1024).toFixed(0)}MB each
</p>
</div>
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<File className="h-5 w-5 text-gray-500" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
```
### 3. Pagination Component
```typescript
// File: src/components/common/pagination.tsx
'use client';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface PaginationProps {
currentPage: number;
totalPages: number;
total: number;
}
export function Pagination({
currentPage,
totalPages,
total,
}: PaginationProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const createPageURL = (pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
return (
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing page {currentPage} of {totalPages} ({total} total items)
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => router.push(createPageURL(currentPage - 1))}
disabled={currentPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
variant={pageNum === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() => router.push(createPageURL(pageNum))}
>
{pageNum}
</Button>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => router.push(createPageURL(currentPage + 1))}
disabled={currentPage >= totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
```
### 4. Status Badge Component
```typescript
// File: src/components/common/status-badge.tsx
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface StatusBadgeProps {
status: string;
className?: string;
}
const statusConfig = {
DRAFT: { label: 'Draft', variant: 'secondary' },
PENDING: { label: 'Pending', variant: 'warning' },
IN_REVIEW: { label: 'In Review', variant: 'info' },
APPROVED: { label: 'Approved', variant: 'success' },
REJECTED: { label: 'Rejected', variant: 'destructive' },
CLOSED: { label: 'Closed', variant: 'outline' },
};
export function StatusBadge({ status, className }: StatusBadgeProps) {
const config = statusConfig[status] || { label: status, variant: 'default' };
return (
<Badge
variant={config.variant as any}
className={cn('uppercase', className)}
>
{config.label}
</Badge>
);
}
```
### 5. Confirmation Dialog
```typescript
// File: src/components/common/confirm-dialog.tsx
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
onConfirm: () => void;
confirmText?: string;
cancelText?: string;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
```
### 6. Toast Notifications
```bash
npx shadcn-ui@latest add toast
```
```typescript
// File: src/lib/stores/toast-store.ts (if not using Shadcn toast)
import { create } from 'zustand';
interface Toast {
id: string;
title: string;
description?: string;
variant: 'default' | 'success' | 'error' | 'warning';
}
interface ToastState {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: Math.random().toString() }],
})),
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
}));
```
---
## 🧪 Testing
- [ ] DataTable sorts columns correctly
- [ ] File upload accepts/rejects files based on criteria
- [ ] Pagination navigates pages correctly
- [ ] Status badges show correct colors
- [ ] Confirmation dialog confirms/cancels actions
- [ ] Toast notifications appear and dismiss
---
## 🔗 Related Documents
- [ADR-012: UI Component Library](../../05-decisions/ADR-012-ui-component-library.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,408 @@
# TASK-FE-006: RFA Management UI
**ID:** TASK-FE-006
**Title:** RFA List, Create, View & Workflow UI
**Category:** Business Modules
**Priority:** P1 (High)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-003, TASK-FE-005, TASK-BE-007
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build comprehensive UI for Request for Approval (RFA) management including list with filters, create/edit forms with items, detail view, and approval workflow.
---
## 🎯 Objectives
1. Create RFA list with status filtering
2. Implement RFA creation form with multiple items
3. Build detail view showing items and approval history
4. Add approval workflow UI (Approve/Reject with comments)
5. Implement revision management
6. Add response tracking
---
## ✅ Acceptance Criteria
- [ ] List displays RFAs with pagination and filters
- [ ] Create form allows adding multiple RFA items
- [ ] Detail view shows items, attachments, and workflow history
- [ ] Approve/Reject dialog with comments functional
- [ ] Revision history visible
- [ ] Response tracking works (Approved/Rejected/Approved with Comments)
---
## 🔧 Implementation Steps
### Step 1: RFA List Page
```typescript
// File: src/app/(dashboard)/rfas/page.tsx
import { RFAList } from '@/components/rfas/list';
import { RFAFilters } from '@/components/rfas/filters';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { Plus } from 'lucide-react';
export default async function RFAsPage({
searchParams,
}: {
searchParams: { page?: string; status?: string };
}) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">RFAs (Request for Approval)</h1>
<p className="text-gray-600 mt-1">
Manage approval requests and submissions
</p>
</div>
<Link href="/rfas/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New RFA
</Button>
</Link>
</div>
<RFAFilters />
<RFAList />
</div>
);
}
```
### Step 2: RFA Form with Items
```typescript
// File: src/components/rfas/form.tsx
'use client';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import { Plus, Trash2 } from 'lucide-react';
const rfaItemSchema = z.object({
item_no: z.string(),
description: z.string().min(5),
quantity: z.number().min(0),
unit: z.string(),
drawing_reference: z.string().optional(),
});
const rfaSchema = z.object({
subject: z.string().min(5),
description: z.string().optional(),
contract_id: z.number(),
discipline_id: z.number(),
items: z.array(rfaItemSchema).min(1, 'At least one item required'),
});
type RFAFormData = z.infer<typeof rfaSchema>;
export function RFAForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<RFAFormData>({
resolver: zodResolver(rfaSchema),
defaultValues: {
items: [{ item_no: '1', description: '', quantity: 0, unit: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
const onSubmit = async (data: RFAFormData) => {
console.log(data);
// Submit to API
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl space-y-6">
{/* Basic Info */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">RFA Information</h3>
<div className="space-y-4">
<div>
<Label>Subject *</Label>
<Input {...register('subject')} />
{errors.subject && (
<p className="text-sm text-red-600 mt-1">
{errors.subject.message}
</p>
)}
</div>
<div>
<Label>Description</Label>
<Input {...register('description')} />
</div>
</div>
</Card>
{/* RFA Items */}
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">RFA Items</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({
item_no: (fields.length + 1).toString(),
description: '',
quantity: 0,
unit: '',
})
}
>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
<div className="space-y-4">
{fields.map((field, index) => (
<Card key={field.id} className="p-4 bg-gray-50">
<div className="flex justify-between items-start mb-3">
<h4 className="font-medium">Item #{index + 1}</h4>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
)}
</div>
<div className="grid grid-cols-4 gap-3">
<div>
<Label>Item No.</Label>
<Input {...register(`items.${index}.item_no`)} />
</div>
<div className="col-span-2">
<Label>Description *</Label>
<Input {...register(`items.${index}.description`)} />
</div>
<div>
<Label>Quantity</Label>
<Input
type="number"
{...register(`items.${index}.quantity`, {
valueAsNumber: true,
})}
/>
</div>
</div>
</Card>
))}
</div>
{errors.items?.root && (
<p className="text-sm text-red-600 mt-2">
{errors.items.root.message}
</p>
)}
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Create RFA</Button>
</div>
</form>
);
}
```
### Step 3: RFA Detail with Approval Actions
```typescript
// File: src/components/rfas/detail.tsx
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { CheckCircle, XCircle } from 'lucide-react';
export function RFADetail({ data }: { data: any }) {
const [approvalDialog, setApprovalDialog] = useState<
'approve' | 'reject' | null
>(null);
const [comments, setComments] = useState('');
const handleApproval = async (action: 'approve' | 'reject') => {
// Call API
console.log({ action, comments });
setApprovalDialog(null);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold">{data.subject}</h1>
<div className="flex gap-3 mt-2">
<Badge>{data.status}</Badge>
<span className="text-gray-600">RFA No: {data.rfa_number}</span>
</div>
</div>
{data.status === 'PENDING' && (
<div className="flex gap-2">
<Button
variant="outline"
className="text-green-600"
onClick={() => setApprovalDialog('approve')}
>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</Button>
<Button
variant="outline"
className="text-red-600"
onClick={() => setApprovalDialog('reject')}
>
<XCircle className="mr-2 h-4 w-4" />
Reject
</Button>
</div>
)}
</div>
{/* RFA Items */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">RFA Items</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left">Item No.</th>
<th className="px-4 py-2 text-left">Description</th>
<th className="px-4 py-2 text-right">Quantity</th>
<th className="px-4 py-2 text-left">Unit</th>
<th className="px-4 py-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{data.items?.map((item: any) => (
<tr key={item.rfa_item_id} className="border-t">
<td className="px-4 py-3">{item.item_no}</td>
<td className="px-4 py-3">{item.description}</td>
<td className="px-4 py-3 text-right">{item.quantity}</td>
<td className="px-4 py-3">{item.unit}</td>
<td className="px-4 py-3">
<Badge
variant={
item.status === 'APPROVED' ? 'success' : 'default'
}
>
{item.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Approval Dialog */}
<Dialog
open={approvalDialog !== null}
onOpenChange={() => setApprovalDialog(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{approvalDialog === 'approve' ? 'Approve RFA' : 'Reject RFA'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Comments</Label>
<Textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
rows={4}
placeholder="Enter your comments..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setApprovalDialog(null)}>
Cancel
</Button>
<Button
onClick={() => handleApproval(approvalDialog!)}
variant={
approvalDialog === 'approve' ? 'default' : 'destructive'
}
>
{approvalDialog === 'approve' ? 'Approve' : 'Reject'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
```
---
## 📦 Deliverables
- [ ] RFA list page with filters
- [ ] Create/Edit form with dynamic items
- [ ] Detail view with items table
- [ ] Approval workflow UI (Approve/Reject)
- [ ] Revision management
- [ ] Response tracking
---
## 🔗 Related Documents
- [TASK-BE-007: RFA Module](./TASK-BE-007-rfa-module.md)
- [ADR-013: Form Handling](../../05-decisions/ADR-013-form-handling-validation.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,388 @@
# TASK-FE-007: Drawing Management UI
**ID:** TASK-FE-007
**Title:** Drawing List, Upload & Revision Management UI
**Category:** Business Modules
**Priority:** P2 (Medium)
**Effort:** 4-6 days
**Dependencies:** TASK-FE-003, TASK-FE-005, TASK-BE-008
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build UI for Drawing Management including Contract Drawings and Shop Drawings with revision tracking, file preview, and comparison features.
---
## 🎯 Objectives
1. Create drawing list with category filtering (Contract/Shop)
2. Implement drawing upload with metadata
3. Build revision management UI
4. Add file preview/download functionality
5. Implement drawing comparison (side-by-side)
6. Add version history view
---
## ✅ Acceptance Criteria
- [ ] List displays drawings grouped by type
- [ ] Upload form accepts drawing files (PDF, DWG)
- [ ] Revision history visible with compare feature
- [ ] File preview works for PDF
- [ ] Download functionality working
- [ ] Metadata (discipline, sheet number) editable
---
## 🔧 Implementation Steps
### Step 1: Drawing List with Category Tabs
```typescript
// File: src/app/(dashboard)/drawings/page.tsx
'use client';
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DrawingList } from '@/components/drawings/list';
import { Button } from '@/components/ui/button';
import { Upload } from 'lucide-react';
import Link from 'next/link';
export default function DrawingsPage() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Drawings</h1>
<p className="text-gray-600 mt-1">
Manage contract and shop drawings
</p>
</div>
<Link href="/drawings/upload">
<Button>
<Upload className="mr-2 h-4 w-4" />
Upload Drawing
</Button>
</Link>
</div>
<Tabs defaultValue="contract">
<TabsList>
<TabsTrigger value="contract">Contract Drawings</TabsTrigger>
<TabsTrigger value="shop">Shop Drawings</TabsTrigger>
</TabsList>
<TabsContent value="contract">
<DrawingList type="CONTRACT" />
</TabsContent>
<TabsContent value="shop">
<DrawingList type="SHOP" />
</TabsContent>
</Tabs>
</div>
);
}
```
### Step 2: Drawing Card with Preview
```typescript
// File: src/components/drawings/card.tsx
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { FileText, Download, Eye, GitCompare } from 'lucide-react';
import Link from 'next/link';
export function DrawingCard({ drawing }: { drawing: any }) {
return (
<Card className="p-6 hover:shadow-md transition-shadow">
<div className="flex gap-4">
{/* Thumbnail */}
<div className="w-32 h-32 bg-gray-100 rounded flex items-center justify-center">
<FileText className="h-16 w-16 text-gray-400" />
</div>
{/* Info */}
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-lg font-semibold">
{drawing.drawing_number}
</h3>
<p className="text-sm text-gray-600">{drawing.title}</p>
</div>
<Badge>{drawing.discipline?.discipline_code}</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-3">
<div>
<strong>Sheet:</strong> {drawing.sheet_number}
</div>
<div>
<strong>Revision:</strong> {drawing.current_revision}
</div>
<div>
<strong>Scale:</strong> {drawing.scale || 'N/A'}
</div>
<div>
<strong>Date:</strong>{' '}
{new Date(drawing.issue_date).toLocaleDateString()}
</div>
</div>
<div className="flex gap-2">
<Link href={`/drawings/${drawing.drawing_id}`}>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
View
</Button>
</Link>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Download
</Button>
{drawing.revision_count > 1 && (
<Button variant="outline" size="sm">
<GitCompare className="mr-2 h-4 w-4" />
Compare
</Button>
)}
</div>
</div>
</div>
</Card>
);
}
```
### Step 3: Drawing Upload Form
```typescript
// File: src/app/(dashboard)/drawings/upload/page.tsx
import { DrawingUploadForm } from '@/components/drawings/upload-form';
export default function DrawingUploadPage() {
return (
<div>
<h1 className="text-3xl font-bold mb-6">Upload Drawing</h1>
<DrawingUploadForm />
</div>
);
}
```
```typescript
// File: src/components/drawings/upload-form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card } from '@/components/ui/card';
const drawingSchema = z.object({
drawing_type: z.enum(['CONTRACT', 'SHOP']),
drawing_number: z.string().min(1),
title: z.string().min(5),
discipline_id: z.number(),
sheet_number: z.string(),
scale: z.string().optional(),
file: z.instanceof(File),
});
type DrawingFormData = z.infer<typeof drawingSchema>;
export function DrawingUploadForm() {
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<DrawingFormData>({
resolver: zodResolver(drawingSchema),
});
const onSubmit = async (data: DrawingFormData) => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value);
});
// Upload to API
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Drawing Information</h3>
<div className="space-y-4">
<div>
<Label>Drawing Type *</Label>
<Select onValueChange={(v) => setValue('drawing_type', v as any)}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CONTRACT">Contract Drawing</SelectItem>
<SelectItem value="SHOP">Shop Drawing</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Drawing Number *</Label>
<Input {...register('drawing_number')} />
</div>
<div>
<Label>Sheet Number</Label>
<Input {...register('sheet_number')} />
</div>
</div>
<div>
<Label>Title *</Label>
<Input {...register('title')} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Discipline</Label>
<Select
onValueChange={(v) => setValue('discipline_id', parseInt(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">STR - Structure</SelectItem>
<SelectItem value="2">ARC - Architecture</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Scale</Label>
<Input {...register('scale')} placeholder="1:100" />
</div>
</div>
<div>
<Label>Drawing File *</Label>
<Input
type="file"
accept=".pdf,.dwg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setValue('file', file);
}}
/>
<p className="text-xs text-gray-500 mt-1">
Accepted: PDF, DWG (Max 50MB)
</p>
</div>
</div>
</Card>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Upload Drawing</Button>
</div>
</form>
);
}
```
### Step 4: Revision History
```typescript
// File: src/components/drawings/revision-history.tsx
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
export function RevisionHistory({ revisions }: { revisions: any[] }) {
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Revision History</h3>
<div className="space-y-3">
{revisions.map((rev) => (
<div
key={rev.revision_id}
className="flex items-center justify-between p-3 bg-gray-50 rounded"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<Badge variant={rev.is_current ? 'default' : 'outline'}>
Rev. {rev.revision_number}
</Badge>
{rev.is_current && (
<span className="text-xs text-green-600 font-medium">
CURRENT
</span>
)}
</div>
<p className="text-sm text-gray-600">
{rev.revision_description}
</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(rev.revision_date).toLocaleDateString()} by{' '}
{rev.revised_by_name}
</p>
</div>
<Button variant="outline" size="sm">
<Download className="h-4 w-4" />
</Button>
</div>
))}
</div>
</Card>
);
}
```
---
## 📦 Deliverables
- [ ] Drawing list with Contract/Shop tabs
- [ ] Upload form with file validation
- [ ] Drawing cards with preview
- [ ] Revision history view
- [ ] File download functionality
- [ ] Comparison feature (optional)
---
## 🔗 Related Documents
- [TASK-BE-008: Drawing Module](./TASK-BE-008-drawing-module.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,382 @@
# TASK-FE-008: Search & Global Filters UI
**ID:** TASK-FE-008
**Title:** Global Search, Advanced Filters & Results UI
**Category:** Supporting Features
**Priority:** P2 (Medium)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-003, TASK-BE-010
**Assigned To:** Frontend Developer
---
## 📋 Overview
Implement global search functionality with advanced filters, faceted search, and unified results display across all document types.
---
## 🎯 Objectives
1. Create global search bar in header
2. Build advanced search page with filters
3. Implement faceted search (by type, status, date)
4. Create unified results display
5. Add search suggestions/autocomplete
6. Implement search history
---
## ✅ Acceptance Criteria
- [ ] Global search accessible from header
- [ ] Advanced filters work (type, status, date range, organization)
- [ ] Results show across all document types
- [ ] Search suggestions appear as user types
- [ ] Search history saved locally
- [ ] Results paginated with highlighting
---
## 🔧 Implementation Steps
### Step 1: Global Search Component in Header
```typescript
// File: src/components/layout/global-search.tsx
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Search, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { searchApi } from '@/lib/api/search';
export function GlobalSearch() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const debouncedQuery = useDebounce(query, 300);
// Fetch suggestions
useEffect(() => {
if (debouncedQuery.length > 2) {
searchApi.suggest(debouncedQuery).then(setSuggestions);
}
}, [debouncedQuery]);
const handleSearch = () => {
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query)}`);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search documents..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-9"
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="start">
<Command>
<CommandList>
{suggestions.length === 0 ? (
<CommandEmpty>No results found</CommandEmpty>
) : (
<CommandGroup heading="Suggestions">
{suggestions.map((item: any) => (
<CommandItem
key={item.id}
onSelect={() => {
setQuery(item.title);
router.push(`/${item.type}s/${item.id}`);
setOpen(false);
}}
>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">{item.type}</span>
<span>{item.title}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
```
### Step 2: Advanced Search Page
```typescript
// File: src/app/(dashboard)/search/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { SearchFilters } from '@/components/search/filters';
import { SearchResults } from '@/components/search/results';
import { searchApi } from '@/lib/api/search';
export default function SearchPage() {
const searchParams = useSearchParams();
const query = searchParams.get('q') || '';
const [results, setResults] = useState([]);
const [filters, setFilters] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (query) {
setLoading(true);
searchApi
.search({ query, ...filters })
.then(setResults)
.finally(() => setLoading(false));
}
}, [query, filters]);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-gray-600 mt-1">
Found {results.length} results for "{query}"
</p>
</div>
<div className="grid grid-cols-4 gap-6">
<div className="col-span-1">
<SearchFilters onFilterChange={setFilters} />
</div>
<div className="col-span-3">
<SearchResults results={results} query={query} loading={loading} />
</div>
</div>
</div>
);
}
```
### Step 3: Search Filters Component
```typescript
// File: src/components/search/filters.tsx
'use client';
import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
export function SearchFilters({
onFilterChange,
}: {
onFilterChange: (filters: any) => void;
}) {
const [filters, setFilters] = useState({
types: [],
statuses: [],
dateFrom: null,
dateTo: null,
});
const handleFilterChange = (key: string, value: any) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
onFilterChange(newFilters);
};
return (
<Card className="p-4 space-y-6">
<div>
<h3 className="font-semibold mb-3">Document Type</h3>
<div className="space-y-2">
{['Correspondence', 'RFA', 'Drawing', 'Transmittal'].map((type) => (
<label key={type} className="flex items-center gap-2">
<Checkbox
checked={filters.types.includes(type)}
onCheckedChange={(checked) => {
const newTypes = checked
? [...filters.types, type]
: filters.types.filter((t) => t !== type);
handleFilterChange('types', newTypes);
}}
/>
<span className="text-sm">{type}</span>
</label>
))}
</div>
</div>
<div>
<h3 className="font-semibold mb-3">Status</h3>
<div className="space-y-2">
{['Draft', 'Pending', 'Approved', 'Rejected'].map((status) => (
<label key={status} className="flex items-center gap-2">
<Checkbox />
<span className="text-sm">{status}</span>
</label>
))}
</div>
</div>
<div>
<h3 className="font-semibold mb-3">Date Range</h3>
<div className="space-y-2">
<div>
<Label className="text-xs">From</Label>
<Calendar mode="single" />
</div>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
setFilters({ types: [], statuses: [], dateFrom: null, dateTo: null });
onFilterChange({});
}}
>
Clear Filters
</Button>
</Card>
);
}
```
### Step 4: Search Results Component
```typescript
// File: src/components/search/results.tsx
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
import { FileText, Clipboard, Image } from 'lucide-react';
export function SearchResults({ results, query, loading }: any) {
if (loading) {
return <div>Loading...</div>;
}
if (results.length === 0) {
return (
<Card className="p-12 text-center text-gray-500">
No results found for "{query}"
</Card>
);
}
const getIcon = (type: string) => {
switch (type) {
case 'correspondence':
return FileText;
case 'rfa':
return Clipboard;
case 'drawing':
return Image;
default:
return FileText;
}
};
return (
<div className="space-y-4">
{results.map((result: any) => {
const Icon = getIcon(result.type);
return (
<Card
key={result.id}
className="p-6 hover:shadow-md transition-shadow"
>
<Link href={`/${result.type}s/${result.id}`}>
<div className="flex gap-4">
<div className="flex-shrink-0">
<Icon className="h-6 w-6 text-gray-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold hover:text-primary">
{result.title}
</h3>
<Badge>{result.type}</Badge>
<Badge variant="outline">{result.status}</Badge>
</div>
<p className="text-sm text-gray-600 mb-2">
{result.highlight || result.description}
</p>
<div className="flex gap-4 text-xs text-gray-500">
<span>{result.documentNumber}</span>
<span></span>
<span>
{new Date(result.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
</Link>
</Card>
);
})}
</div>
);
}
```
---
## 📦 Deliverables
- [ ] Global search component in header
- [ ] Advanced search page
- [ ] Filters panel (type, status, date)
- [ ] Results display with highlighting
- [ ] Search suggestions/autocomplete
- [ ] Mobile responsive design
---
## 🔗 Related Documents
- [TASK-BE-010: Search & Elasticsearch](./TASK-BE-010-search-elasticsearch.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,344 @@
# TASK-FE-009: Dashboard & Notifications UI
**ID:** TASK-FE-009
**Title:** Dashboard, Notifications & Activity Feed UI
**Category:** Supporting Features
**Priority:** P3 (Low)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-003, TASK-BE-011
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build dashboard homepage with statistics widgets, recent activity, pending approvals, and real-time notifications system.
---
## 🎯 Objectives
1. Create dashboard homepage with widgets
2. Implement statistics cards (documents, pending approvals)
3. Build recent activity feed
4. Create notifications dropdown
5. Add pending tasks section
6. Implement real-time updates (optional)
---
## ✅ Acceptance Criteria
- [ ] Dashboard displays key statistics
- [ ] Recent activity feed working
- [ ] Notifications dropdown functional
- [ ] Pending tasks visible
- [ ] Charts/graphs display data
- [ ] Real-time updates (if WebSocket implemented)
---
## 🔧 Implementation Steps
### Step 1: Dashboard Page
```typescript
// File: src/app/(dashboard)/page.tsx
import { StatsCards } from '@/components/dashboard/stats-cards';
import { RecentActivity } from '@/components/dashboard/recent-activity';
import { PendingTasks } from '@/components/dashboard/pending-tasks';
import { QuickActions } from '@/components/dashboard/quick-actions';
export default async function DashboardPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-gray-600 mt-1">
Welcome back! Here's what's happening.
</p>
</div>
<QuickActions />
<StatsCards />
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
<RecentActivity />
</div>
<div className="col-span-1">
<PendingTasks />
</div>
</div>
</div>
);
}
```
### Step 2: Statistics Cards
```typescript
// File: src/components/dashboard/stats-cards.tsx
import { Card } from '@/components/ui/card';
import { FileText, Clipboard, CheckCircle, Clock } from 'lucide-react';
export async function StatsCards() {
const stats = await getStats(); // Fetch from API
const cards = [
{
title: 'Total Correspondences',
value: stats.correspondences,
icon: FileText,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
{
title: 'Active RFAs',
value: stats.rfas,
icon: Clipboard,
color: 'text-purple-600',
bgColor: 'bg-purple-50',
},
{
title: 'Approved Documents',
value: stats.approved,
icon: CheckCircle,
color: 'text-green-600',
bgColor: 'bg-green-50',
},
{
title: 'Pending Approvals',
value: stats.pending,
icon: Clock,
color: 'text-orange-600',
bgColor: 'bg-orange-50',
},
];
return (
<div className="grid grid-cols-4 gap-6">
{cards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.title} className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{card.title}</p>
<p className="text-3xl font-bold mt-2">{card.value}</p>
</div>
<div className={`p-3 rounded-lg ${card.bgColor}`}>
<Icon className={`h-6 w-6 ${card.color}`} />
</div>
</div>
</Card>
);
})}
</div>
);
}
```
### Step 3: Recent Activity Feed
```typescript
// File: src/components/dashboard/recent-activity.tsx
import { Card } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
export async function RecentActivity() {
const activities = await getRecentActivities();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Recent Activity</h3>
<div className="space-y-4">
{activities.map((activity) => (
<div
key={activity.id}
className="flex gap-3 pb-4 border-b last:border-0"
>
<Avatar className="h-10 w-10">
<AvatarFallback>{activity.user.initials}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{activity.user.name}</span>
<Badge variant="outline" className="text-xs">
{activity.action}
</Badge>
</div>
<p className="text-sm text-gray-600">{activity.description}</p>
<p className="text-xs text-gray-500 mt-1">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
})}
</p>
</div>
</div>
))}
</div>
</Card>
);
}
```
### Step 4: Notifications Dropdown
```typescript
// File: src/components/layout/notifications-dropdown.tsx
'use client';
import { useState, useEffect } from 'react';
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { notificationApi } from '@/lib/api/notifications';
export function NotificationsDropdown() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
// Fetch notifications
notificationApi.getUnread().then((data) => {
setNotifications(data.items);
setUnreadCount(data.unreadCount);
});
}, []);
const markAsRead = async (id: number) => {
await notificationApi.markAsRead(id);
setNotifications((prev) => prev.filter((n) => n.notification_id !== id));
setUnreadCount((prev) => prev - 1);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
>
{unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
No new notifications
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{notifications.map((notification) => (
<DropdownMenuItem
key={notification.notification_id}
className="flex flex-col items-start p-3 cursor-pointer"
onClick={() => markAsRead(notification.notification_id)}
>
<div className="font-medium text-sm">{notification.title}</div>
<div className="text-xs text-gray-600 mt-1">
{notification.message}
</div>
<div className="text-xs text-gray-400 mt-1">
{formatDistanceToNow(new Date(notification.created_at), {
addSuffix: true,
})}
</div>
</DropdownMenuItem>
))}
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-center justify-center">
View All
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
```
### Step 5: Pending Tasks Widget
```typescript
// File: src/components/dashboard/pending-tasks.tsx
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
export async function PendingTasks() {
const tasks = await getPendingTasks();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Pending Tasks</h3>
<div className="space-y-3">
{tasks.map((task) => (
<Link
key={task.id}
href={task.url}
className="block p-3 bg-gray-50 rounded hover:bg-gray-100 transition-colors"
>
<div className="flex items-start justify-between mb-1">
<span className="text-sm font-medium">{task.title}</span>
<Badge variant="warning" className="text-xs">
{task.daysOverdue > 0 ? `${task.daysOverdue}d overdue` : 'Due'}
</Badge>
</div>
<p className="text-xs text-gray-600">{task.description}</p>
</Link>
))}
</div>
</Card>
);
}
```
---
## 📦 Deliverables
- [ ] Dashboard page with widgets
- [ ] Statistics cards
- [ ] Recent activity feed
- [ ] Notifications dropdown
- [ ] Pending tasks section
- [ ] Quick actions buttons
---
## 🔗 Related Documents
- [TASK-BE-011: Notification & Audit](./TASK-BE-011-notification-audit.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,680 @@
# TASK-FE-010: Admin Panel & Settings UI
**ID:** TASK-FE-010
**Title:** Admin Panel for User & Master Data Management
**Category:** Administration
**Priority:** P2 (Medium)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-002, TASK-FE-005, TASK-BE-012, TASK-BE-013
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build comprehensive Admin Panel for managing users, roles, master data (organizations, projects, contracts, disciplines, document types), system settings, and viewing audit logs.
---
## 🎯 Objectives
1. Create admin layout with separate navigation
2. Build User Management UI (CRUD users, assign roles)
3. Implement Master Data Management screens
4. Create System Settings interface
5. Build Audit Logs viewer
6. Add bulk operations and data import/export
---
## ✅ Acceptance Criteria
- [ ] Admin area accessible only to admins
- [ ] User management (create/edit/delete/deactivate)
- [ ] Role assignment with permission preview
- [ ] Master data CRUD (Organizations, Projects, etc.)
- [ ] Audit logs searchable and filterable
- [ ] System settings editable
- [ ] CSV import/export for bulk operations
---
## 🔧 Implementation Steps
### Step 1: Admin Layout
```typescript
// File: src/app/(admin)/layout.tsx
import { AdminSidebar } from '@/components/admin/sidebar';
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession();
// Check if user has admin role
if (!session?.user?.roles?.some((r) => r.role_name === 'ADMIN')) {
redirect('/');
}
return (
<div className="flex h-screen">
<AdminSidebar />
<div className="flex-1 overflow-auto">{children}</div>
</div>
);
}
```
### Step 2: User Management Page
```typescript
// File: src/app/(admin)/admin/users/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/common/data-table';
import { UserDialog } from '@/components/admin/user-dialog';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Plus } from 'lucide-react';
export default function UsersPage() {
const [users, setUsers] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const columns: ColumnDef<any>[] = [
{
accessorKey: 'username',
header: 'Username',
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'first_name',
header: 'Name',
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`,
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.is_active ? 'success' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
),
},
{
id: 'roles',
header: 'Roles',
cell: ({ row }) => (
<div className="flex gap-1">
{row.original.roles?.map((role: any) => (
<Badge key={role.user_role_id} variant="outline">
{role.role_name}
</Badge>
))}
</div>
),
},
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedUser(row.original);
setDialogOpen(true);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeactivate(row.original.user_id)}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-gray-600 mt-1">
Manage system users and their roles
</p>
</div>
<Button
onClick={() => {
setSelectedUser(null);
setDialogOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
</div>
<DataTable columns={columns} data={users} />
<UserDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
user={selectedUser}
/>
</div>
);
}
```
### Step 3: User Create/Edit Dialog
```typescript
// File: src/components/admin/user-dialog.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
const userSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
first_name: z.string().min(1),
last_name: z.string().min(1),
password: z.string().min(6).optional(),
is_active: z.boolean().default(true),
roles: z.array(z.number()),
});
type UserFormData = z.infer<typeof userSchema>;
interface UserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: any;
}
export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: user || {},
});
const availableRoles = [
{ role_id: 1, role_name: 'ADMIN', description: 'System Administrator' },
{ role_id: 2, role_name: 'USER', description: 'Regular User' },
{ role_id: 3, role_name: 'APPROVER', description: 'Document Approver' },
];
const selectedRoles = watch('roles') || [];
const onSubmit = async (data: UserFormData) => {
// Call API to create/update user
console.log(data);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{user ? 'Edit User' : 'Create New User'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Username *</Label>
<Input {...register('username')} />
{errors.username && (
<p className="text-sm text-red-600 mt-1">
{errors.username.message}
</p>
)}
</div>
<div>
<Label>Email *</Label>
<Input type="email" {...register('email')} />
{errors.email && (
<p className="text-sm text-red-600 mt-1">
{errors.email.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>First Name *</Label>
<Input {...register('first_name')} />
</div>
<div>
<Label>Last Name *</Label>
<Input {...register('last_name')} />
</div>
</div>
{!user && (
<div>
<Label>Password *</Label>
<Input type="password" {...register('password')} />
{errors.password && (
<p className="text-sm text-red-600 mt-1">
{errors.password.message}
</p>
)}
</div>
)}
<div>
<Label className="mb-3 block">Roles</Label>
<div className="space-y-2">
{availableRoles.map((role) => (
<label
key={role.role_id}
className="flex items-start gap-3 p-3 border rounded hover:bg-gray-50"
>
<Checkbox
checked={selectedRoles.includes(role.role_id)}
onCheckedChange={(checked) => {
const newRoles = checked
? [...selectedRoles, role.role_id]
: selectedRoles.filter((id) => id !== role.role_id);
setValue('roles', newRoles);
}}
/>
<div>
<div className="font-medium">{role.role_name}</div>
<div className="text-sm text-gray-600">
{role.description}
</div>
</div>
</label>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox {...register('is_active')} defaultChecked />
<Label>Active</Label>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit">
{user ? 'Update User' : 'Create User'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
```
### Step 4: Master Data Management (Organizations)
```typescript
// File: src/app/(admin)/admin/organizations/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/common/data-table';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export default function OrganizationsPage() {
const [organizations, setOrganizations] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [formData, setFormData] = useState({
org_code: '',
org_name: '',
org_name_th: '',
description: '',
});
const columns = [
{ accessorKey: 'org_code', header: 'Code' },
{ accessorKey: 'org_name', header: 'Name (EN)' },
{ accessorKey: 'org_name_th', header: 'Name (TH)' },
{ accessorKey: 'description', header: 'Description' },
];
const handleSubmit = async () => {
// Call API to create organization
console.log(formData);
setDialogOpen(false);
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Organizations</h1>
<p className="text-gray-600 mt-1">Manage project organizations</p>
</div>
<Button onClick={() => setDialogOpen(true)}>Add Organization</Button>
</div>
<DataTable columns={columns} data={organizations} />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Organization</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Organization Code *</Label>
<Input
value={formData.org_code}
onChange={(e) =>
setFormData({ ...formData, org_code: e.target.value })
}
placeholder="e.g., กทท."
/>
</div>
<div>
<Label>Name (English) *</Label>
<Input
value={formData.org_name}
onChange={(e) =>
setFormData({ ...formData, org_name: e.target.value })
}
/>
</div>
<div>
<Label>Name (Thai)</Label>
<Input
value={formData.org_name_th}
onChange={(e) =>
setFormData({ ...formData, org_name_th: e.target.value })
}
/>
</div>
<div>
<Label>Description</Label>
<Input
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
```
### Step 5: Audit Logs Viewer
```typescript
// File: src/app/(admin)/admin/audit-logs/page.tsx
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
export default function AuditLogsPage() {
const [logs, setLogs] = useState([]);
const [filters, setFilters] = useState({
user: '',
action: '',
entity: '',
});
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">Audit Logs</h1>
<p className="text-gray-600 mt-1">View system activity and changes</p>
</div>
{/* Filters */}
<Card className="p-4">
<div className="grid grid-cols-4 gap-4">
<div>
<Input placeholder="Search user..." />
</div>
<div>
<Select>
<SelectTrigger>
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CREATE">Create</SelectItem>
<SelectItem value="UPDATE">Update</SelectItem>
<SelectItem value="DELETE">Delete</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select>
<SelectTrigger>
<SelectValue placeholder="Entity Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
{/* Logs List */}
<div className="space-y-2">
{logs.map((log: any) => (
<Card key={log.audit_log_id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">{log.user_name}</span>
<Badge>{log.action}</Badge>
<Badge variant="outline">{log.entity_type}</Badge>
</div>
<p className="text-sm text-gray-600">{log.description}</p>
<p className="text-xs text-gray-500 mt-2">
{formatDistanceToNow(new Date(log.created_at), {
addSuffix: true,
})}
</p>
</div>
{log.ip_address && (
<span className="text-xs text-gray-500">
IP: {log.ip_address}
</span>
)}
</div>
</Card>
))}
</div>
</div>
);
}
```
### Step 6: Admin Sidebar Navigation
```typescript
// File: src/components/admin/sidebar.tsx
'use client';
import Link from 'link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Users, Building2, Settings, FileText, Activity } from 'lucide-react';
const menuItems = [
{ href: '/admin/users', label: 'Users', icon: Users },
{ href: '/admin/organizations', label: 'Organizations', icon: Building2 },
{ href: '/admin/projects', label: 'Projects', icon: FileText },
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
];
export function AdminSidebar() {
const pathname = usePathname();
return (
<aside className="w-64 border-r bg-gray-50 p-4">
<h2 className="text-lg font-bold mb-6">Admin Panel</h2>
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-gray-100'
)}
>
<Icon className="h-5 w-5" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</aside>
);
}
```
---
## 📦 Deliverables
- [ ] Admin layout with sidebar navigation
- [ ] User Management (CRUD, roles assignment)
- [ ] Master Data Management screens:
- [ ] Organizations
- [ ] Projects
- [ ] Contracts
- [ ] Disciplines
- [ ] Document Types
- [ ] System Settings interface
- [ ] Audit Logs viewer with filters
- [ ] CSV import/export functionality
---
## 🧪 Testing
### Test Cases
1. **User Management**
- Create new user
- Assign multiple roles
- Deactivate/activate user
- Delete user
2. **Master Data**
- Create organization
- Edit organization details
- Delete organization (check for dependencies)
3. **Audit Logs**
- View all logs
- Filter by user/action/entity
- Search logs
- Export logs
---
## 🔗 Related Documents
- [TASK-BE-012: Master Data Management](./TASK-BE-012-master-data-management.md)
- [TASK-BE-013: User Management](./TASK-BE-013-user-management.md)
- [ADR-004: RBAC Implementation](../../05-decisions/ADR-004-rbac-implementation.md)
---
**Created:** 2025-12-01
**Status:** Ready

View File

@@ -0,0 +1,506 @@
# TASK-FE-011: Workflow Configuration UI
**ID:** TASK-FE-011
**Title:** Workflow DSL Builder & Configuration UI
**Category:** Administration
**Priority:** P2 (Medium)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-010, TASK-BE-006
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build UI for configuring and managing workflows using the DSL-based workflow engine, including visual workflow builder, DSL editor, and workflow testing interface.
---
## 🎯 Objectives
1. Create workflow list and management interface
2. Build DSL editor with syntax highlighting
3. Implement visual workflow builder (drag-and-drop)
4. Add workflow validation and testing tools
5. Create workflow template library
6. Implement workflow versioning UI
---
## ✅ Acceptance Criteria
- [x] List all workflows with status
- [x] Create/edit workflows with DSL editor
- [x] Visual workflow builder functional
- [x] DSL validation shows errors
- [x] Test workflow with sample data
- [ ] Workflow templates available
- [ ] Version history viewable
---
## 🔧 Implementation Steps
### Step 1: Workflow List Page
```typescript
// File: src/app/(admin)/admin/workflows/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Copy, Trash } from 'lucide-react';
import Link from 'next/link';
export default function WorkflowsPage() {
const [workflows, setWorkflows] = useState([]);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Workflow Configuration</h1>
<p className="text-gray-600 mt-1">
Manage workflow definitions and routing rules
</p>
</div>
<Link href="/admin/workflows/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Workflow
</Button>
</Link>
</div>
<div className="grid gap-4">
{workflows.map((workflow: any) => (
<Card key={workflow.workflow_id} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{workflow.workflow_name}
</h3>
<Badge variant={workflow.is_active ? 'success' : 'secondary'}>
{workflow.is_active ? 'Active' : 'Inactive'}
</Badge>
<Badge variant="outline">v{workflow.version}</Badge>
</div>
<p className="text-sm text-gray-600 mb-3">
{workflow.description}
</p>
<div className="flex gap-6 text-sm text-gray-500">
<span>Type: {workflow.workflow_type}</span>
<span>Steps: {workflow.step_count}</span>
<span>
Updated:{' '}
{new Date(workflow.updated_at).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/workflows/${workflow.workflow_id}/edit`}>
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
<Button variant="outline" size="sm">
<Copy className="mr-2 h-4 w-4" />
Clone
</Button>
<Button variant="outline" size="sm" className="text-red-600">
<Trash className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
```
### Step 2: DSL Editor Component
```typescript
// File: src/components/workflows/dsl-editor.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CheckCircle, AlertCircle, Play } from 'lucide-react';
import Editor from '@monaco-editor/react';
interface DSLEditorProps {
initialValue?: string;
onChange?: (value: string) => void;
}
export function DSLEditor({ initialValue = '', onChange }: DSLEditorProps) {
const [dsl, setDsl] = useState(initialValue);
const [validationResult, setValidationResult] = useState<any>(null);
const [isValidating, setIsValidating] = useState(false);
const handleEditorChange = (value: string | undefined) => {
const newValue = value || '';
setDsl(newValue);
onChange?.(newValue);
setValidationResult(null); // Clear validation on change
};
const validateDSL = async () => {
setIsValidating(true);
try {
const response = await fetch('/api/workflows/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dsl }),
});
const result = await response.json();
setValidationResult(result);
} catch (error) {
setValidationResult({ valid: false, errors: ['Validation failed'] });
} finally {
setIsValidating(false);
}
};
const testWorkflow = async () => {
// Open test dialog
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Workflow DSL</h3>
<div className="flex gap-2">
<Button
variant="outline"
onClick={validateDSL}
disabled={isValidating}
>
<CheckCircle className="mr-2 h-4 w-4" />
Validate
</Button>
<Button variant="outline" onClick={testWorkflow}>
<Play className="mr-2 h-4 w-4" />
Test
</Button>
</div>
</div>
<Card className="overflow-hidden">
<Editor
height="500px"
defaultLanguage="yaml"
value={dsl}
onChange={handleEditorChange}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
rulers: [80],
wordWrap: 'on',
}}
/>
</Card>
{validationResult && (
<Alert variant={validationResult.valid ? 'default' : 'destructive'}>
{validationResult.valid ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>
{validationResult.valid ? (
'DSL is valid ✓'
) : (
<div>
<p className="font-medium mb-2">Validation Errors:</p>
<ul className="list-disc list-inside space-y-1">
{validationResult.errors?.map((error: string, i: number) => (
<li key={i} className="text-sm">
{error}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
)}
</div>
);
}
```
### Step 3: Visual Workflow Builder
```typescript
// File: src/components/workflows/visual-builder.tsx
'use client';
import { useState, useCallback } from 'react';
import ReactFlow, {
Node,
Edge,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Connection,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
const nodeTypes = {
start: { color: '#10b981' },
step: { color: '#3b82f6' },
condition: { color: '#f59e0b' },
end: { color: '#ef4444' },
};
export function VisualWorkflowBuilder() {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
const addNode = (type: string) => {
const newNode: Node = {
id: `${type}-${Date.now()}`,
type: 'default',
position: { x: Math.random() * 400, y: Math.random() * 400 },
data: { label: `${type} Node` },
style: {
background: nodeTypes[type]?.color || '#gray',
color: 'white',
padding: 10,
},
};
setNodes((nds) => [...nds, newNode]);
};
const generateDSL = () => {
// Convert visual workflow to DSL
const dsl = {
name: 'Generated Workflow',
steps: nodes.map((node) => ({
step_name: node.data.label,
step_type: 'APPROVAL',
})),
};
return JSON.stringify(dsl, null, 2);
};
return (
<div className="space-y-4">
<div className="flex gap-2">
<Button onClick={() => addNode('start')} variant="outline">
Add Start
</Button>
<Button onClick={() => addNode('step')} variant="outline">
Add Step
</Button>
<Button onClick={() => addNode('condition')} variant="outline">
Add Condition
</Button>
<Button onClick={() => addNode('end')} variant="outline">
Add End
</Button>
<Button onClick={generateDSL} className="ml-auto">
Generate DSL
</Button>
</div>
<Card className="h-[600px]">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Controls />
<Background />
</ReactFlow>
</Card>
</div>
);
}
```
### Step 4: Workflow Editor Page
```typescript
// File: src/app/(admin)/admin/workflows/[id]/edit/page.tsx
'use client';
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DSLEditor } from '@/components/workflows/dsl-editor';
import { VisualWorkflowBuilder } from '@/components/workflows/visual-builder';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card } from '@/components/ui/card';
export default function WorkflowEditPage() {
const [workflowData, setWorkflowData] = useState({
workflow_name: '',
description: '',
workflow_type: 'CORRESPONDENCE',
dsl_definition: '',
});
const handleSave = async () => {
// Save workflow
console.log('Saving workflow:', workflowData);
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Edit Workflow</h1>
<div className="flex gap-2">
<Button variant="outline">Cancel</Button>
<Button onClick={handleSave}>Save Workflow</Button>
</div>
</div>
<Card className="p-6">
<div className="grid gap-4">
<div>
<Label>Workflow Name *</Label>
<Input
value={workflowData.workflow_name}
onChange={(e) =>
setWorkflowData({
...workflowData,
workflow_name: e.target.value,
})
}
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={workflowData.description}
onChange={(e) =>
setWorkflowData({
...workflowData,
description: e.target.value,
})
}
/>
</div>
<div>
<Label>Workflow Type</Label>
<Select
value={workflowData.workflow_type}
onValueChange={(value) =>
setWorkflowData({ ...workflowData, workflow_type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CORRESPONDENCE">Correspondence</SelectItem>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="DRAWING">Drawing</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
<Tabs defaultValue="dsl">
<TabsList>
<TabsTrigger value="dsl">DSL Editor</TabsTrigger>
<TabsTrigger value="visual">Visual Builder</TabsTrigger>
</TabsList>
<TabsContent value="dsl">
<DSLEditor
initialValue={workflowData.dsl_definition}
onChange={(value) =>
setWorkflowData({ ...workflowData, dsl_definition: value })
}
/>
</TabsContent>
<TabsContent value="visual">
<VisualWorkflowBuilder />
</TabsContent>
</Tabs>
</div>
);
}
```
---
## 📦 Deliverables
- [ ] Workflow list page
- [ ] DSL editor with syntax highlighting
- [ ] DSL validation endpoint integration
- [ ] Visual workflow builder (ReactFlow)
- [ ] Workflow testing interface
- [ ] Template library
- [ ] Version history viewer
---
## 🧪 Testing
1. **DSL Editor**
- Write valid DSL → Validates successfully
- Write invalid DSL → Shows errors
- Save workflow → DSL persists
2. **Visual Builder**
- Add nodes → Nodes appear
- Connect nodes → Edges created
- Generate DSL → Valid DSL output
3. **Workflow Management**
- Create workflow → Saves to DB
- Edit workflow → Updates correctly
- Clone workflow → Creates copy
---
## 🔗 Related Documents
- [TASK-BE-006: Workflow Engine](./TASK-BE-006-workflow-engine.md)
- [ADR-001: Unified Workflow Engine](../../05-decisions/ADR-001-unified-workflow-engine.md)
---
**Created:** 2025-12-01
**Status:** ✅ Completed
**Completed Date:** 2025-12-09

View File

@@ -0,0 +1,538 @@
# TASK-FE-012: Document Numbering Configuration UI
**ID:** TASK-FE-012
**Title:** Document Numbering Template Management UI
**Category:** Administration
**Priority:** P2 (Medium)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-010, TASK-BE-004
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build UI for configuring and managing document numbering templates including template builder, preview generator, and number sequence management.
---
## 🎯 Objectives
1. Create numbering template list and management
2. Build template editor with format preview
3. Implement template variable selector
4. Add numbering sequence viewer
5. Create template testing interface
6. Implement annual reset configuration
---
## ✅ Acceptance Criteria
- [x] List all numbering templates by document type
- [x] Create/edit templates with format preview
- [x] Template variables easily selectable
- [x] Preview shows example numbers
- [x] View current number sequences
- [x] Annual reset configurable
- [x] Validation prevents conflicts
---
## 🔧 Implementation Steps
### Step 1: Template List Page
```typescript
// File: src/app/(admin)/admin/numbering/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Eye } from 'lucide-react';
export default function NumberingPage() {
const [templates, setTemplates] = useState([]);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">
Document Numbering Configuration
</h1>
<p className="text-gray-600 mt-1">
Manage document numbering templates and sequences
</p>
</div>
<div className="flex gap-2">
<Select defaultValue="1">
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">LCBP3</SelectItem>
<SelectItem value="2">LCBP3-Maintenance</SelectItem>
</SelectContent>
</Select>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</div>
</div>
<div className="grid gap-4">
{templates.map((template: any) => (
<Card key={template.template_id} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{template.document_type_name}
</h3>
<Badge>{template.discipline_code || 'All'}</Badge>
<Badge variant={template.is_active ? 'success' : 'secondary'}>
{template.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="bg-gray-100 rounded px-3 py-2 mb-3 font-mono text-sm">
{template.template_format}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Example: </span>
<span className="font-medium">
{template.example_number}
</span>
</div>
<div>
<span className="text-gray-600">Current Sequence: </span>
<span className="font-medium">
{template.current_number}
</span>
</div>
<div>
<span className="text-gray-600">Annual Reset: </span>
<span className="font-medium">
{template.reset_annually ? 'Yes' : 'No'}
</span>
</div>
<div>
<span className="text-gray-600">Padding: </span>
<span className="font-medium">
{template.padding_length} digits
</span>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
View Sequences
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
```
### Step 2: Template Editor Component
```typescript
// File: src/components/numbering/template-editor.tsx
'use client';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
const VARIABLES = [
{ key: '{ORIGINATOR}', name: 'Originator Code', example: 'PAT' },
{ key: '{RECIPIENT}', name: 'Recipient Code', example: 'CN' },
{ key: '{CORR_TYPE}', name: 'Correspondence Type', example: 'RFA' },
{ key: '{SUB_TYPE}', name: 'Sub Type', example: '21' },
{ key: '{DISCIPLINE}', name: 'Discipline', example: 'STR' },
{ key: '{RFA_TYPE}', name: 'RFA Type', example: 'SDW' },
{ key: '{YEAR:B.E.}', name: 'Year (B.E.)', example: '2568' },
{ key: '{YEAR:A.D.}', name: 'Year (A.D.)', example: '2025' },
{ key: '{SEQ:4}', name: 'Sequence (4-digit)', example: '0001' },
{ key: '{REV}', name: 'Revision', example: 'A' },
];
export function TemplateEditor({ template, onSave }: any) {
const [format, setFormat] = useState(template?.template_format || '');
const [preview, setPreview] = useState('');
useEffect(() => {
// Generate preview
let previewText = format;
VARIABLES.forEach((v) => {
previewText = previewText.replace(new RegExp(v.key, 'g'), v.example);
});
setPreview(previewText);
}, [format]);
const insertVariable = (variable: string) => {
setFormat((prev) => prev + variable);
};
return (
<Card className="p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Template Configuration</h3>
<div className="grid gap-4">
<div>
<Label>Document Type *</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="RFI">RFI</SelectItem>
<SelectItem value="TRANSMITTAL">Transmittal</SelectItem>
<SelectItem value="LETTER">Letter</SelectItem>
<SelectItem value="MEMO">Memorandum</SelectItem>
<SelectItem value="EMAIL">Email</SelectItem>
<SelectItem value="MOM">Minutes of Meeting</SelectItem>
<SelectItem value="INSTRUCTION">Instruction</SelectItem>
<SelectItem value="NOTICE">Notice</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="All disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
<SelectItem value="STR">STR - Structure</SelectItem>
<SelectItem value="ARC">ARC - Architecture</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Template Format *</Label>
<div className="space-y-2">
<Input
value={format}
onChange={(e) => setFormat(e.target.value)}
placeholder="e.g., {ORG}-{DOCTYPE}-{YYYY}-{SEQ}"
className="font-mono"
/>
<div className="flex flex-wrap gap-2">
{VARIABLES.map((v) => (
<Button
key={v.key}
variant="outline"
size="sm"
onClick={() => insertVariable(v.key)}
type="button"
>
{v.key}
</Button>
))}
</div>
</div>
</div>
<div>
<Label>Preview</Label>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">Example number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{preview || 'Enter format above'}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Sequence Padding Length</Label>
<Input type="number" defaultValue={4} min={1} max={10} />
<p className="text-xs text-gray-500 mt-1">
Number of digits (e.g., 4 = 0001, 0002)
</p>
</div>
<div>
<Label>Starting Number</Label>
<Input type="number" defaultValue={1} min={1} />
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2">
<Checkbox defaultChecked />
<span className="text-sm">Reset annually (on January 1st)</span>
</label>
</div>
</div>
</div>
{/* Variable Reference */}
<div>
<h4 className="font-semibold mb-3">Available Variables</h4>
<div className="grid grid-cols-2 gap-3">
{VARIABLES.map((v) => (
<div
key={v.key}
className="flex items-center justify-between p-2 bg-gray-50 rounded"
>
<div>
<Badge variant="outline" className="font-mono">
{v.key}
</Badge>
<p className="text-xs text-gray-600 mt-1">{v.name}</p>
</div>
<span className="text-sm text-gray-500">{v.example}</span>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button onClick={onSave}>Save Template</Button>
</div>
</Card>
);
}
```
### Step 3: Number Sequence Viewer
```typescript
// File: src/components/numbering/sequence-viewer.tsx
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { RefreshCw } from 'lucide-react';
export function SequenceViewer({ templateId }: { templateId: number }) {
const [sequences, setSequences] = useState([]);
const [search, setSearch] = useState('');
return (
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Number Sequences</h3>
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
<div className="mb-4">
<Input
placeholder="Search by year, organization..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="space-y-2">
{sequences.map((seq: any) => (
<div
key={seq.sequence_id}
className="flex items-center justify-between p-3 bg-gray-50 rounded"
>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{seq.year}</span>
{seq.organization_code && (
<Badge>{seq.organization_code}</Badge>
)}
{seq.discipline_code && (
<Badge variant="outline">{seq.discipline_code}</Badge>
)}
</div>
<div className="text-sm text-gray-600">
Current: {seq.current_number} | Last Generated:{' '}
{seq.last_generated_number}
</div>
</div>
<div className="text-sm text-gray-500">
Updated {new Date(seq.updated_at).toLocaleDateString()}
</div>
</div>
))}
</div>
</Card>
);
}
```
### Step 4: Template Testing Dialog
```typescript
// File: src/components/numbering/template-tester.tsx
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
export function TemplateTester({ open, onOpenChange, template }: any) {
const [testData, setTestData] = useState({
organization_id: 1,
discipline_id: null,
year: new Date().getFullYear(),
});
const [generatedNumber, setGeneratedNumber] = useState('');
const handleTest = async () => {
// Call API to generate test number
const response = await fetch('/api/numbering/test', {
method: 'POST',
body: JSON.stringify({ template_id: template.template_id, ...testData }),
});
const result = await response.json();
setGeneratedNumber(result.number);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Number Generation</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Organization</Label>
<Select value={testData.organization_id.toString()}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Select discipline" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">STR</SelectItem>
<SelectItem value="2">ARC</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleTest} className="w-full">
Generate Test Number
</Button>
{generatedNumber && (
<Card className="p-4 bg-green-50 border-green-200">
<p className="text-sm text-gray-600 mb-1">Generated Number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{generatedNumber}
</p>
</Card>
)}
</div>
</DialogContent>
</Dialog>
);
}
```
---
## 📦 Deliverables
- [ ] Template list page
- [ ] Template editor with variable selector
- [ ] Live preview generator
- [ ] Number sequence viewer
- [ ] Template testing interface
- [ ] Annual reset configuration
- [ ] Validation rules
---
## 🧪 Testing
1. **Template Creation**
- Create template → Preview updates
- Insert variables → Format correct
- Save template → Persists
2. **Number Generation**
- Test template → Generates number
- Variables replaced correctly
- Sequence increments
3. **Sequence Management**
- View sequences → Shows all active sequences
- Search sequences → Filters correctly
---
## 🔗 Related Documents
- [TASK-BE-004: Document Numbering](./TASK-BE-004-document-numbering.md)
- [ADR-002: Document Numbering Strategy](../../05-decisions/ADR-002-document-numbering-strategy.md)
---
**Created:** 2025-12-01
**Status:** ✅ Completed
**Completed Date:** 2025-12-09

View File

@@ -0,0 +1,159 @@
# Task: Circulation & Transmittal UI
**Status:** Not Started
**Priority:** P2 (Medium)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-FE-005, TASK-BE-009
**Owner:** Frontend Team
---
## 📋 Overview
Implement the **Circulation** (Internal Distribution) and **Transmittal** (External Submission) modules in the Frontend. These interfaces will allow users to manage document distribution, track assignees, and generate transmittal slips.
---
## 🎯 Objectives
-**Circulation UI:** Create, View, and Track internal circulations.
-**Transmittal UI:** Create Transmittals, Manage Items, and Print/Export PDF.
-**Integration:** Connect with Backend APIs for data persistence and workflow actions.
-**UX/UI:** User-friendly document selection and assignee management.
---
## 📝 Acceptance Criteria
### 1. Circulation Module
- [ ] **List View:** Display circulations with status, due date, and progress indicators.
- [ ] **Create Form:**
- [ ] Select Subject/Title.
- [ ] **Assignee Selector:** Multi-select users for Main/Action/Info roles.
- [ ] **Document Linker:** Search and select existing Correspondence/RFAs to attach.
- [ ] **Detail View:**
- [ ] Show overall status.
- [ ] List of assignees with their individual status (Pending/Completed).
- [ ] Action button for Assignee to "Complete" their task with remarks.
### 2. Transmittal Module
- [ ] **List View:** Display transmittals with transmittal number, recipient, and date.
- [ ] **Create Form:**
- [ ] Header info (Attention To, Organization, Date).
- [ ] **Item Manager:** Add/Remove documents (Correspondence/RFA/Drawing) to the transmittal list.
- [ ] Specify "Number of Copies" for each item.
- [ ] **Detail View:** Read-only view of the transmittal slip.
- [ ] **PDF Export:** Button to download the generated Transmittal PDF.
---
## 🛠️ Implementation Steps
### 1. API Services & Types
Create TypeScript interfaces and API service methods.
```typescript
// types/circulation.ts
export interface Circulation {
id: number;
circulation_number: string;
subject: string;
due_date: string;
status: 'active' | 'completed';
assignees: CirculationAssignee[];
correspondences: Correspondence[]; // Linked docs
}
export interface CirculationAssignee {
id: number;
user_id: number;
user_name: string; // Mapped from User entity
status: 'pending' | 'completed';
remarks?: string;
}
// services/circulation-service.ts
// - getCirculations(params)
// - getCirculationById(id)
// - createCirculation(data)
// - completeAssignment(id, assigneeId, data)
```
```typescript
// types/transmittal.ts
export interface Transmittal {
id: number;
transmittal_number: string;
attention_to: string;
transmittal_date: string;
items: TransmittalItem[];
}
export interface TransmittalItem {
document_type: 'correspondence' | 'rfa' | 'drawing';
document_id: number;
document_number: string;
document_title: string;
number_of_copies: number;
}
// services/transmittal-service.ts
// - getTransmittals(params)
// - getTransmittalById(id)
// - createTransmittal(data)
// - downloadTransmittalPDF(id)
```
### 2. UI Components
#### Circulation
- **`components/circulation/circulation-list.tsx`**: DataTable with custom columns.
- **`components/circulation/circulation-form.tsx`**:
- Use `Combobox` for searching Users.
- Use `DocumentSelector` (shared component) for linking Correspondence/RFAs.
- **`components/circulation/assignee-status-card.tsx`**: Component to show assignee progress.
#### Transmittal
- **`components/transmittal/transmittal-list.tsx`**: Standard DataTable.
- **`components/transmittal/transmittal-form.tsx`**:
- Header fields (Recipient, Date, etc.)
- **Items Table**: Dynamic rows to add documents.
- Column 1: Document Type (Select).
- Column 2: Document Search (AsyncSelect).
- Column 3: Copies (Input Number).
- Action: Remove Row.
### 3. Pages & Routing
- `app/(dashboard)/circulation/page.tsx`: List View
- `app/(dashboard)/circulation/new/page.tsx`: Create View
- `app/(dashboard)/circulation/[id]/page.tsx`: Detail View
- `app/(dashboard)/transmittals/page.tsx`: List View
- `app/(dashboard)/transmittals/new/page.tsx`: Create View
- `app/(dashboard)/transmittals/[id]/page.tsx`: Detail View
---
## 🧪 Testing Strategy
- **Unit Tests:** Test form validation logic (e.g., at least one assignee required).
- **Integration Tests:** Mock API calls to verify data loading and submission.
- **E2E Tests:**
1. Login as User A.
2. Create a Circulation and assign to User B.
3. Logout and Login as User B.
4. Verify notification/dashboard task.
5. Complete the assignment.
6. Verify Circulation status updates.
---
## 📚 Resources
- [Figma Design - Circulation](https://figma.com/...) (Internal Link)
- [Backend Task: BE-009](../TASK-BE-009-circulation-transmittal.md)

View File

@@ -0,0 +1,116 @@
# TASK-FE-014: Reference Data & Lookups UI
**ID:** TASK-FE-014
**Title:** Reference Data & Lookups Management UI
**Category:** Administration
**Priority:** P3 (Low)
**Effort:** 3-5 days
**Dependencies:** TASK-FE-010, TASK-BE-012
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build a generic or specific UI for managing various system lookup tables (Master Data) that are essential for the application but change infrequently. This includes Disciplines, Drawing Categories, RFA Types, and Tags.
---
## 🎯 Objectives
1. Manage **Correspondence Types** (and Sub-types)
2. Manage **RFA Types** and associated **Approve Codes**
3. Manage **Drawing Categories** (Main & Sub-categories)
4. Manage **Disciplines** (System-wide codes)
5. Manage **Tags** and other minor lookups
---
## ✅ Acceptance Criteria
- [ ] Admin can create/edit/delete Correspondence Types
- [ ] Admin can manage RFA Types and their Approve Codes
- [ ] Admin can configure Drawing Categories (Main/Sub)
- [ ] Admin can manage Disciplines (Code & Name)
- [ ] UI supports "Soft Delete" (Active/Inactive toggle)
- [ ] Updates reflect immediately in dropdowns across the system
---
## 🔧 Implementation Steps
### Step 1: Specific Lookup Pages vs Generic Table
Since these tables have similar structures (Code, Name, Description, IsActive), you can either build:
A. **Generic Master Data Component** (Recommended for simple tables)
B. **Dedicated Pages** for complex relations (like Categories -> Sub-categories)
#### Recommended Approach
- **Dedicated Page:** for RFA Types (due to relationship with Approve Codes)
- **Dedicated Page:** for Drawing Categories (Hierarchical)
- **Generic/Shared Page:** for Disciplines, Tags, Correspondence Types
### Step 2: RFA Configuration Page
```typescript
// File: src/app/(admin)/admin/reference/rfa-types/page.tsx
'use client';
import { useState } from 'react';
import { DataTable } from '@/components/common/data-table';
import { Button } from '@/components/ui/button';
// ... imports
export default function RfaConfigPage() {
const [types, setTypes] = useState([]);
// Columns: Code, Name, Contract, Active Status, Actions
return (
<div className="p-6">
<div className="flex justify-between mb-6">
<h1 className="text-2xl font-bold">RFA Types Configuration</h1>
<Button>Add Type</Button>
</div>
<DataTable data={types} columns={/*...*/} />
</div>
);
}
```
### Step 3: Disciplines Management
```typescript
// File: src/app/(admin)/admin/reference/disciplines/page.tsx
'use client';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
// Simple table to manage 'disciplines'
// Fields: discipline_code, code_name_th, code_name_en
```
### Step 4: Drawing Categories (Hierarchy)
```typescript
// File: src/app/(admin)/admin/reference/drawing-categories/page.tsx
// Needs to handle Main Category -> Sub Category relationship
```
---
## 📦 Deliverables
- [ ] RFA Types Management Page
- [ ] Drawing Categories Management Page
- [ ] Disciplines Management Page
- [ ] Correspondence Types Management Page
- [ ] Unified "Reference Data" Sidebar Group
---
## 🔗 Related Documents
- [TASK-BE-012: Master Data Management](./TASK-BE-012-master-data-management.md)

View File

@@ -0,0 +1,99 @@
# TASK-FE-015: Security & System Administration UI
**ID:** TASK-FE-015
**Title:** Security & System Administration UI
**Category:** Administration
**Priority:** P2 (High)
**Effort:** 5-7 days
**Dependencies:** TASK-FE-010, TASK-BE-002, TASK-BE-011
**Assigned To:** Frontend Developer
---
## 📋 Overview
Provide advanced administrative tools for managing system security (RBAC), monitoring active user sessions, and viewing system-level error logs (specifically for critical features like Document Numbering).
---
## 🎯 Objectives
1. **RBAC Matrix Editor:** Visual interface to assign permissions to roles.
2. **Session Management:** View and revoke active user sessions/tokens.
3. **System Logs:** View specific error logs (e.g., `document_number_errors`) and Audit Logs.
---
## ✅ Acceptance Criteria
- [ ] **RBAC Matrix:** Grid view showing Roles (Columns) vs Permissions (Rows) with toggle switches.
- [ ] **Session Monitor:** List active users/sessions with "Force Logout" capability.
- [ ] **Numbering Logs:** Specific view for `document_number_audit` and `document_number_errors`.
- [ ] **Security:** These pages must be restricted to Super Admin only.
---
## 🔧 Implementation Steps
### Step 1: RBAC Matrix Component
```typescript
// File: src/components/admin/security/rbac-matrix.tsx
'use client';
import { Checkbox } from '@/components/ui/checkbox';
// ...
// Matrix layout:
// | Permission | Admin | User | Approver |
// |------------|-------|------|----------|
// | rfa.view | [x] | [x] | [x] |
// | rfa.create | [x] | [ ] | [ ] |
export function RbacMatrix({ roles, permissions, matrix }) {
const handleToggle = (roleId, permId) => {
// Call API to toggle permission
};
return (
<Table>
{/* ... Render Matrix ... */}
</Table>
);
}
```
### Step 2: Active Sessions Page
```typescript
// File: src/app/(admin)/admin/security/sessions/page.tsx
'use client';
// List active refresh tokens or sessions from backend
// Columns: User, IP, Last Active, Device, Actions (Revoke)
```
### Step 3: Document Numbering Logs
```typescript
// File: src/app/(admin)/admin/logs/numbering/page.tsx
'use client';
// specific table for 'document_number_errors' and 'document_number_audit'
// Critical for diagnosing failed number generation
```
---
## 📦 Deliverables
- [ ] RBAC Configuration Page
- [ ] Active Sessions / Security Page
- [ ] Document Numbering Diagnostics Page
---
## 🔗 Related Documents
- [TASK-BE-002: Auth & RBAC](./TASK-BE-002-auth-rbac.md)
- [TASK-BE-011: Notification & Audit](./TASK-BE-011-notification-audit.md)

View File

@@ -0,0 +1,7 @@
-- Patch: Add workflow.action_review permission to Editor role
-- Required for E2E tests where editor01 needs to perform workflow actions
INSERT IGNORE INTO role_permissions (role_id, permission_id)
VALUES (4, 123);
-- permission_id 123 = workflow.action_review
-- role_id 4 = Editor

View File

@@ -0,0 +1,10 @@
-- Patch: Drop FK constraint for recipient_organization_id
-- Required because -1 is used as sentinel value for "all organizations"
-- Run this on lcbp3_dev database
-- Find and drop the FK constraint(s)
-- The constraint names may vary, check with:
-- SHOW CREATE TABLE document_number_counters;
-- Drop both FK constraints
ALTER TABLE document_number_counters DROP FOREIGN KEY document_number_counters_ibfk_3;
ALTER TABLE document_number_counters DROP FOREIGN KEY fk_recipient_when_not_all;

View File

@@ -0,0 +1,82 @@
-- Patch: Fix CORRESPONDENCE_FLOW_V1 compiled data
-- The compiled JSON used 'target' but code expects 'to'
-- Run this to update existing workflow_definitions
UPDATE workflow_definitions
SET compiled = JSON_OBJECT(
'workflow',
'CORRESPONDENCE_FLOW_V1',
'version',
1,
'initialState',
'DRAFT',
'states',
JSON_OBJECT(
'DRAFT',
JSON_OBJECT(
'terminal',
false,
'transitions',
JSON_OBJECT(
'SUBMIT',
JSON_OBJECT(
'to',
'IN_REVIEW',
'requirements',
JSON_OBJECT('roles', JSON_ARRAY()),
'events',
JSON_ARRAY()
)
)
),
'IN_REVIEW',
JSON_OBJECT(
'terminal',
false,
'transitions',
JSON_OBJECT(
'APPROVE',
JSON_OBJECT(
'to',
'APPROVED',
'requirements',
JSON_OBJECT('roles', JSON_ARRAY()),
'events',
JSON_ARRAY()
),
'REJECT',
JSON_OBJECT(
'to',
'REJECTED',
'requirements',
JSON_OBJECT('roles', JSON_ARRAY()),
'events',
JSON_ARRAY()
),
'RETURN',
JSON_OBJECT(
'to',
'DRAFT',
'requirements',
JSON_OBJECT('roles', JSON_ARRAY()),
'events',
JSON_ARRAY()
)
)
),
'APPROVED',
JSON_OBJECT(
'terminal',
TRUE,
'transitions',
JSON_OBJECT()
),
'REJECTED',
JSON_OBJECT(
'terminal',
TRUE,
'transitions',
JSON_OBJECT()
)
)
)
WHERE workflow_code = 'CORRESPONDENCE_FLOW_V1';