Compare commits
3 Commits
b67362a733
...
05843da1c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05843da1c4 | ||
|
|
8a8307edfc | ||
|
|
bc5df77595 |
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('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.table('projects', function (table) {
|
||||
table.boolean('is_active').defaultTo(true).after('pm');
|
||||
table.string('project_status').defaultTo('active').after('is_active');
|
||||
table.date('completed_date').nullable().after('project_status');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.table('projects', function (table) {
|
||||
table.dropColumn('is_active');
|
||||
table.dropColumn('project_status');
|
||||
table.dropColumn('completed_date');
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -137,7 +137,7 @@ class AttendanceModel {
|
||||
|
||||
// 휴가 유형 정보 조회
|
||||
const [vacationTypes] = await db.execute(
|
||||
'SELECT * FROM vacation_types WHERE type_code = ?',
|
||||
'SELECT id, type_code, type_name, hours_deduction, description, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?',
|
||||
[vacationType]
|
||||
);
|
||||
|
||||
@@ -222,7 +222,7 @@ class AttendanceModel {
|
||||
static async getAttendanceTypes() {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.execute(
|
||||
'SELECT * FROM work_attendance_types WHERE is_active = TRUE ORDER BY id'
|
||||
'SELECT id, type_code, type_name, description, is_active, created_at, updated_at FROM work_attendance_types WHERE is_active = TRUE ORDER BY id'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -231,7 +231,7 @@ class AttendanceModel {
|
||||
static async getVacationTypes() {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.execute(
|
||||
'SELECT * FROM vacation_types WHERE is_active = TRUE ORDER BY hours_deduction DESC'
|
||||
'SELECT id, type_code, type_name, hours_deduction, description, is_active, created_at, updated_at FROM vacation_types WHERE is_active = TRUE ORDER BY hours_deduction DESC'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -242,7 +242,7 @@ class AttendanceModel {
|
||||
const currentYear = year || new Date().getFullYear();
|
||||
|
||||
const [rows] = await db.execute(`
|
||||
SELECT * FROM worker_vacation_balance
|
||||
SELECT id, worker_id, year, total_annual_leave, used_annual_leave, notes, created_at, updated_at FROM worker_vacation_balance
|
||||
WHERE worker_id = ? AND year = ?
|
||||
`, [workerId, currentYear]);
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ const getAllByDate = async (date) => {
|
||||
const getById = async (id, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(`SELECT * FROM DailyIssueReports WHERE id = ?`, [id]);
|
||||
const [rows] = await db.query(`SELECT id, date, worker_id, project_id, issue_type_id, description, created_at, start_time, end_time FROM DailyIssueReports WHERE id = ?`, [id]);
|
||||
callback(null, rows[0]);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
|
||||
@@ -7,7 +7,7 @@ const { getDb } = require('../dbPool');
|
||||
const getAllWorkTypes = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query('SELECT * FROM work_types ORDER BY name ASC');
|
||||
const [rows] = await db.query('SELECT id, name, description, category, created_at, updated_at FROM work_types ORDER BY name ASC');
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
console.error('작업 유형 조회 오류:', err);
|
||||
@@ -18,7 +18,7 @@ const getAllWorkTypes = async (callback) => {
|
||||
const getAllWorkStatusTypes = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query('SELECT * FROM work_status_types ORDER BY id ASC');
|
||||
const [rows] = await db.query('SELECT id, name, description, is_error, created_at FROM work_status_types ORDER BY id ASC');
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
console.error('업무 상태 유형 조회 오류:', err);
|
||||
@@ -29,7 +29,7 @@ const getAllWorkStatusTypes = async (callback) => {
|
||||
const getAllErrorTypes = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query('SELECT * FROM error_types ORDER BY name ASC');
|
||||
const [rows] = await db.query('SELECT id, name, description, severity, solution_guide, created_at, updated_at FROM error_types ORDER BY name ASC');
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
console.error('에러 유형 조회 오류:', err);
|
||||
@@ -689,7 +689,7 @@ const removeByDateAndWorker = async (date, worker_id, deletedBy, callback) => {
|
||||
|
||||
// 삭제 전 정보 저장 (감사 로그용)
|
||||
const [reportInfos] = await conn.query(
|
||||
'SELECT * FROM daily_work_reports WHERE report_date = ? AND worker_id = ?',
|
||||
'SELECT id, report_date, worker_id, project_id, work_type_id, work_status_id, error_type_id, work_hours, created_at, updated_at, created_by, updated_by FROM daily_work_reports WHERE report_date = ? AND worker_id = ?',
|
||||
[date, worker_id]
|
||||
);
|
||||
|
||||
@@ -948,7 +948,7 @@ const removeReportById = async (reportId, deletedByUserId) => {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 감사 로그를 위해 삭제 전 정보 조회
|
||||
const [reportInfo] = await conn.query('SELECT * FROM daily_work_reports WHERE id = ?', [reportId]);
|
||||
const [reportInfo] = await conn.query('SELECT id, report_date, worker_id, project_id, work_type_id, work_status_id, error_type_id, work_hours, created_at, updated_at, created_by, updated_by FROM daily_work_reports WHERE id = ?', [reportId]);
|
||||
|
||||
// 실제 삭제 작업
|
||||
const [result] = await conn.query('DELETE FROM daily_work_reports WHERE id = ?', [reportId]);
|
||||
|
||||
@@ -18,7 +18,7 @@ const create = async (type, callback) => {
|
||||
const getAll = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(`SELECT * FROM IssueTypes ORDER BY category, subcategory`);
|
||||
const [rows] = await db.query(`SELECT issue_type_id, category, subcategory FROM IssueTypes ORDER BY category, subcategory`);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
|
||||
@@ -29,7 +29,7 @@ const getAll = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT * FROM projects ORDER BY project_id DESC`
|
||||
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects ORDER BY project_id DESC`
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
@@ -42,7 +42,7 @@ const getActiveProjects = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT * FROM projects
|
||||
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY project_name ASC`
|
||||
);
|
||||
@@ -56,7 +56,7 @@ const getById = async (project_id, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT * FROM projects WHERE project_id = ?`,
|
||||
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects WHERE project_id = ?`,
|
||||
[project_id]
|
||||
);
|
||||
callback(null, rows[0]);
|
||||
|
||||
@@ -4,7 +4,7 @@ const { getDb } = require('../dbPool');
|
||||
const getAll = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query('SELECT * FROM Tools');
|
||||
const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools');
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
@@ -15,7 +15,7 @@ const getAll = async (callback) => {
|
||||
const getById = async (id, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query('SELECT * FROM Tools WHERE id = ?', [id]);
|
||||
const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools WHERE id = ?', [id]);
|
||||
callback(null, rows[0]);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
|
||||
@@ -5,7 +5,7 @@ const findByUsername = async (username) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM users WHERE username = ?', [username]
|
||||
'SELECT user_id, username, password, name, email, role, access_level, worker_id, is_active, last_login_at, password_changed_at, failed_login_attempts, locked_until, created_at, updated_at FROM users WHERE username = ?', [username]
|
||||
);
|
||||
return rows[0];
|
||||
} catch (err) {
|
||||
|
||||
@@ -110,7 +110,7 @@ const getByRange = async (start, end, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT * FROM WorkReports
|
||||
`SELECT id, \`date\`, worker_id, project_id, morning_task_id, afternoon_task_id, overtime_hours, overtime_task_id, work_details, note, memo, created_at, updated_at, morning_project_id, afternoon_project_id, overtime_project_id, task_id FROM WorkReports
|
||||
WHERE \`date\` BETWEEN ? AND ?
|
||||
ORDER BY \`date\` ASC`,
|
||||
[start, end]
|
||||
@@ -128,7 +128,7 @@ const getById = async (id, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT * FROM WorkReports WHERE id = ?`,
|
||||
`SELECT id, \`date\`, worker_id, project_id, morning_task_id, afternoon_task_id, overtime_hours, overtime_task_id, work_details, note, memo, created_at, updated_at, morning_project_id, afternoon_project_id, overtime_project_id, task_id FROM WorkReports WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
callback(null, rows[0]);
|
||||
|
||||
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();
|
||||
@@ -55,6 +55,10 @@ services:
|
||||
- ./api.hyungi.net/routes:/usr/src/app/routes
|
||||
- ./api.hyungi.net/controllers:/usr/src/app/controllers
|
||||
- ./api.hyungi.net/models:/usr/src/app/models
|
||||
- ./api.hyungi.net/config:/usr/src/app/config
|
||||
- ./api.hyungi.net/middlewares:/usr/src/app/middlewares
|
||||
- ./api.hyungi.net/utils:/usr/src/app/utils
|
||||
- ./api.hyungi.net/services:/usr/src/app/services
|
||||
- ./api.hyungi.net/index.js:/usr/src/app/index.js
|
||||
logging:
|
||||
driver: "json-file"
|
||||
|
||||
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;
|
||||
```
|
||||
13
docs/deployment_notes.md
Normal file
13
docs/deployment_notes.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 배포 관련 참고 사항 (Deployment Notes)
|
||||
|
||||
**작성일**: 2025-12-19
|
||||
|
||||
## 테스트 환경 (Development/Test Environment)
|
||||
|
||||
* 현재 이 프로젝트는 **MacBook Pro**에서 **Docker Desktop**을 통해 테스트 서버로 운영되고 있습니다.
|
||||
* 개발 및 테스트 시에는 `docker-compose.yml`을 사용하여 로컬 환경에서 데이터베이스 및 API 서버를 실행합니다.
|
||||
|
||||
## 프로덕션 환경 (Production Environment)
|
||||
|
||||
* 이 FB 프로그램은 최종적으로 회사 **Synology NAS 923+**에 **Container Manager**를 통해 설치 및 배포될 예정입니다.
|
||||
* 배포 시에는 Synology Container Manager의 환경 설정에 맞게 Docker Compose 파일을 조정해야 할 수 있습니다. 특히 볼륨 마운트 경로, 네트워크 설정, 환경 변수 관리 등에 유의해야 합니다.
|
||||
@@ -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))
|
||||
|
||||
---
|
||||
|
||||
@@ -198,27 +216,27 @@ git commit -m "refactor: Phase 1 - 긴급 보안 및 중복 제거"
|
||||
## YYYY-MM-DD: [작업 제목]
|
||||
|
||||
### 변경 사항
|
||||
-
|
||||
-
|
||||
|
||||
### 영향 범위
|
||||
**수정된 파일**:
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
**영향받는 기능**:
|
||||
-
|
||||
-
|
||||
|
||||
### 코드 변경
|
||||
|
||||
#### Before
|
||||
\`\`\`javascript
|
||||
````javascript
|
||||
// 변경 전 코드
|
||||
\`\`\`
|
||||
````
|
||||
|
||||
#### After
|
||||
\`\`\`javascript
|
||||
````javascript
|
||||
// 변경 후 코드
|
||||
\`\`\`
|
||||
````
|
||||
|
||||
### 테스트
|
||||
- [ ] 단위 테스트 통과
|
||||
@@ -232,15 +250,15 @@ git commit -m "refactor: Phase 1 - 긴급 보안 및 중복 제거"
|
||||
- 개선율:
|
||||
|
||||
### 관련 이슈
|
||||
-
|
||||
-
|
||||
|
||||
### 비고
|
||||
-
|
||||
-
|
||||
|
||||
### Git Commit
|
||||
\`\`\`bash
|
||||
````bash
|
||||
git commit -m "refactor: [커밋 메시지]"
|
||||
\`\`\`
|
||||
````
|
||||
|
||||
---
|
||||
```
|
||||
@@ -303,4 +321,4 @@ git commit -m "refactor: [커밋 메시지]"
|
||||
|
||||
---
|
||||
|
||||
*마지막 업데이트: 2025-12-11*
|
||||
*마지막 업데이트: 2025-12-11*
|
||||
@@ -1,9 +1,6 @@
|
||||
// /js/manage-project.js
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
// The ensureAuthenticated, API, and getAuthHeaders functions are now handled by the global api-helper.js
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
@@ -40,13 +37,8 @@ projectForm?.addEventListener('submit', async e => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/projects`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
const result = await apiPost('/projects', body);
|
||||
if (result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
projectForm.reset();
|
||||
loadProjects();
|
||||
@@ -62,34 +54,24 @@ async function loadProjects() {
|
||||
const tbody = document.getElementById('projectTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="9">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/projects`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const result = await apiGet('/projects');
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
if (result.success && Array.isArray(result.data)) {
|
||||
result.data.forEach(item => {
|
||||
const row = createRow(item, [
|
||||
'project_id', 'job_no', 'project_name', 'contract_date',
|
||||
'due_date', 'delivery_method', 'site', 'pm'
|
||||
], async p => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/projects/${p.project_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
const delRes = await apiDelete(`/projects/${p.project_id}`);
|
||||
if (delRes.success) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadProjects();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
alert('❌ 삭제 실패: ' + (delRes.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
|
||||
131
web-ui/js/modules/calendar/CalendarAPI.js
Normal file
131
web-ui/js/modules/calendar/CalendarAPI.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// web-ui/js/modules/calendar/CalendarAPI.js
|
||||
|
||||
/**
|
||||
* 캘린더와 관련된 모든 API 호출을 관리하는 전역 객체입니다.
|
||||
*/
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
const CalendarAPI = {};
|
||||
|
||||
/**
|
||||
* 활성화된 모든 작업자 목록을 가져옵니다.
|
||||
* @returns {Promise<Array>} 작업자 객체 배열
|
||||
*/
|
||||
CalendarAPI.getWorkers = async function() {
|
||||
try {
|
||||
// api-helper.js 에 정의된 전역 apiGet 함수를 사용합니다.
|
||||
const response = await window.apiGet('/workers');
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
// 활성화된 작업자만 필터링
|
||||
const activeWorkers = response.data.filter(worker =>
|
||||
worker.status === 'active' || worker.is_active === 1 || worker.is_active === true
|
||||
);
|
||||
return activeWorkers;
|
||||
}
|
||||
console.warn('API 응답 형식이 올바르지 않거나 데이터가 없습니다:', response);
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('작업자 데이터 로딩 중 API 오류 발생:', error);
|
||||
// 에러를 다시 던져서 호출부에서 처리할 수 있도록 함
|
||||
throw new Error('작업자 데이터를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 월별 작업 데이터 로드 (집계 테이블 사용으로 최적화)
|
||||
* @param {number} year
|
||||
* @param {number} month (0-indexed)
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
CalendarAPI.getMonthlyCalendarData = async function(year, month) {
|
||||
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||
try {
|
||||
const response = await window.apiGet(`/monthly-status/calendar?year=${year}&month=${month + 1}`);
|
||||
if (response.success) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error(response.message || '집계 데이터 조회 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${monthKey} 집계 데이터 로딩 오류:`, error);
|
||||
console.log(`📋 폴백: ${monthKey} 기존 방식 로딩 시작...`);
|
||||
return await _getMonthlyWorkDataFallback(year, month);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 일일 상세 데이터 조회 (모달용)
|
||||
* @param {string} dateStr (YYYY-MM-DD)
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
CalendarAPI.getDailyDetails = async function(dateStr) {
|
||||
try {
|
||||
const response = await window.apiGet(`/monthly-status/daily-details?date=${dateStr}`);
|
||||
if (response.success) {
|
||||
return response.data;
|
||||
}
|
||||
// Fallback to old API if new one fails
|
||||
const fallbackResponse = await window.apiGet(`/daily-work-reports?date=${dateStr}&view_all=true`);
|
||||
return {
|
||||
workers: fallbackResponse.data, // Assuming structure is different
|
||||
summary: {} // No summary in fallback
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('일일 작업 데이터 로딩 오류:', error);
|
||||
throw new Error('해당 날짜의 작업 데이터를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 작업자의 하루치 작업을 모두 삭제합니다.
|
||||
* @param {number} workerId
|
||||
* @param {string} date (YYYY-MM-DD)
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
CalendarAPI.deleteWorkerDayWork = async function(workerId, date) {
|
||||
return await window.apiDelete(`/daily-work-reports/date/${date}/worker/${workerId}`);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 폴백: 순차적 로딩 (지연 시간 포함) - Private helper
|
||||
* @param {number} year
|
||||
* @param {number} month (0-indexed)
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function _getMonthlyWorkDataFallback(year, month) {
|
||||
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||
const monthData = {};
|
||||
try {
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const currentDay = new Date(firstDay);
|
||||
|
||||
const promises = [];
|
||||
while (currentDay <= lastDay) {
|
||||
const dateStr = currentDay.toISOString().split('T')[0];
|
||||
promises.push(window.apiGet(`/daily-work-reports?date=${dateStr}&view_all=true`));
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
let day = 1;
|
||||
for (const result of results) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
monthData[dateStr] = result.success && Array.isArray(result.data) ? result.data : [];
|
||||
day++;
|
||||
}
|
||||
return monthData;
|
||||
} catch (error) {
|
||||
console.error(`${monthKey} 순차 로딩 오류:`, error);
|
||||
throw new Error('작업 데이터를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 전역 스코프에 CalendarAPI 객체 할당
|
||||
window.CalendarAPI = CalendarAPI;
|
||||
|
||||
})(window);
|
||||
34
web-ui/js/modules/calendar/CalendarState.js
Normal file
34
web-ui/js/modules/calendar/CalendarState.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// web-ui/js/modules/calendar/CalendarState.js
|
||||
|
||||
/**
|
||||
* 캘린더 페이지의 모든 상태를 관리하는 전역 객체입니다.
|
||||
*/
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
const CalendarState = {
|
||||
// 캘린더 상태
|
||||
currentDate: new Date(),
|
||||
monthlyData: {}, // 월별 데이터 캐시
|
||||
allWorkers: [], // 전체 작업자 목록 캐시
|
||||
|
||||
// 모달 상태
|
||||
currentModalDate: null,
|
||||
currentEditingWork: null,
|
||||
existingWorks: [],
|
||||
|
||||
// 상태 초기화
|
||||
reset: function() {
|
||||
this.currentDate = new Date();
|
||||
this.monthlyData = {};
|
||||
// allWorkers는 유지
|
||||
this.currentModalDate = null;
|
||||
this.currentEditingWork = null;
|
||||
this.existingWorks = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 스코프에 CalendarState 객체 할당
|
||||
window.CalendarState = CalendarState;
|
||||
|
||||
})(window);
|
||||
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);
|
||||
@@ -1,12 +1,12 @@
|
||||
// 작업 현황 캘린더 JavaScript
|
||||
|
||||
// 전역 변수
|
||||
let currentDate = new Date();
|
||||
let monthlyData = {}; // 월별 데이터 캐시
|
||||
// 작업자 데이터는 allWorkers 변수 사용
|
||||
let currentModalDate = null;
|
||||
let currentEditingWork = null;
|
||||
let existingWorks = [];
|
||||
// 전역 변수 대신 CalendarState 사용
|
||||
// let currentDate = new Date();
|
||||
// let monthlyData = {}; // 월별 데이터 캐시
|
||||
// let allWorkers = []; // 작업자 데이터는 allWorkers 변수 사용
|
||||
// let currentModalDate = null;
|
||||
// let currentEditingWork = null;
|
||||
// let existingWorks = [];
|
||||
|
||||
// DOM 요소
|
||||
const elements = {
|
||||
@@ -68,17 +68,17 @@ function initializeElements() {
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
elements.prevMonthBtn.addEventListener('click', () => {
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
CalendarState.currentDate.setMonth(CalendarState.currentDate.getMonth() - 1);
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
elements.nextMonthBtn.addEventListener('click', () => {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
CalendarState.currentDate.setMonth(CalendarState.currentDate.getMonth() + 1);
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
elements.todayBtn.addEventListener('click', () => {
|
||||
currentDate = new Date();
|
||||
CalendarState.currentDate = new Date();
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
@@ -101,23 +101,19 @@ function setupEventListeners() {
|
||||
|
||||
// 작업자 데이터 로드 (캐시)
|
||||
async function loadWorkersData() {
|
||||
if (allWorkers.length > 0) return allWorkers;
|
||||
if (CalendarState.allWorkers.length > 0) return CalendarState.allWorkers;
|
||||
|
||||
try {
|
||||
console.log('👥 작업자 데이터 로딩...');
|
||||
const response = await window.apiCall('/workers');
|
||||
const workers = Array.isArray(response) ? response : (response.data || []);
|
||||
console.log('👥 작업자 데이터 로딩 (from CalendarAPI)...');
|
||||
// The new API function already filters for active workers
|
||||
const activeWorkers = await CalendarAPI.getWorkers();
|
||||
CalendarState.allWorkers = activeWorkers;
|
||||
|
||||
// 활성화된 작업자만 필터링
|
||||
allWorkers = workers.filter(worker => {
|
||||
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
|
||||
});
|
||||
|
||||
console.log(`✅ 작업자 ${allWorkers.length}명 로드 완료 (전체: ${workers.length}명)`);
|
||||
return allWorkers;
|
||||
console.log(`✅ 작업자 ${CalendarState.allWorkers.length}명 로드 완료`);
|
||||
return CalendarState.allWorkers;
|
||||
} catch (error) {
|
||||
console.error('작업자 데이터 로딩 오류:', error);
|
||||
showToast('작업자 데이터를 불러오는데 실패했습니다.', 'error');
|
||||
showToast(error.message, 'error');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -126,353 +122,26 @@ async function loadWorkersData() {
|
||||
async function loadMonthlyWorkData(year, month) {
|
||||
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||
|
||||
if (monthlyData[monthKey]) {
|
||||
if (CalendarState.monthlyData[monthKey]) {
|
||||
console.log(`📋 캐시된 ${monthKey} 데이터 사용`);
|
||||
return monthlyData[monthKey];
|
||||
return CalendarState.monthlyData[monthKey];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`📋 ${monthKey} 집계 데이터 로딩...`);
|
||||
|
||||
// 새로운 월별 집계 API 사용 (단일 호출)
|
||||
const response = await window.apiCall(`/monthly-status/calendar?year=${year}&month=${month + 1}`);
|
||||
|
||||
if (response.success) {
|
||||
const calendarData = response.data;
|
||||
|
||||
console.log(`📊 ${monthKey} 집계 데이터:`, Object.keys(calendarData).length, '일');
|
||||
|
||||
// 날짜별 상태 데이터로 변환
|
||||
const monthData = {};
|
||||
|
||||
// 해당 월의 모든 날짜 초기화
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const currentDay = new Date(firstDay);
|
||||
|
||||
while (currentDay <= lastDay) {
|
||||
const dateStr = currentDay.toISOString().split('T')[0];
|
||||
|
||||
if (calendarData[dateStr]) {
|
||||
// 집계 데이터가 있는 경우
|
||||
const dayData = calendarData[dateStr];
|
||||
monthData[dateStr] = {
|
||||
hasData: dayData.workingWorkers > 0,
|
||||
hasIssues: dayData.hasIssues,
|
||||
hasErrors: dayData.hasErrors,
|
||||
hasOvertimeWarning: dayData.hasOvertimeWarning,
|
||||
totalWorkers: dayData.totalWorkers,
|
||||
workerCount: dayData.totalWorkers,
|
||||
workingWorkers: dayData.workingWorkers,
|
||||
incompleteWorkers: dayData.incompleteWorkers,
|
||||
partialWorkers: dayData.partialWorkers,
|
||||
errorWorkers: dayData.errorWorkers,
|
||||
overtimeWarningWorkers: dayData.overtimeWarningWorkers,
|
||||
totalHours: dayData.totalHours,
|
||||
totalTasks: dayData.totalTasks,
|
||||
errorCount: dayData.errorCount,
|
||||
lastUpdated: dayData.lastUpdated
|
||||
};
|
||||
} else {
|
||||
// 집계 데이터가 없는 경우 (작업 없음)
|
||||
monthData[dateStr] = {
|
||||
hasData: false,
|
||||
hasIssues: false,
|
||||
hasErrors: false,
|
||||
workerCount: 0,
|
||||
workingWorkers: 0,
|
||||
incompleteWorkers: 0,
|
||||
partialWorkers: 0,
|
||||
errorWorkers: 0,
|
||||
totalHours: 0,
|
||||
totalTasks: 0,
|
||||
errorCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
monthlyData[monthKey] = monthData;
|
||||
|
||||
console.log(`✅ ${monthKey} 집계 데이터 로드 완료 (${Object.keys(monthData).length}일 데이터)`);
|
||||
console.log('📊 월별 데이터 샘플:', Object.entries(monthData).slice(0, 5));
|
||||
return monthData;
|
||||
} else {
|
||||
throw new Error(response.message || '집계 데이터 조회 실패');
|
||||
}
|
||||
|
||||
const data = await CalendarAPI.getMonthlyCalendarData(year, month);
|
||||
CalendarState.monthlyData[monthKey] = data; // Cache the data
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`${monthKey} 집계 데이터 로딩 오류:`, error);
|
||||
|
||||
// 폴백: 기존 방식으로 순차 로딩
|
||||
console.log(`📋 폴백: ${monthKey} 기존 방식 로딩 시작...`);
|
||||
return await loadMonthlyWorkDataFallback(year, month);
|
||||
console.error(`${monthKey} 데이터 로딩 오류:`, error);
|
||||
showToast(error.message, 'error');
|
||||
return {}; // Return empty object on failure
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백: 순차적 로딩 (지연 시간 포함)
|
||||
async function loadMonthlyWorkDataFallback(year, month) {
|
||||
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||
const monthData = {};
|
||||
|
||||
try {
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const currentDay = new Date(firstDay);
|
||||
|
||||
let loadedCount = 0;
|
||||
const totalDays = lastDay.getDate();
|
||||
|
||||
while (currentDay <= lastDay) {
|
||||
const dateStr = currentDay.toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/daily-work-reports?date=${dateStr}&view_all=true`);
|
||||
monthData[dateStr] = Array.isArray(response) ? response : (response.data || []);
|
||||
loadedCount++;
|
||||
|
||||
// 진행률 표시
|
||||
if (loadedCount % 5 === 0) {
|
||||
console.log(`📋 ${monthKey} 로딩 진행률: ${loadedCount}/${totalDays}`);
|
||||
}
|
||||
|
||||
// API 부하 방지를 위한 지연 (500ms)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`${dateStr} 데이터 로딩 실패:`, error.message);
|
||||
monthData[dateStr] = [];
|
||||
}
|
||||
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
monthlyData[monthKey] = monthData;
|
||||
|
||||
console.log(`✅ ${monthKey} 순차 로딩 완료 (${loadedCount}/${totalDays}일)`);
|
||||
return monthData;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`${monthKey} 순차 로딩 오류:`, error);
|
||||
showToast('작업 데이터를 불러오는데 실패했습니다.', 'error');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 캘린더 렌더링
|
||||
async function renderCalendar() {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = 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 = allWorkers ? 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: allWorkers ? 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}`);
|
||||
currentModalDate = dateStr;
|
||||
CalendarState.currentModalDate = dateStr;
|
||||
|
||||
// 날짜 포맷팅
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
@@ -487,18 +156,12 @@ async function openDailyWorkModal(dateStr) {
|
||||
elements.modalTitle.textContent = `${year}년 ${month}월 ${day}일 (${dayName}) 작업 현황`;
|
||||
|
||||
try {
|
||||
// 새로운 집계 API로 작업자별 상세 정보 조회
|
||||
const response = await window.apiCall(`/monthly-status/daily-details?date=${dateStr}`);
|
||||
const response = await CalendarAPI.getDailyDetails(dateStr);
|
||||
|
||||
if (response.success) {
|
||||
const { workers, summary } = response.data;
|
||||
renderModalDataFromSummary(workers, summary);
|
||||
} else {
|
||||
// 폴백: 기존 API 사용
|
||||
console.log('집계 API 실패, 기존 API로 폴백');
|
||||
const fallbackResponse = await window.apiCall(`/daily-work-reports?date=${dateStr}&view_all=true`);
|
||||
const workData = Array.isArray(fallbackResponse) ? fallbackResponse : (fallbackResponse.data || []);
|
||||
renderModalData(workData);
|
||||
if (response.workers) { // New API structure
|
||||
renderModalDataFromSummary(response.workers, response.summary);
|
||||
} else { // Fallback structure
|
||||
renderModalData(response);
|
||||
}
|
||||
|
||||
// 모달 표시
|
||||
@@ -514,13 +177,13 @@ async function openDailyWorkModal(dateStr) {
|
||||
// 집계 데이터로 모달 렌더링 (최적화된 버전)
|
||||
async function renderModalDataFromSummary(workers, summary) {
|
||||
// 전체 작업자 목록 가져오기
|
||||
const allWorkers = await loadWorkersData();
|
||||
const allWorkersList = await loadWorkersData();
|
||||
|
||||
// 작업한 작업자 ID 목록
|
||||
const workedWorkerIds = new Set(workers.map(w => w.workerId));
|
||||
|
||||
// 미기입 작업자 추가 (대시보드와 동일한 상태 판단 로직 적용)
|
||||
const missingWorkers = allWorkers
|
||||
const missingWorkers = allWorkersList
|
||||
.filter(worker => !workedWorkerIds.has(worker.worker_id))
|
||||
.map(worker => {
|
||||
return {
|
||||
@@ -541,11 +204,11 @@ async function renderModalDataFromSummary(workers, summary) {
|
||||
});
|
||||
|
||||
// 전체 작업자 목록 (작업한 사람 + 미기입 사람)
|
||||
const allWorkersList = [...workers, ...missingWorkers];
|
||||
const allModalWorkers = [...workers, ...missingWorkers];
|
||||
|
||||
// 요약 정보 업데이트 (전체 작업자 수 포함)
|
||||
if (elements.modalTotalWorkers) {
|
||||
elements.modalTotalWorkers.textContent = `${allWorkersList.length}명`;
|
||||
elements.modalTotalWorkers.textContent = `${allModalWorkers.length}명`;
|
||||
}
|
||||
if (elements.modalTotalHours) {
|
||||
elements.modalTotalHours.textContent = `${summary.totalHours.toFixed(1)}h`;
|
||||
@@ -559,12 +222,12 @@ async function renderModalDataFromSummary(workers, summary) {
|
||||
}
|
||||
|
||||
// 작업자 리스트 렌더링
|
||||
if (allWorkersList.length === 0) {
|
||||
if (allModalWorkers.length === 0) {
|
||||
elements.modalWorkersList.innerHTML = '<div class="empty-state">등록된 작업자가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const workersHtml = allWorkersList.map(worker => {
|
||||
const workersHtml = allModalWorkers.map(worker => {
|
||||
// 상태 텍스트 및 색상 결정 (에러가 있어도 작업시간 기준으로 판단)
|
||||
let statusText = '미입력';
|
||||
let statusClass = 'incomplete';
|
||||
@@ -603,13 +266,13 @@ async function renderModalDataFromSummary(workers, summary) {
|
||||
|
||||
// 삭제 버튼 (관리자/그룹장만 표시, 작업이 있는 경우에만)
|
||||
const deleteBtn = isAdmin && worker.totalWorkCount > 0 ? `
|
||||
<button class="btn-delete-worker-work" onclick="event.stopPropagation(); deleteWorkerDayWork(${worker.workerId}, '${currentModalDate}', '${worker.workerName}')" title="이 작업자의 해당 날짜 작업 전체 삭제">
|
||||
<button class="btn-delete-worker-work" onclick="event.stopPropagation(); deleteWorkerDayWork(${worker.workerId}, '${CalendarState.currentModalDate}', '${worker.workerName}')" title="이 작업자의 해당 날짜 작업 전체 삭제">
|
||||
🗑️
|
||||
</button>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<div class="worker-card ${statusClass}" onclick="openWorkerModal(${worker.workerId}, '${currentModalDate}')">
|
||||
<div class="worker-card ${statusClass}" onclick="openWorkerModal(${worker.workerId}, '${CalendarState.currentModalDate}')">
|
||||
<div class="worker-avatar">
|
||||
<div class="avatar-circle">
|
||||
<span class="avatar-text">${initial}</span>
|
||||
@@ -636,7 +299,7 @@ async function renderModalDataFromSummary(workers, summary) {
|
||||
</div>
|
||||
<div class="worker-actions">
|
||||
${deleteBtn}
|
||||
<button class="btn-work-entry" onclick="event.stopPropagation(); openWorkerModal(${worker.workerId}, '${currentModalDate}')" title="작업입력">
|
||||
<button class="btn-work-entry" onclick="event.stopPropagation(); openWorkerModal(${worker.workerId}, '${CalendarState.currentModalDate}')" title="작업입력">
|
||||
작업입력
|
||||
</button>
|
||||
</div>
|
||||
@@ -789,7 +452,7 @@ function filterWorkersList() {
|
||||
function closeDailyWorkModal() {
|
||||
elements.dailyWorkModal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
currentModalDate = null;
|
||||
CalendarState.currentModalDate = null;
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
@@ -848,16 +511,16 @@ async function deleteWorkerDayWork(workerId, date, workerName) {
|
||||
showToast('작업을 삭제하는 중...', 'info');
|
||||
|
||||
// 날짜+작업자별 전체 삭제 API 호출
|
||||
const result = await window.apiCall(`/daily-work-reports/date/${date}/worker/${workerId}`, 'DELETE');
|
||||
const result = await CalendarAPI.deleteWorkerDayWork(workerId, date);
|
||||
|
||||
console.log('✅ 작업 삭제 성공:', result);
|
||||
showToast(`${workerName}의 ${date} 작업이 삭제되었습니다.`, 'success');
|
||||
|
||||
// 모달 데이터 새로고침
|
||||
await openDailyWorkModal(currentModalDate);
|
||||
await openDailyWorkModal(CalendarState.currentModalDate);
|
||||
|
||||
// 캘린더도 새로고침
|
||||
await loadCalendarData();
|
||||
await renderCalendar();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 작업 삭제 실패:', error);
|
||||
@@ -869,7 +532,7 @@ async function deleteWorkerDayWork(workerId, date, workerName) {
|
||||
async function openWorkerModal(workerId, date) {
|
||||
try {
|
||||
// 작업자 정보 찾기
|
||||
const worker = allWorkers.find(w => w.worker_id === workerId);
|
||||
const worker = CalendarState.allWorkers.find(w => w.worker_id === workerId);
|
||||
if (!worker) {
|
||||
showToast('작업자 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
@@ -1083,8 +746,8 @@ async function saveWorkEntry() {
|
||||
await renderCalendar();
|
||||
|
||||
// 현재 열린 모달이 있다면 새로고침
|
||||
if (currentModalDate) {
|
||||
await openDailyWorkModal(currentModalDate);
|
||||
if (CalendarState.currentModalDate) {
|
||||
await openDailyWorkModal(CalendarState.currentModalDate);
|
||||
}
|
||||
} else {
|
||||
const action = editingWorkId ? '수정' : '저장';
|
||||
@@ -1106,7 +769,7 @@ function closeDailyWorkModal() {
|
||||
}
|
||||
|
||||
// 전역 변수로 작업자 목록 저장
|
||||
let allWorkers = [];
|
||||
// let allWorkers = []; // Now in CalendarState
|
||||
|
||||
// 시간 업데이트 함수
|
||||
function updateCurrentTime() {
|
||||
@@ -1299,13 +962,13 @@ async function loadExistingWorks(workerId, date) {
|
||||
}
|
||||
}
|
||||
|
||||
existingWorks = workerWorks;
|
||||
CalendarState.existingWorks = workerWorks;
|
||||
renderExistingWorks();
|
||||
updateTabCounter();
|
||||
|
||||
} catch (error) {
|
||||
console.error('기존 작업 로드 오류:', error);
|
||||
existingWorks = [];
|
||||
CalendarState.existingWorks = [];
|
||||
renderExistingWorks();
|
||||
updateTabCounter();
|
||||
}
|
||||
@@ -1313,7 +976,7 @@ async function loadExistingWorks(workerId, date) {
|
||||
|
||||
// 기존 작업 목록 렌더링
|
||||
function renderExistingWorks() {
|
||||
console.log('🎨 작업 목록 렌더링 시작:', existingWorks);
|
||||
console.log('🎨 작업 목록 렌더링 시작:', CalendarState.existingWorks);
|
||||
|
||||
const existingWorkList = document.getElementById('existingWorkList');
|
||||
const noExistingWork = document.getElementById('noExistingWork');
|
||||
@@ -1326,15 +989,15 @@ function renderExistingWorks() {
|
||||
}
|
||||
|
||||
// 총 작업 시간 계산
|
||||
const totalHours = existingWorks.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
const totalHours = CalendarState.existingWorks.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
|
||||
console.log(`📊 작업 통계: ${existingWorks.length}건, 총 ${totalHours}시간`);
|
||||
console.log(`📊 작업 통계: ${CalendarState.existingWorks.length}건, 총 ${totalHours}시간`);
|
||||
|
||||
// 요약 정보 업데이트
|
||||
if (totalWorkCount) totalWorkCount.textContent = existingWorks.length;
|
||||
if (totalWorkCount) totalWorkCount.textContent = CalendarState.existingWorks.length;
|
||||
if (totalWorkHours) totalWorkHours.textContent = totalHours.toFixed(1);
|
||||
|
||||
if (existingWorks.length === 0) {
|
||||
if (CalendarState.existingWorks.length === 0) {
|
||||
existingWorkList.style.display = 'none';
|
||||
if (noExistingWork) noExistingWork.style.display = 'block';
|
||||
console.log('ℹ️ 작업이 없어서 빈 상태 표시');
|
||||
@@ -1345,7 +1008,7 @@ function renderExistingWorks() {
|
||||
if (noExistingWork) noExistingWork.style.display = 'none';
|
||||
|
||||
// 각 작업 데이터 상세 로그
|
||||
existingWorks.forEach((work, index) => {
|
||||
CalendarState.existingWorks.forEach((work, index) => {
|
||||
console.log(`📋 작업 ${index + 1}:`, {
|
||||
id: work.id,
|
||||
project_name: work.project_name,
|
||||
@@ -1357,7 +1020,7 @@ function renderExistingWorks() {
|
||||
});
|
||||
|
||||
// 작업 목록 HTML 생성
|
||||
const worksHtml = existingWorks.map((work, index) => {
|
||||
const worksHtml = CalendarState.existingWorks.map((work, index) => {
|
||||
const workItemHtml = `
|
||||
<div class="work-item" data-work-id="${work.id}">
|
||||
<div class="work-item-header">
|
||||
@@ -1394,8 +1057,8 @@ function renderExistingWorks() {
|
||||
const renderedItems = existingWorkList.querySelectorAll('.work-item');
|
||||
console.log(`✅ 렌더링 완료: ${renderedItems.length}개 작업 아이템이 DOM에 추가됨`);
|
||||
|
||||
if (renderedItems.length !== existingWorks.length) {
|
||||
console.error(`⚠️ 렌더링 불일치: 데이터 ${existingWorks.length}건 vs DOM ${renderedItems.length}개`);
|
||||
if (renderedItems.length !== CalendarState.existingWorks.length) {
|
||||
console.error(`⚠️ 렌더링 불일치: 데이터 ${CalendarState.existingWorks.length}건 vs DOM ${renderedItems.length}개`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1403,20 +1066,20 @@ function renderExistingWorks() {
|
||||
function updateTabCounter() {
|
||||
const existingTabBtn = document.querySelector('[data-tab="existing"]');
|
||||
if (existingTabBtn) {
|
||||
existingTabBtn.innerHTML = `📋 기존 작업 (${existingWorks.length}건)`;
|
||||
existingTabBtn.innerHTML = `📋 기존 작업 (${CalendarState.existingWorks.length}건)`;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 수정
|
||||
function editWork(workId) {
|
||||
const work = existingWorks.find(w => w.id === workId);
|
||||
const work = CalendarState.existingWorks.find(w => w.id === workId);
|
||||
if (!work) {
|
||||
showToast('작업 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 수정 모드로 전환
|
||||
currentEditingWork = work;
|
||||
CalendarState.currentEditingWork = work;
|
||||
|
||||
// 새 작업 탭으로 전환
|
||||
switchTab('new');
|
||||
@@ -1439,7 +1102,7 @@ function editWork(workId) {
|
||||
|
||||
// 작업 삭제 확인
|
||||
function confirmDeleteWork(workId) {
|
||||
const work = existingWorks.find(w => w.id === workId);
|
||||
const work = CalendarState.existingWorks.find(w => w.id === workId);
|
||||
if (!work) {
|
||||
showToast('작업 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
@@ -1464,8 +1127,8 @@ async function deleteWorkById(workId) {
|
||||
await loadExistingWorks(workerId, date);
|
||||
|
||||
// 현재 열린 모달이 있다면 새로고침
|
||||
if (currentModalDate) {
|
||||
await openDailyWorkModal(currentModalDate);
|
||||
if (CalendarState.currentModalDate) {
|
||||
await openDailyWorkModal(CalendarState.currentModalDate);
|
||||
}
|
||||
} else {
|
||||
showToast(response.message || '작업 삭제에 실패했습니다.', 'error');
|
||||
@@ -1478,7 +1141,7 @@ async function deleteWorkById(workId) {
|
||||
|
||||
// 작업 폼 초기화
|
||||
function resetWorkForm() {
|
||||
currentEditingWork = null;
|
||||
CalendarState.currentEditingWork = null;
|
||||
|
||||
// 폼 필드 초기화
|
||||
document.getElementById('editingWorkId').value = '';
|
||||
@@ -1496,8 +1159,8 @@ function resetWorkForm() {
|
||||
|
||||
// 작업 삭제 (수정 모드에서)
|
||||
function deleteWork() {
|
||||
if (currentEditingWork) {
|
||||
confirmDeleteWork(currentEditingWork.id);
|
||||
if (CalendarState.currentEditingWork) {
|
||||
confirmDeleteWork(CalendarState.currentEditingWork.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -337,6 +337,9 @@
|
||||
<script src="/js/api-config.js?v=13"></script>
|
||||
<script src="/js/auth-check.js?v=13"></script>
|
||||
<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