Main: revise specs to 1.5.0 (completed)

This commit is contained in:
2025-12-01 01:28:32 +07:00
parent 241022ada6
commit 71c091055a
69 changed files with 28252 additions and 74 deletions

623
specs/06-tasks/README.md Normal file
View File

@@ -0,0 +1,623 @@
# Development Tasks
**Project:** LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)
**Version:** 1.5.0
**Last Updated:** 2025-12-01
---
## 📋 Overview
This directory contains detailed development tasks for both **Backend** and **Frontend** development of LCBP3-DMS.
### Backend Tasks (13 tasks)
Comprehensive backend implementation covering:
- Foundation (Database, Auth)
- Core Services (File Storage, Document Numbering, Workflow Engine)
- Business Modules (Correspondence, RFA, Drawing)
- Supporting Services (Search, Notifications, Master Data)
### Frontend Tasks (5+ tasks)
Complete frontend UI development including:
- Setup & Configuration
- Authentication UI
- Layout & Navigation
- Business Module UIs
- Common Reusable Components
**Total Estimated Timeline:** 24-26 weeks for complete MVP
### Task Status Legend
- 🔴 **Not Started** - ยังไม่เริ่มทำ
- 🟡 **In Progress** - กำลังดำเนินการ
- 🟢 **Completed** - เสร็จสมบูรณ์
- ⏸️ **Blocked** - มีสิ่งที่ Block การทำงาน
### Priority Levels
- **P0 (Critical):** ต้องทำก่อน เป็น Foundation
- **P1 (High):** สำคัญมาก Core Business Logic
- **P2 (Medium):** สำคัญปานกลาง Supporting Features
- **P3 (Low):** ทำทีหลังได้ Enhancements
---
## 🗺️ Task Roadmap
```mermaid
graph TB
subgraph "Phase 1: Foundation (P0)"
T001[TASK-BE-001<br/>Database Migrations]
T002[TASK-BE-002<br/>Auth & RBAC]
end
subgraph "Phase 2: Core Infrastructure (P0-P1)"
T013[TASK-BE-013<br/>User Management]
T012[TASK-BE-012<br/>Master Data]
T003[TASK-BE-003<br/>File Storage]
T004[TASK-BE-004<br/>Doc Numbering]
T006[TASK-BE-006<br/>Workflow Engine]
end
subgraph "Phase 3: Business Modules (P1)"
T005[TASK-BE-005<br/>Correspondence]
T007[TASK-BE-007<br/>RFA]
end
subgraph "Phase 4: Supporting Modules (P2)"
T008[TASK-BE-008<br/>Drawing]
T009[TASK-BE-009<br/>Circulation/Transmittal]
T010[TASK-BE-010<br/>Search/Elasticsearch]
end
subgraph "Phase 5: Services (P3)"
T011[TASK-BE-011<br/>Notification/Audit]
end
T001 --> T002
T002 --> T013
T002 --> T012
T002 --> T003
T002 --> T004
T002 --> T006
T013 --> T005
T012 --> T005
T003 --> T005
T004 --> T005
T006 --> T005
T013 --> T007
T012 --> T007
T003 --> T007
T004 --> T007
T006 --> T007
T012 --> T008
T003 --> T008
T004 --> T008
T012 --> T009
T003 --> T009
T006 --> T009
T005 --> T010
T007 --> T010
T002 --> T011
style T001 fill:#ff6b6b
style T002 fill:#ff6b6b
style T013 fill:#feca57
style T012 fill:#feca57
style T003 fill:#feca57
style T004 fill:#feca57
style T006 fill:#ff6b6b
style T005 fill:#feca57
style T007 fill:#feca57
style T008 fill:#48dbfb
style T009 fill:#48dbfb
style T010 fill:#48dbfb
style T011 fill:#a29bfe
```
---
## 📊 Task List
### Phase 1: Foundation (2-3 weeks)
| ID | Task | Priority | Effort | Status | Dependencies |
| ---------------------------------------------- | --------------------------- | -------- | -------- | -------------- | ------------ |
| [BE-001](./TASK-BE-001-database-migrations.md) | Database Setup & Migrations | P0 | 2-3 days | 🔴 Not Started | None |
| [BE-002](./TASK-BE-002-auth-rbac.md) | Auth & RBAC Module | P0 | 5-7 days | 🔴 Not Started | BE-001 |
### Phase 2: Core Infrastructure (3-4 weeks)
| ID | Task | Priority | Effort | Status | Dependencies |
| ------------------------------------------------- | -------------------------- | -------- | ---------- | -------------- | -------------- |
| [BE-013](./TASK-BE-013-user-management.md) | User Management | P1 | 5-7 days | 🔴 Not Started | BE-001, BE-002 |
| [BE-012](./TASK-BE-012-master-data-management.md) | Master Data Management | P1 | 6-8 days | 🔴 Not Started | BE-001, BE-002 |
| [BE-003](./TASK-BE-003-file-storage.md) | File Storage (Two-Phase) | P1 | 4-5 days | 🔴 Not Started | BE-001, BE-002 |
| [BE-004](./TASK-BE-004-document-numbering.md) | Document Numbering Service | P1 | 5-6 days | 🔴 Not Started | BE-001, BE-002 |
| [BE-006](./TASK-BE-006-workflow-engine.md) | Workflow Engine | P0 | 10-14 days | 🔴 Not Started | BE-001, BE-002 |
### Phase 3: Business Modules (4-5 weeks)
| ID | Task | Priority | Effort | Status | Dependencies |
| ------------------------------------------------ | --------------------- | -------- | --------- | -------------- | ---------------------------------- |
| [BE-005](./TASK-BE-005-correspondence-module.md) | Correspondence Module | P1 | 7-10 days | 🔴 Not Started | BE-001~004, BE-006, BE-012, BE-013 |
| [BE-007](./TASK-BE-007-rfa-module.md) | RFA Module | P1 | 8-12 days | 🔴 Not Started | BE-001~004, BE-006, BE-012, BE-013 |
### Phase 4: Supporting Modules (2-3 weeks)
| ID | Task | Priority | Effort | Status | Dependencies |
| -------------------------------------------------- | ------------------------- | -------- | -------- | -------------- | -------------------------- |
| [BE-008](./TASK-BE-008-drawing-module.md) | Drawing Module | P2 | 6-8 days | 🔴 Not Started | BE-001~004, BE-012 |
| [BE-009](./TASK-BE-009-circulation-transmittal.md) | Circulation & Transmittal | P2 | 5-7 days | 🔴 Not Started | BE-001~003, BE-006, BE-012 |
| [BE-010](./TASK-BE-010-search-elasticsearch.md) | Search & Elasticsearch | P2 | 4-6 days | 🔴 Not Started | BE-001, BE-005, BE-007 |
### Phase 5: Supporting Services (1 week)
| ID | Task | Priority | Effort | Status | Dependencies |
| --------------------------------------------- | ------------------------ | -------- | -------- | -------------- | -------------- |
| [BE-011](./TASK-BE-011-notification-audit.md) | Notification & Audit Log | P3 | 3-5 days | 🔴 Not Started | BE-001, BE-002 |
---
## 🎨 Frontend Tasks
### Phase 1: Foundation (Weeks 1-2)
| Task | Title | Priority | Effort | Dependencies |
| ------------------------------------------------- | --------------------------------- | -------- | -------- | -------------- |
| [TASK-FE-001](./TASK-FE-001-frontend-setup.md) | Frontend Setup & Configuration | P0 | 2-3 days | None |
| [TASK-FE-002](./TASK-FE-002-auth-ui.md) | Authentication & Authorization UI | P0 | 3-4 days | FE-001, BE-002 |
| [TASK-FE-003](./TASK-FE-003-layout-navigation.md) | Layout & Navigation System | P0 | 3-4 days | FE-001, FE-002 |
### Phase 2: Core Components (Week 3)
| Task | Title | Priority | Effort | Dependencies |
| ------------------------------------------------- | ------------------------------- | -------- | -------- | ------------ |
| [TASK-FE-005](./TASK-FE-005-common-components.md) | Common Components & Reusable UI | P1 | 3-4 days | FE-001 |
### Phase 3: Business Modules (Weeks 4-8)
| Task | Title | Priority | Effort | Dependencies |
| ------------------------------------------------- | ---------------------------- | -------- | -------- | ---------------------- |
| [TASK-FE-004](./TASK-FE-004-correspondence-ui.md) | Correspondence Management UI | P1 | 5-7 days | FE-003, FE-005, BE-005 |
| [TASK-FE-006](./TASK-FE-006-rfa-ui.md) | RFA Management UI | P1 | 5-7 days | FE-003, FE-005, BE-007 |
| [TASK-FE-007](./TASK-FE-007-drawing-ui.md) | Drawing Management UI | P2 | 4-6 days | FE-003, FE-005, BE-008 |
### Phase 4: Supporting Features (Week 9)
| Task | Title | Priority | Effort | Dependencies |
| ------------------------------------------------------- | ---------------------------- | -------- | -------- | -------------- |
| [TASK-FE-008](./TASK-FE-008-search-ui.md) | Search & Global Filters | P2 | 3-4 days | FE-003, BE-010 |
| [TASK-FE-009](./TASK-FE-009-dashboard-notifications.md) | Dashboard & Notifications UI | P3 | 3-4 days | FE-003, BE-011 |
### Phase 5: Administration (Weeks 10-11)
| Task | Title | Priority | Effort | Dependencies |
| --------------------------------------------------- | ---------------------------- | -------- | -------- | ------------------------------ |
| [TASK-FE-010](./TASK-FE-010-admin-panel.md) | Admin Panel & Settings UI | P2 | 5-7 days | FE-002, FE-005, BE-012, BE-013 |
| [TASK-FE-011](./TASK-FE-011-workflow-config-ui.md) | Workflow Configuration UI | P2 | 5-7 days | FE-010, BE-006 |
| [TASK-FE-012](./TASK-FE-012-numbering-config-ui.md) | Document Numbering Config UI | P2 | 3-4 days | FE-010, BE-004 |
---
## 📅 Estimated Timeline
### Sprint Planning (2-week sprints)
#### Sprint 1-2: Foundation (4 weeks)
- Week 1-2: Database Migrations (BE-001)
- Week 2-4: Auth & RBAC (BE-002)
- _Milestone:_ User can login and access protected routes
#### Sprint 3-5: Core Infrastructure (6 weeks)
- Week 5-6: User Management (BE-013) + Master Data (BE-012)
- Week 7-8: File Storage (BE-003) + Document Numbering (BE-004)
- Week 9-10: Workflow Engine (BE-006)
- _Milestone:_ Complete infrastructure ready for business modules
#### Sprint 6-8: Business Modules (6 weeks)
- Week 11-14: Correspondence Module (BE-005)
- Week 15-17: RFA Module (BE-007)
- _Milestone:_ Core business documents managed
#### Sprint 9-10: Supporting Modules (4 weeks)
- Week 18-19: Drawing Module (BE-008)
- Week 20: Circulation & Transmittal (BE-009)
- Week 21: Search & Elasticsearch (BE-010)
- _Milestone:_ Complete document ecosystem
#### Sprint 11: Supporting Services (1 week)
- Week 22: Notification & Audit (BE-011)
- _Milestone:_ Full MVP ready
**Total Estimated Time:** ~22 weeks (5.5 months)
---
## 🎯 Task Details
### TASK-BE-001: Database Setup & Migrations
- **Type:** Infrastructure
- **Key Deliverables:**
- TypeORM configuration
- 50+ entity classes
- Migration scripts
- Seed data
- **Why First:** Foundation for all other modules
### TASK-BE-002: Auth & RBAC
- **Type:** Security & Infrastructure
- **Key Deliverables:**
- JWT authentication
- 4-level RBAC with CASL
- Permission guards
- Idempotency interceptor
- **Why Critical:** Required for all protected endpoints
### TASK-BE-003: File Storage (Two-Phase)
- **Type:** Core Service
- **Key Deliverables:**
- Two-phase upload system
- Virus scanning (ClamAV)
- File validation
- Cleanup jobs
- **Related ADR:** [ADR-003](../05-decisions/ADR-003-file-storage-approach.md)
### TASK-BE-004: Document Numbering
- **Type:** Core Service
- **Key Deliverables:**
- Double-lock mechanism (Redis + DB)
- Template-based generator
- Concurrent-safe implementation
- **Related ADR:** [ADR-002](../05-decisions/ADR-002-document-numbering-strategy.md)
### TASK-BE-006: Workflow Engine
- **Type:** Core Infrastructure
- **Key Deliverables:**
- DSL parser และ validator
- DSL parser and validator
- State machine management
- Guard and effect executors
- History tracking
- **Related ADR:** [ADR-001](../05-decisions/ADR-001-unified-workflow-engine.md)
### TASK-BE-005: Correspondence Module
- **Type:** Business Module
- **Key Deliverables:**
- Master-Revision pattern implementation
- CRUD operations with workflow
- Attachment management
- Search & filter
- **Why Critical:** Core business document type
### TASK-BE-006: Workflow Engine
- **Type:** Core Infrastructure
- **Key Deliverables:**
- DSL parser and validator
- State machine management
- Guard and effect executors
- History tracking
- **Related ADR:** [ADR-001](../05-decisions/ADR-001-unified-workflow-engine.md)
### TASK-BE-007: RFA Module
- **Type:** Business Module
- **Key Deliverables:**
- Master-Revision pattern
- RFA Items management
- Approval workflow integration
- Review/Respond actions
- **Why Important:** Critical approval process
### TASK-BE-008: Drawing Module
- **Type:** Supporting Module
- **Key Deliverables:**
- Contract Drawing management
- Shop Drawing with revisions
- Drawing categories and references
- Version control
- **Why Important:** Technical document management
### TASK-BE-009: Circulation & Transmittal
- **Type:** Supporting Module
- **Key Deliverables:**
- Circulation sheet with assignees
- Transmittal with document items
- PDF generation for transmittal
- Workflow integration
- **Why Important:** Internal and external document distribution
### TASK-BE-010: Search & Elasticsearch
- **Type:** Performance Enhancement
- **Key Deliverables:**
- Elasticsearch integration
- Full-text search across documents
- Async indexing via queue
- Advanced filters and aggregations
- **Why Important:** Improved search UX
### TASK-BE-011: Notification & Audit
- **Type:** Supporting Services
- **Key Deliverables:**
- Email and LINE notifications
- In-app notifications
- Audit log recording
- Audit log export
- **Why Important:** User engagement and compliance
---
## 🔗 Dependencies Graph
```
BE-001 (Database)
├── BE-002 (Auth)
│ ├── BE-004 (Doc Numbering)
│ ├── BE-006 (Workflow)
│ └── BE-011 (Notification/Audit)
├── BE-003 (File Storage)
│ ├── BE-005 (Correspondence)
│ ├── BE-007 (RFA)
│ ├── BE-008 (Drawing)
│ └── BE-009 (Circulation/Transmittal)
├── BE-005 (Correspondence)
│ └── BE-010 (Search)
└── BE-007 (RFA)
└── BE-010 (Search)
```
---
## ✅ Definition of Done (DoD)
สำหรับทุก Task ต้องผ่านเกณฑ์ดังนี้:
### Code Quality
- ✅ Code เป็นไปตาม [Backend Guidelines](../03-implementation/backend-guidelines.md)
- ✅ No `any` types (TypeScript Strict Mode)
- ✅ ESLint และ Prettier passed
- ✅ No console.log (use Logger)
### Testing
- ✅ Unit Tests (coverage ≥ 80%)
- ✅ Integration Tests สำหรับ Critical Paths
- ✅ E2E Tests (ถ้ามี)
- ✅ Load Tests สำหรับ Performance-Critical Features
### Documentation
- ✅ API Documentation (Swagger/OpenAPI)
- ✅ Code Comments (JSDoc for public methods)
- ✅ README updated (ถ้าจำเป็น)
### Review
- ✅ Code Review โดยอย่างน้อย 1 คน
- ✅ QA Testing passed
- ✅ No Critical/High bugs
---
## 🚨 Risk Management
### High-Risk Tasks
| Task | Risk | Mitigation |
| ------ | ---------------------------- | ----------------------------------- |
| BE-004 | Race conditions in numbering | Comprehensive concurrent testing |
| BE-006 | Complex DSL parsing | Extensive validation และ testing |
| BE-002 | Security vulnerabilities | Security audit, penetration testing |
### Blockers Tracking
Track potential blockers:
- Redis service availability (for BE-004, BE-002)
- ClamAV service availability (for BE-003)
- External API dependencies (ถ้ามี)
---
## 📚 Related Documentation
### Architecture
- [System Architecture](../02-architecture/system-architecture.md)
- [Data Model](../02-architecture/data-model.md)
- [API Design](../02-architecture/api-design.md)
### Guidelines
- [Backend Guidelines](../03-implementation/backend-guidelines.md)
- [Testing Strategy](../03-implementation/testing-strategy.md)
### Decisions
- [ADR-001: Unified Workflow Engine](../05-decisions/ADR-001-unified-workflow-engine.md)
- [ADR-002: Document Numbering Strategy](../05-decisions/ADR-002-document-numbering-strategy.md)
- [ADR-003: Two-Phase File Storage](../05-decisions/ADR-003-file-storage-approach.md)
- [ADR-004: RBAC Implementation](../05-decisions/ADR-004-rbac-implementation.md)
- [ADR-005: Technology Stack](../05-decisions/ADR-005-technology-stack.md)
- [ADR-006: Redis Caching Strategy](../05-decisions/ADR-006-redis-caching-strategy.md)
---
## 📝 How to Use This Directory
### For Developers
1. **เลือก Task:** เริ่มจาก P0 dependencies ก่อน
2. **อ่าน Task File:** เข้าใจ Objectives และ Acceptance Criteria
3. **ติดตาม Implementation Steps:** Follow code examples
4. **เขียน Tests:** ตามที่ระบุใน Testing section
5. **Update Status:** ให้ทีมทราบความคืบหน้า
### For Project Managers
1. **Track Progress:** ใช้ Task List และ Status
2. **Monitor Dependencies:** ตรวจสอบว่า Blocked หรือไม่
3. **Estimate Timeline:** ใช้ Effort estimates
4. **Review Risks:** ติดตาม High-Risk tasks
---
## 🎬 Getting Started
```bash
# 1. Clone repository
git clone https://git.np-dms.work/lcbp3/backend.git
cd backend
# 2. Install dependencies
npm install
# 3. Setup environment
cp .env.example .env
# Edit .env with your configuration
# 4. Start database (Docker)
docker-compose up -d mariadb redis
# 5. Run migrations
npm run migration:run
# 6. Run seed
npm run seed
# 7. Start development server
npm run start:dev
```
---
## <20> Future Enhancements (Post-MVP)
The following features are **NOT required for MVP** but may be considered for future phases based on user feedback and business priorities:
### Phase 6: Reports & Analytics (Optional - P3)
**Estimated Effort:** 3-4 weeks
| Feature | Description | Priority | Effort |
| --------------------- | ---------------------------------- | -------- | -------- |
| Dashboard System | Real-time charts and metrics | P3 | 5-7 days |
| Standard Reports | Document status, workflow progress | P3 | 4-5 days |
| Custom Report Builder | User-defined report templates | P3 | 6-8 days |
| Export to Excel/PDF | Report export functionality | P3 | 3-4 days |
| Data Analytics | Trend analysis and insights | P3 | 5-6 days |
**Business Value:**
- Management visibility into project status
- Performance metrics and KPIs
- Compliance reporting
### Phase 7: Advanced Features (Optional - P3)
**Estimated Effort:** 2-3 weeks
| Feature | Description | Priority | Effort |
| ----------------------- | ----------------------------- | -------- | -------- |
| Document Templates | Letter and email templates | P3 | 3-4 days |
| Advanced Rate Limiting | Per-user quotas, throttling | P2 | 2-3 days |
| Structured Logging | Winston/Pino integration | P3 | 2-3 days |
| APM Integration | New Relic, Datadog monitoring | P3 | 3-4 days |
| Email Queue Retry Logic | Failed email retry mechanism | P2 | 2-3 days |
| Bulk Operations | Bulk update, bulk approve | P3 | 4-5 days |
**Business Value:**
- Improved operational efficiency
- Better system observability
- Enhanced user experience
### Phase 8: Mobile & Offline Support (Optional - P2)
**Estimated Effort:** 4-6 weeks
| Feature | Description | Priority | Effort |
| -------------------------- | ------------------------ | -------- | --------- |
| Mobile App (React Native) | iOS and Android apps | P2 | 3-4 weeks |
| Offline-First Architecture | PWA with service workers | P2 | 2-3 weeks |
| Mobile Push Notifications | Firebase Cloud Messaging | P2 | 1 week |
| Mobile Document Scanner | OCR integration | P3 | 1-2 weeks |
**Business Value:**
- Field access for construction sites
- Work offline, sync later
- Real-time mobile notifications
### Phase 9: Integration & API (Optional - P2)
**Estimated Effort:** 2-3 weeks
| Feature | Description | Priority | Effort |
| ------------------------ | ----------------------------- | -------- | --------- |
| REST API Documentation | OpenAPI 3.0 spec | P2 | 3-4 days |
| Webhook System | External system notifications | P2 | 4-5 days |
| Third-party Integrations | Email, Calendar, Drive | P3 | 1-2 weeks |
| GraphQL API | Alternative to REST | P3 | 1-2 weeks |
| API Versioning | v1, v2 support | P2 | 2-3 days |
**Business Value:**
- Integration with existing systems
- Extensibility for future needs
- Developer-friendly APIs
### Decision Criteria for Future Enhancements
Add these features when:
- ✅ MVP is stable and in production
- ✅ User feedback indicates need
- ✅ Business case is justified
- ✅ Resources are available
- ✅ Does not compromise core functionality
**Do NOT add these features if:**
- ❌ MVP is not yet complete
- ❌ Core features have bugs
- ❌ Team is understaffed
- ❌ No clear business value
---
## <20>📧 Contact & Support
- **Backend Team Lead:** [Name]
- **System Architect:** Nattanin Peancharoen
- **Project Channel:** Slack #lcbp3-backend
---
**Version:** 1.5.0
**Last Updated:** 2025-11-30

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.4.5
- ✅ 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

View File

@@ -0,0 +1,476 @@
# Task: Document Numbering Service
**Status:** Not Started
**Priority:** P1 (High - Critical for Documents)
**Estimated Effort:** 5-6 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง DocumentNumberingService ที่ใช้ Double-Lock mechanism (Redis + DB Optimistic Lock) สำหรับการสร้างเลขที่เอกสารอัตโนมัติ
---
## 🎯 Objectives
- ✅ Template-Based Number Generation
- ✅ Double-Lock Protection (Redis + DB)
- ✅ Concurrent-Safe (No duplicate numbers)
- ✅ Support Disciplines
- ✅ Year-Based Reset
---
## 📝 Acceptance Criteria
1. **Number Generation:**
- ✅ Generate unique sequential numbers
- ✅ Support format: `{ORG}-{TYPE}-{DISCIPLINE}-{YEAR}-{SEQ:4}`
- ✅ No duplicates even with 100+ concurrent requests
- ✅ Generate within 100ms (p90)
2. **Lock Mechanism:**
- ✅ Redis lock acquired (TTL: 3 seconds)
- ✅ DB optimistic lock with version column
- ✅ Retry on conflict (3 times max)
- ✅ Exponential backoff
3. **Format Templates:**
- ✅ Configure per Project/Type
- ✅ Support all token types
- ✅ Validate format before use
---
## 🛠️ Implementation Steps
### 1. Entity - Document Number Format
```typescript
// File: backend/src/modules/document-numbering/entities/document-number-format.entity.ts
@Entity('document_number_formats')
export class DocumentNumberFormat {
@PrimaryGeneratedColumn()
id: number;
@Column()
project_id: number;
@Column()
correspondence_type_id: number;
@Column({ length: 255 })
format_template: string;
// Example: '{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}'
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
correspondenceType: CorrespondenceType;
}
```
### 2. Entity - Document Number Counter
```typescript
// File: backend/src/modules/document-numbering/entities/document-number-counter.entity.ts
@Entity('document_number_counters')
export class DocumentNumberCounter {
@PrimaryColumn()
project_id: number;
@PrimaryColumn()
originator_organization_id: number;
@PrimaryColumn()
correspondence_type_id: number;
@PrimaryColumn({ default: 0 })
discipline_id: number;
@PrimaryColumn()
current_year: number;
@Column({ default: 0 })
last_number: number;
@VersionColumn() // Optimistic Lock
version: number;
@UpdateDateColumn()
updated_at: Date;
}
```
### 3. Numbering Service
```typescript
// File: backend/src/modules/document-numbering/document-numbering.service.ts
import Redlock from 'redlock';
interface NumberingContext {
projectId: number;
organizationId: number;
typeId: number;
disciplineId?: number;
year?: number;
}
@Injectable()
export class DocumentNumberingService {
constructor(
@InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
@InjectRepository(Organization)
private orgRepo: Repository<Organization>,
@InjectRepository(CorrespondenceType)
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(Discipline)
private disciplineRepo: Repository<Discipline>,
private redlock: Redlock,
private logger: Logger
) {}
async generateNextNumber(context: NumberingContext): Promise<string> {
const year = context.year || new Date().getFullYear();
const disciplineId = context.disciplineId || 0;
// Build Redis lock key
const lockKey = this.buildLockKey(
context.projectId,
context.organizationId,
context.typeId,
disciplineId,
year
);
// Retry logic with exponential backoff
return this.retryWithBackoff(
async () =>
await this.generateNumberWithLock(lockKey, context, year, disciplineId),
3,
200
);
}
private async generateNumberWithLock(
lockKey: string,
context: NumberingContext,
year: number,
disciplineId: number
): Promise<string> {
// Step 1: Acquire Redis lock
const lock = await this.redlock.acquire([lockKey], 3000); // 3 sec TTL
try {
// Step 2: Get or create counter
let counter = await this.counterRepo.findOne({
where: {
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
},
});
if (!counter) {
// Initialize new counter
counter = this.counterRepo.create({
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
last_number: 0,
version: 0,
});
await this.counterRepo.save(counter);
}
const currentVersion = counter.version;
const nextNumber = counter.last_number + 1;
// Step 3: Update counter with Optimistic Lock
const result = await this.counterRepo
.createQueryBuilder()
.update(DocumentNumberCounter)
.set({
last_number: nextNumber,
})
.where({
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
version: currentVersion, // Optimistic lock check
})
.execute();
if (result.affected === 0) {
throw new ConflictException('Counter version conflict - retrying...');
}
// Step 4: Format number
const formattedNumber = await this.formatNumber({
projectId: context.projectId,
typeId: context.typeId,
organizationId: context.organizationId,
disciplineId,
year,
sequenceNumber: nextNumber,
});
this.logger.log(`Generated number: ${formattedNumber}`);
return formattedNumber;
} finally {
// Step 5: Release lock
await lock.release();
}
}
private async formatNumber(data: any): Promise<string> {
// Get format template
const format = await this.formatRepo.findOne({
where: {
project_id: data.projectId,
correspondence_type_id: data.typeId,
},
});
if (!format) {
throw new NotFoundException('Document number format not found');
}
// Parse and replace tokens
let result = format.format_template;
const tokens = await this.buildTokens(data);
for (const [token, value] of Object.entries(tokens)) {
result = result.replace(token, value);
}
return result;
}
private async buildTokens(data: any): Promise<Record<string, string>> {
const org = await this.orgRepo.findOne({
where: { id: data.organizationId },
});
const type = await this.typeRepo.findOne({ where: { id: data.typeId } });
let discipline = null;
if (data.disciplineId > 0) {
discipline = await this.disciplineRepo.findOne({
where: { id: data.disciplineId },
});
}
return {
'{ORG_CODE}': org?.organization_code || 'ORG',
'{TYPE_CODE}': type?.type_code || 'TYPE',
'{DISCIPLINE_CODE}': discipline?.discipline_code || 'GEN',
'{YEAR}': data.year.toString(),
'{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
'{SEQ:5}': data.sequenceNumber.toString().padStart(5, '0'),
};
}
private buildLockKey(...parts: Array<number | string>): string {
return `doc_num:${parts.join(':')}`;
}
private async retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number,
initialDelay: number
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (!(error instanceof ConflictException) || attempt === maxRetries) {
throw error;
}
lastError = error;
const delay = initialDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
this.logger.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
}
}
throw lastError;
}
}
```
### 4. Module
```typescript
// File: backend/src/modules/document-numbering/document-numbering.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([
DocumentNumberCounter,
DocumentNumberFormat,
Organization,
CorrespondenceType,
Discipline,
]),
RedisModule,
],
providers: [DocumentNumberingService],
exports: [DocumentNumberingService],
})
export class DocumentNumberingModule {}
```
---
## ✅ Testing & Verification
### 1. Concurrent Test
```typescript
describe('DocumentNumberingService - Concurrency', () => {
it('should generate 100 unique numbers concurrently', async () => {
const context = {
projectId: 1,
organizationId: 3,
typeId: 1,
disciplineId: 2,
year: 2025,
};
const promises = Array(100)
.fill(null)
.map(() => service.generateNextNumber(context));
const results = await Promise.all(promises);
// Check uniqueness
const unique = new Set(results);
expect(unique.size).toBe(100);
// Check format
results.forEach((num) => {
expect(num).toMatch(/^TEAM-RFA-STR-2025-\d{4}$/);
});
});
it('should handle Redis lock timeout', async () => {
// Mock Redis lock to always timeout
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));
await expect(service.generateNextNumber(context)).rejects.toThrow();
});
it('should retry on version conflict', async () => {
// Simulate conflict on first attempt
let attempt = 0;
jest.spyOn(counterRepo, 'createQueryBuilder').mockImplementation(() => {
attempt++;
return {
update: () => ({
set: () => ({
where: () => ({
execute: async () => ({
affected: attempt === 1 ? 0 : 1, // Fail first, succeed second
}),
}),
}),
}),
} as any;
});
const result = await service.generateNextNumber(context);
expect(result).toBeDefined();
expect(attempt).toBe(2);
});
});
```
### 2. Load Test
```yaml
# artillery.yml
config:
target: 'http://localhost:3000'
phases:
- duration: 30
arrivalRate: 20 # 20 req/sec
scenarios:
- name: 'Generate Document Numbers'
flow:
- post:
url: '/correspondences'
json:
title: 'Load Test {{ $randomString() }}'
project_id: 1
type_id: 1
discipline_id: 2
```
---
## 📚 Related Documents
- [ADR-002: Document Numbering Strategy](../05-decisions/ADR-002-document-numbering-strategy.md)
- [Backend Guidelines - Document Numbering](../03-implementation/backend-guidelines.md#document-numbering)
---
## 📦 Deliverables
- [ ] DocumentNumberingService
- [ ] DocumentNumberCounter Entity
- [ ] DocumentNumberFormat Entity
- [ ] Format Template Parser
- [ ] Redis Lock Integration
- [ ] Retry Logic with Backoff
- [ ] Unit Tests (90% coverage)
- [ ] Concurrent Tests
- [ ] Load Tests
- [ ] Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ----------------------- | ------ | --------------------------------- |
| Redis lock failure | High | Retry + DB fallback |
| Version conflicts | Medium | Exponential backoff retry |
| Lock timeout | Medium | Increase TTL, optimize queries |
| Performance degradation | High | Redis caching, connection pooling |
---
## 📌 Notes
- Redis lock TTL: 3 seconds
- Max retries: 3
- Exponential backoff: 200ms → 400ms → 800ms
- Format template stored in database (configurable)
- Counters reset automatically per year

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:** Not Started
**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:** Not Started
**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:** Not Started
**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:** Not Started
**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,493 @@
# Task: Search & Elasticsearch Integration
**Status:** Not Started
**Priority:** P2 (Medium - Performance Enhancement)
**Estimated Effort:** 4-6 days
**Dependencies:** TASK-BE-001, TASK-BE-005, TASK-BE-007
**Owner:** Backend Team
---
## 📋 Overview
สร้าง Search Module ที่ integrate กับ Elasticsearch สำหรับ Full-text Search และ Advanced Filtering
---
## 🎯 Objectives
- ✅ Elasticsearch Integration
- ✅ Full-text Search (Correspondences, RFAs, Drawings)
- ✅ Advanced Filters
- ✅ Search Result Aggregations
- ✅ Auto-indexing
---
## 📝 Acceptance Criteria
1. **Search Capabilities:**
- ✅ Search across multiple document types
- ✅ Full-text search in title, description
- ✅ Filter by project, status, date range
- ✅ Sort results by relevance/date
2. **Indexing:**
- ✅ Auto-index on document create/update
- ✅ Async indexing (via queue)
- ✅ Bulk re-indexing command
3. **Performance:**
- ✅ Search results < 500ms
- ✅ Pagination support
- ✅ Highlight search terms
---
## 🛠️ Implementation Steps
### 1. Elasticsearch Module Setup
```typescript
// File: backend/src/modules/search/search.module.ts
import { ElasticsearchModule } from '@nestjs/elasticsearch';
@Module({
imports: [
ElasticsearchModule.register({
node: process.env.ELASTICSEARCH_URL || 'http://localhost:9200',
}),
],
providers: [SearchService, SearchIndexer],
exports: [SearchService],
})
export class SearchModule {}
```
### 2. Index Mapping
```typescript
// File: backend/src/modules/search/mappings/correspondence.mapping.ts
export const correspondenceMapping = {
properties: {
id: { type: 'integer' },
correspondence_number: { type: 'keyword' },
title: {
type: 'text',
analyzer: 'standard',
fields: {
keyword: { type: 'keyword' },
},
},
description: {
type: 'text',
analyzer: 'standard',
},
project_id: { type: 'integer' },
project_name: { type: 'keyword' },
status: { type: 'keyword' },
created_at: { type: 'date' },
created_by_username: { type: 'keyword' },
organization_name: { type: 'keyword' },
type_name: { type: 'keyword' },
discipline_name: { type: 'keyword' },
},
};
```
### 3. Search Service
```typescript
// File: backend/src/modules/search/search.service.ts
import { Injectable } from '@nestjs/common';
import { ElasticsearchService } from '@nestjs/elasticsearch';
@Injectable()
export class SearchService {
private readonly INDEX_NAME = 'lcbp3-documents';
constructor(private elasticsearch: ElasticsearchService) {}
async onModuleInit() {
// Create index if not exists
const indexExists = await this.elasticsearch.indices.exists({
index: this.INDEX_NAME,
});
if (!indexExists) {
await this.createIndex();
}
}
private async createIndex(): Promise<void> {
await this.elasticsearch.indices.create({
index: this.INDEX_NAME,
body: {
mappings: {
properties: {
document_type: { type: 'keyword' },
...correspondenceMapping.properties,
...rfaMapping.properties,
},
},
},
});
}
async search(query: SearchQueryDto): Promise<SearchResult> {
const must: any[] = [];
const filter: any[] = [];
// Full-text search
if (query.search) {
must.push({
multi_match: {
query: query.search,
fields: ['title^2', 'description', 'correspondence_number'],
fuzziness: 'AUTO',
},
});
}
// Filters
if (query.document_type) {
filter.push({ term: { document_type: query.document_type } });
}
if (query.project_id) {
filter.push({ term: { project_id: query.project_id } });
}
if (query.status) {
filter.push({ term: { status: query.status } });
}
if (query.date_from || query.date_to) {
const range: any = {};
if (query.date_from) range.gte = query.date_from;
if (query.date_to) range.lte = query.date_to;
filter.push({ range: { created_at: range } });
}
// Execute search
const page = query.page || 1;
const limit = query.limit || 20;
const from = (page - 1) * limit;
const result = await this.elasticsearch.search({
index: this.INDEX_NAME,
body: {
from,
size: limit,
query: {
bool: {
must,
filter,
},
},
sort: query.sort_by
? [{ [query.sort_by]: { order: query.sort_order || 'desc' } }]
: [{ _score: 'desc' }, { created_at: 'desc' }],
highlight: {
fields: {
title: {},
description: {},
},
},
aggs: {
document_types: {
terms: { field: 'document_type' },
},
statuses: {
terms: { field: 'status' },
},
projects: {
terms: { field: 'project_id' },
},
},
},
});
return {
items: result.hits.hits.map((hit) => ({
...hit._source,
_score: hit._score,
_highlights: hit.highlight,
})),
total: result.hits.total.value,
page,
limit,
totalPages: Math.ceil(result.hits.total.value / limit),
aggregations: result.aggregations,
};
}
async indexDocument(
documentType: string,
documentId: number,
data: any
): Promise<void> {
await this.elasticsearch.index({
index: this.INDEX_NAME,
id: `${documentType}-${documentId}`,
body: {
document_type: documentType,
...data,
},
});
}
async updateDocument(
documentType: string,
documentId: number,
data: any
): Promise<void> {
await this.elasticsearch.update({
index: this.INDEX_NAME,
id: `${documentType}-${documentId}`,
body: {
doc: data,
},
});
}
async deleteDocument(
documentType: string,
documentId: number
): Promise<void> {
await this.elasticsearch.delete({
index: this.INDEX_NAME,
id: `${documentType}-${documentId}`,
});
}
}
```
### 4. Search Indexer (Queue Worker)
```typescript
// File: backend/src/modules/search/search-indexer.service.ts
import { Processor, Process } from '@nestjs/bullmq';
import { Job } from 'bullmq';
@Processor('search-indexing')
export class SearchIndexer {
constructor(
private searchService: SearchService,
@InjectRepository(Correspondence)
private correspondenceRepo: Repository<Correspondence>,
@InjectRepository(Rfa)
private rfaRepo: Repository<Rfa>
) {}
@Process('index-correspondence')
async indexCorrespondence(job: Job<{ id: number }>) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id: job.data.id },
relations: ['project', 'originatorOrganization', 'revisions'],
});
if (!correspondence) {
return;
}
const latestRevision = correspondence.revisions[0];
await this.searchService.indexDocument(
'correspondence',
correspondence.id,
{
id: correspondence.id,
correspondence_number: correspondence.correspondence_number,
title: correspondence.title,
description: latestRevision?.description,
project_id: correspondence.project_id,
project_name: correspondence.project.project_name,
status: correspondence.status,
created_at: correspondence.created_at,
organization_name:
correspondence.originatorOrganization.organization_name,
}
);
}
@Process('index-rfa')
async indexRfa(job: Job<{ id: number }>) {
const rfa = await this.rfaRepo.findOne({
where: { id: job.data.id },
relations: ['project', 'revisions'],
});
if (!rfa) {
return;
}
const latestRevision = rfa.revisions[0];
await this.searchService.indexDocument('rfa', rfa.id, {
id: rfa.id,
rfa_number: rfa.rfa_number,
title: rfa.subject,
description: latestRevision?.description,
project_id: rfa.project_id,
project_name: rfa.project.project_name,
status: rfa.status,
created_at: rfa.created_at,
});
}
@Process('bulk-reindex')
async bulkReindex(job: Job) {
// Re-index all correspondences
const correspondences = await this.correspondenceRepo.find({
relations: ['project', 'originatorOrganization', 'revisions'],
});
for (const corr of correspondences) {
await this.indexCorrespondence({ data: { id: corr.id } } as Job);
}
// Re-index all RFAs
const rfas = await this.rfaRepo.find({
relations: ['project', 'revisions'],
});
for (const rfa of rfas) {
await this.indexRfa({ data: { id: rfa.id } } as Job);
}
}
}
```
### 5. Integration with Service
```typescript
// File: backend/src/modules/correspondence/correspondence.service.ts (updated)
@Injectable()
export class CorrespondenceService {
constructor(
// ... existing dependencies
private searchQueue: Queue
) {}
async create(
dto: CreateCorrespondenceDto,
userId: number
): Promise<Correspondence> {
const correspondence = await this.dataSource.transaction(/* ... */);
// Queue for indexing (async)
await this.searchQueue.add('index-correspondence', {
id: correspondence.id,
});
return correspondence;
}
async update(id: number, dto: UpdateCorrespondenceDto): Promise<void> {
await this.corrRepo.update(id, dto);
// Re-index
await this.searchQueue.add('index-correspondence', { id });
}
}
```
### 6. Search Controller
```typescript
// File: backend/src/modules/search/search.controller.ts
@Controller('search')
@UseGuards(JwtAuthGuard)
export class SearchController {
constructor(private searchService: SearchService) {}
@Get()
async search(@Query() query: SearchQueryDto) {
return this.searchService.search(query);
}
@Post('reindex')
@RequirePermission('admin.manage')
async reindex() {
await this.searchQueue.add('bulk-reindex', {});
return { message: 'Re-indexing started' };
}
}
```
---
## ✅ Testing & Verification
### 1. Unit Tests
```typescript
describe('SearchService', () => {
it('should search with full-text query', async () => {
const result = await service.search({
search: 'foundation',
page: 1,
limit: 20,
});
expect(result.items).toBeDefined();
expect(result.total).toBeGreaterThan(0);
});
it('should filter by project and status', async () => {
const result = await service.search({
project_id: 1,
status: 'submitted',
});
result.items.forEach((item) => {
expect(item.project_id).toBe(1);
expect(item.status).toBe('submitted');
});
});
});
```
---
## 📚 Related Documents
- [System Architecture - Search](../02-architecture/system-architecture.md#elasticsearch)
- [ADR-005: Technology Stack](../05-decisions/ADR-005-technology-stack.md)
---
## 📦 Deliverables
- [ ] SearchService with Elasticsearch
- [ ] Search Indexer (Queue Worker)
- [ ] Index Mappings
- [ ] Queue Integration
- [ ] Search Controller
- [ ] Bulk Re-indexing Command
- [ ] Unit Tests (75% coverage)
- [ ] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
| ------------------ | ------ | --------------------- |
| Elasticsearch down | Medium | Fallback to DB search |
| Index out of sync | Medium | Regular re-indexing |
| Large result sets | Low | Pagination + limits |
---
## 📌 Notes
- Async indexing via BullMQ
- Index correspondence, RFA, drawings
- Support Thai language search
- Highlight matching terms
- Aggregations for faceted search
- Re-index command for admin

View File

@@ -0,0 +1,524 @@
# Task: Notification & Audit Log Services
**Status:** Not Started
**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:** Not Started
**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:** Not Started
**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,505 @@
# 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
- [ ] List all workflows with status
- [ ] Create/edit workflows with DSL editor
- [ ] Visual workflow builder functional
- [ ] DSL validation shows errors
- [ ] 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:** Ready

View File

@@ -0,0 +1,517 @@
# 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
- [ ] List all numbering templates by document type
- [ ] Create/edit templates with format preview
- [ ] Template variables easily selectable
- [ ] Preview shows example numbers
- [ ] View current number sequences
- [ ] Annual reset configurable
- [ ] 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>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</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: '{ORG}', name: 'Organization Code', example: 'กทท' },
{ key: '{DOCTYPE}', name: 'Document Type', example: 'CORR' },
{ key: '{DISC}', name: 'Discipline', example: 'STR' },
{ key: '{YYYY}', name: 'Year (4-digit)', example: '2025' },
{ key: '{YY}', name: 'Year (2-digit)', example: '25' },
{ key: '{MM}', name: 'Month', example: '12' },
{ key: '{SEQ}', name: 'Sequence Number', example: '0001' },
{ key: '{CONTRACT}', name: 'Contract Code', example: 'C01' },
];
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="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="drawing">Drawing</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:** Ready