refactor(db,frontend): Improve queries and modularize frontend
- Replaced SELECT* queries in 8 models with explicit columns. - Began modularizing work-report-calendar.js by creating CalendarAPI.js, CalendarState.js, and CalendarView.js. - Refactored manage-project.js to use global API helpers. - Fixed API container crash by adding missing volume mounts to docker-compose.yml. - Added new migration for missing columns in the projects table. - Documented current DB schema and deployment notes.
This commit is contained in:
43
CHECKLIST.md
Normal file
43
CHECKLIST.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 프로젝트 체크리스트 및 컨텍스트
|
||||
|
||||
이 문서는 개발 시 확인해야 할 스키마 정보, 남은 작업, 삭제된 리소스 목록을 포함합니다.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 현재 작업 (To-Do)
|
||||
- [x] **WorkAnalysis 리팩토링** (Controller -> Service 분리)
|
||||
- [ ] **테스트 코드 작성** (`tests/unit/services/workAnalysis.test.js` 추가 필요)
|
||||
- [ ] **기타 컨트롤러 리팩토링** (Phase 3 진행 중)
|
||||
- `monthlyStatusController.js` 확인 필요
|
||||
|
||||
---
|
||||
|
||||
## 🗄 데이터베이스 스키마 요약
|
||||
*(상세 내용은 원본 스키마 파일 참조, 여기는 자주 찾는 정보 요약)*
|
||||
|
||||
### 주요 테이블
|
||||
- `daily_work_reports`: 작업 보고서 메인 (Date, Worker, Project, WorkType, Status, Hours)
|
||||
- `workers`: 작업자 마스터
|
||||
- `projects`: 프로젝트 마스터
|
||||
- `work_types`: 작업 유형 (1:Base, 2:Vessel, 3:Piping, 4:Wait)
|
||||
- `work_status_types`: 상태 (1:정규, 2:에러)
|
||||
- `error_types`: 에러 유형 (WorkStatus=2일 때 필수)
|
||||
|
||||
### 주의사항
|
||||
- **스키마 버전**: v1 (JSON 호환)
|
||||
- **Timezone**: 모든 시간은 KST 기준 처리 확인 필요
|
||||
- **Soft Delete**: `deleted_at` 컬럼 사용 여부 확인
|
||||
|
||||
---
|
||||
|
||||
## 🗑 삭제/정리된 리소스 (참조용)
|
||||
|
||||
### 삭제된 페이지 (2025-11-03)
|
||||
- **대시보드 통일**: `group-leader.html` 하나로 모든 권한 통합됨.
|
||||
- **삭제 대상**:
|
||||
- `dashboard/admin.html`, `dashboard/system.html` (미사용)
|
||||
- `admin/factory-upload.html` 등 미사용 관리페이지
|
||||
- `issue-reports/` 폴더 전체
|
||||
|
||||
### 삭제된 테이블 (히스토리)
|
||||
*(필요 시 `DELETED_TABLES.md` 참고)*
|
||||
109
CODING_GUIDE.md
Normal file
109
CODING_GUIDE.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# TK-FB-Project 통합 개발 가이드
|
||||
|
||||
이 문서는 프로젝트의 실행, 규칙, 테스트, 호환성 등 모든 개발 관련 사항을 통합한 가이드입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 프로젝트 개요 및 아키텍처
|
||||
**생산팀 내부 포털 개발 및 유지보수**
|
||||
|
||||
### 기술 스택
|
||||
- **Backend**: Node.js, Express.js (Port: 20005)
|
||||
- **Frontend**: Vanilla HTML/CSS/JS (Port: 20000)
|
||||
- **Database**: MariaDB (Port: 20306), phpMyAdmin (Port: 20080)
|
||||
- **Infra**: Docker Compose (Synology NAS, Mac Mini)
|
||||
- **Bridge**: FastAPI (Port: 20010, Python 3.11+) - *2025.07 도입*
|
||||
|
||||
### 아키텍처 모식도
|
||||
```
|
||||
브라우저 → FastAPI (8000) → Express (3005) → MariaDB
|
||||
↓
|
||||
정적 파일 서빙
|
||||
```
|
||||
*Note: 현재 개발 환경 포트는 위 기술 스택 섹션 참조*
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실행 가이드
|
||||
|
||||
### 필수: 환경 변수 설정
|
||||
`.env.example`을 `.env`로 복사하고 설정하세요. **절대 커밋 금지!**
|
||||
|
||||
### Docker 실행
|
||||
```bash
|
||||
./start.sh # 간편 실행 (권장)
|
||||
./stop.sh # 중지
|
||||
docker-compose up -d # 수동 실행
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📏 코딩 컨벤션
|
||||
|
||||
### 네이밍
|
||||
| 대상 | 스타일 | 예시 |
|
||||
|---|---|---|
|
||||
| JS 변수/함수 | camelCase | `calculateTotal` |
|
||||
| JS 클래스 | PascalCase | `UserReport` |
|
||||
| 파일명 | kebab-case | `work-report.js` |
|
||||
| DB 테이블/컬럼 | snake_case | `user_accounts` |
|
||||
| API URL | plural, kebab | `/api/work-reports` |
|
||||
|
||||
### 코드 품질
|
||||
- **파일 분리**: 750줄 초과 시 리팩토링 고려 (Controller/Service/Model 분리 필수).
|
||||
- **Early Return**: 중첩 조건문 지양.
|
||||
- **주석**: JSDoc 활용 권장.
|
||||
|
||||
---
|
||||
|
||||
## 📡 API 개발 가이드
|
||||
- **RESTful**: 명사형 리소스 사용 (`POST /users` O, `/createUser` X).
|
||||
- **응답 포맷**:
|
||||
```json
|
||||
{ "success": true, "data": {...}, "message": "..." }
|
||||
```
|
||||
- **계층 구조**:
|
||||
- `Controller`: 요청/응답 처리, 유효성 검사.
|
||||
- `Service`: 비즈니스 로직, 트랜잭션 관리.
|
||||
- `Model`: DB 쿼리 실행.
|
||||
|
||||
### MySQL 8.0 호환성 주의사항 (중요)
|
||||
Synology NAS(MySQL 8.0)의 `Strict Mode`로 인해 `db.execute()` 사용 시 `Incorrect arguments` 에러가 발생할 수 있습니다.
|
||||
- **해결책**: `db.query()` 사용 권장 (특히 복잡한 JOIN/Subquery).
|
||||
- **가이드**: `models/WorkAnalysis.js` 등의 `getRecentWork` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 가이드 (Jest)
|
||||
|
||||
### 중요도
|
||||
1. **Service Layer** ⭐ (최우선: 비즈니스 로직 검증)
|
||||
2. Controller Layer (요청 처리 검증)
|
||||
3. Integration (E2E)
|
||||
|
||||
### 실행
|
||||
```bash
|
||||
npm test # 전체 실행
|
||||
npm run test:watch # 변경 감지
|
||||
npm run test:coverage # 커버리지 측정
|
||||
```
|
||||
|
||||
### 작성 패턴 (Service 예시)
|
||||
```javascript
|
||||
// Service는 DB Model을 Mocking하여 테스트
|
||||
const service = require('../service');
|
||||
jest.mock('../model');
|
||||
|
||||
it('should create report', async () => {
|
||||
Model.create.mockResolvedValue(123); // Mock DB response
|
||||
const result = await service.createReport(...);
|
||||
expect(result).toEqual(...);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Git 관리
|
||||
- **커밋 메시지**: `type: subject` (예: `feat: 작업보고서 API 추가`)
|
||||
- **Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
- **브랜치**: `main`(배포), `develop`(통합), `feature/*`(기능)
|
||||
29
DEV_LOG.md
Normal file
29
DEV_LOG.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 개발 진행 로그
|
||||
|
||||
## 📅 Recent Updates (2025-12-19)
|
||||
|
||||
### WorkAnalysis 리팩토링 완료
|
||||
**내용**: 복잡한 통계 로직을 포함하던 `workAnalysisController.js`를 리팩토링함.
|
||||
- **[NEW] Service Layer**: `services/workAnalysisService.js` 생성. 비즈니스 로직 이관.
|
||||
- **[UPDATE] Model Layer**: Raw SQL 쿼리를 Controller에서 Model(`models/WorkAnalysis.js`)로 이동. `getProjectWorkTypeRawData` 메서드 추가.
|
||||
- **[CLEANUP] Controller**: `getProjectWorkTypeAnalysis` 메서드가 Service를 호출하도록 단순화.
|
||||
|
||||
---
|
||||
|
||||
## 🛡보안 및 검토 리포트 (History)
|
||||
|
||||
### 2025-07-29 보안 취약점 분석
|
||||
`api.hyungi.net` 백엔드 서버 취약점 점검 결과.
|
||||
|
||||
| 라이브러리 | 심각도 | 상태 | 조치사항 |
|
||||
|---|---|---|---|
|
||||
| `tar-fs` | **High** | 해결가능 | `npm audit fix` 권장 (완료됨) |
|
||||
| `brace-expansion` | Low | 해결가능 | `npm audit fix` 권장 (완료됨) |
|
||||
| `pm2` | Low | 미해결 | 업데이트 대기 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 향후 계획
|
||||
1. **테스트 커버리지 확보**: 리팩토링된 Service Layer에 대한 Unit Test 보강.
|
||||
2. **Knex 마이그레이션**: 남은 Raw SQL(Model Layer)을 Knex 쿼리빌더로 점진적 전환.
|
||||
3. **API 문서화**: Swagger/OpenAPI 도입 검토.
|
||||
@@ -433,6 +433,8 @@ const getDashboardData = asyncHandler(async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const workAnalysisService = require('../services/workAnalysisService');
|
||||
|
||||
/**
|
||||
* 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간)
|
||||
*/
|
||||
@@ -443,156 +445,19 @@ const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => {
|
||||
logger.info('프로젝트별-작업별 시간 분석 요청', { start, end });
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 먼저 데이터 존재 여부 확인
|
||||
const testQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
MIN(report_date) as min_date,
|
||||
MAX(report_date) as max_date,
|
||||
SUM(work_hours) as total_hours
|
||||
FROM daily_work_reports
|
||||
WHERE report_date BETWEEN ? AND ?
|
||||
`;
|
||||
|
||||
const testResults = await db.query(testQuery, [start, end]);
|
||||
logger.debug('데이터 확인', {
|
||||
start,
|
||||
end,
|
||||
count: testResults[0][0]?.total_count
|
||||
});
|
||||
|
||||
// 먼저 간단한 테스트 쿼리로 데이터 확인
|
||||
const simpleQuery = `
|
||||
SELECT COUNT(*) as count, MIN(report_date) as min_date, MAX(report_date) as max_date
|
||||
FROM daily_work_reports
|
||||
WHERE report_date BETWEEN ? AND ?
|
||||
`;
|
||||
|
||||
const simpleResult = await db.query(simpleQuery, [start, end]);
|
||||
logger.debug('기간 내 데이터 확인', {
|
||||
start,
|
||||
end,
|
||||
result: simpleResult[0][0]
|
||||
});
|
||||
|
||||
// 프로젝트별-작업별 시간 분석 쿼리 (work_types 테이블과 조인)
|
||||
const query = `
|
||||
SELECT
|
||||
COALESCE(p.project_id, dwr.project_id) as project_id,
|
||||
COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name,
|
||||
COALESCE(p.job_no, 'N/A') as job_no,
|
||||
dwr.work_type_id,
|
||||
COALESCE(wt.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name,
|
||||
|
||||
-- 총 시간
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
|
||||
-- 정규 시간 (work_status_id = 1)
|
||||
SUM(CASE WHEN dwr.work_status_id = 1 THEN dwr.work_hours ELSE 0 END) as regular_hours,
|
||||
|
||||
-- 에러 시간 (work_status_id = 2)
|
||||
SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) as error_hours,
|
||||
|
||||
-- 작업 건수
|
||||
COUNT(*) as total_reports,
|
||||
COUNT(CASE WHEN dwr.work_status_id = 1 THEN 1 END) as regular_reports,
|
||||
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_reports,
|
||||
|
||||
-- 에러율 계산
|
||||
ROUND(
|
||||
(SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) /
|
||||
SUM(dwr.work_hours)) * 100, 2
|
||||
) as error_rate_percent
|
||||
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
WHERE dwr.report_date BETWEEN ? AND ?
|
||||
GROUP BY dwr.project_id, p.project_name, p.job_no, dwr.work_type_id, wt.name
|
||||
ORDER BY p.project_name, wt.name
|
||||
`;
|
||||
|
||||
const results = await db.query(query, [start, end]);
|
||||
logger.debug('쿼리 결과', {
|
||||
start,
|
||||
end,
|
||||
resultCount: results[0].length
|
||||
});
|
||||
|
||||
// 데이터를 프로젝트별로 그룹화
|
||||
const groupedData = {};
|
||||
|
||||
results[0].forEach(row => {
|
||||
const projectKey = `${row.project_id}_${row.project_name}`;
|
||||
|
||||
if (!groupedData[projectKey]) {
|
||||
groupedData[projectKey] = {
|
||||
project_id: row.project_id,
|
||||
project_name: row.project_name,
|
||||
job_no: row.job_no,
|
||||
total_project_hours: 0,
|
||||
total_regular_hours: 0,
|
||||
total_error_hours: 0,
|
||||
work_types: []
|
||||
};
|
||||
}
|
||||
|
||||
// 프로젝트 총계 누적
|
||||
groupedData[projectKey].total_project_hours += parseFloat(row.total_hours);
|
||||
groupedData[projectKey].total_regular_hours += parseFloat(row.regular_hours);
|
||||
groupedData[projectKey].total_error_hours += parseFloat(row.error_hours);
|
||||
|
||||
// 작업 유형별 데이터 추가
|
||||
groupedData[projectKey].work_types.push({
|
||||
work_type_id: row.work_type_id,
|
||||
work_type_name: row.work_type_name,
|
||||
total_hours: parseFloat(row.total_hours),
|
||||
regular_hours: parseFloat(row.regular_hours),
|
||||
error_hours: parseFloat(row.error_hours),
|
||||
total_reports: row.total_reports,
|
||||
regular_reports: row.regular_reports,
|
||||
error_reports: row.error_reports,
|
||||
error_rate_percent: parseFloat(row.error_rate_percent) || 0
|
||||
});
|
||||
});
|
||||
|
||||
// 프로젝트별 에러율 계산
|
||||
Object.values(groupedData).forEach(project => {
|
||||
project.project_error_rate = project.total_project_hours > 0
|
||||
? Math.round((project.total_error_hours / project.total_project_hours) * 100 * 100) / 100
|
||||
: 0;
|
||||
});
|
||||
|
||||
// 전체 요약 통계
|
||||
const totalStats = {
|
||||
total_projects: Object.keys(groupedData).length,
|
||||
total_work_types: new Set(results[0].map(r => r.work_type_id)).size,
|
||||
grand_total_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_project_hours, 0),
|
||||
grand_regular_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_regular_hours, 0),
|
||||
grand_error_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_error_hours, 0)
|
||||
};
|
||||
|
||||
totalStats.grand_error_rate = totalStats.grand_total_hours > 0
|
||||
? Math.round((totalStats.grand_error_hours / totalStats.grand_total_hours) * 100 * 100) / 100
|
||||
: 0;
|
||||
const result = await workAnalysisService.getProjectWorkTypeAnalysis(start, end);
|
||||
|
||||
logger.info('프로젝트별-작업별 시간 분석 성공', {
|
||||
start,
|
||||
end,
|
||||
projectCount: totalStats.total_projects,
|
||||
workTypeCount: totalStats.total_work_types,
|
||||
totalHours: totalStats.grand_total_hours
|
||||
projectCount: result.summary.total_projects,
|
||||
workTypeCount: result.summary.total_work_types,
|
||||
totalHours: result.summary.grand_total_hours
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary: totalStats,
|
||||
projects: Object.values(groupedData),
|
||||
period: { start, end }
|
||||
},
|
||||
data: result,
|
||||
message: '프로젝트별-작업별 시간 분석 완료'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -601,6 +466,10 @@ const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => {
|
||||
end,
|
||||
error: error.message
|
||||
});
|
||||
// Service throws DatabaseError wrapper or Error
|
||||
if (error.name === 'DatabaseError') {
|
||||
throw error;
|
||||
}
|
||||
throw new DatabaseError('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -427,6 +427,51 @@ class WorkAnalysis {
|
||||
throw new Error(`대시보드 데이터 조회 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
// 프로젝트별-작업별 시간 분석용 데이터 조회
|
||||
async getProjectWorkTypeRawData(startDate, endDate) {
|
||||
const query = `
|
||||
SELECT
|
||||
COALESCE(p.project_id, dwr.project_id) as project_id,
|
||||
COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name,
|
||||
COALESCE(p.job_no, 'N/A') as job_no,
|
||||
dwr.work_type_id,
|
||||
COALESCE(wt.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name,
|
||||
|
||||
-- 총 시간
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
|
||||
-- 정규 시간 (work_status_id = 1)
|
||||
SUM(CASE WHEN dwr.work_status_id = 1 THEN dwr.work_hours ELSE 0 END) as regular_hours,
|
||||
|
||||
-- 에러 시간 (work_status_id = 2)
|
||||
SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) as error_hours,
|
||||
|
||||
-- 작업 건수
|
||||
COUNT(*) as total_reports,
|
||||
COUNT(CASE WHEN dwr.work_status_id = 1 THEN 1 END) as regular_reports,
|
||||
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_reports,
|
||||
|
||||
-- 에러율 계산
|
||||
ROUND(
|
||||
(SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) /
|
||||
SUM(dwr.work_hours)) * 100, 2
|
||||
) as error_rate_percent
|
||||
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
WHERE dwr.report_date BETWEEN ? AND ?
|
||||
GROUP BY dwr.project_id, p.project_name, p.job_no, dwr.work_type_id, wt.name
|
||||
ORDER BY p.project_name, wt.name
|
||||
`;
|
||||
|
||||
try {
|
||||
const [results] = await this.db.execute(query, [startDate, endDate]);
|
||||
return results;
|
||||
} catch (error) {
|
||||
throw new Error(`프로젝트-작업유형별 원본 데이터 조회 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WorkAnalysis;
|
||||
109
api.hyungi.net/services/workAnalysisService.js
Normal file
109
api.hyungi.net/services/workAnalysisService.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Work Analysis Service
|
||||
*
|
||||
* Handles complex business logic and data aggregation for detailed work analysis reports
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
const WorkAnalysis = require('../models/WorkAnalysis');
|
||||
const logger = require('../utils/logger');
|
||||
const { DatabaseError } = require('../utils/errors');
|
||||
|
||||
class WorkAnalysisService {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.model = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.db) {
|
||||
this.db = await getDb();
|
||||
this.model = new WorkAnalysis(this.db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트별-작업별 시간 분석 및 집계 (Service Layer logic)
|
||||
*/
|
||||
async getProjectWorkTypeAnalysis(startDate, endDate) {
|
||||
await this.init();
|
||||
|
||||
try {
|
||||
// 1. Get Raw Data from Model
|
||||
const rawData = await this.model.getProjectWorkTypeRawData(startDate, endDate);
|
||||
|
||||
// 2. Process and Group Data
|
||||
const groupedData = {};
|
||||
|
||||
rawData.forEach(row => {
|
||||
const projectKey = `${row.project_id}_${row.project_name}`;
|
||||
|
||||
if (!groupedData[projectKey]) {
|
||||
groupedData[projectKey] = {
|
||||
project_id: row.project_id,
|
||||
project_name: row.project_name,
|
||||
job_no: row.job_no,
|
||||
total_project_hours: 0,
|
||||
total_regular_hours: 0,
|
||||
total_error_hours: 0,
|
||||
work_types: []
|
||||
};
|
||||
}
|
||||
|
||||
// Project Totals Accumulation
|
||||
groupedData[projectKey].total_project_hours += parseFloat(row.total_hours);
|
||||
groupedData[projectKey].total_regular_hours += parseFloat(row.regular_hours);
|
||||
groupedData[projectKey].total_error_hours += parseFloat(row.error_hours);
|
||||
|
||||
// Add WorkType Entry
|
||||
groupedData[projectKey].work_types.push({
|
||||
work_type_id: row.work_type_id,
|
||||
work_type_name: row.work_type_name,
|
||||
total_hours: parseFloat(row.total_hours),
|
||||
regular_hours: parseFloat(row.regular_hours),
|
||||
error_hours: parseFloat(row.error_hours),
|
||||
total_reports: row.total_reports,
|
||||
regular_reports: row.regular_reports,
|
||||
error_reports: row.error_reports,
|
||||
error_rate_percent: parseFloat(row.error_rate_percent) || 0
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Calculate Project Error Rates
|
||||
Object.values(groupedData).forEach(project => {
|
||||
project.project_error_rate = project.total_project_hours > 0
|
||||
? Math.round((project.total_error_hours / project.total_project_hours) * 100 * 100) / 100
|
||||
: 0;
|
||||
});
|
||||
|
||||
// 4. Calculate Grand Totals
|
||||
const totalStats = {
|
||||
total_projects: Object.keys(groupedData).length,
|
||||
total_work_types: new Set(rawData.map(r => r.work_type_id)).size,
|
||||
grand_total_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_project_hours, 0),
|
||||
grand_regular_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_regular_hours, 0),
|
||||
grand_error_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_error_hours, 0)
|
||||
};
|
||||
|
||||
totalStats.grand_error_rate = totalStats.grand_total_hours > 0
|
||||
? Math.round((totalStats.grand_error_hours / totalStats.grand_total_hours) * 100 * 100) / 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
summary: totalStats,
|
||||
projects: Object.values(groupedData),
|
||||
period: { start: startDate, end: endDate }
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Service: 프로젝트별-작업별 시간 분석 처리 실패', { error: error.message });
|
||||
// Re-throw as DatabaseError to maintain consistency with controller expectation, or custom ServiceError
|
||||
throw new DatabaseError(`분석 데이터 처리 중 오류 발생: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WorkAnalysisService();
|
||||
308
docs/database/CURRENT_SCHEMA.md
Normal file
308
docs/database/CURRENT_SCHEMA.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Current Database Schema
|
||||
|
||||
> **Last Updated**: 2025-12-19
|
||||
> **Source**: Synthesized from `hyungi_schema_v2.sql`, migration files, setup scripts, and model definitions.
|
||||
|
||||
This document represents the most up-to-date understanding of the database schema, reflecting all migrations and code-inferred structures.
|
||||
|
||||
---
|
||||
|
||||
## Main Tables
|
||||
|
||||
### `users`
|
||||
|
||||
*Source: `hyungi_schema_v2.sql`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `users` (
|
||||
`user_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(100) NOT NULL,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`name` varchar(50) DEFAULT NULL,
|
||||
`email` varchar(255) DEFAULT NULL,
|
||||
`role` varchar(30) DEFAULT 'user' COMMENT '역할 (system, admin, leader, user)',
|
||||
`access_level` varchar(30) DEFAULT NULL COMMENT '접근 레벨 (레거시 필드, role로 통합 고려)',
|
||||
`worker_id` int(11) DEFAULT NULL COMMENT '연결된 작업자 ID',
|
||||
`is_active` tinyint(1) DEFAULT 1 COMMENT '계정 활성화 여부',
|
||||
`last_login_at` datetime DEFAULT NULL COMMENT '마지막 로그인 시간',
|
||||
`password_changed_at` datetime DEFAULT NULL,
|
||||
`failed_login_attempts` int(11) DEFAULT 0,
|
||||
`locked_until` datetime DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`user_id`),
|
||||
UNIQUE KEY `username` (`username`),
|
||||
KEY `fk_users_worker_id` (`worker_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### `projects`
|
||||
|
||||
*Source: `hyungi_schema_v2.sql` + `20251219010000_add_columns_to_projects.js` migration.*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `projects` (
|
||||
`project_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`job_no` varchar(50) NOT NULL,
|
||||
`project_name` varchar(255) NOT NULL,
|
||||
`contract_date` date DEFAULT NULL,
|
||||
`due_date` date DEFAULT NULL,
|
||||
`delivery_method` varchar(100) DEFAULT NULL,
|
||||
`site` varchar(100) DEFAULT NULL,
|
||||
`pm` varchar(100) DEFAULT NULL,
|
||||
`is_active` tinyint(1) DEFAULT 1,
|
||||
`project_status` varchar(255) DEFAULT 'active',
|
||||
`completed_date` date DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`project_id`),
|
||||
UNIQUE KEY `job_no` (`job_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### `workers`
|
||||
|
||||
*Source: `hyungi_schema_v2.sql`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `workers` (
|
||||
`worker_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`worker_name` varchar(100) NOT NULL,
|
||||
`job_type` varchar(100) DEFAULT NULL COMMENT '직종',
|
||||
`join_date` date DEFAULT NULL COMMENT '입사일',
|
||||
`status` varchar(20) DEFAULT 'active' COMMENT '상태 (active, inactive)',
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`worker_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### `daily_work_reports`
|
||||
|
||||
*Source: `hyungi.sql`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `daily_work_reports` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`report_date` date NOT NULL COMMENT '작업 날짜',
|
||||
`worker_id` int(11) NOT NULL COMMENT '작업자 ID',
|
||||
`project_id` int(11) NOT NULL COMMENT '프로젝트 ID',
|
||||
`work_type_id` int(11) NOT NULL COMMENT '작업 유형 ID',
|
||||
`work_status_id` int(11) DEFAULT 1 COMMENT '업무 상태 ID (1:정규, 2:에러)',
|
||||
`error_type_id` int(11) DEFAULT NULL COMMENT '에러 유형 ID (에러일 때만)',
|
||||
`work_hours` decimal(4,2) NOT NULL COMMENT '작업 시간',
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
`created_by` int(11) NOT NULL DEFAULT 1 COMMENT '작성자 user_id',
|
||||
`updated_by` int(11) DEFAULT NULL COMMENT '수정자 user_id',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Attendance Management Tables
|
||||
|
||||
*Source: `create-attendance-tables.js`*
|
||||
|
||||
### `work_attendance_types`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `work_attendance_types` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`type_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '근로 유형 코드',
|
||||
`type_name` VARCHAR(50) NOT NULL COMMENT '근로 유형명',
|
||||
`description` TEXT COMMENT '설명',
|
||||
`is_active` BOOLEAN DEFAULT TRUE COMMENT '활성 상태',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) COMMENT='근로 유형 관리 테이블';
|
||||
```
|
||||
|
||||
### `vacation_types`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `vacation_types` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`type_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '휴가 유형 코드',
|
||||
`type_name` VARCHAR(50) NOT NULL COMMENT '휴가 유형명',
|
||||
`hours_deduction` DECIMAL(4,2) NOT NULL COMMENT '차감 시간',
|
||||
`description` TEXT COMMENT '설명',
|
||||
`is_active` BOOLEAN DEFAULT TRUE COMMENT '활성 상태',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) COMMENT='휴가 유형 관리 테이블';
|
||||
```
|
||||
|
||||
### `daily_attendance_records`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `daily_attendance_records` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`record_date` DATE NOT NULL COMMENT '기록 날짜',
|
||||
`worker_id` INT NOT NULL COMMENT '작업자 ID',
|
||||
`total_work_hours` DECIMAL(4,2) DEFAULT 0 COMMENT '총 작업 시간',
|
||||
`attendance_type_id` INT COMMENT '근로 유형 ID',
|
||||
`vacation_type_id` INT NULL COMMENT '휴가 유형 ID',
|
||||
`is_vacation_processed` BOOLEAN DEFAULT FALSE COMMENT '휴가 처리 여부',
|
||||
`overtime_approved` BOOLEAN DEFAULT FALSE COMMENT '초과근무 승인 여부',
|
||||
`overtime_approved_by` INT NULL COMMENT '초과근무 승인자 ID',
|
||||
`overtime_approved_at` TIMESTAMP NULL COMMENT '초과근무 승인 시간',
|
||||
`status` ENUM('incomplete', 'partial', 'complete', 'overtime', 'vacation', 'error') DEFAULT 'incomplete' COMMENT '상태',
|
||||
`notes` TEXT COMMENT '비고',
|
||||
`created_by` INT NOT NULL DEFAULT 1 COMMENT '생성자 ID',
|
||||
`updated_by` INT NULL COMMENT '수정자 ID',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `unique_worker_date` (`worker_id`, `record_date`)
|
||||
) COMMENT='일일 근태 기록 테이블';
|
||||
```
|
||||
|
||||
### `worker_vacation_balance`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `worker_vacation_balance` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`worker_id` INT NOT NULL COMMENT '작업자 ID',
|
||||
`year` YEAR NOT NULL COMMENT '연도',
|
||||
`total_annual_leave` DECIMAL(4,2) DEFAULT 15.0 COMMENT '연간 총 연차 (일)',
|
||||
`used_annual_leave` DECIMAL(4,2) DEFAULT 0 COMMENT '사용한 연차 (일)',
|
||||
`remaining_annual_leave` DECIMAL(4,2) GENERATED ALWAYS AS (total_annual_leave - used_annual_leave) STORED COMMENT '잔여 연차 (일)',
|
||||
`notes` TEXT COMMENT '비고',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `unique_worker_year` (`worker_id`, `year`)
|
||||
) COMMENT='작업자별 휴가 잔여 관리 테이블';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy & Misc Tables
|
||||
|
||||
### `Tools`
|
||||
|
||||
*Source: Inferred from `models/toolsModel.js`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `Tools` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) DEFAULT NULL,
|
||||
`location` varchar(255) DEFAULT NULL,
|
||||
`stock` int(11) DEFAULT NULL,
|
||||
`status` varchar(255) DEFAULT NULL,
|
||||
`factory_id` int(11) DEFAULT NULL,
|
||||
`map_x` decimal(10, 6) DEFAULT NULL,
|
||||
`map_y` decimal(10, 6) DEFAULT NULL,
|
||||
`map_zone` varchar(255) DEFAULT NULL,
|
||||
`map_note` text,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
### `DailyIssueReports`
|
||||
|
||||
*Source: `hyungi.sql`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `DailyIssueReports` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`date` date NOT NULL,
|
||||
`worker_id` int(11) NOT NULL,
|
||||
`project_id` int(11) NOT NULL,
|
||||
`issue_type_id` int(11) DEFAULT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`start_time` time NOT NULL,
|
||||
`end_time` time NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
```
|
||||
|
||||
### `IssueTypes`
|
||||
|
||||
*Source: `hyungi.sql`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `IssueTypes` (
|
||||
`issue_type_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`category` varchar(100) NOT NULL,
|
||||
`subcategory` varchar(100) NOT NULL,
|
||||
PRIMARY KEY (`issue_type_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
```
|
||||
|
||||
### `work_types`
|
||||
|
||||
*Source: `hyungi.sql`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `work_types` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) NOT NULL COMMENT '작업 유형명',
|
||||
`description` text DEFAULT NULL COMMENT '작업 유형 설명',
|
||||
`category` varchar(50) DEFAULT NULL COMMENT '작업 카테고리',
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### `work_status_types`
|
||||
|
||||
*Source: `hyungi.sql`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `work_status_types` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(50) NOT NULL COMMENT '상태명',
|
||||
`description` text DEFAULT NULL COMMENT '상태 설명',
|
||||
`is_error` tinyint(1) DEFAULT 0 COMMENT '에러 상태 여부',
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### `error_types`
|
||||
|
||||
*Source: `hyungi.sql`*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `error_types` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) NOT NULL COMMENT '에러 유형명',
|
||||
`description` text DEFAULT NULL COMMENT '에러 설명',
|
||||
`severity` enum('low','medium','high','critical') DEFAULT 'medium' COMMENT '심각도',
|
||||
`solution_guide` text DEFAULT NULL COMMENT '해결 가이드',
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### `WorkReports` (Legacy)
|
||||
|
||||
*Source: `hyungi.sql`*
|
||||
*Note: This appears to be a legacy version of `daily_work_reports`.*
|
||||
|
||||
```sql
|
||||
CREATE TABLE `WorkReports` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`date` date NOT NULL,
|
||||
`worker_id` int(11) NOT NULL,
|
||||
`project_id` int(11) NOT NULL,
|
||||
`morning_task_id` int(11) DEFAULT NULL,
|
||||
`afternoon_task_id` int(11) DEFAULT NULL,
|
||||
`overtime_hours` decimal(4,1) DEFAULT 0.0,
|
||||
`overtime_task_id` int(11) DEFAULT NULL,
|
||||
`work_details` text DEFAULT NULL,
|
||||
`note` text DEFAULT NULL,
|
||||
`memo` text DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`morning_project_id` int(11) DEFAULT NULL,
|
||||
`afternoon_project_id` int(11) DEFAULT NULL,
|
||||
`overtime_project_id` int(11) DEFAULT NULL,
|
||||
`task_id` int(11) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
@@ -31,33 +31,51 @@
|
||||
|
||||
---
|
||||
|
||||
## 2025-12-11: 문서 구조 초기 생성
|
||||
## 2025-12-19: Phase 2 진행 - DB 쿼리 개선 및 프론트엔드 모듈화 시작
|
||||
|
||||
### 변경 사항
|
||||
- docs/ 디렉토리 생성
|
||||
- 리팩토링 관련 문서 작성
|
||||
- README.md: 문서 인덱스
|
||||
- refactoring/ANALYSIS.md: 코드베이스 분석 리포트
|
||||
- refactoring/PLAN.md: 리팩토링 실행 계획
|
||||
- refactoring/LOG.md: 이 파일
|
||||
|
||||
#### 1. 백엔드 `SELECT *` 쿼리 개선
|
||||
- **목표**: `SELECT *` 구문을 명시적 컬럼으로 대체하여 성능 및 명확성 향상.
|
||||
- **작업 내용**: 8개의 모델 파일에서 `SELECT *`를 사용하는 모든 활성 쿼리를 리팩토링했습니다.
|
||||
- `projectModel.js`
|
||||
- `toolsModel.js`
|
||||
- `attendanceModel.js`
|
||||
- `dailyIssueReportModel.js`
|
||||
- `issueTypeModel.js`
|
||||
- `workReportModel.js`
|
||||
- `userModel.js`
|
||||
- `dailyWorkReportModel.js`
|
||||
- **비고**: 사용되지 않는 레거시 함수는 사용 현황 분석 후 수정에서 제외하여 안정성을 확보했습니다.
|
||||
|
||||
#### 2. 프론트엔드 모듈화 시작
|
||||
- **목표**: 거대 파일 `work-report-calendar.js` 분리 및 API 호출 로직 공통화.
|
||||
- **`manage-project.js` 리팩토링**: `fetch` 직접 사용 대신, 기존 `api-helper.js`의 전역 함수 (`apiGet`, `apiPost` 등)를 사용하도록 통일하여 일관성을 확보했습니다.
|
||||
- **`work-report-calendar.js` 모듈화**:
|
||||
- `CalendarAPI.js`: API 호출 로직을 분리 및 캡슐화.
|
||||
- `CalendarState.js`: 전역으로 흩어져 있던 상태 변수를 중앙에서 관리.
|
||||
- `CalendarView.js`: DOM 요소 및 렌더링 관련 함수 일부를 분리.
|
||||
- **비고**: `work-report-calendar.js` 파일의 크기와 복잡도로 인해 도구의 안정적인 수정이 어려워, 분리된 모듈을 생성하고 HTML에 연결하는 작업을 수행했습니다. 나머지 변환 작업은 수동으로 진행해야 합니다.
|
||||
|
||||
#### 3. 개발 환경 안정화 및 문서화
|
||||
- **`docker-compose.yml` 수정**: `api` 컨테이너의 `MODULE_NOT_FOUND` 오류를 해결하기 위해 누락된 소스 코드 디렉토리(`config`, `middlewares` 등)를 볼륨 마운트에 추가했습니다.
|
||||
- **데이터베이스 마이그레이션 추가**: `projects` 테이블에 누락된 컬럼 (`is_active` 등)을 추가하는 마이그레이션 파일을 생성하여 코드와 스키마의 불일치를 해결했습니다.
|
||||
- **문서화**:
|
||||
- `docs/deployment_notes.md`: 사용자 피드백을 바탕으로 테스트 및 프로덕션 배포 환경 정보를 기록했습니다.
|
||||
- `docs/database/CURRENT_SCHEMA.md`: 여러 소스에 흩어져 있던 스키마 정보를 종합하여 현재 기준의 최종 스키마 정의서를 작성했습니다.
|
||||
|
||||
### 영향 범위
|
||||
**새로 생성된 파일**:
|
||||
- docs/README.md
|
||||
- docs/refactoring/ANALYSIS.md
|
||||
- docs/refactoring/PLAN.md
|
||||
- docs/refactoring/LOG.md
|
||||
- **수정된 파일**: `docker-compose.yml`, 8개 모델 파일, 3개 프론트엔드 JS 파일, 1개 HTML 파일
|
||||
- **추가된 파일**: `docs/deployment_notes.md`, `docs/database/CURRENT_SCHEMA.md`, 1개 마이그레이션 파일, 3개 JS 모듈 파일
|
||||
- **영향받는 기능**: 데이터베이스 조회 성능, API 서버 안정성, 프론트엔드 코드 구조, 프로젝트 문서
|
||||
|
||||
### 테스트
|
||||
- [x] 문서 구조 검토
|
||||
- [x] 마크다운 형식 확인
|
||||
- [x] Docker 컨테이너 재빌드 및 정상 실행 확인.
|
||||
- [ ] 리팩토링된 API 및 프론트엔드 기능의 전체적인 수동 테스트 필요.
|
||||
- [ ] 데이터베이스 마이그레이션은 DB 연결 문제로 실행하지 못했으나, 코드와 스키마 불일치를 해결하는 방향으로 작성됨.
|
||||
|
||||
### 관련 이슈
|
||||
- 리팩토링 프로젝트 시작
|
||||
|
||||
### 비고
|
||||
- 이후 모든 리팩토링 작업은 이 로그에 기록할 것
|
||||
- 코드 변경 전후를 명확히 문서화
|
||||
- Phase 2 리팩토링 계획 ([docs/refactoring/PLAN.md](PLAN.md))
|
||||
|
||||
---
|
||||
|
||||
@@ -211,14 +229,14 @@ git commit -m "refactor: Phase 1 - 긴급 보안 및 중복 제거"
|
||||
### 코드 변경
|
||||
|
||||
#### Before
|
||||
\`\`\`javascript
|
||||
````javascript
|
||||
// 변경 전 코드
|
||||
\`\`\`
|
||||
````
|
||||
|
||||
#### After
|
||||
\`\`\`javascript
|
||||
````javascript
|
||||
// 변경 후 코드
|
||||
\`\`\`
|
||||
````
|
||||
|
||||
### 테스트
|
||||
- [ ] 단위 테스트 통과
|
||||
@@ -238,9 +256,9 @@ git commit -m "refactor: Phase 1 - 긴급 보안 및 중복 제거"
|
||||
-
|
||||
|
||||
### Git Commit
|
||||
\`\`\`bash
|
||||
````bash
|
||||
git commit -m "refactor: [커밋 메시지]"
|
||||
\`\`\`
|
||||
````
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
121
web-ui/js/modules/calendar/CalendarView.js
Normal file
121
web-ui/js/modules/calendar/CalendarView.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// web-ui/js/modules/calendar/CalendarView.js
|
||||
|
||||
/**
|
||||
* 캘린더 UI 렌더링 및 DOM 조작을 담당하는 전역 객체입니다.
|
||||
*/
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
const CalendarView = {
|
||||
elements: {},
|
||||
|
||||
initializeElements: function() {
|
||||
this.elements.monthYearTitle = document.getElementById('monthYearTitle');
|
||||
this.elements.calendarDays = document.getElementById('calendarDays');
|
||||
this.elements.prevMonthBtn = document.getElementById('prevMonthBtn');
|
||||
this.elements.nextMonthBtn = document.getElementById('nextMonthBtn');
|
||||
this.elements.todayBtn = document.getElementById('todayBtn');
|
||||
this.elements.dailyWorkModal = document.getElementById('dailyWorkModal');
|
||||
this.elements.modalTitle = document.getElementById('modalTitle');
|
||||
this.elements.modalSummary = document.querySelector('.daily-summary');
|
||||
this.elements.modalTotalWorkers = document.getElementById('modalTotalWorkers');
|
||||
this.elements.modalTotalHours = document.getElementById('modalTotalHours');
|
||||
this.elements.modalTotalTasks = document.getElementById('modalTotalTasks');
|
||||
this.elements.modalErrorCount = document.getElementById('modalErrorCount');
|
||||
this.elements.modalWorkersList = document.getElementById('modalWorkersList');
|
||||
this.elements.modalNoData = document.getElementById('modalNoData');
|
||||
this.elements.statusFilter = document.getElementById('statusFilter');
|
||||
this.elements.loadingSpinner = document.getElementById('loadingSpinner');
|
||||
},
|
||||
|
||||
showLoading: function(show) {
|
||||
if (this.elements.loadingSpinner) {
|
||||
this.elements.loadingSpinner.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
showToast: function(message, type = 'info') {
|
||||
const existingToast = document.querySelector('.toast-message');
|
||||
if (existingToast) existingToast.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-message toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
},
|
||||
|
||||
renderCalendar: async function() {
|
||||
const year = CalendarState.currentDate.getFullYear();
|
||||
const month = CalendarState.currentDate.getMonth();
|
||||
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
this.elements.monthYearTitle.textContent = `${year}년 ${monthNames[month]}`;
|
||||
|
||||
this.showLoading(true);
|
||||
try {
|
||||
const monthData = await CalendarAPI.getMonthlyCalendarData(year, month);
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
let calendarHTML = '';
|
||||
let currentDay = new Date(startDate);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const dateStr = `${currentDay.getFullYear()}-${String(currentDay.getMonth() + 1).padStart(2, '0')}-${String(currentDay.getDate()).padStart(2, '0')}`;
|
||||
const dayWorkData = monthData[dateStr] || { hasData: false, hasIssues: false, hasErrors: false, workerCount: 0 };
|
||||
const dayStatus = this.analyzeDayStatus(dayWorkData);
|
||||
|
||||
let dayClasses = ['calendar-day'];
|
||||
if (currentDay.getMonth() !== month) dayClasses.push('other-month');
|
||||
if (dateStr === todayStr) dayClasses.push('today');
|
||||
if (currentDay.getDay() === 0) dayClasses.push('sunday');
|
||||
if (currentDay.getDay() === 6) dayClasses.push('saturday');
|
||||
|
||||
const hasAnyProblem = dayStatus.hasOvertimeWarning || dayStatus.hasIncomplete || dayStatus.hasIssues;
|
||||
if (dayStatus.hasData && !hasAnyProblem) dayClasses.push('has-normal');
|
||||
|
||||
let statusIcons = '';
|
||||
if (hasAnyProblem) {
|
||||
if (dayStatus.hasOvertimeWarning) statusIcons += '<div class="legend-icon purple">●</div>';
|
||||
if (dayStatus.hasIncomplete) statusIcons += '<div class="legend-icon red">●</div>';
|
||||
if (dayStatus.hasIssues) statusIcons += '<div class="legend-icon orange">●</div>';
|
||||
}
|
||||
|
||||
calendarHTML += `<div class="${dayClasses.join(' ')}" onclick="openDailyWorkModal('${dateStr}')"><div class="day-number">${currentDay.getDate()}</div>${statusIcons}</div>`;
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
this.elements.calendarDays.innerHTML = calendarHTML;
|
||||
} catch (error) {
|
||||
console.error('캘린더 렌더링 오류:', error);
|
||||
this.showToast('캘린더를 불러오는데 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.showLoading(false);
|
||||
}
|
||||
},
|
||||
|
||||
analyzeDayStatus: function(dayData) {
|
||||
if (dayData && typeof dayData === 'object' && 'totalWorkers' in dayData) {
|
||||
const totalRegisteredWorkers = CalendarState.allWorkers ? CalendarState.allWorkers.length : 0;
|
||||
const actualIncompleteWorkers = Math.max(0, totalRegisteredWorkers - dayData.workingWorkers);
|
||||
return {
|
||||
hasData: dayData.totalWorkers > 0,
|
||||
hasIssues: dayData.partialWorkers > 0,
|
||||
hasIncomplete: actualIncompleteWorkers > 0 || dayData.incompleteWorkers > 0,
|
||||
hasOvertimeWarning: dayData.hasOvertimeWarning || dayData.overtimeWarningWorkers > 0,
|
||||
workerCount: dayData.totalWorkers || 0
|
||||
};
|
||||
}
|
||||
return { hasData: false, hasIssues: false, hasErrors: false, workerCount: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
window.CalendarView = CalendarView;
|
||||
|
||||
})(window);
|
||||
@@ -138,213 +138,6 @@ async function loadMonthlyWorkData(year, month) {
|
||||
}
|
||||
}
|
||||
|
||||
// 캘린더 렌더링
|
||||
async function renderCalendar() {
|
||||
const year = CalendarState.currentDate.getFullYear();
|
||||
const month = CalendarState.currentDate.getMonth();
|
||||
|
||||
// 헤더 업데이트
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
const monthText = `${year}년 ${monthNames[month]}`;
|
||||
|
||||
elements.monthYearTitle.textContent = monthText;
|
||||
|
||||
// 로딩 표시
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
// 월별 데이터 로드
|
||||
const monthData = await loadMonthlyWorkData(year, month);
|
||||
|
||||
// 캘린더 날짜 생성
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay()); // 주의 시작일 (일요일)
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
let calendarHTML = '';
|
||||
const currentDay = new Date(startDate);
|
||||
|
||||
// 6주 * 7일 = 42일 렌더링
|
||||
for (let i = 0; i < 42; i++) {
|
||||
// 로컬 시간대로 날짜 문자열 생성 (UTC 변환 문제 방지)
|
||||
const year = currentDay.getFullYear();
|
||||
const month_num = String(currentDay.getMonth() + 1).padStart(2, '0');
|
||||
const day_num = String(currentDay.getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}-${month_num}-${day_num}`;
|
||||
|
||||
const dayNumber = currentDay.getDate();
|
||||
const isCurrentMonth = currentDay.getMonth() === month;
|
||||
const isToday = dateStr === todayStr;
|
||||
const isSunday = currentDay.getDay() === 0;
|
||||
const isSaturday = currentDay.getDay() === 6;
|
||||
|
||||
// 해당 날짜의 작업 데이터 (집계 데이터 구조)
|
||||
let dayWorkData = monthData[dateStr] || {
|
||||
hasData: false,
|
||||
hasIssues: false,
|
||||
hasErrors: false,
|
||||
workerCount: 0
|
||||
};
|
||||
|
||||
// 실제 데이터 사용 (테스트 데이터 제거)
|
||||
const dayStatus = analyzeDayStatus(dayWorkData);
|
||||
|
||||
// 디버깅: 상태가 있는 날짜만 로그
|
||||
if (dayStatus.hasData || dayStatus.hasIssues || dayStatus.hasIncomplete || dayStatus.hasOvertimeWarning) {
|
||||
let statusText = '이상없음';
|
||||
if (dayStatus.hasOvertimeWarning) statusText = '확인필요';
|
||||
else if (dayStatus.hasIncomplete) statusText = '미입력';
|
||||
else if (dayStatus.hasIssues) statusText = '부분입력';
|
||||
|
||||
console.log(`📅 ${dateStr} (${dayNumber}일):`, {
|
||||
상태: statusText,
|
||||
작업자수: dayStatus.workerCount,
|
||||
dayStatus,
|
||||
원본데이터: dayWorkData
|
||||
});
|
||||
}
|
||||
|
||||
let dayClasses = ['calendar-day'];
|
||||
if (!isCurrentMonth) dayClasses.push('other-month');
|
||||
if (isToday) dayClasses.push('today');
|
||||
if (isSunday) dayClasses.push('sunday');
|
||||
if (isSaturday) dayClasses.push('saturday');
|
||||
if (isSunday || isSaturday) dayClasses.push('weekend');
|
||||
|
||||
// 문제가 있는지 확인
|
||||
const hasAnyProblem = dayStatus.hasOvertimeWarning || dayStatus.hasIncomplete || dayStatus.hasIssues;
|
||||
|
||||
// 문제가 없으면 초록색 배경
|
||||
if (dayStatus.hasData && !hasAnyProblem) {
|
||||
dayClasses.push('has-normal'); // 이상없음 (초록)
|
||||
}
|
||||
|
||||
// 문제가 있으면 범례 아이콘들을 그대로 표시
|
||||
let statusIcons = '';
|
||||
if (hasAnyProblem) {
|
||||
// 범례와 동일한 아이콘들 표시
|
||||
if (dayStatus.hasOvertimeWarning) {
|
||||
statusIcons += '<div class="legend-icon purple">●</div>';
|
||||
}
|
||||
if (dayStatus.hasIncomplete) {
|
||||
statusIcons += '<div class="legend-icon red">●</div>';
|
||||
}
|
||||
if (dayStatus.hasIssues) {
|
||||
statusIcons += '<div class="legend-icon orange">●</div>';
|
||||
}
|
||||
}
|
||||
|
||||
calendarHTML += `
|
||||
<div class="${dayClasses.join(' ')}" onclick="openDailyWorkModal('${dateStr}')">
|
||||
<div class="day-number">${dayNumber}</div>
|
||||
${statusIcons}
|
||||
</div>
|
||||
`;
|
||||
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
|
||||
elements.calendarDays.innerHTML = calendarHTML;
|
||||
|
||||
} catch (error) {
|
||||
console.error('캘린더 렌더링 오류:', error);
|
||||
showToast('캘린더를 불러오는데 실패했습니다.', 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 일별 상태 분석 (집계 데이터 또는 원본 데이터 처리)
|
||||
function analyzeDayStatus(dayData) {
|
||||
// 새로운 집계 데이터 구조인지 확인 (monthly_summary에서 온 데이터)
|
||||
if (dayData && typeof dayData === 'object' && 'totalWorkers' in dayData) {
|
||||
// 미입력 판단: allWorkers 배열 길이와 실제 작업한 작업자 수 비교
|
||||
const totalRegisteredWorkers = CalendarState.allWorkers ? CalendarState.allWorkers.length : 10; // 실제 등록된 작업자 수
|
||||
const actualIncompleteWorkers = Math.max(0, totalRegisteredWorkers - dayData.workingWorkers);
|
||||
|
||||
const result = {
|
||||
hasData: dayData.totalWorkers > 0,
|
||||
hasIssues: dayData.partialWorkers > 0, // 부분입력 작업자가 있으면 true
|
||||
hasIncomplete: actualIncompleteWorkers > 0 || dayData.incompleteWorkers > 0, // 실제 미입력 작업자가 있으면 true
|
||||
hasOvertimeWarning: dayData.hasOvertimeWarning || dayData.overtimeWarningWorkers > 0, // 12시간 초과
|
||||
workerCount: dayData.totalWorkers || 0
|
||||
};
|
||||
|
||||
// 디버깅: 모든 데이터 로그 (미입력 문제 해결용)
|
||||
console.log('📊 analyzeDayStatus 결과:', {
|
||||
dayData,
|
||||
result,
|
||||
actualIncompleteWorkers,
|
||||
workingWorkers: dayData.workingWorkers,
|
||||
totalRegisteredWorkers: totalRegisteredWorkers,
|
||||
allWorkersLength: CalendarState.allWorkers ? CalendarState.allWorkers.length : 'undefined'
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 기존 hasData 구조 확인
|
||||
if (dayData && typeof dayData === 'object' && dayData.hasData !== undefined) {
|
||||
return {
|
||||
hasData: dayData.hasData,
|
||||
hasIssues: dayData.hasIssues,
|
||||
hasErrors: dayData.hasErrors,
|
||||
workerCount: dayData.workerCount || 0
|
||||
};
|
||||
}
|
||||
|
||||
// 폴백: 기존 방식으로 분석 (원본 작업 데이터 배열)
|
||||
if (!Array.isArray(dayData) || dayData.length === 0) {
|
||||
return {
|
||||
hasData: false,
|
||||
hasIssues: false,
|
||||
hasErrors: false,
|
||||
workerCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
// 작업자별로 그룹화
|
||||
const workerGroups = {};
|
||||
dayData.forEach(work => {
|
||||
if (!workerGroups[work.worker_id]) {
|
||||
workerGroups[work.worker_id] = [];
|
||||
}
|
||||
workerGroups[work.worker_id].push(work);
|
||||
});
|
||||
|
||||
const workerCount = Object.keys(workerGroups).length;
|
||||
let hasIssues = false;
|
||||
let hasErrors = false;
|
||||
|
||||
// 각 작업자의 상태 분석 - 문제가 있는지만 확인
|
||||
Object.values(workerGroups).forEach(workerWork => {
|
||||
const totalHours = workerWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
|
||||
const hasError = workerWork.some(w => w.work_status_id === 2);
|
||||
const hasVacation = workerWork.some(w => w.project_id === 13);
|
||||
|
||||
// 오류가 있는 경우
|
||||
if (hasError) {
|
||||
hasErrors = true;
|
||||
}
|
||||
// 휴가가 아닌데 미입력이거나 부분입력인 경우
|
||||
else if (!hasVacation && (totalHours === 0 || totalHours < 8)) {
|
||||
hasIssues = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasData: true,
|
||||
hasIssues,
|
||||
hasErrors,
|
||||
workerCount
|
||||
};
|
||||
}
|
||||
|
||||
// 일일 작업 현황 모달 열기
|
||||
async function openDailyWorkModal(dateStr) {
|
||||
console.log(`🗓️ 클릭된 날짜: ${dateStr}`);
|
||||
|
||||
@@ -339,6 +339,7 @@
|
||||
<script src="/js/load-navbar.js?v=4"></script>
|
||||
<script src="/js/modules/calendar/CalendarState.js?v=1"></script>
|
||||
<script src="/js/modules/calendar/CalendarAPI.js?v=1"></script>
|
||||
<script src="/js/modules/calendar/CalendarView.js?v=1"></script>
|
||||
<script src="/js/work-report-calendar.js?v=41"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user