diff --git a/CHECKLIST.md b/CHECKLIST.md new file mode 100644 index 0000000..01544f3 --- /dev/null +++ b/CHECKLIST.md @@ -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` 참고)* diff --git a/CODING_GUIDE.md b/CODING_GUIDE.md new file mode 100644 index 0000000..1befa89 --- /dev/null +++ b/CODING_GUIDE.md @@ -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/*`(기능) diff --git a/DEV_LOG.md b/DEV_LOG.md new file mode 100644 index 0000000..822d607 --- /dev/null +++ b/DEV_LOG.md @@ -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 도입 검토. diff --git a/DATABASE_SCHEMA.md b/_archive/DATABASE_SCHEMA.md similarity index 100% rename from DATABASE_SCHEMA.md rename to _archive/DATABASE_SCHEMA.md diff --git a/DELETED_PAGES.md b/_archive/DELETED_PAGES.md similarity index 100% rename from DELETED_PAGES.md rename to _archive/DELETED_PAGES.md diff --git a/DELETED_TABLES.md b/_archive/DELETED_TABLES.md similarity index 100% rename from DELETED_TABLES.md rename to _archive/DELETED_TABLES.md diff --git a/MYSQL_COMPATIBILITY_NOTES.md b/_archive/MYSQL_COMPATIBILITY_NOTES.md similarity index 100% rename from MYSQL_COMPATIBILITY_NOTES.md rename to _archive/MYSQL_COMPATIBILITY_NOTES.md diff --git a/README.md b/_archive/README.md similarity index 100% rename from README.md rename to _archive/README.md diff --git a/TESTING_GUIDE.md b/_archive/TESTING_GUIDE.md similarity index 100% rename from TESTING_GUIDE.md rename to _archive/TESTING_GUIDE.md diff --git a/검토_리포트.md b/_archive/검토_리포트.md similarity index 100% rename from 검토_리포트.md rename to _archive/검토_리포트.md diff --git a/룰.md b/_archive/룰.md similarity index 100% rename from 룰.md rename to _archive/룰.md diff --git a/api.hyungi.net/controllers/workAnalysisController.js b/api.hyungi.net/controllers/workAnalysisController.js index c15fad3..506e003 100644 --- a/api.hyungi.net/controllers/workAnalysisController.js +++ b/api.hyungi.net/controllers/workAnalysisController.js @@ -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('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다'); } }); diff --git a/api.hyungi.net/models/WorkAnalysis.js b/api.hyungi.net/models/WorkAnalysis.js index 7ae9910..855d14a 100644 --- a/api.hyungi.net/models/WorkAnalysis.js +++ b/api.hyungi.net/models/WorkAnalysis.js @@ -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; \ No newline at end of file diff --git a/api.hyungi.net/services/workAnalysisService.js b/api.hyungi.net/services/workAnalysisService.js new file mode 100644 index 0000000..c080719 --- /dev/null +++ b/api.hyungi.net/services/workAnalysisService.js @@ -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(); diff --git a/docs/database/CURRENT_SCHEMA.md b/docs/database/CURRENT_SCHEMA.md new file mode 100644 index 0000000..c250625 --- /dev/null +++ b/docs/database/CURRENT_SCHEMA.md @@ -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; +``` diff --git a/docs/refactoring/LOG.md b/docs/refactoring/LOG.md index 7cc3bc5..f2e90c0 100644 --- a/docs/refactoring/LOG.md +++ b/docs/refactoring/LOG.md @@ -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* \ No newline at end of file diff --git a/web-ui/js/modules/calendar/CalendarView.js b/web-ui/js/modules/calendar/CalendarView.js new file mode 100644 index 0000000..2c009f8 --- /dev/null +++ b/web-ui/js/modules/calendar/CalendarView.js @@ -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 += '
'; + if (dayStatus.hasIncomplete) statusIcons += '
'; + if (dayStatus.hasIssues) statusIcons += '
'; + } + + calendarHTML += `
${currentDay.getDate()}
${statusIcons}
`; + 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); \ No newline at end of file diff --git a/web-ui/js/work-report-calendar.js b/web-ui/js/work-report-calendar.js index dc65879..d904aa5 100644 --- a/web-ui/js/work-report-calendar.js +++ b/web-ui/js/work-report-calendar.js @@ -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 += '
'; - } - if (dayStatus.hasIncomplete) { - statusIcons += '
'; - } - if (dayStatus.hasIssues) { - statusIcons += '
'; - } - } - - calendarHTML += ` -
-
${dayNumber}
- ${statusIcons} -
- `; - - 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}`); diff --git a/web-ui/pages/common/daily-work-report-viewer.html b/web-ui/pages/common/daily-work-report-viewer.html index aebddfb..c89b480 100644 --- a/web-ui/pages/common/daily-work-report-viewer.html +++ b/web-ui/pages/common/daily-work-report-viewer.html @@ -339,6 +339,7 @@ + \ No newline at end of file