From b6485e314047138e5994b4199d753dd167af051c Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 29 Jan 2026 15:46:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=9E=A5=20=ED=98=84=ED=99=A9=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실시간 작업장 현황을 지도로 시각화 - 작업장 관리 페이지에서 정의한 구역 정보 활용 - TBM 작업자 및 방문자 현황 표시 주요 변경사항: - dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거) - workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현 - modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가 시각화 방식: - 인원 없음: 회색 테두리 + 작업장 이름 - 내부 작업자: 파란색 영역 + 인원 수 - 외부 방문자: 보라색 영역 + 인원 수 - 둘 다: 초록색 영역 + 총 인원 수 기술 구현: - Canvas API 기반 사각형 영역 렌더링 - map-regions API를 통한 데이터 일관성 보장 - 클릭 이벤트로 상세 정보 모달 표시 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CODING_GUIDE.md | 61 + DEV_LOG.md | 138 +- api.hyungi.net/config/routes.js | 8 + api.hyungi.net/config/security.js | 9 + .../controllers/attendanceController.js | 32 +- api.hyungi.net/controllers/tbmController.js | 68 +- api.hyungi.net/controllers/userController.js | 163 +- .../controllers/vacationBalanceController.js | 357 +++++ .../controllers/vacationRequestController.js | 565 +++++++ .../controllers/vacationTypeController.js | 333 ++++ .../controllers/visitRequestController.js | 555 +++++++ .../controllers/workerController.js | 54 +- ...04_add_work_details_to_team_assignments.js | 37 + ...20260127000001_remove_unused_tbm_fields.js | 20 + ...20260127000002_add_workplace_map_images.js | 53 + ...0127010000_add_workplace_purpose_fields.js | 17 + .../20260127020000_make_leader_id_nullable.js | 30 + ...27030000_add_tbm_fields_to_work_reports.js | 55 + ...7040000_update_pages_for_current_system.js | 152 ++ ...20260129000000_create_vacation_requests.js | 57 + ...0260129000001_register_attendance_pages.js | 84 + ...0260129000002_add_attendance_is_present.js | 35 + .../20260129000003_update_vacation_pages.js | 57 + .../20260129000004_extend_vacation_types.js | 55 + ...9000005_create_vacation_balance_details.js | 87 + .../20260129000006_register_vacation_pages.js | 38 + ...60129010000_create_visit_request_system.js | 153 ++ .../20260129010001_register_visit_pages.js | 50 + api.hyungi.net/models/attendanceModel.js | 66 +- api.hyungi.net/models/tbmModel.js | 904 +++++++---- api.hyungi.net/models/vacationBalanceModel.js | 288 ++++ api.hyungi.net/models/vacationRequestModel.js | 271 ++++ api.hyungi.net/models/vacationTypeModel.js | 132 ++ api.hyungi.net/models/visitRequestModel.js | 505 ++++++ api.hyungi.net/models/workplaceModel.js | 182 ++- api.hyungi.net/routes/attendanceRoutes.js | 6 + .../routes/dailyWorkReportRoutes.js | 3 + api.hyungi.net/routes/tbmRoutes.js | 44 +- api.hyungi.net/routes/userRoutes.js | 6 + .../routes/vacationBalanceRoutes.js | 31 + .../routes/vacationRequestRoutes.js | 34 + api.hyungi.net/routes/vacationTypeRoutes.js | 31 + api.hyungi.net/routes/visitRequestRoutes.js | 66 + api.hyungi.net/services/attendanceService.js | 75 +- api.hyungi.net/utils/hangulToRoman.js | 20 +- web-ui/css/admin-settings.css | 97 ++ web-ui/css/annual-vacation-overview.css | 348 ++++ web-ui/css/daily-work-report.css | 199 +++ web-ui/css/vacation-allocation.css | 472 ++++++ web-ui/js/admin-settings.js | 359 ++++- web-ui/js/annual-vacation-overview.js | 412 +++++ web-ui/js/api-config.js | 5 +- web-ui/js/auth-check.js | 118 +- web-ui/js/daily-work-report.js | 284 +++- web-ui/js/equipment-management.js | 36 +- web-ui/js/load-navbar.js | 92 +- web-ui/js/modern-dashboard.js | 80 +- web-ui/js/safety-management.js | 447 ++++++ web-ui/js/safety-training-conduct.js | 553 +++++++ web-ui/js/tbm.js | 1407 +++++++++++++++-- web-ui/js/vacation-allocation.js | 864 ++++++++++ web-ui/js/vacation-common.js | 234 +++ web-ui/js/visit-request.js | 531 +++++++ web-ui/js/workplace-layout-map.js | 494 ++++++ web-ui/js/workplace-status.js | 448 ++++++ web-ui/pages/admin/accounts.html | 47 +- .../admin/attendance-report-comparison.html | 493 ++++++ web-ui/pages/admin/codes.html | 6 + web-ui/pages/admin/equipments.html | 57 +- web-ui/pages/admin/projects.html | 6 + web-ui/pages/admin/safety-management.html | 291 ++++ .../pages/admin/safety-training-conduct.html | 327 ++++ web-ui/pages/admin/tasks.html | 6 + web-ui/pages/admin/workers.html | 6 + web-ui/pages/admin/workplaces.html | 6 + .../common/annual-vacation-overview.html | 143 ++ web-ui/pages/common/daily-attendance.html | 395 +++++ web-ui/pages/common/monthly-attendance.html | 490 ++++++ web-ui/pages/common/vacation-allocation.html | 354 +++++ web-ui/pages/common/vacation-approval.html | 267 ++++ web-ui/pages/common/vacation-input.html | 294 ++++ web-ui/pages/common/vacation-management.html | 461 ++++++ web-ui/pages/common/vacation-request.html | 272 ++++ web-ui/pages/dashboard.html | 156 +- web-ui/pages/work/report-create.html | 4 +- web-ui/pages/work/tbm.html | 288 +++- web-ui/pages/work/visit-request.html | 371 +++++ 87 files changed, 17509 insertions(+), 698 deletions(-) create mode 100644 api.hyungi.net/controllers/vacationBalanceController.js create mode 100644 api.hyungi.net/controllers/vacationRequestController.js create mode 100644 api.hyungi.net/controllers/vacationTypeController.js create mode 100644 api.hyungi.net/controllers/visitRequestController.js create mode 100644 api.hyungi.net/db/migrations/20260126010004_add_work_details_to_team_assignments.js create mode 100644 api.hyungi.net/db/migrations/20260127000001_remove_unused_tbm_fields.js create mode 100644 api.hyungi.net/db/migrations/20260127000002_add_workplace_map_images.js create mode 100644 api.hyungi.net/db/migrations/20260127010000_add_workplace_purpose_fields.js create mode 100644 api.hyungi.net/db/migrations/20260127020000_make_leader_id_nullable.js create mode 100644 api.hyungi.net/db/migrations/20260127030000_add_tbm_fields_to_work_reports.js create mode 100644 api.hyungi.net/db/migrations/20260127040000_update_pages_for_current_system.js create mode 100644 api.hyungi.net/db/migrations/20260129000000_create_vacation_requests.js create mode 100644 api.hyungi.net/db/migrations/20260129000001_register_attendance_pages.js create mode 100644 api.hyungi.net/db/migrations/20260129000002_add_attendance_is_present.js create mode 100644 api.hyungi.net/db/migrations/20260129000003_update_vacation_pages.js create mode 100644 api.hyungi.net/db/migrations/20260129000004_extend_vacation_types.js create mode 100644 api.hyungi.net/db/migrations/20260129000005_create_vacation_balance_details.js create mode 100644 api.hyungi.net/db/migrations/20260129000006_register_vacation_pages.js create mode 100644 api.hyungi.net/db/migrations/20260129010000_create_visit_request_system.js create mode 100644 api.hyungi.net/db/migrations/20260129010001_register_visit_pages.js create mode 100644 api.hyungi.net/models/vacationBalanceModel.js create mode 100644 api.hyungi.net/models/vacationRequestModel.js create mode 100644 api.hyungi.net/models/vacationTypeModel.js create mode 100644 api.hyungi.net/models/visitRequestModel.js create mode 100644 api.hyungi.net/routes/vacationBalanceRoutes.js create mode 100644 api.hyungi.net/routes/vacationRequestRoutes.js create mode 100644 api.hyungi.net/routes/vacationTypeRoutes.js create mode 100644 api.hyungi.net/routes/visitRequestRoutes.js create mode 100644 web-ui/css/annual-vacation-overview.css create mode 100644 web-ui/css/vacation-allocation.css create mode 100644 web-ui/js/annual-vacation-overview.js create mode 100644 web-ui/js/safety-management.js create mode 100644 web-ui/js/safety-training-conduct.js create mode 100644 web-ui/js/vacation-allocation.js create mode 100644 web-ui/js/vacation-common.js create mode 100644 web-ui/js/visit-request.js create mode 100644 web-ui/js/workplace-layout-map.js create mode 100644 web-ui/js/workplace-status.js create mode 100644 web-ui/pages/admin/attendance-report-comparison.html create mode 100644 web-ui/pages/admin/safety-management.html create mode 100644 web-ui/pages/admin/safety-training-conduct.html create mode 100644 web-ui/pages/common/annual-vacation-overview.html create mode 100644 web-ui/pages/common/daily-attendance.html create mode 100644 web-ui/pages/common/monthly-attendance.html create mode 100644 web-ui/pages/common/vacation-allocation.html create mode 100644 web-ui/pages/common/vacation-approval.html create mode 100644 web-ui/pages/common/vacation-input.html create mode 100644 web-ui/pages/common/vacation-management.html create mode 100644 web-ui/pages/common/vacation-request.html create mode 100644 web-ui/pages/work/visit-request.html diff --git a/CODING_GUIDE.md b/CODING_GUIDE.md index d14425b..acfbb34 100644 --- a/CODING_GUIDE.md +++ b/CODING_GUIDE.md @@ -193,6 +193,67 @@ cp web-ui/templates/work-layout.html web-ui/pages/work/new-page.html --- +## 🔐 페이지 접근 권한 관리 + +### 권한 체크 방식 +1. **관리자 전용 페이지**: `admin-only` 클래스 사용 +2. **페이지별 권한 체크**: `pages` 테이블 기반 권한 확인 +3. **클라이언트 측**: `auth-check.js`에서 자동 권한 검증 + +### 페이지 등록 (pages 테이블) +새 페이지 생성 시 반드시 `pages` 테이블에 등록: + +```sql +-- 마이그레이션 예시 +INSERT INTO pages (page_name, page_url, page_category, description, display_order, is_active) +VALUES + ('출입 신청', '/pages/work/visit-request.html', 'work', '작업장 출입 및 안전교육 신청', 150, 1), + ('안전관리', '/pages/admin/safety-management.html', 'admin', '출입 신청 승인 및 안전교육 관리', 210, 1); +``` + +### 페이지 권한 할당 +- **Admin**: 모든 페이지 자동 접근 가능 +- **일반 사용자**: `page_access` 테이블에 명시적 권한 부여 필요 + +```sql +-- 특정 사용자에게 페이지 권한 부여 +INSERT INTO page_access (user_id, page_id, granted_by, granted_at) +VALUES (123, 45, 1, NOW()); +``` + +### HTML 페이지 설정 +```html + + +
+

안전관리

+
+
+ + + +
+

출입 신청

+
+
+``` + +### API 라우트 보호 +```javascript +const { verifyToken } = require('../middlewares/authMiddleware'); + +// 모든 라우트에 인증 필요 +router.use(verifyToken); + +// 관리자 전용 라우트 +router.put('/requests/:id/approve', (req, res) => { + // verifyToken에서 req.user 제공 + // 필요시 추가 권한 체크 +}); +``` + +--- + ## 📡 API 개발 가이드 - **RESTful**: 명사형 리소스 사용 (`POST /users` O, `/createUser` X). - **응답 포맷**: diff --git a/DEV_LOG.md b/DEV_LOG.md index 816b76f..5043e9a 100644 --- a/DEV_LOG.md +++ b/DEV_LOG.md @@ -1,6 +1,142 @@ # 개발 진행 로그 -## 📅 Recent Updates (2025-12-19) +## 📅 Recent Updates (2026-01-29) + +### 🗺️ 대시보드 작업장 현황 지도 구현 (2026-01-29) +**개요**: 대시보드에 실시간 작업장 현황을 지도로 시각화하여 작업자 및 방문자 현황을 한눈에 파악 + +**배경**: +- 기존 "오늘의 작업 현황" 테이블 방식은 직관성 부족 +- 작업장별 위치와 인원 현황을 시각적으로 표시할 필요 +- 작업장 관리 페이지에서 설정한 구역 정보 활용 + +**구현 내용**: +1. **대시보드 UI 개편** + - **제거**: "오늘의 작업 현황" 테이블 섹션 + - **추가**: "작업장 현황" 지도 섹션 + - 공장 선택 드롭다운 (제1공장 기본 선택) + - 실시간 새로고침 버튼 + +2. **작업장 현황 지도 기능** (`web-ui/js/workplace-status.js`) + - **데이터 소스**: + - `map-regions` API: 작업장 관리 페이지에서 정의한 구역 정보 + - `tbm/sessions` API: 금일 TBM 작업 정보 + - `workplace-visits/requests` API: 금일 방문자 신청 정보 + + - **시각화 방식**: + - 모든 작업장 구역을 사각형으로 표시 + - 인원 없음: 회색 테두리 + 작업장 이름 + - 내부 작업자만: 파란색 영역 + 인원 수 배지 + - 외부 방문자만: 보라색 영역 + 인원 수 배지 + - 작업자+방문자: 초록색 영역 + 총 인원 수 배지 + + - **상호작용**: + - 작업장 구역 클릭 → 상세 정보 모달 표시 + - 내부 작업자: 작업명 + 인원 수 + 작업 위치 + 프로젝트명 + - 외부 방문자: 업체명 + 인원 수 + 방문 시간 + 목적 + +3. **TBM 데이터 통합** + - 세션별 작업 정보 집계 (`task_name`, `team_member_count`) + - 조장 포함 총 인원 계산 + - Draft 상태 세션도 예정 작업으로 표시 + +4. **기술적 구현**: + - Canvas API 기반 지도 렌더링 + - 사각형 좌표 변환 (퍼센트 → 픽셀) + - 마우스 클릭 위치를 영역 좌표로 매핑 + - 작업장 관리 페이지와 동일한 `map-regions` API 사용으로 데이터 일관성 보장 + +**수정 파일**: +- `web-ui/pages/dashboard.html` - 작업장 현황 섹션 추가 +- `web-ui/js/workplace-status.js` - 신규 생성 (지도 렌더링 및 데이터 로직) +- `web-ui/js/modern-dashboard.js` - 삭제된 DOM 요소 조건부 체크 추가 + +**효과**: +- 작업장별 실시간 인원 현황을 시각적으로 파악 가능 +- 작업 배치 및 안전 관리 의사결정 지원 +- 외부 방문자 위치 추적으로 안전 관리 강화 + +--- + +### 🏖️ 휴가 관리 시스템 리팩토링 및 페이지 분리 (2026-01-29) +**개요**: 코딩 가이드 준수를 위해 536줄의 단일 파일을 2개 페이지로 분리하고 공통 함수 라이브러리 생성 + +**배경**: +- 기존 `vacation-management.html` (536줄) - 코딩 가이드 위반 (파일 길이 초과) +- 단일 파일에 작업자/관리자 기능이 혼재 +- 코드 중복 발생 + +**구조 개선**: +1. **공통 함수 라이브러리 생성** + - **파일**: `web-ui/js/vacation-common.js` + - **역할**: 모든 휴가 페이지에서 사용하는 공통 함수 모음 + - **주요 함수**: + - `loadWorkers()`: 작업자 목록 로드 + - `loadVacationTypes()`: 휴가 유형 로드 + - `getCurrentUser()`: 현재 사용자 정보 조회 + - `renderVacationRequests()`: 휴가 신청 목록 렌더링 + - `approveVacationRequest()`: 휴가 승인 + - `rejectVacationRequest()`: 휴가 거부 + - `deleteVacationRequest()`: 휴가 신청 삭제 + +2. **2개 페이지로 분리** + - **`vacation-request.html`** (작업자 휴가 신청) + - 역할: 휴가 신청 및 본인 신청 내역 확인 + - 권한: 모든 작업자 (자동으로 본인 선택됨) + - 기능: + - 휴가 잔여 현황 표시 + - 휴가 신청 폼 + - 내 신청 내역 (삭제 가능 - pending만) + + - **`vacation-management.html`** (관리자 휴가 관리) + - 역할: 휴가 승인/직접입력/전체내역 관리 (3개 탭) + - 권한: 관리자 전용 (system/admin) + - 기능: + - **탭 1: 승인 대기 목록** - 승인/거부 버튼 + - **탭 2: 직접 입력** - 승인 절차 없이 휴가 정보 직접 입력 + - 작업자 선택 시 휴가 잔여 표시 + - 입력 즉시 승인 상태로 저장 + - 최근 입력 내역 표시 + - **탭 3: 전체 신청 내역** - 날짜 필터링 지원 + +3. **데이터베이스 페이지 등록** + - **마이그레이션**: `20260129000003_update_vacation_pages.js` + - **변경사항**: + - 기존 `vacation-management` 페이지 삭제 + - 신규 2개 페이지 등록 (vacation-request, vacation-management) + - `is_admin_only` 플래그로 권한 구분 (vacation-request: 0, vacation-management: 1) + - `display_order`로 표시 순서 관리 + +4. **파일 정리** + - 기존: `vacation-management.html` → `.old` 확장자로 이름 변경 + - 향후: 충분한 테스트 후 삭제 예정 + +**기술적 개선사항**: +- 코드 중복 제거: 공통 함수를 vacation-common.js로 추출 +- 권한 체크 강화: 페이지 로드 시 access_level 확인 및 리다이렉트 +- 이벤트 기반 UI 업데이트: 'vacation-updated' 이벤트로 페이지 간 동기화 +- 역할 기반 접근 제어: 작업자는 본인 정보만, 관리자는 전체 관리 +- 탭 기반 UI: 관리자 페이지는 3개 탭으로 기능 구분 + +**파일 변경사항**: +``` +[NEW] web-ui/js/vacation-common.js (공통 함수 라이브러리) +[NEW] web-ui/pages/common/vacation-request.html (작업자 휴가 신청) +[NEW] web-ui/pages/common/vacation-management.html (관리자 3-탭 관리) +[NEW] api.hyungi.net/db/migrations/20260129000003_update_vacation_pages.js +[UPDATED] web-ui/pages/dashboard.html (휴가 관련 링크 업데이트) +[RENAMED] web-ui/pages/common/vacation-management.html → vacation-management.html.old +``` + +**코딩 가이드 준수**: +- ✅ 파일 길이 제한 준수 (기존 536줄 → 각 350줄 이하) +- ✅ 공통 로직 분리 (DRY 원칙) +- ✅ 단일 책임 원칙 (각 페이지가 명확한 역할) +- ✅ 역할 기반 접근 제어 명확화 + +--- + +## 📅 Previous Updates (2025-12-19) ### WorkAnalysis 리팩토링 완료 **내용**: 복잡한 통계 로직을 포함하던 `workAnalysisController.js`를 리팩토링함. diff --git a/api.hyungi.net/config/routes.js b/api.hyungi.net/config/routes.js index 51c5dfc..6bd43f8 100644 --- a/api.hyungi.net/config/routes.js +++ b/api.hyungi.net/config/routes.js @@ -44,6 +44,10 @@ function setupRoutes(app) { const equipmentRoutes = require('../routes/equipmentRoutes'); const taskRoutes = require('../routes/taskRoutes'); const tbmRoutes = require('../routes/tbmRoutes'); + const vacationRequestRoutes = require('../routes/vacationRequestRoutes'); + const vacationTypeRoutes = require('../routes/vacationTypeRoutes'); + const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes'); + const visitRequestRoutes = require('../routes/visitRequestRoutes'); // Rate Limiters 설정 const rateLimit = require('express-rate-limit'); @@ -141,6 +145,10 @@ function setupRoutes(app) { app.use('/api/workplaces', workplaceRoutes); app.use('/api/equipments', equipmentRoutes); app.use('/api/tasks', taskRoutes); + app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리 + app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리 + app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리 + app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리 app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 app.use('/api/tbm', tbmRoutes); // TBM 시스템 app.use('/api', uploadBgRoutes); diff --git a/api.hyungi.net/config/security.js b/api.hyungi.net/config/security.js index d669c8f..e58037e 100644 --- a/api.hyungi.net/config/security.js +++ b/api.hyungi.net/config/security.js @@ -86,6 +86,15 @@ const helmetOptions = { */ permittedCrossDomainPolicies: { permittedPolicies: 'none' + }, + + /** + * Cross-Origin-Resource-Policy + * 크로스 오리진 리소스 공유 설정 + * 이미지 등 정적 파일을 다른 포트에서 로드할 수 있도록 허용 + */ + crossOriginResourcePolicy: { + policy: 'cross-origin' } }; diff --git a/api.hyungi.net/controllers/attendanceController.js b/api.hyungi.net/controllers/attendanceController.js index 4fa546b..793fb97 100644 --- a/api.hyungi.net/controllers/attendanceController.js +++ b/api.hyungi.net/controllers/attendanceController.js @@ -154,6 +154,34 @@ const getMonthlyAttendanceStats = asyncHandler(async (req, res) => { }); }); +/** + * 출근 체크 목록 조회 (아침용, 휴가 정보 포함) + */ +const getCheckinList = asyncHandler(async (req, res) => { + const { date } = req.query; + const data = await attendanceService.getCheckinListService(date); + + res.json({ + success: true, + data, + message: '출근 체크 목록을 성공적으로 조회했습니다' + }); +}); + +/** + * 출근 체크 저장 (일괄 처리) + */ +const saveCheckins = asyncHandler(async (req, res) => { + const { date, checkins } = req.body; // checkins: [{worker_id, is_present}, ...] + const result = await attendanceService.saveCheckinsService(date, checkins); + + res.json({ + success: true, + data: result, + message: '출근 체크가 성공적으로 저장되었습니다' + }); +}); + module.exports = { getDailyAttendanceStatus, getDailyAttendanceRecords, @@ -163,5 +191,7 @@ module.exports = { getAttendanceTypes, getVacationTypes, getWorkerVacationBalance, - getMonthlyAttendanceStats + getMonthlyAttendanceStats, + getCheckinList, + saveCheckins }; diff --git a/api.hyungi.net/controllers/tbmController.js b/api.hyungi.net/controllers/tbmController.js index 52e9e04..422b6c3 100644 --- a/api.hyungi.net/controllers/tbmController.js +++ b/api.hyungi.net/controllers/tbmController.js @@ -10,7 +10,7 @@ const TbmController = { createSession: (req, res) => { const sessionData = { session_date: req.body.session_date, - leader_id: req.body.leader_id, + leader_id: req.body.leader_id || null, project_id: req.body.project_id || null, work_location: req.body.work_location || null, work_description: req.body.work_description || null, @@ -19,11 +19,11 @@ const TbmController = { created_by: req.user.user_id }; - // 필수 필드 검증 - if (!sessionData.session_date || !sessionData.leader_id) { + // 필수 필드 검증 (날짜만 필수, leader_id는 관리자의 경우 null 허용) + if (!sessionData.session_date) { return res.status(400).json({ success: false, - message: 'TBM 날짜와 팀장 정보는 필수입니다.' + message: 'TBM 날짜는 필수입니다.' }); } @@ -179,7 +179,7 @@ const TbmController = { // ==================== 팀 구성 관련 ==================== /** - * 팀원 추가 + * 팀원 추가 (작업자별 상세 정보 포함) */ addTeamMember: (req, res) => { const assignmentData = { @@ -188,7 +188,12 @@ const TbmController = { assigned_role: req.body.assigned_role || null, work_detail: req.body.work_detail || null, is_present: req.body.is_present, - absence_reason: req.body.absence_reason || null + absence_reason: req.body.absence_reason || null, + project_id: req.body.project_id || null, + work_type_id: req.body.work_type_id || null, + task_id: req.body.task_id || null, + workplace_category_id: req.body.workplace_category_id || null, + workplace_id: req.body.workplace_id || null }; if (!assignmentData.worker_id) { @@ -300,6 +305,30 @@ const TbmController = { }); }, + /** + * 세션의 모든 팀원 삭제 (수정 시 사용) + */ + clearAllTeamMembers: (req, res) => { + const { sessionId } = req.params; + + TbmModel.clearAllTeamMembers(sessionId, (err, result) => { + if (err) { + console.error('팀원 전체 삭제 오류:', err); + return res.status(500).json({ + success: false, + message: '팀원 전체 삭제 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + message: '모든 팀원이 삭제되었습니다.', + data: { deletedCount: result.affectedRows } + }); + }); + }, + // ==================== 안전 체크리스트 관련 ==================== /** @@ -564,6 +593,33 @@ const TbmController = { }); } + res.json({ + success: true, + data: results + }); + }); + }, + + /** + * 작업보고서가 작성되지 않은 TBM 팀 배정 조회 + */ + getIncompleteWorkReports: (req, res) => { + const userId = req.user.user_id; + const accessLevel = req.user.access_level; + + // 관리자는 모든 TBM 조회, 일반 사용자는 본인이 작성한 것만 조회 + const filterUserId = (accessLevel === 'system' || accessLevel === 'admin') ? null : userId; + + TbmModel.getIncompleteWorkReports(filterUserId, (err, results) => { + if (err) { + console.error('미완료 작업보고서 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '미완료 작업보고서 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + res.json({ success: true, data: results diff --git a/api.hyungi.net/controllers/userController.js b/api.hyungi.net/controllers/userController.js index 8b8e22e..25eca27 100644 --- a/api.hyungi.net/controllers/userController.js +++ b/api.hyungi.net/controllers/userController.js @@ -218,16 +218,16 @@ const updateUser = asyncHandler(async (req, res) => { checkAdminPermission(req.user); const { id } = req.params; - const { username, name, email, phone, role, password } = req.body; + const { username, name, email, phone, role, role_id, password } = req.body; if (!id || isNaN(id)) { throw new ValidationError('유효하지 않은 사용자 ID입니다'); } - logger.info('사용자 수정 요청', { userId: id }); + logger.info('사용자 수정 요청', { userId: id, body: req.body }); // 최소 하나의 수정 필드가 필요 - if (!username && !name && email === undefined && phone === undefined && !role && !password) { + if (!username && !name && email === undefined && phone === undefined && !role && !role_id && !password) { throw new ValidationError('수정할 필드가 없습니다'); } @@ -283,13 +283,35 @@ const updateUser = asyncHandler(async (req, res) => { values.push(phone || null); } - if (role) { - const validRoles = ['admin', 'group_leader', 'worker']; - if (!validRoles.includes(role)) { - throw new ValidationError('유효하지 않은 권한입니다'); + // role_id 또는 role 문자열 처리 + if (role_id) { + // role_id가 유효한지 확인 + const [roleCheck] = await db.execute('SELECT id, name FROM roles WHERE id = ?', [role_id]); + if (roleCheck.length === 0) { + throw new ValidationError('유효하지 않은 역할 ID입니다'); } - updates.push('role = ?, access_level = ?'); - values.push(role, role); + updates.push('role_id = ?'); + values.push(role_id); + logger.info('role_id로 역할 변경', { userId: id, role_id, role_name: roleCheck[0].name }); + } else if (role) { + // role 문자열을 role_id로 변환 (하위 호환성) + const roleNameMap = { + 'admin': 'Admin', + 'system': 'System Admin', + 'user': 'User', + 'guest': 'Guest', + 'group_leader': 'User', // 임시 매핑 + 'worker': 'User' // 임시 매핑 + }; + const roleName = roleNameMap[role.toLowerCase()] || role; + const [roleCheck] = await db.execute('SELECT id FROM roles WHERE name = ?', [roleName]); + + if (roleCheck.length === 0) { + throw new ValidationError(`유효하지 않은 권한입니다: ${role}`); + } + updates.push('role_id = ?'); + values.push(roleCheck[0].id); + logger.info('role 문자열로 역할 변경', { userId: id, role, role_id: roleCheck[0].id }); } if (password) { @@ -297,7 +319,7 @@ const updateUser = asyncHandler(async (req, res) => { throw new ValidationError('비밀번호는 최소 6자 이상이어야 합니다'); } const hashedPassword = await bcrypt.hash(password, 10); - updates.push('password_hash = ?'); + updates.push('password = ?'); values.push(hashedPassword); } @@ -306,6 +328,7 @@ const updateUser = asyncHandler(async (req, res) => { const updateQuery = `UPDATE users SET ${updates.join(', ')} WHERE user_id = ?`; + logger.info('실행할 UPDATE 쿼리', { query: updateQuery, values }); await db.execute(updateQuery, values); logger.info('사용자 수정 성공', { @@ -324,7 +347,7 @@ const updateUser = asyncHandler(async (req, res) => { if (error instanceof NotFoundError || error instanceof ValidationError || error instanceof ConflictError) { throw error; } - logger.error('사용자 수정 실패', { userId: id, error: error.message }); + logger.error('사용자 수정 실패', { userId: id, error: error.message, stack: error.stack }); throw new DatabaseError('사용자 정보를 수정하는데 실패했습니다'); } }); @@ -458,11 +481,127 @@ const deleteUser = asyncHandler(async (req, res) => { } }); +/** + * 사용자의 페이지 접근 권한 조회 + */ +const getUserPageAccess = asyncHandler(async (req, res) => { + const { id } = req.params; + + if (!id || isNaN(id)) { + throw new ValidationError('유효하지 않은 사용자 ID입니다'); + } + + logger.info('사용자 페이지 권한 조회 요청', { userId: id }); + + const { getDb } = require('../dbPool'); + const db = await getDb(); + + try { + const query = ` + SELECT + p.id as page_id, + p.page_key, + p.page_name, + p.page_path, + p.category, + COALESCE(upa.can_access, 0) as can_access + FROM pages p + LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ? + WHERE p.is_active = 1 + ORDER BY p.category, p.display_order + `; + + const [pageAccess] = await db.execute(query, [id]); + + logger.info('사용자 페이지 권한 조회 성공', { userId: id, pageCount: pageAccess.length }); + + res.json({ + success: true, + data: { + pageAccess + }, + message: '페이지 권한 조회 성공' + }); + } catch (error) { + logger.error('사용자 페이지 권한 조회 실패', { userId: id, error: error.message }); + throw new DatabaseError('페이지 권한을 조회하는데 실패했습니다'); + } +}); + +/** + * 사용자의 페이지 접근 권한 업데이트 + */ +const updateUserPageAccess = asyncHandler(async (req, res) => { + checkAdminPermission(req.user); + + const { id } = req.params; + const { pageAccess } = req.body; + + if (!id || isNaN(id)) { + throw new ValidationError('유효하지 않은 사용자 ID입니다'); + } + + if (!Array.isArray(pageAccess)) { + throw new ValidationError('pageAccess는 배열이어야 합니다'); + } + + logger.info('사용자 페이지 권한 업데이트 요청', { + userId: id, + pageCount: pageAccess.length, + updatedBy: req.user.username + }); + + const { getDb } = require('../dbPool'); + const db = await getDb(); + + try { + // 트랜잭션 시작 + await db.query('START TRANSACTION'); + + // 기존 권한 삭제 + await db.execute('DELETE FROM user_page_access WHERE user_id = ?', [id]); + + // 새 권한 삽입 + if (pageAccess.length > 0) { + const values = pageAccess.map(p => [id, p.page_id, p.can_access]); + const placeholders = values.map(() => '(?, ?, ?)').join(', '); + const flatValues = values.flat(); + + await db.execute( + `INSERT INTO user_page_access (user_id, page_id, can_access) VALUES ${placeholders}`, + flatValues + ); + } + + // 커밋 + await db.query('COMMIT'); + + logger.info('사용자 페이지 권한 업데이트 성공', { + userId: id, + pageCount: pageAccess.length, + updatedBy: req.user.username + }); + + res.json({ + success: true, + data: { user_id: id }, + message: '페이지 권한이 성공적으로 업데이트되었습니다' + }); + } catch (error) { + // 롤백 + await db.query('ROLLBACK'); + logger.error('사용자 페이지 권한 업데이트 실패', { userId: id, error: error.message }); + throw new DatabaseError('페이지 권한을 업데이트하는데 실패했습니다'); + } +}); + module.exports = { getAllUsers, getUserById, createUser, updateUser, updateUserStatus, - deleteUser + deleteUser, + getUserPageAccess, + updateUserPageAccess }; diff --git a/api.hyungi.net/controllers/vacationBalanceController.js b/api.hyungi.net/controllers/vacationBalanceController.js new file mode 100644 index 0000000..6983293 --- /dev/null +++ b/api.hyungi.net/controllers/vacationBalanceController.js @@ -0,0 +1,357 @@ +/** + * vacationBalanceController.js + * 휴가 잔액 관련 컨트롤러 + */ + +const vacationBalanceModel = require('../models/vacationBalanceModel'); +const vacationTypeModel = require('../models/vacationTypeModel'); + +const vacationBalanceController = { + /** + * 특정 작업자의 휴가 잔액 조회 (특정 연도) + * GET /api/vacation-balances/worker/:workerId/year/:year + */ + async getByWorkerAndYear(req, res) { + try { + const { workerId, year } = req.params; + + vacationBalanceModel.getByWorkerAndYear(workerId, year, (err, results) => { + if (err) { + console.error('휴가 잔액 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 잔액을 조회하는 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('getByWorkerAndYear 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 모든 작업자의 휴가 잔액 조회 (특정 연도) + * GET /api/vacation-balances/year/:year + */ + async getAllByYear(req, res) { + try { + const { year } = req.params; + + vacationBalanceModel.getAllByYear(year, (err, results) => { + if (err) { + console.error('전체 휴가 잔액 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '전체 휴가 잔액을 조회하는 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('getAllByYear 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 잔액 생성 + * POST /api/vacation-balances + */ + async createBalance(req, res) { + try { + const { + worker_id, + vacation_type_id, + year, + total_days, + used_days, + notes + } = req.body; + const created_by = req.user.user_id; + + // 필수 필드 검증 + if (!worker_id || !vacation_type_id || !year || total_days === undefined) { + return res.status(400).json({ + success: false, + message: '필수 필드가 누락되었습니다 (worker_id, vacation_type_id, year, total_days)' + }); + } + + // 중복 체크 + vacationBalanceModel.getByWorkerTypeYear(worker_id, vacation_type_id, year, (err, existing) => { + if (err) { + console.error('중복 체크 오류:', err); + return res.status(500).json({ + success: false, + message: '중복 체크 중 오류가 발생했습니다' + }); + } + + if (existing && existing.length > 0) { + return res.status(400).json({ + success: false, + message: '이미 해당 작업자의 해당 연도 휴가 잔액이 존재합니다' + }); + } + + const balanceData = { + worker_id, + vacation_type_id, + year, + total_days, + used_days: used_days || 0, + notes: notes || null, + created_by + }; + + vacationBalanceModel.create(balanceData, (err, result) => { + if (err) { + console.error('휴가 잔액 생성 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 잔액을 생성하는 중 오류가 발생했습니다' + }); + } + + res.status(201).json({ + success: true, + message: '휴가 잔액이 생성되었습니다', + data: { id: result.insertId } + }); + }); + }); + } catch (error) { + console.error('createBalance 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 잔액 수정 + * PUT /api/vacation-balances/:id + */ + async updateBalance(req, res) { + try { + const { id } = req.params; + const { total_days, used_days, notes } = req.body; + + const updateData = {}; + if (total_days !== undefined) updateData.total_days = total_days; + if (used_days !== undefined) updateData.used_days = used_days; + if (notes !== undefined) updateData.notes = notes; + updateData.updated_at = new Date(); + + if (Object.keys(updateData).length === 1) { + return res.status(400).json({ + success: false, + message: '수정할 데이터가 없습니다' + }); + } + + vacationBalanceModel.update(id, updateData, (err, result) => { + if (err) { + console.error('휴가 잔액 수정 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 잔액을 수정하는 중 오류가 발생했습니다' + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '휴가 잔액을 찾을 수 없습니다' + }); + } + + res.json({ + success: true, + message: '휴가 잔액이 수정되었습니다' + }); + }); + } catch (error) { + console.error('updateBalance 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 잔액 삭제 + * DELETE /api/vacation-balances/:id + */ + async deleteBalance(req, res) { + try { + const { id } = req.params; + + vacationBalanceModel.delete(id, (err, result) => { + if (err) { + console.error('휴가 잔액 삭제 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 잔액을 삭제하는 중 오류가 발생했습니다' + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '휴가 잔액을 찾을 수 없습니다' + }); + } + + res.json({ + success: true, + message: '휴가 잔액이 삭제되었습니다' + }); + }); + } catch (error) { + console.error('deleteBalance 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 근속년수 기반 연차 자동 계산 및 생성 + * POST /api/vacation-balances/auto-calculate + */ + async autoCalculateAndCreate(req, res) { + try { + const { worker_id, hire_date, year } = req.body; + const created_by = req.user.user_id; + + if (!worker_id || !hire_date || !year) { + return res.status(400).json({ + success: false, + message: '필수 필드가 누락되었습니다 (worker_id, hire_date, year)' + }); + } + + // 연차 일수 계산 + const annualDays = vacationBalanceModel.calculateAnnualLeaveDays(hire_date, year); + + // ANNUAL 휴가 유형 ID 조회 + vacationTypeModel.getByCode('ANNUAL', (err, types) => { + if (err || !types || types.length === 0) { + console.error('ANNUAL 휴가 유형 조회 오류:', err); + return res.status(500).json({ + success: false, + message: 'ANNUAL 휴가 유형을 찾을 수 없습니다' + }); + } + + const annualTypeId = types[0].id; + + // 중복 체크 + vacationBalanceModel.getByWorkerTypeYear(worker_id, annualTypeId, year, (err, existing) => { + if (err) { + console.error('중복 체크 오류:', err); + return res.status(500).json({ + success: false, + message: '중복 체크 중 오류가 발생했습니다' + }); + } + + if (existing && existing.length > 0) { + return res.status(400).json({ + success: false, + message: '이미 해당 작업자의 해당 연도 연차가 존재합니다' + }); + } + + const balanceData = { + worker_id, + vacation_type_id: annualTypeId, + year, + total_days: annualDays, + used_days: 0, + notes: `근속년수 기반 자동 계산 (입사일: ${hire_date})`, + created_by + }; + + vacationBalanceModel.create(balanceData, (err, result) => { + if (err) { + console.error('휴가 잔액 생성 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 잔액을 생성하는 중 오류가 발생했습니다' + }); + } + + res.status(201).json({ + success: true, + message: `${annualDays}일의 연차가 자동으로 생성되었습니다`, + data: { + id: result.insertId, + calculated_days: annualDays + } + }); + }); + }); + }); + } catch (error) { + console.error('autoCalculateAndCreate 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 작업자의 사용 가능한 휴가 일수 조회 + * GET /api/vacation-balances/worker/:workerId/year/:year/available + */ + async getAvailableDays(req, res) { + try { + const { workerId, year } = req.params; + + vacationBalanceModel.getAvailableVacationDays(workerId, year, (err, results) => { + if (err) { + console.error('사용 가능 휴가 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '사용 가능 휴가를 조회하는 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('getAvailableDays 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + } +}; + +module.exports = vacationBalanceController; diff --git a/api.hyungi.net/controllers/vacationRequestController.js b/api.hyungi.net/controllers/vacationRequestController.js new file mode 100644 index 0000000..8d4df9f --- /dev/null +++ b/api.hyungi.net/controllers/vacationRequestController.js @@ -0,0 +1,565 @@ +/** + * vacationRequestController.js + * 휴가 신청 관련 컨트롤러 + */ + +const vacationRequestModel = require('../models/vacationRequestModel'); +// TODO: workerVacationBalanceModel 구현 필요 +// const workerVacationBalanceModel = require('../models/workerVacationBalanceModel'); + +const vacationRequestController = { + /** + * 휴가 신청 생성 + */ + async createRequest(req, res) { + try { + const { worker_id, vacation_type_id, start_date, end_date, days_used, reason } = req.body; + const requested_by = req.user.user_id; + + // 필수 필드 검증 + if (!worker_id || !vacation_type_id || !start_date || !end_date || !days_used) { + return res.status(400).json({ + success: false, + message: '필수 필드가 누락되었습니다' + }); + } + + // 날짜 유효성 검증 + const startDate = new Date(start_date); + const endDate = new Date(end_date); + + if (endDate < startDate) { + return res.status(400).json({ + success: false, + message: '종료일은 시작일보다 이후여야 합니다' + }); + } + + // 기간 중복 체크 + vacationRequestModel.checkOverlap(worker_id, start_date, end_date, null, (err, results) => { + if (err) { + console.error('기간 중복 체크 오류:', err); + return res.status(500).json({ + success: false, + message: '기간 중복 체크 중 오류가 발생했습니다' + }); + } + + if (results[0].count > 0) { + return res.status(400).json({ + success: false, + message: '해당 기간에 이미 신청된 휴가가 있습니다' + }); + } + + // TODO: 잔여 연차 확인 로직 구현 필요 + // 현재는 잔여 연차 확인 없이 신청 가능 + + // 휴가 신청 생성 + const requestData = { + worker_id, + vacation_type_id, + start_date, + end_date, + days_used, + reason: reason || null, + status: 'pending', + requested_by + }; + + vacationRequestModel.create(requestData, (err, result) => { + if (err) { + console.error('휴가 신청 생성 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 생성 중 오류가 발생했습니다' + }); + } + + res.status(201).json({ + success: true, + message: '휴가 신청이 완료되었습니다', + data: { + request_id: result.insertId + } + }); + }); + }); + } catch (error) { + console.error('휴가 신청 생성 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 신청 목록 조회 + */ + async getAllRequests(req, res) { + try { + const filters = { + worker_id: req.query.worker_id, + status: req.query.status, + start_date: req.query.start_date, + end_date: req.query.end_date, + vacation_type_id: req.query.vacation_type_id + }; + + // 일반 사용자는 자신의 신청만 조회 가능 + if (req.user.access_level !== 'system') { + if (req.user.worker_id) { + filters.worker_id = req.user.worker_id; + } else { + return res.status(403).json({ + success: false, + message: '권한이 없습니다' + }); + } + } + + vacationRequestModel.getAll(filters, (err, results) => { + if (err) { + console.error('휴가 신청 목록 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 목록 조회 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('휴가 신청 목록 조회 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 특정 휴가 신청 조회 + */ + async getRequestById(req, res) { + try { + const { id } = req.params; + + vacationRequestModel.getById(id, (err, results) => { + if (err) { + console.error('휴가 신청 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 조회 중 오류가 발생했습니다' + }); + } + + if (results.length === 0) { + return res.status(404).json({ + success: false, + message: '해당 휴가 신청을 찾을 수 없습니다' + }); + } + + const request = results[0]; + + // 권한 검증: 관리자 또는 본인만 조회 가능 + if (req.user.access_level !== 'system' && req.user.worker_id !== request.worker_id) { + return res.status(403).json({ + success: false, + message: '권한이 없습니다' + }); + } + + res.json({ + success: true, + data: request + }); + }); + } catch (error) { + console.error('휴가 신청 조회 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 신청 수정 (대기 중인 신청만) + */ + async updateRequest(req, res) { + try { + const { id } = req.params; + const { start_date, end_date, days_used, reason } = req.body; + + // 기존 신청 조회 + vacationRequestModel.getById(id, (err, results) => { + if (err) { + console.error('휴가 신청 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 조회 중 오류가 발생했습니다' + }); + } + + if (results.length === 0) { + return res.status(404).json({ + success: false, + message: '해당 휴가 신청을 찾을 수 없습니다' + }); + } + + const existingRequest = results[0]; + + // 권한 검증 + if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) { + return res.status(403).json({ + success: false, + message: '권한이 없습니다' + }); + } + + // 대기 중인 신청만 수정 가능 + if (existingRequest.status !== 'pending') { + return res.status(400).json({ + success: false, + message: '승인/거부된 신청은 수정할 수 없습니다' + }); + } + + const updateData = {}; + if (start_date) updateData.start_date = start_date; + if (end_date) updateData.end_date = end_date; + if (days_used) updateData.days_used = days_used; + if (reason !== undefined) updateData.reason = reason; + + // 날짜가 변경된 경우 중복 체크 + if (start_date || end_date) { + const newStartDate = start_date || existingRequest.start_date; + const newEndDate = end_date || existingRequest.end_date; + + vacationRequestModel.checkOverlap( + existingRequest.worker_id, + newStartDate, + newEndDate, + id, + (err, overlapResults) => { + if (err) { + console.error('기간 중복 체크 오류:', err); + return res.status(500).json({ + success: false, + message: '기간 중복 체크 중 오류가 발생했습니다' + }); + } + + if (overlapResults[0].count > 0) { + return res.status(400).json({ + success: false, + message: '해당 기간에 이미 신청된 휴가가 있습니다' + }); + } + + // 수정 실행 + vacationRequestModel.update(id, updateData, (err, result) => { + if (err) { + console.error('휴가 신청 수정 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 수정 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + message: '휴가 신청이 수정되었습니다' + }); + }); + } + ); + } else { + // 날짜 변경 없이 바로 수정 + vacationRequestModel.update(id, updateData, (err, result) => { + if (err) { + console.error('휴가 신청 수정 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 수정 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + message: '휴가 신청이 수정되었습니다' + }); + }); + } + }); + } catch (error) { + console.error('휴가 신청 수정 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 신청 삭제 (대기 중인 신청만) + */ + async deleteRequest(req, res) { + try { + const { id } = req.params; + + // 기존 신청 조회 + vacationRequestModel.getById(id, (err, results) => { + if (err) { + console.error('휴가 신청 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 조회 중 오류가 발생했습니다' + }); + } + + if (results.length === 0) { + return res.status(404).json({ + success: false, + message: '해당 휴가 신청을 찾을 수 없습니다' + }); + } + + const existingRequest = results[0]; + + // 권한 검증 + if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) { + return res.status(403).json({ + success: false, + message: '권한이 없습니다' + }); + } + + // 대기 중인 신청만 삭제 가능 + if (existingRequest.status !== 'pending') { + return res.status(400).json({ + success: false, + message: '승인/거부된 신청은 삭제할 수 없습니다' + }); + } + + vacationRequestModel.delete(id, (err, result) => { + if (err) { + console.error('휴가 신청 삭제 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 삭제 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + message: '휴가 신청이 삭제되었습니다' + }); + }); + }); + } catch (error) { + console.error('휴가 신청 삭제 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 신청 승인 (관리자만) + */ + async approveRequest(req, res) { + try { + const { id } = req.params; + const { review_note } = req.body; + const reviewed_by = req.user.user_id; + + // 관리자 권한 확인 + if (req.user.access_level !== 'system') { + return res.status(403).json({ + success: false, + message: '관리자만 승인할 수 있습니다' + }); + } + + // 기존 신청 조회 + vacationRequestModel.getById(id, (err, results) => { + if (err) { + console.error('휴가 신청 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 조회 중 오류가 발생했습니다' + }); + } + + if (results.length === 0) { + return res.status(404).json({ + success: false, + message: '해당 휴가 신청을 찾을 수 없습니다' + }); + } + + const request = results[0]; + + if (request.status !== 'pending') { + return res.status(400).json({ + success: false, + message: '이미 처리된 신청입니다' + }); + } + + // 상태 업데이트 + const statusData = { + status: 'approved', + reviewed_by, + review_note + }; + + vacationRequestModel.updateStatus(id, statusData, (err, result) => { + if (err) { + console.error('휴가 승인 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 승인 중 오류가 발생했습니다' + }); + } + + // TODO: 잔여 연차에서 차감 로직 구현 필요 + // 현재는 연차 차감 없이 승인만 처리 + + res.json({ + success: true, + message: '휴가 신청이 승인되었습니다' + }); + }); + }); + } catch (error) { + console.error('휴가 승인 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 신청 거부 (관리자만) + */ + async rejectRequest(req, res) { + try { + const { id } = req.params; + const { review_note } = req.body; + const reviewed_by = req.user.user_id; + + // 관리자 권한 확인 + if (req.user.access_level !== 'system') { + return res.status(403).json({ + success: false, + message: '관리자만 거부할 수 있습니다' + }); + } + + // 기존 신청 조회 + vacationRequestModel.getById(id, (err, results) => { + if (err) { + console.error('휴가 신청 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 신청 조회 중 오류가 발생했습니다' + }); + } + + if (results.length === 0) { + return res.status(404).json({ + success: false, + message: '해당 휴가 신청을 찾을 수 없습니다' + }); + } + + const request = results[0]; + + if (request.status !== 'pending') { + return res.status(400).json({ + success: false, + message: '이미 처리된 신청입니다' + }); + } + + // 상태 업데이트 + const statusData = { + status: 'rejected', + reviewed_by, + review_note + }; + + vacationRequestModel.updateStatus(id, statusData, (err, result) => { + if (err) { + console.error('휴가 거부 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 거부 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + message: '휴가 신청이 거부되었습니다' + }); + }); + }); + } catch (error) { + console.error('휴가 거부 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 대기 중인 휴가 신청 목록 (관리자용) + */ + async getPendingRequests(req, res) { + try { + // 관리자 권한 확인 + if (req.user.access_level !== 'system') { + return res.status(403).json({ + success: false, + message: '관리자만 조회할 수 있습니다' + }); + } + + vacationRequestModel.getAllPending((err, results) => { + if (err) { + console.error('대기 중인 휴가 신청 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '대기 중인 휴가 신청 조회 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('대기 중인 휴가 신청 조회 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + } +}; + +module.exports = vacationRequestController; diff --git a/api.hyungi.net/controllers/vacationTypeController.js b/api.hyungi.net/controllers/vacationTypeController.js new file mode 100644 index 0000000..9eddaeb --- /dev/null +++ b/api.hyungi.net/controllers/vacationTypeController.js @@ -0,0 +1,333 @@ +/** + * vacationTypeController.js + * 휴가 유형 관련 컨트롤러 + */ + +const vacationTypeModel = require('../models/vacationTypeModel'); + +const vacationTypeController = { + /** + * 모든 활성 휴가 유형 조회 + * GET /api/vacation-types + */ + async getAllTypes(req, res) { + try { + vacationTypeModel.getAll((err, results) => { + if (err) { + console.error('휴가 유형 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 유형을 조회하는 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('getAllTypes 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 시스템 기본 휴가 유형 조회 + * GET /api/vacation-types/system + */ + async getSystemTypes(req, res) { + try { + vacationTypeModel.getSystemTypes((err, results) => { + if (err) { + console.error('시스템 휴가 유형 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '시스템 휴가 유형을 조회하는 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('getSystemTypes 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 특별 휴가 유형 조회 + * GET /api/vacation-types/special + */ + async getSpecialTypes(req, res) { + try { + vacationTypeModel.getSpecialTypes((err, results) => { + if (err) { + console.error('특별 휴가 유형 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '특별 휴가 유형을 조회하는 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('getSpecialTypes 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 특별 휴가 유형 생성 (관리자만) + * POST /api/vacation-types + */ + async createType(req, res) { + try { + const { + type_code, + type_name, + deduct_days, + priority, + description + } = req.body; + + // 필수 필드 검증 + if (!type_code || !type_name || !deduct_days) { + return res.status(400).json({ + success: false, + message: '필수 필드가 누락되었습니다 (type_code, type_name, deduct_days)' + }); + } + + // type_code 중복 체크 + vacationTypeModel.getByCode(type_code, (err, existingTypes) => { + if (err) { + console.error('type_code 중복 체크 오류:', err); + return res.status(500).json({ + success: false, + message: 'type_code 중복 체크 중 오류가 발생했습니다' + }); + } + + if (existingTypes && existingTypes.length > 0) { + return res.status(400).json({ + success: false, + message: '이미 존재하는 type_code입니다' + }); + } + + // 특별 휴가 유형으로 생성 + const typeData = { + type_code, + type_name, + deduct_days, + priority: priority || 50, + description: description || null, + is_special: true, + is_system: false, + is_active: true + }; + + vacationTypeModel.create(typeData, (err, result) => { + if (err) { + console.error('휴가 유형 생성 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 유형을 생성하는 중 오류가 발생했습니다' + }); + } + + res.status(201).json({ + success: true, + message: '특별 휴가 유형이 생성되었습니다', + data: { id: result.insertId } + }); + }); + }); + } catch (error) { + console.error('createType 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 유형 수정 (관리자만) + * PUT /api/vacation-types/:id + */ + async updateType(req, res) { + try { + const { id } = req.params; + const { + type_name, + deduct_days, + priority, + description, + is_active + } = req.body; + + // 먼저 해당 유형 조회 + vacationTypeModel.getById(id, (err, types) => { + if (err) { + console.error('휴가 유형 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 유형을 조회하는 중 오류가 발생했습니다' + }); + } + + if (!types || types.length === 0) { + return res.status(404).json({ + success: false, + message: '휴가 유형을 찾을 수 없습니다' + }); + } + + const type = types[0]; + + // 시스템 기본 휴가의 경우 제한적으로만 수정 가능 + const updateData = {}; + if (type.is_system) { + // 시스템 휴가는 priority와 description만 수정 가능 + if (priority !== undefined) updateData.priority = priority; + if (description !== undefined) updateData.description = description; + } else { + // 특별 휴가는 모든 필드 수정 가능 + if (type_name) updateData.type_name = type_name; + if (deduct_days !== undefined) updateData.deduct_days = deduct_days; + if (priority !== undefined) updateData.priority = priority; + if (description !== undefined) updateData.description = description; + if (is_active !== undefined) updateData.is_active = is_active; + } + + if (Object.keys(updateData).length === 0) { + return res.status(400).json({ + success: false, + message: '수정할 데이터가 없습니다' + }); + } + + updateData.updated_at = new Date(); + + vacationTypeModel.update(id, updateData, (err, result) => { + if (err) { + console.error('휴가 유형 수정 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 유형을 수정하는 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + message: '휴가 유형이 수정되었습니다' + }); + }); + }); + } catch (error) { + console.error('updateType 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 특별 휴가 유형 삭제 (관리자만, 시스템 기본 휴가는 삭제 불가) + * DELETE /api/vacation-types/:id + */ + async deleteType(req, res) { + try { + const { id } = req.params; + + vacationTypeModel.delete(id, (err, result) => { + if (err) { + console.error('휴가 유형 삭제 오류:', err); + return res.status(500).json({ + success: false, + message: '휴가 유형을 삭제하는 중 오류가 발생했습니다' + }); + } + + if (result.affectedRows === 0) { + return res.status(400).json({ + success: false, + message: '삭제할 수 없습니다. 시스템 기본 휴가이거나 존재하지 않는 휴가 유형입니다' + }); + } + + res.json({ + success: true, + message: '휴가 유형이 삭제되었습니다' + }); + }); + } catch (error) { + console.error('deleteType 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + }, + + /** + * 휴가 유형 우선순위 일괄 업데이트 (관리자만) + * PUT /api/vacation-types/priorities + */ + async updatePriorities(req, res) { + try { + const { priorities } = req.body; + + // priorities = [{ id: 1, priority: 10 }, { id: 2, priority: 20 }, ...] + if (!priorities || !Array.isArray(priorities)) { + return res.status(400).json({ + success: false, + message: 'priorities 배열이 필요합니다' + }); + } + + vacationTypeModel.updatePriorities(priorities, (err, result) => { + if (err) { + console.error('우선순위 업데이트 오류:', err); + return res.status(500).json({ + success: false, + message: '우선순위를 업데이트하는 중 오류가 발생했습니다' + }); + } + + res.json({ + success: true, + message: '우선순위가 업데이트되었습니다', + data: { updated: result.affectedRows } + }); + }); + } catch (error) { + console.error('updatePriorities 오류:', error); + res.status(500).json({ + success: false, + message: '서버 오류가 발생했습니다' + }); + } + } +}; + +module.exports = vacationTypeController; diff --git a/api.hyungi.net/controllers/visitRequestController.js b/api.hyungi.net/controllers/visitRequestController.js new file mode 100644 index 0000000..8a7e5b6 --- /dev/null +++ b/api.hyungi.net/controllers/visitRequestController.js @@ -0,0 +1,555 @@ +const visitRequestModel = require('../models/visitRequestModel'); + +// ==================== 출입 신청 관리 ==================== + +/** + * 출입 신청 생성 + */ +exports.createVisitRequest = (req, res) => { + const requester_id = req.user.user_id; + const requestData = { + requester_id, + ...req.body + }; + + // 필수 필드 검증 + const requiredFields = ['visitor_company', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id']; + for (const field of requiredFields) { + if (!requestData[field]) { + return res.status(400).json({ + success: false, + message: `${field}는 필수 입력 항목입니다.` + }); + } + } + + visitRequestModel.createVisitRequest(requestData, (err, requestId) => { + if (err) { + console.error('출입 신청 생성 오류:', err); + return res.status(500).json({ + success: false, + message: '출입 신청 생성 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.status(201).json({ + success: true, + message: '출입 신청이 성공적으로 생성되었습니다.', + data: { request_id: requestId } + }); + }); +}; + +/** + * 출입 신청 목록 조회 + */ +exports.getAllVisitRequests = (req, res) => { + const filters = { + status: req.query.status, + visit_date: req.query.visit_date, + start_date: req.query.start_date, + end_date: req.query.end_date, + requester_id: req.query.requester_id, + category_id: req.query.category_id + }; + + visitRequestModel.getAllVisitRequests(filters, (err, requests) => { + if (err) { + console.error('출입 신청 목록 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '출입 신청 목록 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: requests + }); + }); +}; + +/** + * 출입 신청 상세 조회 + */ +exports.getVisitRequestById = (req, res) => { + const requestId = req.params.id; + + visitRequestModel.getVisitRequestById(requestId, (err, request) => { + if (err) { + console.error('출입 신청 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '출입 신청 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (!request) { + return res.status(404).json({ + success: false, + message: '출입 신청을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + data: request + }); + }); +}; + +/** + * 출입 신청 수정 + */ +exports.updateVisitRequest = (req, res) => { + const requestId = req.params.id; + const requestData = req.body; + + visitRequestModel.updateVisitRequest(requestId, requestData, (err, result) => { + if (err) { + console.error('출입 신청 수정 오류:', err); + return res.status(500).json({ + success: false, + message: '출입 신청 수정 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '출입 신청을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '출입 신청이 수정되었습니다.' + }); + }); +}; + +/** + * 출입 신청 삭제 + */ +exports.deleteVisitRequest = (req, res) => { + const requestId = req.params.id; + + visitRequestModel.deleteVisitRequest(requestId, (err, result) => { + if (err) { + console.error('출입 신청 삭제 오류:', err); + return res.status(500).json({ + success: false, + message: '출입 신청 삭제 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '출입 신청을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '출입 신청이 삭제되었습니다.' + }); + }); +}; + +/** + * 출입 신청 승인 + */ +exports.approveVisitRequest = (req, res) => { + const requestId = req.params.id; + const approvedBy = req.user.user_id; + + visitRequestModel.approveVisitRequest(requestId, approvedBy, (err, result) => { + if (err) { + console.error('출입 신청 승인 오류:', err); + return res.status(500).json({ + success: false, + message: '출입 신청 승인 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '출입 신청을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '출입 신청이 승인되었습니다.' + }); + }); +}; + +/** + * 출입 신청 반려 + */ +exports.rejectVisitRequest = (req, res) => { + const requestId = req.params.id; + const approvedBy = req.user.user_id; + const rejectionReason = req.body.rejection_reason || '사유 없음'; + + const rejectionData = { + approved_by: approvedBy, + rejection_reason: rejectionReason + }; + + visitRequestModel.rejectVisitRequest(requestId, rejectionData, (err, result) => { + if (err) { + console.error('출입 신청 반려 오류:', err); + return res.status(500).json({ + success: false, + message: '출입 신청 반려 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '출입 신청을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '출입 신청이 반려되었습니다.' + }); + }); +}; + +// ==================== 방문 목적 관리 ==================== + +/** + * 모든 방문 목적 조회 + */ +exports.getAllVisitPurposes = (req, res) => { + visitRequestModel.getAllVisitPurposes((err, purposes) => { + if (err) { + console.error('방문 목적 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '방문 목적 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: purposes + }); + }); +}; + +/** + * 활성 방문 목적만 조회 + */ +exports.getActiveVisitPurposes = (req, res) => { + visitRequestModel.getActiveVisitPurposes((err, purposes) => { + if (err) { + console.error('활성 방문 목적 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '활성 방문 목적 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: purposes + }); + }); +}; + +/** + * 방문 목적 추가 + */ +exports.createVisitPurpose = (req, res) => { + const purposeData = req.body; + + if (!purposeData.purpose_name) { + return res.status(400).json({ + success: false, + message: 'purpose_name은 필수 입력 항목입니다.' + }); + } + + visitRequestModel.createVisitPurpose(purposeData, (err, purposeId) => { + if (err) { + console.error('방문 목적 추가 오류:', err); + return res.status(500).json({ + success: false, + message: '방문 목적 추가 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.status(201).json({ + success: true, + message: '방문 목적이 추가되었습니다.', + data: { purpose_id: purposeId } + }); + }); +}; + +/** + * 방문 목적 수정 + */ +exports.updateVisitPurpose = (req, res) => { + const purposeId = req.params.id; + const purposeData = req.body; + + visitRequestModel.updateVisitPurpose(purposeId, purposeData, (err, result) => { + if (err) { + console.error('방문 목적 수정 오류:', err); + return res.status(500).json({ + success: false, + message: '방문 목적 수정 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '방문 목적을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '방문 목적이 수정되었습니다.' + }); + }); +}; + +/** + * 방문 목적 삭제 + */ +exports.deleteVisitPurpose = (req, res) => { + const purposeId = req.params.id; + + visitRequestModel.deleteVisitPurpose(purposeId, (err, result) => { + if (err) { + console.error('방문 목적 삭제 오류:', err); + return res.status(500).json({ + success: false, + message: '방문 목적 삭제 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '방문 목적을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '방문 목적이 삭제되었습니다.' + }); + }); +}; + +// ==================== 안전교육 기록 관리 ==================== + +/** + * 안전교육 기록 생성 + */ +exports.createTrainingRecord = (req, res) => { + const trainerId = req.user.user_id; + const trainingData = { + trainer_id: trainerId, + ...req.body + }; + + // 필수 필드 검증 + const requiredFields = ['request_id', 'training_date', 'training_start_time']; + for (const field of requiredFields) { + if (!trainingData[field]) { + return res.status(400).json({ + success: false, + message: `${field}는 필수 입력 항목입니다.` + }); + } + } + + visitRequestModel.createTrainingRecord(trainingData, (err, trainingId) => { + if (err) { + console.error('안전교육 기록 생성 오류:', err); + return res.status(500).json({ + success: false, + message: '안전교육 기록 생성 중 오류가 발생했습니다.', + error: err.message + }); + } + + // 안전교육 기록이 생성되면 출입 신청 상태를 training_completed로 변경 + console.log(`[교육 완료] request_id=${trainingData.request_id} 상태를 training_completed로 변경 중...`); + visitRequestModel.updateVisitRequestStatus(trainingData.request_id, 'training_completed', (statusErr) => { + if (statusErr) { + console.error('출입 신청 상태 업데이트 오류:', statusErr); + // 에러가 발생해도 교육 기록은 생성되었으므로 성공 응답 + } else { + console.log(`[교육 완료] request_id=${trainingData.request_id} 상태 변경 성공`); + } + + res.status(201).json({ + success: true, + message: '안전교육 기록이 생성되었습니다.', + data: { training_id: trainingId } + }); + }); + }); +}; + +/** + * 특정 출입 신청의 안전교육 기록 조회 + */ +exports.getTrainingRecordByRequestId = (req, res) => { + const requestId = req.params.requestId; + + visitRequestModel.getTrainingRecordByRequestId(requestId, (err, record) => { + if (err) { + console.error('안전교육 기록 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '안전교육 기록 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: record || null + }); + }); +}; + +/** + * 안전교육 기록 수정 + */ +exports.updateTrainingRecord = (req, res) => { + const trainingId = req.params.id; + const trainingData = req.body; + + visitRequestModel.updateTrainingRecord(trainingId, trainingData, (err, result) => { + if (err) { + console.error('안전교육 기록 수정 오류:', err); + return res.status(500).json({ + success: false, + message: '안전교육 기록 수정 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '안전교육 기록을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '안전교육 기록이 수정되었습니다.' + }); + }); +}; + +/** + * 안전교육 완료 (서명 포함) + */ +exports.completeTraining = (req, res) => { + const trainingId = req.params.id; + const signatureData = req.body.signature_data; + + if (!signatureData) { + return res.status(400).json({ + success: false, + message: '서명 데이터가 필요합니다.' + }); + } + + visitRequestModel.completeTraining(trainingId, signatureData, (err, result) => { + if (err) { + console.error('안전교육 완료 처리 오류:', err); + return res.status(500).json({ + success: false, + message: '안전교육 완료 처리 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '안전교육 기록을 찾을 수 없습니다.' + }); + } + + // 교육 완료 후 출입 신청 상태를 'training_completed'로 변경 + visitRequestModel.getTrainingRecordByRequestId(trainingId, (err, record) => { + if (err || !record) { + return res.json({ + success: true, + message: '안전교육이 완료되었습니다.' + }); + } + + visitRequestModel.updateVisitRequestStatus(record.request_id, 'training_completed', (err) => { + if (err) { + console.error('출입 신청 상태 업데이트 오류:', err); + } + + res.json({ + success: true, + message: '안전교육이 완료되었습니다.' + }); + }); + }); + }); +}; + +/** + * 안전교육 기록 목록 조회 + */ +exports.getTrainingRecords = (req, res) => { + const filters = { + training_date: req.query.training_date, + start_date: req.query.start_date, + end_date: req.query.end_date, + trainer_id: req.query.trainer_id + }; + + visitRequestModel.getTrainingRecords(filters, (err, records) => { + if (err) { + console.error('안전교육 기록 목록 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '안전교육 기록 목록 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: records + }); + }); +}; diff --git a/api.hyungi.net/controllers/workerController.js b/api.hyungi.net/controllers/workerController.js index 9c8b20b..75fdd02 100644 --- a/api.hyungi.net/controllers/workerController.js +++ b/api.hyungi.net/controllers/workerController.js @@ -178,36 +178,68 @@ exports.updateWorker = asyncHandler(async (req, res) => { // 계정 생성/해제 처리 const db = await getDb(); const hasAccount = currentWorker.user_id !== null && currentWorker.user_id !== undefined; + let accountAction = null; + let accountUsername = null; + + console.log('🔍 계정 생성 체크:', { + createAccount, + hasAccount, + currentWorker_user_id: currentWorker.user_id, + worker_name: workerData.worker_name + }); if (createAccount && !hasAccount && workerData.worker_name) { // 계정 생성 + console.log('✅ 계정 생성 로직 시작'); try { + console.log('🔑 사용자명 생성 중...'); const username = await generateUniqueUsername(workerData.worker_name, db); + console.log('🔑 생성된 사용자명:', username); + const hashedPassword = await bcrypt.hash('1234', 10); + console.log('🔒 비밀번호 해싱 완료'); // User 역할 조회 + console.log('👤 User 역할 조회 중...'); const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']); + console.log('👤 User 역할 조회 결과:', userRole); if (userRole && userRole.length > 0) { + console.log('💾 계정 DB 삽입 시작...'); await db.query( `INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, NOW(), NOW())`, [username, hashedPassword, workerData.worker_name, id, userRole[0].id] ); + console.log('✅ 계정 DB 삽입 완료'); + accountAction = 'created'; + accountUsername = username; logger.info('작업자 계정 생성 성공', { worker_id: id, username }); + } else { + console.log('❌ User 역할을 찾을 수 없음'); } } catch (accountError) { + console.error('❌ 계정 생성 오류:', accountError); logger.error('계정 생성 실패', { worker_id: id, error: accountError.message }); + accountAction = 'failed'; } - } else if (!createAccount && hasAccount) { + } else { + console.log('⏭️ 계정 생성 조건 불만족:', { createAccount, hasAccount, hasWorkerName: !!workerData.worker_name }); + } + + if (!createAccount && hasAccount) { // 계정 연동 해제 (users.worker_id = NULL) try { await db.query('UPDATE users SET worker_id = NULL WHERE worker_id = ?', [id]); + accountAction = 'unlinked'; logger.info('작업자 계정 연동 해제 성공', { worker_id: id }); } catch (unlinkError) { logger.error('계정 연동 해제 실패', { worker_id: id, error: unlinkError.message }); + accountAction = 'unlink_failed'; } + } else if (createAccount && hasAccount) { + accountAction = 'already_exists'; } // 작업자 관련 캐시 무효화 @@ -216,10 +248,26 @@ exports.updateWorker = asyncHandler(async (req, res) => { logger.info('작업자 수정 성공', { worker_id: id }); + // 응답 메시지 구성 + let message = '작업자 정보가 성공적으로 수정되었습니다'; + if (accountAction === 'created') { + message += ` (계정 생성 완료: ${accountUsername}, 초기 비밀번호: 1234)`; + } else if (accountAction === 'unlinked') { + message += ' (계정 연동 해제 완료)'; + } else if (accountAction === 'already_exists') { + message += ' (이미 계정이 존재합니다)'; + } else if (accountAction === 'failed') { + message += ' (계정 생성 실패)'; + } + res.json({ success: true, - data: { changes }, - message: '작업자 정보가 성공적으로 수정되었습니다' + data: { + changes, + account_action: accountAction, + account_username: accountUsername + }, + message }); }); diff --git a/api.hyungi.net/db/migrations/20260126010004_add_work_details_to_team_assignments.js b/api.hyungi.net/db/migrations/20260126010004_add_work_details_to_team_assignments.js new file mode 100644 index 0000000..3e4f6f6 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260126010004_add_work_details_to_team_assignments.js @@ -0,0 +1,37 @@ +/** + * 마이그레이션: tbm_team_assignments 테이블 확장 + * 작업자별 프로젝트/공정/작업/작업장 정보 저장 가능하도록 컬럼 추가 및 외래키 설정 + */ + +exports.up = async function(knex) { + // 1. workplace_category_id와 workplace_id를 UNSIGNED로 변경 + await knex.raw(` + ALTER TABLE tbm_team_assignments + MODIFY COLUMN workplace_category_id INT UNSIGNED NULL COMMENT '작업자별 작업장 대분류 (공장)', + MODIFY COLUMN workplace_id INT UNSIGNED NULL COMMENT '작업자별 작업장 ID' + `); + + // 2. 외래키 제약조건 추가 + return knex.schema.alterTable('tbm_team_assignments', function(table) { + // 외래키 제약조건 추가 + table.foreign('workplace_category_id') + .references('category_id') + .inTable('workplace_categories') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + table.foreign('workplace_id') + .references('workplace_id') + .inTable('workplaces') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + }); +}; + +exports.down = function(knex) { + return knex.schema.alterTable('tbm_team_assignments', function(table) { + // 외래키 제약조건 제거 + table.dropForeign('workplace_category_id'); + table.dropForeign('workplace_id'); + }); +}; diff --git a/api.hyungi.net/db/migrations/20260127000001_remove_unused_tbm_fields.js b/api.hyungi.net/db/migrations/20260127000001_remove_unused_tbm_fields.js new file mode 100644 index 0000000..1850a27 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260127000001_remove_unused_tbm_fields.js @@ -0,0 +1,20 @@ +/** + * 마이그레이션: tbm_sessions 테이블에서 불필요한 컬럼 제거 + * work_description, safety_notes, start_time 컬럼 제거 + */ + +exports.up = function(knex) { + return knex.schema.alterTable('tbm_sessions', function(table) { + table.dropColumn('work_description'); + table.dropColumn('safety_notes'); + table.dropColumn('start_time'); + }); +}; + +exports.down = function(knex) { + return knex.schema.alterTable('tbm_sessions', function(table) { + table.text('work_description').nullable().comment('작업 내용'); + table.text('safety_notes').nullable().comment('안전 관련 특이사항'); + table.time('start_time').nullable().comment('시작 시간'); + }); +}; diff --git a/api.hyungi.net/db/migrations/20260127000002_add_workplace_map_images.js b/api.hyungi.net/db/migrations/20260127000002_add_workplace_map_images.js new file mode 100644 index 0000000..2d276eb --- /dev/null +++ b/api.hyungi.net/db/migrations/20260127000002_add_workplace_map_images.js @@ -0,0 +1,53 @@ +/** + * 마이그레이션: 작업장 지도 이미지 기능 추가 + * - workplace_categories에 layout_image 필드 추가 + * - workplace_map_regions 테이블 생성 (클릭 가능한 영역 정의) + */ + +exports.up = async function(knex) { + // 1. workplace_categories 테이블에 layout_image 필드 추가 + await knex.schema.alterTable('workplace_categories', function(table) { + table.string('layout_image', 500).nullable().comment('공장 배치도 이미지 경로'); + }); + + // 2. 작업장 지도 클릭 영역 정의 테이블 생성 + await knex.schema.createTable('workplace_map_regions', function(table) { + table.increments('region_id').primary().comment('영역 ID'); + table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID'); + table.integer('category_id').unsigned().notNullable().comment('공장 카테고리 ID'); + + // 좌표 정보 (비율 기반: 0~100%) + table.decimal('x_start', 5, 2).notNullable().comment('시작 X 좌표 (%)'); + table.decimal('y_start', 5, 2).notNullable().comment('시작 Y 좌표 (%)'); + table.decimal('x_end', 5, 2).notNullable().comment('끝 X 좌표 (%)'); + table.decimal('y_end', 5, 2).notNullable().comment('끝 Y 좌표 (%)'); + + table.string('shape', 20).defaultTo('rect').comment('영역 모양 (rect, circle, polygon)'); + table.text('polygon_points').nullable().comment('다각형인 경우 좌표 배열 (JSON)'); + + table.timestamps(true, true); + + // 외래키 + table.foreign('workplace_id') + .references('workplace_id') + .inTable('workplaces') + .onDelete('CASCADE') + .onUpdate('CASCADE'); + + table.foreign('category_id') + .references('category_id') + .inTable('workplace_categories') + .onDelete('CASCADE') + .onUpdate('CASCADE'); + }); +}; + +exports.down = async function(knex) { + // 테이블 삭제 + await knex.schema.dropTableIfExists('workplace_map_regions'); + + // 필드 제거 + await knex.schema.alterTable('workplace_categories', function(table) { + table.dropColumn('layout_image'); + }); +}; diff --git a/api.hyungi.net/db/migrations/20260127010000_add_workplace_purpose_fields.js b/api.hyungi.net/db/migrations/20260127010000_add_workplace_purpose_fields.js new file mode 100644 index 0000000..022ed3d --- /dev/null +++ b/api.hyungi.net/db/migrations/20260127010000_add_workplace_purpose_fields.js @@ -0,0 +1,17 @@ +/** + * 마이그레이션: 작업장 용도 및 표시 순서 필드 추가 + */ + +exports.up = function(knex) { + return knex.schema.alterTable('workplaces', function(table) { + table.string('workplace_purpose', 50).nullable().comment('작업장 용도 (작업구역, 설비, 휴게시설, 회의실 등)'); + table.integer('display_priority').defaultTo(0).comment('표시 우선순위 (숫자가 작을수록 먼저 표시)'); + }); +}; + +exports.down = function(knex) { + return knex.schema.alterTable('workplaces', function(table) { + table.dropColumn('workplace_purpose'); + table.dropColumn('display_priority'); + }); +}; diff --git a/api.hyungi.net/db/migrations/20260127020000_make_leader_id_nullable.js b/api.hyungi.net/db/migrations/20260127020000_make_leader_id_nullable.js new file mode 100644 index 0000000..f05ed42 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260127020000_make_leader_id_nullable.js @@ -0,0 +1,30 @@ +/** + * leader_id를 nullable로 변경 + * 관리자가 TBM을 입력할 때 leader_id를 NULL로 설정하고 created_by를 사용 + */ + +exports.up = async function(knex) { + // 1. 외래 키 제약조건 삭제 (존재하는 경우에만) + try { + await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign'); + } catch (err) { + console.log('외래 키가 이미 존재하지 않음 (정상)'); + } + + // 2. leader_id를 nullable로 변경 (UNSIGNED 제거하여 workers.worker_id와 타입 일치) + await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NULL'); + + // 3. 외래 키 제약조건 다시 추가 (nullable 허용) + await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE SET NULL'); +}; + +exports.down = async function(knex) { + // 1. 외래 키 제약조건 삭제 + await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign'); + + // 2. leader_id를 NOT NULL로 되돌림 + await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NOT NULL'); + + // 3. 외래 키 제약조건 다시 추가 + await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE CASCADE'); +}; diff --git a/api.hyungi.net/db/migrations/20260127030000_add_tbm_fields_to_work_reports.js b/api.hyungi.net/db/migrations/20260127030000_add_tbm_fields_to_work_reports.js new file mode 100644 index 0000000..6d6ac4e --- /dev/null +++ b/api.hyungi.net/db/migrations/20260127030000_add_tbm_fields_to_work_reports.js @@ -0,0 +1,55 @@ +/** + * daily_work_reports 테이블에 TBM 연동 필드 추가 + * - TBM 세션 및 팀 배정과 연결 + * - 작업 시간 및 오류 시간 추적 + */ + +exports.up = async function(knex) { + await knex.schema.table('daily_work_reports', (table) => { + // TBM 연동 필드 + table.integer('tbm_session_id').unsigned().nullable() + .comment('연결된 TBM 세션 ID'); + table.integer('tbm_assignment_id').unsigned().nullable() + .comment('연결된 TBM 팀 배정 ID'); + + // 작업 시간 추적 + table.time('start_time').nullable() + .comment('작업 시작 시간'); + table.time('end_time').nullable() + .comment('작업 종료 시간'); + table.decimal('total_hours', 5, 2).nullable() + .comment('총 작업 시간'); + table.decimal('regular_hours', 5, 2).nullable() + .comment('정규 작업 시간 (총 시간 - 오류 시간)'); + table.decimal('error_hours', 5, 2).nullable() + .comment('부적합 사항 처리 시간'); + + // 외래 키 제약조건 + table.foreign('tbm_session_id') + .references('session_id') + .inTable('tbm_sessions') + .onDelete('SET NULL'); + + table.foreign('tbm_assignment_id') + .references('assignment_id') + .inTable('tbm_team_assignments') + .onDelete('SET NULL'); + }); +}; + +exports.down = async function(knex) { + await knex.schema.table('daily_work_reports', (table) => { + // 외래 키 제약조건 삭제 + table.dropForeign('tbm_session_id'); + table.dropForeign('tbm_assignment_id'); + + // 컬럼 삭제 + table.dropColumn('tbm_session_id'); + table.dropColumn('tbm_assignment_id'); + table.dropColumn('start_time'); + table.dropColumn('end_time'); + table.dropColumn('total_hours'); + table.dropColumn('regular_hours'); + table.dropColumn('error_hours'); + }); +}; diff --git a/api.hyungi.net/db/migrations/20260127040000_update_pages_for_current_system.js b/api.hyungi.net/db/migrations/20260127040000_update_pages_for_current_system.js new file mode 100644 index 0000000..4a19ab6 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260127040000_update_pages_for_current_system.js @@ -0,0 +1,152 @@ +/** + * 현재 사용 중인 페이지를 pages 테이블에 업데이트 + */ + +exports.up = async function(knex) { + // 기존 페이지 모두 삭제 + await knex('pages').del(); + + // 현재 사용 중인 페이지들을 등록 + await knex('pages').insert([ + // 공통 페이지 + { + page_key: 'dashboard', + page_name: '대시보드', + page_path: '/pages/dashboard.html', + category: 'common', + description: '전체 현황 대시보드', + is_admin_only: 0, + display_order: 1 + }, + + // 작업 관련 페이지 + { + page_key: 'work.tbm', + page_name: 'TBM', + page_path: '/pages/work/tbm.html', + category: 'work', + description: 'TBM (Tool Box Meeting) 관리', + is_admin_only: 0, + display_order: 10 + }, + { + page_key: 'work.report_create', + page_name: '작업보고서 작성', + page_path: '/pages/work/report-create.html', + category: 'work', + description: '일일 작업보고서 작성', + is_admin_only: 0, + display_order: 11 + }, + { + page_key: 'work.report_view', + page_name: '작업보고서 조회', + page_path: '/pages/work/report-view.html', + category: 'work', + description: '작업보고서 조회 및 검색', + is_admin_only: 0, + display_order: 12 + }, + { + page_key: 'work.analysis', + page_name: '작업 분석', + page_path: '/pages/work/analysis.html', + category: 'work', + description: '작업 통계 및 분석', + is_admin_only: 0, + display_order: 13 + }, + + // Admin 페이지 + { + page_key: 'admin.accounts', + page_name: '계정 관리', + page_path: '/pages/admin/accounts.html', + category: 'admin', + description: '사용자 계정 관리', + is_admin_only: 1, + display_order: 20 + }, + { + page_key: 'admin.page_access', + page_name: '페이지 권한 관리', + page_path: '/pages/admin/page-access.html', + category: 'admin', + description: '사용자별 페이지 접근 권한 관리', + is_admin_only: 1, + display_order: 21 + }, + { + page_key: 'admin.workers', + page_name: '작업자 관리', + page_path: '/pages/admin/workers.html', + category: 'admin', + description: '작업자 정보 관리', + is_admin_only: 1, + display_order: 22 + }, + { + page_key: 'admin.projects', + page_name: '프로젝트 관리', + page_path: '/pages/admin/projects.html', + category: 'admin', + description: '프로젝트 관리', + is_admin_only: 1, + display_order: 23 + }, + { + page_key: 'admin.workplaces', + page_name: '작업장 관리', + page_path: '/pages/admin/workplaces.html', + category: 'admin', + description: '작업장소 관리', + is_admin_only: 1, + display_order: 24 + }, + { + page_key: 'admin.codes', + page_name: '코드 관리', + page_path: '/pages/admin/codes.html', + category: 'admin', + description: '시스템 코드 관리', + is_admin_only: 1, + display_order: 25 + }, + { + page_key: 'admin.tasks', + page_name: '작업 관리', + page_path: '/pages/admin/tasks.html', + category: 'admin', + description: '작업 유형 관리', + is_admin_only: 1, + display_order: 26 + }, + + // 프로필 페이지 + { + page_key: 'profile.info', + page_name: '내 정보', + page_path: '/pages/profile/info.html', + category: 'profile', + description: '내 프로필 정보', + is_admin_only: 0, + display_order: 30 + }, + { + page_key: 'profile.password', + page_name: '비밀번호 변경', + page_path: '/pages/profile/password.html', + category: 'profile', + description: '비밀번호 변경', + is_admin_only: 0, + display_order: 31 + } + ]); + + console.log('✅ 현재 사용 중인 페이지 목록 업데이트 완료'); +}; + +exports.down = async function(knex) { + await knex('pages').del(); + console.log('✅ 페이지 목록 삭제 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260129000000_create_vacation_requests.js b/api.hyungi.net/db/migrations/20260129000000_create_vacation_requests.js new file mode 100644 index 0000000..3cdc18d --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129000000_create_vacation_requests.js @@ -0,0 +1,57 @@ +/** + * Migration: Create vacation_requests table + * Purpose: Track vacation request workflow (request, approval/rejection) + * Date: 2026-01-29 + */ + +exports.up = async function(knex) { + // Create vacation_requests table + await knex.schema.createTable('vacation_requests', (table) => { + table.increments('request_id').primary().comment('휴가 신청 ID'); + + // 작업자 정보 + table.integer('worker_id').notNullable().comment('작업자 ID'); + table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE'); + + // 휴가 정보 + table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID'); + table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT'); + + table.date('start_date').notNullable().comment('휴가 시작일'); + table.date('end_date').notNullable().comment('휴가 종료일'); + table.decimal('days_used', 4, 1).notNullable().comment('사용 일수 (0.5일 단위)'); + + table.text('reason').nullable().comment('휴가 사유'); + + // 신청 및 승인 정보 + table.enum('status', ['pending', 'approved', 'rejected']) + .notNullable() + .defaultTo('pending') + .comment('승인 상태: pending(대기), approved(승인), rejected(거부)'); + + table.integer('requested_by').notNullable().comment('신청자 user_id'); + table.foreign('requested_by').references('user_id').inTable('users').onDelete('RESTRICT'); + + table.integer('reviewed_by').nullable().comment('승인/거부자 user_id'); + table.foreign('reviewed_by').references('user_id').inTable('users').onDelete('SET NULL'); + + table.timestamp('reviewed_at').nullable().comment('승인/거부 일시'); + table.text('review_note').nullable().comment('승인/거부 메모'); + + // 타임스탬프 + table.timestamp('created_at').defaultTo(knex.fn.now()).comment('신청 일시'); + table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정 일시'); + + // 인덱스 + table.index('worker_id', 'idx_vacation_requests_worker'); + table.index('status', 'idx_vacation_requests_status'); + table.index(['start_date', 'end_date'], 'idx_vacation_requests_dates'); + }); + + console.log('✅ vacation_requests 테이블 생성 완료'); +}; + +exports.down = async function(knex) { + await knex.schema.dropTableIfExists('vacation_requests'); + console.log('✅ vacation_requests 테이블 삭제 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260129000001_register_attendance_pages.js b/api.hyungi.net/db/migrations/20260129000001_register_attendance_pages.js new file mode 100644 index 0000000..e9ee5c9 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129000001_register_attendance_pages.js @@ -0,0 +1,84 @@ +/** + * Migration: Register attendance management pages + * Purpose: Add 4 new pages to pages table for attendance management system + * Date: 2026-01-29 + */ + +exports.up = async function(knex) { + // 페이지 등록 (실제 pages 테이블 컬럼에 맞춤) + await knex('pages').insert([ + { + page_key: 'daily-attendance', + page_name: '일일 출퇴근 입력', + page_path: '/pages/common/daily-attendance.html', + description: '일일 출퇴근 기록 입력 페이지 (관리자/조장)', + category: 'common', + is_admin_only: false, + display_order: 50 + }, + { + page_key: 'monthly-attendance', + page_name: '월별 출퇴근 현황', + page_path: '/pages/common/monthly-attendance.html', + description: '월별 출퇴근 현황 조회 페이지', + category: 'common', + is_admin_only: false, + display_order: 51 + }, + { + page_key: 'vacation-management', + page_name: '휴가 관리', + page_path: '/pages/common/vacation-management.html', + description: '휴가 신청 및 승인 관리 페이지', + category: 'common', + is_admin_only: false, + display_order: 52 + }, + { + page_key: 'attendance-report-comparison', + page_name: '출퇴근-작업보고서 대조', + page_path: '/pages/admin/attendance-report-comparison.html', + description: '출퇴근 기록과 작업보고서 대조 페이지 (관리자)', + category: 'admin', + is_admin_only: true, + display_order: 120 + } + ]); + + console.log('✅ 출퇴근 관리 페이지 4개 등록 완료'); + + // Admin 사용자(user_id=1)에게 페이지 접근 권한 부여 + const adminUserId = 1; + const pages = await knex('pages') + .whereIn('page_key', [ + 'daily-attendance', + 'monthly-attendance', + 'vacation-management', + 'attendance-report-comparison' + ]) + .select('id'); + + const accessRecords = pages.map(page => ({ + user_id: adminUserId, + page_id: page.id, + can_access: true, + granted_by: adminUserId + })); + + await knex('user_page_access').insert(accessRecords); + console.log('✅ Admin 사용자에게 출퇴근 관리 페이지 접근 권한 부여 완료'); +}; + +exports.down = async function(knex) { + // 페이지 삭제 (user_page_access는 FK CASCADE로 자동 삭제됨) + await knex('pages') + .whereIn('page_key', [ + 'daily-attendance', + 'monthly-attendance', + 'vacation-management', + 'attendance-report-comparison' + ]) + .delete(); + + console.log('✅ 출퇴근 관리 페이지 삭제 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260129000002_add_attendance_is_present.js b/api.hyungi.net/db/migrations/20260129000002_add_attendance_is_present.js new file mode 100644 index 0000000..0ebafd3 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129000002_add_attendance_is_present.js @@ -0,0 +1,35 @@ +/** + * 출퇴근 출근 여부 필드 추가 + * 아침 출근 확인용 간단한 필드 + */ + +exports.up = async function(knex) { + // 컬럼 존재 여부 확인 + const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present'); + + if (!hasColumn) { + await knex.schema.table('daily_attendance_records', (table) => { + // 출근 여부 (아침에 체크) + table.boolean('is_present').defaultTo(true).comment('출근 여부'); + }); + + // 기존 데이터는 모두 출근으로 처리 + await knex('daily_attendance_records') + .whereNotNull('id') + .update({ is_present: true }); + + console.log('✅ is_present 컬럼 추가 완료'); + } else { + console.log('⏭️ is_present 컬럼이 이미 존재합니다'); + } +}; + +exports.down = async function(knex) { + const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present'); + + if (hasColumn) { + await knex.schema.table('daily_attendance_records', (table) => { + table.dropColumn('is_present'); + }); + } +}; diff --git a/api.hyungi.net/db/migrations/20260129000003_update_vacation_pages.js b/api.hyungi.net/db/migrations/20260129000003_update_vacation_pages.js new file mode 100644 index 0000000..54b57c9 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129000003_update_vacation_pages.js @@ -0,0 +1,57 @@ +/** + * 휴가 관리 페이지 분리 및 등록 + * - 기존 vacation-management.html을 2개 페이지로 분리 + * - vacation-request.html: 작업자 휴가 신청 및 본인 내역 확인 + * - vacation-management.html: 관리자 휴가 승인/직접입력/전체내역 (3개 탭) + */ + +exports.up = async function(knex) { + // 기존 vacation-management 페이지 삭제 + await knex('pages') + .where('page_key', 'vacation-management') + .del(); + + // 새로운 휴가 관리 페이지 2개 등록 + await knex('pages').insert([ + { + page_key: 'vacation-request', + page_name: '휴가 신청', + page_path: '/pages/common/vacation-request.html', + category: 'common', + description: '작업자가 휴가를 신청하고 본인의 신청 내역을 확인하는 페이지', + is_admin_only: 0, + display_order: 51 + }, + { + page_key: 'vacation-management', + page_name: '휴가 관리', + page_path: '/pages/common/vacation-management.html', + category: 'common', + description: '관리자가 휴가 승인, 직접 입력, 전체 내역을 관리하는 페이지', + is_admin_only: 1, + display_order: 52 + } + ]); + + console.log('✅ 휴가 관리 페이지 분리 완료 (기존 1개 → 신규 2개)'); +}; + +exports.down = async function(knex) { + // 새로운 페이지 삭제 + await knex('pages') + .whereIn('page_key', ['vacation-request', 'vacation-management']) + .del(); + + // 기존 vacation-management 페이지 복원 + await knex('pages').insert({ + page_key: 'vacation-management', + page_name: '휴가 관리', + page_path: '/pages/common/vacation-management.html.old', + category: 'common', + description: '휴가 신청 및 승인 관리', + is_admin_only: 0, + display_order: 50 + }); + + console.log('✅ 휴가 관리 페이지 롤백 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260129000004_extend_vacation_types.js b/api.hyungi.net/db/migrations/20260129000004_extend_vacation_types.js new file mode 100644 index 0000000..0672e80 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129000004_extend_vacation_types.js @@ -0,0 +1,55 @@ +/** + * vacation_types 테이블 확장 + * - 특별 휴가 유형 추가 기능 + * - 차감 우선순위 관리 + * - 시스템 기본 휴가 보호 + */ + +exports.up = async function(knex) { + // vacation_types 테이블 확장 + await knex.schema.table('vacation_types', (table) => { + table.boolean('is_special').defaultTo(false).comment('특별 휴가 여부 (장기근속, 출산 등)'); + table.integer('priority').defaultTo(99).comment('차감 우선순위 (낮을수록 먼저 차감)'); + table.text('description').nullable().comment('휴가 설명'); + table.boolean('is_system').defaultTo(true).comment('시스템 기본 휴가 (삭제 불가)'); + }); + + // 기존 휴가 유형에 우선순위 설정 + await knex('vacation_types').where('type_code', 'ANNUAL').update({ + priority: 10, + is_system: true, + description: '근로기준법에 따른 연차 유급휴가' + }); + + await knex('vacation_types').where('type_code', 'HALF_ANNUAL').update({ + priority: 10, + is_system: true, + description: '반일 연차 (0.5일)' + }); + + await knex('vacation_types').where('type_code', 'SICK').update({ + priority: 20, + is_system: true, + description: '병가' + }); + + await knex('vacation_types').where('type_code', 'SPECIAL').update({ + priority: 0, + is_system: true, + description: '경조사 휴가 (무급)' + }); + + console.log('✅ vacation_types 테이블 확장 완료'); +}; + +exports.down = async function(knex) { + // 컬럼 삭제 + await knex.schema.table('vacation_types', (table) => { + table.dropColumn('is_special'); + table.dropColumn('priority'); + table.dropColumn('description'); + table.dropColumn('is_system'); + }); + + console.log('✅ vacation_types 테이블 롤백 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260129000005_create_vacation_balance_details.js b/api.hyungi.net/db/migrations/20260129000005_create_vacation_balance_details.js new file mode 100644 index 0000000..75b8f7b --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129000005_create_vacation_balance_details.js @@ -0,0 +1,87 @@ +/** + * vacation_balance_details 테이블 생성 및 데이터 마이그레이션 + * - 작업자별, 휴가 유형별, 연도별 휴가 잔액 관리 + * - 기존 worker_vacation_balance 데이터 이관 + */ + +exports.up = async function(knex) { + // vacation_balance_details 테이블 생성 + await knex.schema.createTable('vacation_balance_details', (table) => { + table.increments('id').primary(); + table.integer('worker_id').notNullable().comment('작업자 ID'); + table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID'); + table.integer('year').notNullable().comment('연도'); + table.decimal('total_days', 4, 1).defaultTo(0).comment('총 발생 일수'); + table.decimal('used_days', 4, 1).defaultTo(0).comment('사용 일수'); + table.text('notes').nullable().comment('비고'); + table.integer('created_by').notNullable().comment('생성자 ID'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // 인덱스 + table.unique(['worker_id', 'vacation_type_id', 'year'], 'unique_worker_vacation_year'); + table.index(['worker_id', 'year'], 'idx_worker_year'); + table.index('vacation_type_id', 'idx_vacation_type'); + + // 외래키 + table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE'); + table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT'); + table.foreign('created_by').references('user_id').inTable('users'); + }); + + // remaining_days를 generated column으로 추가 (Raw SQL) + await knex.raw(` + ALTER TABLE vacation_balance_details + ADD COLUMN remaining_days DECIMAL(4,1) + GENERATED ALWAYS AS (total_days - used_days) STORED + COMMENT '잔여 일수' + `); + + console.log('✅ vacation_balance_details 테이블 생성 완료'); + + // 기존 worker_vacation_balance 데이터를 vacation_balance_details로 마이그레이션 + const existingBalances = await knex('worker_vacation_balance').select('*'); + + if (existingBalances.length > 0) { + // ANNUAL 휴가 유형 ID 조회 + const annualType = await knex('vacation_types') + .where('type_code', 'ANNUAL') + .first(); + + if (!annualType) { + throw new Error('ANNUAL 휴가 유형을 찾을 수 없습니다'); + } + + // 관리자 사용자 ID 조회 (created_by 용) + // role_id 1 = System Admin, 2 = Admin + const adminUser = await knex('users') + .whereIn('role_id', [1, 2]) + .first(); + + const createdById = adminUser ? adminUser.user_id : 1; + + // 데이터 변환 및 삽입 + const balanceDetails = existingBalances.map(balance => ({ + worker_id: balance.worker_id, + vacation_type_id: annualType.id, + year: balance.year, + total_days: balance.total_annual_leave || 0, + used_days: balance.used_annual_leave || 0, + notes: 'Migrated from worker_vacation_balance', + created_by: createdById, + created_at: balance.created_at, + updated_at: balance.updated_at + })); + + await knex('vacation_balance_details').insert(balanceDetails); + + console.log(`✅ ${balanceDetails.length}건의 기존 휴가 데이터 마이그레이션 완료`); + } +}; + +exports.down = async function(knex) { + // vacation_balance_details 테이블 삭제 + await knex.schema.dropTableIfExists('vacation_balance_details'); + + console.log('✅ vacation_balance_details 테이블 롤백 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260129000006_register_vacation_pages.js b/api.hyungi.net/db/migrations/20260129000006_register_vacation_pages.js new file mode 100644 index 0000000..0773a7f --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129000006_register_vacation_pages.js @@ -0,0 +1,38 @@ +/** + * 새로운 휴가 관리 페이지 등록 + * - annual-vacation-overview: 연간 연차 현황 (차트) + * - vacation-allocation: 휴가 발생 입력 및 관리 + */ + +exports.up = async function(knex) { + await knex('pages').insert([ + { + page_key: 'annual-vacation-overview', + page_name: '연간 연차 현황', + page_path: '/pages/common/annual-vacation-overview.html', + category: 'common', + description: '모든 작업자의 연간 연차 현황을 차트로 시각화', + is_admin_only: 1, + display_order: 54 + }, + { + page_key: 'vacation-allocation', + page_name: '휴가 발생 입력', + page_path: '/pages/common/vacation-allocation.html', + category: 'common', + description: '작업자별 휴가 발생 입력 및 특별 휴가 관리', + is_admin_only: 1, + display_order: 55 + } + ]); + + console.log('✅ 휴가 관리 신규 페이지 2개 등록 완료'); +}; + +exports.down = async function(knex) { + await knex('pages') + .whereIn('page_key', ['annual-vacation-overview', 'vacation-allocation']) + .del(); + + console.log('✅ 휴가 관리 페이지 롤백 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260129010000_create_visit_request_system.js b/api.hyungi.net/db/migrations/20260129010000_create_visit_request_system.js new file mode 100644 index 0000000..ae65516 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129010000_create_visit_request_system.js @@ -0,0 +1,153 @@ +/** + * 마이그레이션: 출입 신청 및 안전교육 시스템 + * - 방문 목적 타입 테이블 + * - 출입 신청 테이블 + * - 안전교육 기록 테이블 + */ + +exports.up = async function(knex) { + // 1. 방문 목적 타입 테이블 생성 + await knex.schema.createTable('visit_purpose_types', function(table) { + table.increments('purpose_id').primary().comment('방문 목적 ID'); + table.string('purpose_name', 100).notNullable().comment('방문 목적명'); + table.integer('display_order').defaultTo(0).comment('표시 순서'); + table.boolean('is_active').defaultTo(true).comment('활성 여부'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + }); + + // 초기 데이터 삽입 + await knex('visit_purpose_types').insert([ + { purpose_name: '외주작업', display_order: 1, is_active: true }, + { purpose_name: '검사', display_order: 2, is_active: true }, + { purpose_name: '견학', display_order: 3, is_active: true }, + { purpose_name: '기타', display_order: 99, is_active: true } + ]); + + // 2. 출입 신청 테이블 생성 + await knex.schema.createTable('workplace_visit_requests', function(table) { + table.increments('request_id').primary().comment('신청 ID'); + + // 신청자 정보 + table.integer('requester_id').notNullable().comment('신청자 user_id'); + + // 방문자 정보 + table.string('visitor_company', 200).notNullable().comment('방문자 소속 (회사명 또는 "일용직")'); + table.integer('visitor_count').defaultTo(1).comment('방문 인원'); + + // 방문 장소 + table.integer('category_id').unsigned().notNullable().comment('방문 구역 (공장)'); + table.integer('workplace_id').unsigned().notNullable().comment('방문 작업장'); + + // 방문 일시 + table.date('visit_date').notNullable().comment('방문 날짜'); + table.time('visit_time').notNullable().comment('방문 시간'); + + // 방문 목적 + table.integer('purpose_id').unsigned().notNullable().comment('방문 목적 ID'); + table.text('notes').nullable().comment('비고'); + + // 상태 관리 + table.enum('status', ['pending', 'approved', 'rejected', 'training_completed']) + .defaultTo('pending') + .comment('신청 상태'); + + // 승인 정보 + table.integer('approved_by').nullable().comment('승인자 user_id'); + table.timestamp('approved_at').nullable().comment('승인 시간'); + table.text('rejection_reason').nullable().comment('반려 사유'); + + // 타임스탬프 + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // 외래키 + table.foreign('requester_id') + .references('user_id') + .inTable('users') + .onDelete('RESTRICT') + .onUpdate('CASCADE'); + + table.foreign('category_id') + .references('category_id') + .inTable('workplace_categories') + .onDelete('RESTRICT') + .onUpdate('CASCADE'); + + table.foreign('workplace_id') + .references('workplace_id') + .inTable('workplaces') + .onDelete('RESTRICT') + .onUpdate('CASCADE'); + + table.foreign('purpose_id') + .references('purpose_id') + .inTable('visit_purpose_types') + .onDelete('RESTRICT') + .onUpdate('CASCADE'); + + table.foreign('approved_by') + .references('user_id') + .inTable('users') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + // 인덱스 + table.index('visit_date', 'idx_visit_date'); + table.index('status', 'idx_status'); + table.index(['visit_date', 'status'], 'idx_visit_date_status'); + }); + + // 3. 안전교육 기록 테이블 생성 + await knex.schema.createTable('safety_training_records', function(table) { + table.increments('training_id').primary().comment('교육 기록 ID'); + + table.integer('request_id').unsigned().notNullable().comment('출입 신청 ID'); + + // 교육 진행 정보 + table.integer('trainer_id').notNullable().comment('교육 진행자 user_id'); + table.date('training_date').notNullable().comment('교육 날짜'); + table.time('training_start_time').notNullable().comment('교육 시작 시간'); + table.time('training_end_time').nullable().comment('교육 종료 시간'); + + // 교육 내용 + table.text('training_topics').nullable().comment('교육 내용 (JSON 배열)'); + + // 서명 데이터 (Base64 이미지) + table.text('signature_data', 'longtext').nullable().comment('교육 이수자 서명 (Base64 PNG)'); + + // 완료 정보 + table.timestamp('completed_at').nullable().comment('교육 완료 시간'); + + // 타임스탬프 + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // 외래키 + table.foreign('request_id') + .references('request_id') + .inTable('workplace_visit_requests') + .onDelete('CASCADE') + .onUpdate('CASCADE'); + + table.foreign('trainer_id') + .references('user_id') + .inTable('users') + .onDelete('RESTRICT') + .onUpdate('CASCADE'); + + // 인덱스 + table.index('training_date', 'idx_training_date'); + table.index('request_id', 'idx_request_id'); + }); + + console.log('✅ 출입 신청 및 안전교육 시스템 테이블 생성 완료'); +}; + +exports.down = async function(knex) { + // 역순으로 테이블 삭제 + await knex.schema.dropTableIfExists('safety_training_records'); + await knex.schema.dropTableIfExists('workplace_visit_requests'); + await knex.schema.dropTableIfExists('visit_purpose_types'); + + console.log('✅ 출입 신청 및 안전교육 시스템 테이블 삭제 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260129010001_register_visit_pages.js b/api.hyungi.net/db/migrations/20260129010001_register_visit_pages.js new file mode 100644 index 0000000..6721083 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260129010001_register_visit_pages.js @@ -0,0 +1,50 @@ +/** + * 마이그레이션: 출입 신청 및 안전관리 페이지 등록 + */ + +exports.up = async function(knex) { + // 1. 출입 신청 페이지 등록 + await knex('pages').insert({ + page_key: 'visit-request', + page_name: '출입 신청', + page_path: '/pages/work/visit-request.html', + category: 'work', + description: '작업장 출입 신청 및 안전교육 신청', + is_admin_only: 0, + display_order: 15 + }); + + // 2. 안전관리 대시보드 페이지 등록 + await knex('pages').insert({ + page_key: 'safety-management', + page_name: '안전관리', + page_path: '/pages/admin/safety-management.html', + category: 'admin', + description: '출입 신청 승인 및 안전교육 관리', + is_admin_only: 0, + display_order: 60 + }); + + // 3. 안전교육 진행 페이지 등록 + await knex('pages').insert({ + page_key: 'safety-training-conduct', + page_name: '안전교육 진행', + page_path: '/pages/admin/safety-training-conduct.html', + category: 'admin', + description: '안전교육 실시 및 서명 관리', + is_admin_only: 0, + display_order: 61 + }); + + console.log('✅ 출입 신청 및 안전관리 페이지 등록 완료'); +}; + +exports.down = async function(knex) { + await knex('pages').whereIn('page_key', [ + 'visit-request', + 'safety-management', + 'safety-training-conduct' + ]).delete(); + + console.log('✅ 출입 신청 및 안전관리 페이지 삭제 완료'); +}; diff --git a/api.hyungi.net/models/attendanceModel.js b/api.hyungi.net/models/attendanceModel.js index 5b16472..51295f0 100644 --- a/api.hyungi.net/models/attendanceModel.js +++ b/api.hyungi.net/models/attendanceModel.js @@ -297,7 +297,7 @@ class AttendanceModel { // 휴가 유형 정보 조회 const [vacationTypes] = await db.execute( - 'SELECT id, type_code, type_name, hours_deduction, description, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?', + 'SELECT id, type_code, type_name, deduct_days, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?', [vacationType] ); @@ -391,7 +391,7 @@ class AttendanceModel { static async getVacationTypes() { const db = await getDb(); const [rows] = await db.execute( - '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' + 'SELECT id, type_code, type_name, deduct_days, is_active, created_at, updated_at FROM vacation_types WHERE is_active = TRUE ORDER BY deduct_days DESC' ); return rows; } @@ -458,6 +458,68 @@ class AttendanceModel { const [rows] = await db.execute(query, params); return rows; } + + // 출근 체크 기록 생성 또는 업데이트 + static async upsertCheckin(checkinData) { + const db = await getDb(); + const { worker_id, record_date, is_present } = checkinData; + + // 해당 날짜에 기록이 있는지 확인 + const [existing] = await db.execute( + 'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?', + [worker_id, record_date] + ); + + if (existing.length > 0) { + // 업데이트 + await db.execute( + 'UPDATE daily_attendance_records SET is_present = ? WHERE id = ?', + [is_present, existing[0].id] + ); + return existing[0].id; + } else { + // 새로 생성 (기본값으로) + const [result] = await db.execute( + `INSERT INTO daily_attendance_records + (worker_id, record_date, is_present, attendance_type_id, created_by) + VALUES (?, ?, ?, 1, 1)`, + [worker_id, record_date, is_present] + ); + return result.insertId; + } + } + + // 특정 날짜의 출근 체크 목록 조회 (휴가 정보 포함) + static async getCheckinList(date) { + const db = await getDb(); + const query = ` + SELECT + w.worker_id, + w.worker_name, + w.job_type, + w.employment_status, + COALESCE(dar.is_present, TRUE) as is_present, + dar.id as record_id, + vr.request_id as vacation_request_id, + vr.status as vacation_status, + vt.type_name as vacation_type_name, + vt.type_code as vacation_type_code, + vr.days_used as vacation_days + FROM workers w + LEFT JOIN daily_attendance_records dar + ON w.worker_id = dar.worker_id AND dar.record_date = ? + LEFT JOIN vacation_requests vr + ON w.worker_id = vr.worker_id + AND ? BETWEEN vr.start_date AND vr.end_date + AND vr.status = 'approved' + LEFT JOIN vacation_types vt ON vr.vacation_type_id = vt.id + WHERE w.employment_status = 'employed' + ORDER BY w.worker_name + `; + + const [rows] = await db.execute(query, [date, date]); + return rows; + } } module.exports = AttendanceModel; diff --git a/api.hyungi.net/models/tbmModel.js b/api.hyungi.net/models/tbmModel.js index 81eb201..df5ec0c 100644 --- a/api.hyungi.net/models/tbmModel.js +++ b/api.hyungi.net/models/tbmModel.js @@ -1,5 +1,5 @@ // models/tbmModel.js - TBM 시스템 모델 -const db = require('../db/connection'); +const { getDb } = require('../dbPool'); const TbmModel = { // ==================== TBM 세션 관련 ==================== @@ -7,221 +7,327 @@ const TbmModel = { /** * TBM 세션 생성 */ - createSession: (sessionData, callback) => { - const sql = ` - INSERT INTO tbm_sessions - (session_date, leader_id, project_id, work_type_id, task_id, work_location, - work_description, safety_notes, start_time, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `; + createSession: async (sessionData, callback) => { + try { + const db = await getDb(); + const sql = ` + INSERT INTO tbm_sessions + (session_date, leader_id, project_id, work_type_id, task_id, work_location, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + `; - const values = [ - sessionData.session_date, - sessionData.leader_id, - sessionData.project_id, - sessionData.work_type_id, - sessionData.task_id, - sessionData.work_location, - sessionData.work_description, - sessionData.safety_notes, - sessionData.start_time, - sessionData.created_by - ]; + const values = [ + sessionData.session_date, + sessionData.leader_id, + sessionData.project_id || null, + sessionData.work_type_id || null, + sessionData.task_id || null, + sessionData.work_location || null, + sessionData.created_by + ]; - db.query(sql, values, callback); + const [result] = await db.query(sql, values); + callback(null, result); + } catch (err) { + callback(err); + } }, /** * 특정 날짜의 TBM 세션 조회 */ - getSessionsByDate: (date, callback) => { - const sql = ` - SELECT - s.*, - w.worker_name as leader_name, - w.job_type as leader_job_type, - p.project_name, - p.job_no, - wt.name as work_type_name, - wt.category as work_type_category, - t.task_name, - u.username as created_by_username, - COUNT(DISTINCT ta.worker_id) as team_member_count - FROM tbm_sessions s - LEFT JOIN workers w ON s.leader_id = w.worker_id - LEFT JOIN projects p ON s.project_id = p.project_id - LEFT JOIN work_types wt ON s.work_type_id = wt.id - LEFT JOIN tasks t ON s.task_id = t.task_id - LEFT JOIN users u ON s.created_by = u.user_id - LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id - WHERE s.session_date = ? - GROUP BY s.session_id - ORDER BY s.start_time DESC - `; + getSessionsByDate: async (date, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + s.*, + w.worker_name as leader_name, + w.job_type as leader_job_type, + u.username as created_by_username, + u.name as created_by_name, + COUNT(DISTINCT ta.worker_id) as team_member_count, + -- 첫 번째 팀원의 작업 정보 가져오기 + first_ta.project_id, + first_ta.work_type_id, + first_ta.task_id, + first_ta.workplace_id, + first_p.project_name, + first_wt.name as work_type_name, + first_t.task_name, + first_wp.workplace_name as work_location + FROM tbm_sessions s + LEFT JOIN workers w ON s.leader_id = w.worker_id + LEFT JOIN users u ON s.created_by = u.user_id + LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id + -- 첫 번째 팀원 정보 (가장 먼저 등록된 작업) + LEFT JOIN ( + SELECT * FROM tbm_team_assignments + WHERE (session_id, assignment_id) IN ( + SELECT session_id, MIN(assignment_id) + FROM tbm_team_assignments + GROUP BY session_id + ) + ) first_ta ON s.session_id = first_ta.session_id + LEFT JOIN projects first_p ON first_ta.project_id = first_p.project_id + LEFT JOIN work_types first_wt ON first_ta.work_type_id = first_wt.id + LEFT JOIN tasks first_t ON first_ta.task_id = first_t.task_id + LEFT JOIN workplaces first_wp ON first_ta.workplace_id = first_wp.workplace_id + WHERE s.session_date = ? + GROUP BY s.session_id + ORDER BY s.session_id DESC + `; - db.query(sql, [date], callback); + const [rows] = await db.query(sql, [date]); + callback(null, rows); + } catch (err) { + callback(err); + } }, /** * TBM 세션 상세 조회 */ - getSessionById: (sessionId, callback) => { - const sql = ` - SELECT - s.*, - w.worker_name as leader_name, - w.job_type as leader_job_type, - w.phone_number as leader_phone, - p.project_name, - p.job_no, - p.site, - wt.name as work_type_name, - wt.category as work_type_category, - t.task_name, - t.description as task_description, - u.username as created_by_username, - u.name as created_by_name - FROM tbm_sessions s - LEFT JOIN workers w ON s.leader_id = w.worker_id - LEFT JOIN projects p ON s.project_id = p.project_id - LEFT JOIN work_types wt ON s.work_type_id = wt.id - LEFT JOIN tasks t ON s.task_id = t.task_id - LEFT JOIN users u ON s.created_by = u.user_id - WHERE s.session_id = ? - `; + getSessionById: async (sessionId, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + s.*, + w.worker_name as leader_name, + w.job_type as leader_job_type, + w.phone_number as leader_phone, + p.project_name, + p.job_no, + p.site, + wt.name as work_type_name, + wt.category as work_type_category, + t.task_name, + t.description as task_description, + u.username as created_by_username, + u.name as created_by_name + FROM tbm_sessions s + LEFT JOIN workers w ON s.leader_id = w.worker_id + LEFT JOIN projects p ON s.project_id = p.project_id + LEFT JOIN work_types wt ON s.work_type_id = wt.id + LEFT JOIN tasks t ON s.task_id = t.task_id + LEFT JOIN users u ON s.created_by = u.user_id + WHERE s.session_id = ? + `; - db.query(sql, [sessionId], callback); + const [rows] = await db.query(sql, [sessionId]); + callback(null, rows); + } catch (err) { + callback(err); + } }, /** * TBM 세션 수정 */ - updateSession: (sessionId, sessionData, callback) => { - const sql = ` - UPDATE tbm_sessions - SET - project_id = ?, - work_location = ?, - work_description = ?, - safety_notes = ?, - status = ?, - updated_at = NOW() - WHERE session_id = ? - `; + updateSession: async (sessionId, sessionData, callback) => { + try { + const db = await getDb(); + const sql = ` + UPDATE tbm_sessions + SET + project_id = ?, + work_location = ?, + status = ?, + updated_at = NOW() + WHERE session_id = ? + `; - const values = [ - sessionData.project_id, - sessionData.work_location, - sessionData.work_description, - sessionData.safety_notes, - sessionData.status, - sessionId - ]; + const values = [ + sessionData.project_id, + sessionData.work_location, + sessionData.status, + sessionId + ]; - db.query(sql, values, callback); + const [result] = await db.query(sql, values); + callback(null, result); + } catch (err) { + callback(err); + } }, /** * TBM 세션 완료 처리 */ - completeSession: (sessionId, endTime, callback) => { - const sql = ` - UPDATE tbm_sessions - SET - status = 'completed', - end_time = ?, - updated_at = NOW() - WHERE session_id = ? - `; + completeSession: async (sessionId, endTime, callback) => { + try { + const db = await getDb(); + const sql = ` + UPDATE tbm_sessions + SET + status = 'completed', + end_time = ?, + updated_at = NOW() + WHERE session_id = ? + `; - db.query(sql, [endTime, sessionId], callback); + const [result] = await db.query(sql, [endTime, sessionId]); + callback(null, result); + } catch (err) { + callback(err); + } }, // ==================== 팀 구성 관련 ==================== /** - * 팀원 추가 + * 팀원 추가 (작업자별 상세 정보 포함) */ - addTeamMember: (assignmentData, callback) => { - const sql = ` - INSERT INTO tbm_team_assignments - (session_id, worker_id, assigned_role, work_detail, is_present, absence_reason) - VALUES (?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - assigned_role = VALUES(assigned_role), - work_detail = VALUES(work_detail), - is_present = VALUES(is_present), - absence_reason = VALUES(absence_reason) - `; + addTeamMember: async (assignmentData, callback) => { + try { + const db = await getDb(); + const sql = ` + INSERT INTO tbm_team_assignments + (session_id, worker_id, assigned_role, work_detail, is_present, absence_reason, + project_id, work_type_id, task_id, workplace_category_id, workplace_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + assigned_role = VALUES(assigned_role), + work_detail = VALUES(work_detail), + is_present = VALUES(is_present), + absence_reason = VALUES(absence_reason), + project_id = VALUES(project_id), + work_type_id = VALUES(work_type_id), + task_id = VALUES(task_id), + workplace_category_id = VALUES(workplace_category_id), + workplace_id = VALUES(workplace_id) + `; - const values = [ - assignmentData.session_id, - assignmentData.worker_id, - assignmentData.assigned_role, - assignmentData.work_detail, - assignmentData.is_present !== undefined ? assignmentData.is_present : true, - assignmentData.absence_reason - ]; + const values = [ + assignmentData.session_id, + assignmentData.worker_id, + assignmentData.assigned_role, + assignmentData.work_detail, + assignmentData.is_present !== undefined ? assignmentData.is_present : true, + assignmentData.absence_reason, + assignmentData.project_id || null, + assignmentData.work_type_id || null, + assignmentData.task_id || null, + assignmentData.workplace_category_id || null, + assignmentData.workplace_id || null + ]; - db.query(sql, values, callback); - }, - - /** - * 팀 구성 일괄 추가 - */ - addTeamMembers: (sessionId, members, callback) => { - if (!members || members.length === 0) { - return callback(null, { affectedRows: 0 }); + const [result] = await db.query(sql, values); + callback(null, result); + } catch (err) { + callback(err); } - - const values = members.map(m => [ - sessionId, - m.worker_id, - m.assigned_role || null, - m.work_detail || null, - m.is_present !== undefined ? m.is_present : true, - m.absence_reason || null - ]); - - const sql = ` - INSERT INTO tbm_team_assignments - (session_id, worker_id, assigned_role, work_detail, is_present, absence_reason) - VALUES ? - `; - - db.query(sql, [values], callback); }, /** - * TBM 세션의 팀 구성 조회 + * 팀 구성 일괄 추가 (작업자별 상세 정보 포함) */ - getTeamMembers: (sessionId, callback) => { - const sql = ` - SELECT - ta.*, - w.worker_name, - w.job_type, - w.phone_number, - w.department - FROM tbm_team_assignments ta - INNER JOIN workers w ON ta.worker_id = w.worker_id - WHERE ta.session_id = ? - ORDER BY ta.assigned_at DESC - `; + addTeamMembers: async (sessionId, members, callback) => { + try { + if (!members || members.length === 0) { + return callback(null, { affectedRows: 0 }); + } - db.query(sql, [sessionId], callback); + const db = await getDb(); + const values = members.map(m => [ + sessionId, + m.worker_id, + m.assigned_role || null, + m.work_detail || null, + m.is_present !== undefined ? m.is_present : true, + m.absence_reason || null, + m.project_id || null, + m.work_type_id || null, + m.task_id || null, + m.workplace_category_id || null, + m.workplace_id || null + ]); + + const sql = ` + INSERT INTO tbm_team_assignments + (session_id, worker_id, assigned_role, work_detail, is_present, absence_reason, + project_id, work_type_id, task_id, workplace_category_id, workplace_id) + VALUES ? + `; + + const [result] = await db.query(sql, [values]); + callback(null, result); + } catch (err) { + callback(err); + } + }, + + /** + * TBM 세션의 팀 구성 조회 (작업자별 상세 정보 포함) + */ + getTeamMembers: async (sessionId, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + ta.*, + w.worker_name, + w.job_type, + w.phone_number, + w.department, + p.project_name, + wt.name as work_type_name, + t.task_name, + wc.category_name AS workplace_category_name, + wp.workplace_name + FROM tbm_team_assignments ta + INNER JOIN workers w ON ta.worker_id = w.worker_id + LEFT JOIN projects p ON ta.project_id = p.project_id + LEFT JOIN work_types wt ON ta.work_type_id = wt.id + LEFT JOIN tasks t ON ta.task_id = t.task_id + LEFT JOIN workplace_categories wc ON ta.workplace_category_id = wc.category_id + LEFT JOIN workplaces wp ON ta.workplace_id = wp.workplace_id + WHERE ta.session_id = ? + ORDER BY ta.assigned_at DESC + `; + + const [rows] = await db.query(sql, [sessionId]); + callback(null, rows); + } catch (err) { + callback(err); + } }, /** * 팀원 제거 */ - removeTeamMember: (sessionId, workerId, callback) => { - const sql = ` - DELETE FROM tbm_team_assignments - WHERE session_id = ? AND worker_id = ? - `; + removeTeamMember: async (sessionId, workerId, callback) => { + try { + const db = await getDb(); + const sql = ` + DELETE FROM tbm_team_assignments + WHERE session_id = ? AND worker_id = ? + `; - db.query(sql, [sessionId, workerId], callback); + const [result] = await db.query(sql, [sessionId, workerId]); + callback(null, result); + } catch (err) { + callback(err); + } + }, + + /** + * 세션의 모든 팀원 삭제 + */ + clearAllTeamMembers: async (sessionId, callback) => { + try { + const db = await getDb(); + const sql = ` + DELETE FROM tbm_team_assignments + WHERE session_id = ? + `; + + const [result] = await db.query(sql, [sessionId]); + callback(null, result); + } catch (err) { + callback(err); + } }, // ==================== 안전 체크리스트 관련 ==================== @@ -229,108 +335,138 @@ const TbmModel = { /** * 모든 안전 체크 항목 조회 */ - getAllSafetyChecks: (callback) => { - const sql = ` - SELECT * - FROM tbm_safety_checks - WHERE is_active = 1 - ORDER BY check_category, display_order - `; + getAllSafetyChecks: async (callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT * + FROM tbm_safety_checks + WHERE is_active = 1 + ORDER BY check_category, display_order + `; - db.query(sql, callback); + const [rows] = await db.query(sql); + callback(null, rows); + } catch (err) { + callback(err); + } }, /** * 카테고리별 안전 체크 항목 조회 */ - getSafetyChecksByCategory: (category, callback) => { - const sql = ` - SELECT * - FROM tbm_safety_checks - WHERE check_category = ? AND is_active = 1 - ORDER BY display_order - `; + getSafetyChecksByCategory: async (category, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT * + FROM tbm_safety_checks + WHERE check_category = ? AND is_active = 1 + ORDER BY display_order + `; - db.query(sql, [category], callback); + const [rows] = await db.query(sql, [category]); + callback(null, rows); + } catch (err) { + callback(err); + } }, /** * TBM 세션의 안전 체크 기록 조회 */ - getSafetyRecords: (sessionId, callback) => { - const sql = ` - SELECT - sr.*, - sc.check_category, - sc.check_item, - sc.description, - sc.is_required, - u.username as checked_by_username, - u.name as checked_by_name - FROM tbm_safety_records sr - INNER JOIN tbm_safety_checks sc ON sr.check_id = sc.check_id - LEFT JOIN users u ON sr.checked_by = u.user_id - WHERE sr.session_id = ? - ORDER BY sc.check_category, sc.display_order - `; + getSafetyRecords: async (sessionId, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + sr.*, + sc.check_category, + sc.check_item, + sc.description, + sc.is_required, + u.username as checked_by_username, + u.name as checked_by_name + FROM tbm_safety_records sr + INNER JOIN tbm_safety_checks sc ON sr.check_id = sc.check_id + LEFT JOIN users u ON sr.checked_by = u.user_id + WHERE sr.session_id = ? + ORDER BY sc.check_category, sc.display_order + `; - db.query(sql, [sessionId], callback); + const [rows] = await db.query(sql, [sessionId]); + callback(null, rows); + } catch (err) { + callback(err); + } }, /** * 안전 체크 기록 저장/업데이트 */ - saveSafetyRecord: (recordData, callback) => { - const sql = ` - INSERT INTO tbm_safety_records - (session_id, check_id, is_checked, notes, checked_by, checked_at) - VALUES (?, ?, ?, ?, ?, NOW()) - ON DUPLICATE KEY UPDATE - is_checked = VALUES(is_checked), - notes = VALUES(notes), - checked_by = VALUES(checked_by), - checked_at = NOW() - `; + saveSafetyRecord: async (recordData, callback) => { + try { + const db = await getDb(); + const sql = ` + INSERT INTO tbm_safety_records + (session_id, check_id, is_checked, notes, checked_by, checked_at) + VALUES (?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + is_checked = VALUES(is_checked), + notes = VALUES(notes), + checked_by = VALUES(checked_by), + checked_at = NOW() + `; - const values = [ - recordData.session_id, - recordData.check_id, - recordData.is_checked, - recordData.notes, - recordData.checked_by - ]; + const values = [ + recordData.session_id, + recordData.check_id, + recordData.is_checked, + recordData.notes, + recordData.checked_by + ]; - db.query(sql, values, callback); + const [result] = await db.query(sql, values); + callback(null, result); + } catch (err) { + callback(err); + } }, /** * 안전 체크 일괄 저장 */ - saveSafetyRecords: (sessionId, records, checkedBy, callback) => { - if (!records || records.length === 0) { - return callback(null, { affectedRows: 0 }); + saveSafetyRecords: async (sessionId, records, checkedBy, callback) => { + try { + if (!records || records.length === 0) { + return callback(null, { affectedRows: 0 }); + } + + const db = await getDb(); + const values = records.map(r => [ + sessionId, + r.check_id, + r.is_checked, + r.notes || null, + checkedBy + ]); + + const sql = ` + INSERT INTO tbm_safety_records + (session_id, check_id, is_checked, notes, checked_by, checked_at) + VALUES ? + ON DUPLICATE KEY UPDATE + is_checked = VALUES(is_checked), + notes = VALUES(notes), + checked_by = VALUES(checked_by), + checked_at = NOW() + `; + + const [result] = await db.query(sql, [values]); + callback(null, result); + } catch (err) { + callback(err); } - - const values = records.map(r => [ - sessionId, - r.check_id, - r.is_checked, - r.notes || null, - checkedBy - ]); - - const sql = ` - INSERT INTO tbm_safety_records - (session_id, check_id, is_checked, notes, checked_by, checked_at) - VALUES ? - ON DUPLICATE KEY UPDATE - is_checked = VALUES(is_checked), - notes = VALUES(notes), - checked_by = VALUES(checked_by), - checked_at = NOW() - `; - - db.query(sql, [values], callback); }, // ==================== 작업 인계 관련 ==================== @@ -338,85 +474,108 @@ const TbmModel = { /** * 작업 인계 생성 */ - createHandover: (handoverData, callback) => { - const sql = ` - INSERT INTO team_handovers - (session_id, from_leader_id, to_leader_id, handover_date, handover_time, - reason, handover_notes, worker_ids) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `; + createHandover: async (handoverData, callback) => { + try { + const db = await getDb(); + const sql = ` + INSERT INTO team_handovers + (session_id, from_leader_id, to_leader_id, handover_date, handover_time, + reason, handover_notes, worker_ids) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; - const values = [ - handoverData.session_id, - handoverData.from_leader_id, - handoverData.to_leader_id, - handoverData.handover_date, - handoverData.handover_time, - handoverData.reason, - handoverData.handover_notes, - JSON.stringify(handoverData.worker_ids || []) - ]; + const values = [ + handoverData.session_id, + handoverData.from_leader_id, + handoverData.to_leader_id, + handoverData.handover_date, + handoverData.handover_time, + handoverData.reason, + handoverData.handover_notes, + JSON.stringify(handoverData.worker_ids || []) + ]; - db.query(sql, values, callback); + const [result] = await db.query(sql, values); + callback(null, result); + } catch (err) { + callback(err); + } }, /** * 작업 인계 확인 */ - confirmHandover: (handoverId, confirmedBy, callback) => { - const sql = ` - UPDATE team_handovers - SET - is_confirmed = 1, - confirmed_at = NOW(), - confirmed_by = ? - WHERE handover_id = ? - `; + confirmHandover: async (handoverId, confirmedBy, callback) => { + try { + const db = await getDb(); + const sql = ` + UPDATE team_handovers + SET + is_confirmed = 1, + confirmed_at = NOW(), + confirmed_by = ? + WHERE handover_id = ? + `; - db.query(sql, [confirmedBy, handoverId], callback); + const [result] = await db.query(sql, [confirmedBy, handoverId]); + callback(null, result); + } catch (err) { + callback(err); + } }, /** * 특정 날짜의 작업 인계 목록 조회 */ - getHandoversByDate: (date, callback) => { - const sql = ` - SELECT - h.*, - w1.worker_name as from_leader_name, - w2.worker_name as to_leader_name, - u.username as confirmed_by_username, - u.name as confirmed_by_name - FROM team_handovers h - INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id - INNER JOIN workers w2 ON h.to_leader_id = w2.worker_id - LEFT JOIN users u ON h.confirmed_by = u.user_id - WHERE h.handover_date = ? - ORDER BY h.handover_time DESC - `; + getHandoversByDate: async (date, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + h.*, + w1.worker_name as from_leader_name, + w2.worker_name as to_leader_name, + u.username as confirmed_by_username, + u.name as confirmed_by_name + FROM team_handovers h + INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id + INNER JOIN workers w2 ON h.to_leader_id = w2.worker_id + LEFT JOIN users u ON h.confirmed_by = u.user_id + WHERE h.handover_date = ? + ORDER BY h.handover_time DESC + `; - db.query(sql, [date], callback); + const [rows] = await db.query(sql, [date]); + callback(null, rows); + } catch (err) { + callback(err); + } }, /** * 인수자가 받은 미확인 인계 건 조회 */ - getPendingHandovers: (toLeaderId, callback) => { - const sql = ` - SELECT - h.*, - w1.worker_name as from_leader_name, - w1.phone_number as from_leader_phone, - s.work_location, - s.work_description - FROM team_handovers h - INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id - LEFT JOIN tbm_sessions s ON h.session_id = s.session_id - WHERE h.to_leader_id = ? AND h.is_confirmed = 0 - ORDER BY h.handover_date DESC, h.handover_time DESC - `; + getPendingHandovers: async (toLeaderId, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + h.*, + w1.worker_name as from_leader_name, + w1.phone_number as from_leader_phone, + s.work_location + FROM team_handovers h + INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id + LEFT JOIN tbm_sessions s ON h.session_id = s.session_id + WHERE h.to_leader_id = ? AND h.is_confirmed = 0 + ORDER BY h.handover_date DESC, h.handover_time DESC + `; - db.query(sql, [toLeaderId], callback); + const [rows] = await db.query(sql, [toLeaderId]); + callback(null, rows); + } catch (err) { + callback(err); + } }, // ==================== 통계 및 리포트 ==================== @@ -424,42 +583,121 @@ const TbmModel = { /** * 특정 기간의 TBM 통계 */ - getTbmStatistics: (startDate, endDate, callback) => { - const sql = ` - SELECT - DATE(session_date) as date, - COUNT(DISTINCT session_id) as session_count, - COUNT(DISTINCT leader_id) as leader_count, - SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count - FROM tbm_sessions - WHERE session_date BETWEEN ? AND ? - GROUP BY DATE(session_date) - ORDER BY date DESC - `; + getTbmStatistics: async (startDate, endDate, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + DATE(session_date) as date, + COUNT(DISTINCT session_id) as session_count, + COUNT(DISTINCT leader_id) as leader_count, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count + FROM tbm_sessions + WHERE session_date BETWEEN ? AND ? + GROUP BY DATE(session_date) + ORDER BY date DESC + `; - db.query(sql, [startDate, endDate], callback); + const [rows] = await db.query(sql, [startDate, endDate]); + callback(null, rows); + } catch (err) { + callback(err); + } }, /** * 리더별 TBM 진행 현황 */ - getLeaderStatistics: (startDate, endDate, callback) => { - const sql = ` - SELECT - s.leader_id, - w.worker_name as leader_name, - COUNT(DISTINCT s.session_id) as total_sessions, - SUM(CASE WHEN s.status = 'completed' THEN 1 ELSE 0 END) as completed_sessions, - COUNT(DISTINCT ta.worker_id) as total_team_members - FROM tbm_sessions s - INNER JOIN workers w ON s.leader_id = w.worker_id - LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id - WHERE s.session_date BETWEEN ? AND ? - GROUP BY s.leader_id - ORDER BY total_sessions DESC - `; + getLeaderStatistics: async (startDate, endDate, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + s.leader_id, + w.worker_name as leader_name, + COUNT(DISTINCT s.session_id) as total_sessions, + SUM(CASE WHEN s.status = 'completed' THEN 1 ELSE 0 END) as completed_sessions, + COUNT(DISTINCT ta.worker_id) as total_team_members + FROM tbm_sessions s + INNER JOIN workers w ON s.leader_id = w.worker_id + LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id + WHERE s.session_date BETWEEN ? AND ? + GROUP BY s.leader_id + ORDER BY total_sessions DESC + `; - db.query(sql, [startDate, endDate], callback); + const [rows] = await db.query(sql, [startDate, endDate]); + callback(null, rows); + } catch (err) { + callback(err); + } + }, + + /** + * 작업보고서가 작성되지 않은 TBM 세션의 팀 배정 조회 + * @param {number|null} userId - 조회할 사용자 ID (null이면 모든 TBM 조회 - 관리자용) + */ + getIncompleteWorkReports: async (userId, callback) => { + try { + const db = await getDb(); + + // WHERE 조건 동적 생성 + let whereClause = ` + WHERE dwr.id IS NULL + AND s.status = 'draft' + `; + const params = []; + + // userId가 있으면 created_by 조건 추가 (일반 사용자) + if (userId !== null && userId !== undefined) { + whereClause = ` + WHERE s.created_by = ? + AND dwr.id IS NULL + AND s.status = 'draft' + `; + params.push(userId); + } + + const sql = ` + SELECT + ta.assignment_id, + ta.session_id, + ta.worker_id, + ta.project_id, + ta.work_type_id, + ta.task_id, + ta.workplace_category_id, + ta.workplace_id, + s.session_date, + s.status as session_status, + s.created_by, + w.worker_name, + w.job_type, + p.project_name, + wt.name as work_type_name, + t.task_name, + wp.workplace_name, + wc.category_name, + creator.name as created_by_name + FROM tbm_team_assignments ta + INNER JOIN tbm_sessions s ON ta.session_id = s.session_id + INNER JOIN workers w ON ta.worker_id = w.worker_id + LEFT JOIN users creator ON s.created_by = creator.user_id + LEFT JOIN projects p ON ta.project_id = p.project_id + LEFT JOIN work_types wt ON ta.work_type_id = wt.id + LEFT JOIN tasks t ON ta.task_id = t.task_id + LEFT JOIN workplaces wp ON ta.workplace_id = wp.workplace_id + LEFT JOIN workplace_categories wc ON ta.workplace_category_id = wc.category_id + LEFT JOIN daily_work_reports dwr ON ta.assignment_id = dwr.tbm_assignment_id + ${whereClause} + ORDER BY s.session_date DESC, ta.assignment_id ASC + `; + + const [rows] = await db.query(sql, params); + callback(null, rows); + } catch (err) { + callback(err); + } } }; diff --git a/api.hyungi.net/models/vacationBalanceModel.js b/api.hyungi.net/models/vacationBalanceModel.js new file mode 100644 index 0000000..d2dc681 --- /dev/null +++ b/api.hyungi.net/models/vacationBalanceModel.js @@ -0,0 +1,288 @@ +/** + * vacationBalanceModel.js + * 휴가 잔액 관련 데이터베이스 쿼리 모델 + */ + +const { getDb } = require('../dbPool'); + +const vacationBalanceModel = { + /** + * 특정 작업자의 모든 휴가 잔액 조회 (특정 연도) + */ + async getByWorkerAndYear(workerId, year, callback) { + try { + const db = await getDb(); + const query = ` + SELECT + vbd.*, + vt.type_name, + vt.type_code, + vt.priority, + vt.is_special + FROM vacation_balance_details vbd + INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id + WHERE vbd.worker_id = ? AND vbd.year = ? + ORDER BY vt.priority ASC, vt.type_name ASC + `; + const [rows] = await db.query(query, [workerId, year]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 특정 작업자의 특정 휴가 유형 잔액 조회 + */ + async getByWorkerTypeYear(workerId, vacationTypeId, year, callback) { + try { + const db = await getDb(); + const query = ` + SELECT + vbd.*, + vt.type_name, + vt.type_code + FROM vacation_balance_details vbd + INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id + WHERE vbd.worker_id = ? + AND vbd.vacation_type_id = ? + AND vbd.year = ? + `; + const [rows] = await db.query(query, [workerId, vacationTypeId, year]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 모든 작업자의 휴가 잔액 조회 (특정 연도) + * - 연간 연차 현황 차트용 + */ + async getAllByYear(year, callback) { + try { + const db = await getDb(); + const query = ` + SELECT + vbd.*, + w.worker_name, + w.employment_status, + vt.type_name, + vt.type_code, + vt.priority + FROM vacation_balance_details vbd + INNER JOIN workers w ON vbd.worker_id = w.worker_id + INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id + WHERE vbd.year = ? + AND w.employment_status = 'employed' + ORDER BY w.worker_name ASC, vt.priority ASC + `; + const [rows] = await db.query(query, [year]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 잔액 생성 + */ + async create(balanceData, callback) { + try { + const db = await getDb(); + const query = `INSERT INTO vacation_balance_details SET ?`; + const [rows] = await db.query(query, balanceData); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 잔액 수정 + */ + async update(id, updateData, callback) { + try { + const db = await getDb(); + const query = `UPDATE vacation_balance_details SET ? WHERE id = ?`; + const [rows] = await db.query(query, [updateData, id]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 잔액 삭제 + */ + async delete(id, callback) { + try { + const db = await getDb(); + const query = `DELETE FROM vacation_balance_details WHERE id = ?`; + const [rows] = await db.query(query, [id]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 작업자의 휴가 사용 일수 업데이트 (차감) + * - 휴가 신청 승인 시 호출 + */ + async deductDays(workerId, vacationTypeId, year, daysToDeduct, callback) { + try { + const db = await getDb(); + const query = ` + UPDATE vacation_balance_details + SET used_days = used_days + ?, + updated_at = NOW() + WHERE worker_id = ? + AND vacation_type_id = ? + AND year = ? + `; + const [rows] = await db.query(query, [daysToDeduct, workerId, vacationTypeId, year]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 작업자의 휴가 사용 일수 복구 (취소) + * - 휴가 신청 취소/거부 시 호출 + */ + async restoreDays(workerId, vacationTypeId, year, daysToRestore, callback) { + try { + const db = await getDb(); + const query = ` + UPDATE vacation_balance_details + SET used_days = GREATEST(0, used_days - ?), + updated_at = NOW() + WHERE worker_id = ? + AND vacation_type_id = ? + AND year = ? + `; + const [rows] = await db.query(query, [daysToRestore, workerId, vacationTypeId, year]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 특정 작업자의 사용 가능한 휴가 일수 확인 + * - 우선순위가 높은 순서대로 차감 가능 여부 확인 + */ + async getAvailableVacationDays(workerId, year, callback) { + try { + const db = await getDb(); + const query = ` + SELECT + vbd.id, + vbd.vacation_type_id, + vt.type_name, + vt.type_code, + vt.priority, + vbd.total_days, + vbd.used_days, + vbd.remaining_days + FROM vacation_balance_details vbd + INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id + WHERE vbd.worker_id = ? + AND vbd.year = ? + AND vbd.remaining_days > 0 + ORDER BY vt.priority ASC + `; + const [rows] = await db.query(query, [workerId, year]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 작업자별 휴가 잔액 일괄 생성 (연도별) + * - 매년 초 또는 입사 시 사용 + */ + async bulkCreate(balances, callback) { + try { + const db = await getDb(); + + if (!balances || balances.length === 0) { + return callback(new Error('생성할 휴가 잔액 데이터가 없습니다')); + } + + const query = `INSERT INTO vacation_balance_details + (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by) + VALUES ?`; + + const values = balances.map(b => [ + b.worker_id, + b.vacation_type_id, + b.year, + b.total_days || 0, + b.used_days || 0, + b.notes || null, + b.created_by + ]); + + const [rows] = await db.query(query, [values]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 근속년수 기반 연차 일수 계산 (한국 근로기준법) + * @param {Date} hireDate - 입사일 + * @param {number} targetYear - 대상 연도 + * @returns {number} - 부여받을 연차 일수 + */ + calculateAnnualLeaveDays(hireDate, targetYear) { + const hire = new Date(hireDate); + const targetDate = new Date(targetYear, 0, 1); + + // 근속 월수 계산 + const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12 + + (targetDate.getMonth() - hire.getMonth()); + + // 1년 미만: 월 1일 + if (monthsDiff < 12) { + return Math.floor(monthsDiff); + } + + // 1년 이상: 15일 기본 + 2년마다 1일 추가 (최대 25일) + const yearsWorked = Math.floor(monthsDiff / 12); + const additionalDays = Math.floor((yearsWorked - 1) / 2); + + return Math.min(15 + additionalDays, 25); + }, + + /** + * 특정 ID로 휴가 잔액 조회 + */ + async getById(id, callback) { + try { + const db = await getDb(); + const query = ` + SELECT + vbd.*, + w.worker_name, + vt.type_name, + vt.type_code + FROM vacation_balance_details vbd + INNER JOIN workers w ON vbd.worker_id = w.worker_id + INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id + WHERE vbd.id = ? + `; + const [rows] = await db.query(query, [id]); + callback(null, rows); + } catch (error) { + callback(error); + } + } +}; + +module.exports = vacationBalanceModel; diff --git a/api.hyungi.net/models/vacationRequestModel.js b/api.hyungi.net/models/vacationRequestModel.js new file mode 100644 index 0000000..adee31c --- /dev/null +++ b/api.hyungi.net/models/vacationRequestModel.js @@ -0,0 +1,271 @@ +/** + * vacationRequestModel.js + * 휴가 신청 관련 데이터베이스 쿼리 모델 + */ + +const { getDb } = require('../dbPool'); + +const vacationRequestModel = { + /** + * 휴가 신청 생성 + */ + async create(requestData, callback) { + try { + const db = await getDb(); + const query = `INSERT INTO vacation_requests SET ?`; + const [result] = await db.query(query, requestData); + callback(null, result); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 신청 목록 조회 (필터링 지원) + */ + async getAll(filters = {}, callback) { + try { + const db = await getDb(); + + let query = ` + SELECT + vr.*, + w.worker_name, + vt.type_name as vacation_type_name, + vt.deduct_days as vacation_deduct_days, + requester.name as requester_name, + reviewer.name as reviewer_name + FROM vacation_requests vr + INNER JOIN workers w ON vr.worker_id = w.worker_id + INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id + LEFT JOIN users requester ON vr.requested_by = requester.user_id + LEFT JOIN users reviewer ON vr.reviewed_by = reviewer.user_id + WHERE 1=1 + `; + + const params = []; + + // 작업자 필터 + if (filters.worker_id) { + query += ` AND vr.worker_id = ?`; + params.push(filters.worker_id); + } + + // 상태 필터 + if (filters.status) { + query += ` AND vr.status = ?`; + params.push(filters.status); + } + + // 기간 필터 + if (filters.start_date) { + query += ` AND vr.start_date >= ?`; + params.push(filters.start_date); + } + + if (filters.end_date) { + query += ` AND vr.end_date <= ?`; + params.push(filters.end_date); + } + + // 휴가 유형 필터 + if (filters.vacation_type_id) { + query += ` AND vr.vacation_type_id = ?`; + params.push(filters.vacation_type_id); + } + + query += ` ORDER BY vr.created_at DESC`; + + const [rows] = await db.query(query, params); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 특정 휴가 신청 조회 + */ + async getById(requestId, callback) { + try { + const db = await getDb(); + const query = ` + SELECT + vr.*, + w.worker_name, + w.phone_number as worker_phone, + w.email as worker_email, + vt.type_name as vacation_type_name, + vt.type_code as vacation_type_code, + vt.deduct_days as vacation_deduct_days, + requester.name as requester_name, + requester.username as requester_username, + reviewer.name as reviewer_name, + reviewer.username as reviewer_username + FROM vacation_requests vr + INNER JOIN workers w ON vr.worker_id = w.worker_id + INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id + LEFT JOIN users requester ON vr.requested_by = requester.user_id + LEFT JOIN users reviewer ON vr.reviewed_by = reviewer.user_id + WHERE vr.request_id = ? + `; + const [rows] = await db.query(query, [requestId]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 신청 수정 + */ + async update(requestId, updateData, callback) { + try { + const db = await getDb(); + const query = `UPDATE vacation_requests SET ? WHERE request_id = ?`; + const [result] = await db.query(query, [updateData, requestId]); + callback(null, result); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 신청 삭제 + */ + async delete(requestId, callback) { + try { + const db = await getDb(); + const query = `DELETE FROM vacation_requests WHERE request_id = ?`; + const [result] = await db.query(query, [requestId]); + callback(null, result); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 신청 승인/거부 + */ + async updateStatus(requestId, statusData, callback) { + try { + const db = await getDb(); + const query = ` + UPDATE vacation_requests + SET + status = ?, + reviewed_by = ?, + reviewed_at = NOW(), + review_note = ? + WHERE request_id = ? + `; + const [result] = await db.query(query, [ + statusData.status, + statusData.reviewed_by, + statusData.review_note || null, + requestId + ]); + callback(null, result); + } catch (error) { + callback(error); + } + }, + + /** + * 특정 작업자의 대기 중인 휴가 신청 수 + */ + async getPendingCount(workerId, callback) { + try { + const db = await getDb(); + const query = ` + SELECT COUNT(*) as count + FROM vacation_requests + WHERE worker_id = ? AND status = 'pending' + `; + const [rows] = await db.query(query, [workerId]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 특정 작업자의 승인된 휴가 일수 합계 (특정 기간) + */ + async getApprovedDaysInPeriod(workerId, startDate, endDate, callback) { + try { + const db = await getDb(); + const query = ` + SELECT COALESCE(SUM(days_used), 0) as total_days + FROM vacation_requests + WHERE worker_id = ? + AND status = 'approved' + AND start_date >= ? + AND end_date <= ? + `; + const [rows] = await db.query(query, [workerId, startDate, endDate]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 기간 중복 체크 + */ + async checkOverlap(workerId, startDate, endDate, excludeRequestId = null, callback) { + try { + const db = await getDb(); + let query = ` + SELECT COUNT(*) as count + FROM vacation_requests + WHERE worker_id = ? + AND status IN ('pending', 'approved') + AND ( + (start_date <= ? AND end_date >= ?) OR + (start_date <= ? AND end_date >= ?) OR + (start_date >= ? AND end_date <= ?) + ) + `; + const params = [workerId, startDate, startDate, endDate, endDate, startDate, endDate]; + + if (excludeRequestId) { + query += ` AND request_id != ?`; + params.push(excludeRequestId); + } + + const [rows] = await db.query(query, params); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 모든 대기 중인 휴가 신청 (관리자용) + */ + async getAllPending(callback) { + try { + const db = await getDb(); + const query = ` + SELECT + vr.*, + w.worker_name, + vt.type_name as vacation_type_name, + requester.name as requester_name + FROM vacation_requests vr + INNER JOIN workers w ON vr.worker_id = w.worker_id + INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id + LEFT JOIN users requester ON vr.requested_by = requester.user_id + WHERE vr.status = 'pending' + ORDER BY vr.created_at ASC + `; + const [rows] = await db.query(query); + callback(null, rows); + } catch (error) { + callback(error); + } + } +}; + +module.exports = vacationRequestModel; diff --git a/api.hyungi.net/models/vacationTypeModel.js b/api.hyungi.net/models/vacationTypeModel.js new file mode 100644 index 0000000..9ecce63 --- /dev/null +++ b/api.hyungi.net/models/vacationTypeModel.js @@ -0,0 +1,132 @@ +/** + * vacationTypeModel.js + * 휴가 유형 관련 데이터베이스 쿼리 모델 + */ + +const { getDb } = require('../dbPool'); + +const vacationTypeModel = { + /** + * 모든 활성 휴가 유형 조회 (우선순위 순서대로) + */ + async getAll(callback) { + try { + const db = await getDb(); + const query = ` + SELECT * + FROM vacation_types + WHERE is_active = 1 + ORDER BY priority ASC, id ASC + `; + const [rows] = await db.query(query); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 시스템 기본 휴가 유형만 조회 + */ + async getSystemTypes(callback) { + try { + const db = await getDb(); + const query = ` + SELECT * + FROM vacation_types + WHERE is_system = 1 AND is_active = 1 + ORDER BY priority ASC + `; + const [rows] = await db.query(query); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 특정 ID로 휴가 유형 조회 + */ + async getById(id, callback) { + try { + const db = await getDb(); + const query = `SELECT * FROM vacation_types WHERE id = ?`; + const [rows] = await db.query(query, [id]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 유형 코드로 조회 + */ + async getByCode(code, callback) { + try { + const db = await getDb(); + const query = `SELECT * FROM vacation_types WHERE type_code = ?`; + const [rows] = await db.query(query, [code]); + callback(null, rows); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 유형 생성 + */ + async create(typeData, callback) { + try { + const db = await getDb(); + const query = `INSERT INTO vacation_types SET ?`; + const [result] = await db.query(query, typeData); + callback(null, result); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 유형 수정 + */ + async update(id, updateData, callback) { + try { + const db = await getDb(); + const query = `UPDATE vacation_types SET ? WHERE id = ?`; + const [result] = await db.query(query, [updateData, id]); + callback(null, result); + } catch (error) { + callback(error); + } + }, + + /** + * 휴가 유형 삭제 (논리적 삭제 - is_active = 0) + */ + async delete(id, callback) { + try { + const db = await getDb(); + const query = `UPDATE vacation_types SET is_active = 0, updated_at = NOW() WHERE id = ?`; + const [result] = await db.query(query, [id]); + callback(null, result); + } catch (error) { + callback(error); + } + }, + + /** + * 우선순위 업데이트 + */ + async updatePriority(id, priority, callback) { + try { + const db = await getDb(); + const query = `UPDATE vacation_types SET priority = ?, updated_at = NOW() WHERE id = ?`; + const [result] = await db.query(query, [priority, id]); + callback(null, result); + } catch (error) { + callback(error); + } + } +}; + +module.exports = vacationTypeModel; diff --git a/api.hyungi.net/models/visitRequestModel.js b/api.hyungi.net/models/visitRequestModel.js new file mode 100644 index 0000000..87bb6c9 --- /dev/null +++ b/api.hyungi.net/models/visitRequestModel.js @@ -0,0 +1,505 @@ +const { getDb } = require('../dbPool'); + +// ==================== 출입 신청 관리 ==================== + +/** + * 출입 신청 생성 + */ +const createVisitRequest = async (requestData, callback) => { + try { + const db = await getDb(); + const { + requester_id, + visitor_company, + visitor_count = 1, + category_id, + workplace_id, + visit_date, + visit_time, + purpose_id, + notes = null + } = requestData; + + const [result] = await db.query( + `INSERT INTO workplace_visit_requests + (requester_id, visitor_company, visitor_count, category_id, workplace_id, + visit_date, visit_time, purpose_id, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [requester_id, visitor_company, visitor_count, category_id, workplace_id, + visit_date, visit_time, purpose_id, notes] + ); + + callback(null, result.insertId); + } catch (err) { + callback(err); + } +}; + +/** + * 출입 신청 목록 조회 (필터 옵션 포함) + */ +const getAllVisitRequests = async (filters = {}, callback) => { + try { + const db = await getDb(); + let query = ` + SELECT + vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count, + vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time, + vr.purpose_id, vr.notes, vr.status, + vr.approved_by, vr.approved_at, vr.rejection_reason, + vr.created_at, vr.updated_at, + u.username as requester_name, u.name as requester_full_name, + wc.category_name, w.workplace_name, + vpt.purpose_name, + approver.username as approver_name + FROM workplace_visit_requests vr + INNER JOIN users u ON vr.requester_id = u.user_id + INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id + INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id + INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id + LEFT JOIN users approver ON vr.approved_by = approver.user_id + WHERE 1=1 + `; + + const params = []; + + // 필터 적용 + if (filters.status) { + query += ` AND vr.status = ?`; + params.push(filters.status); + } + + if (filters.visit_date) { + query += ` AND vr.visit_date = ?`; + params.push(filters.visit_date); + } + + if (filters.start_date && filters.end_date) { + query += ` AND vr.visit_date BETWEEN ? AND ?`; + params.push(filters.start_date, filters.end_date); + } + + if (filters.requester_id) { + query += ` AND vr.requester_id = ?`; + params.push(filters.requester_id); + } + + if (filters.category_id) { + query += ` AND vr.category_id = ?`; + params.push(filters.category_id); + } + + query += ` ORDER BY vr.visit_date DESC, vr.visit_time DESC, vr.created_at DESC`; + + const [rows] = await db.query(query, params); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 출입 신청 상세 조회 + */ +const getVisitRequestById = async (requestId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT + vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count, + vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time, + vr.purpose_id, vr.notes, vr.status, + vr.approved_by, vr.approved_at, vr.rejection_reason, + vr.created_at, vr.updated_at, + u.username as requester_name, u.name as requester_full_name, + wc.category_name, w.workplace_name, + vpt.purpose_name, + approver.username as approver_name + FROM workplace_visit_requests vr + INNER JOIN users u ON vr.requester_id = u.user_id + INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id + INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id + INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id + LEFT JOIN users approver ON vr.approved_by = approver.user_id + WHERE vr.request_id = ?`, + [requestId] + ); + + callback(null, rows[0]); + } catch (err) { + callback(err); + } +}; + +/** + * 출입 신청 수정 + */ +const updateVisitRequest = async (requestId, requestData, callback) => { + try { + const db = await getDb(); + const { + visitor_company, + visitor_count, + category_id, + workplace_id, + visit_date, + visit_time, + purpose_id, + notes + } = requestData; + + const [result] = await db.query( + `UPDATE workplace_visit_requests + SET visitor_company = ?, visitor_count = ?, category_id = ?, workplace_id = ?, + visit_date = ?, visit_time = ?, purpose_id = ?, notes = ?, updated_at = NOW() + WHERE request_id = ?`, + [visitor_company, visitor_count, category_id, workplace_id, + visit_date, visit_time, purpose_id, notes, requestId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 출입 신청 삭제 + */ +const deleteVisitRequest = async (requestId, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `DELETE FROM workplace_visit_requests WHERE request_id = ?`, + [requestId] + ); + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 출입 신청 승인 + */ +const approveVisitRequest = async (requestId, approvedBy, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `UPDATE workplace_visit_requests + SET status = 'approved', approved_by = ?, approved_at = NOW(), updated_at = NOW() + WHERE request_id = ?`, + [approvedBy, requestId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 출입 신청 반려 + */ +const rejectVisitRequest = async (requestId, rejectionData, callback) => { + try { + const db = await getDb(); + const { approved_by, rejection_reason } = rejectionData; + + const [result] = await db.query( + `UPDATE workplace_visit_requests + SET status = 'rejected', approved_by = ?, approved_at = NOW(), + rejection_reason = ?, updated_at = NOW() + WHERE request_id = ?`, + [approved_by, rejection_reason, requestId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 출입 신청 상태 변경 + */ +const updateVisitRequestStatus = async (requestId, status, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `UPDATE workplace_visit_requests + SET status = ?, updated_at = NOW() + WHERE request_id = ?`, + [status, requestId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +// ==================== 방문 목적 관리 ==================== + +/** + * 모든 방문 목적 조회 + */ +const getAllVisitPurposes = async (callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT purpose_id, purpose_name, display_order, is_active, created_at + FROM visit_purpose_types + ORDER BY display_order ASC, purpose_id ASC` + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 활성 방문 목적만 조회 + */ +const getActiveVisitPurposes = async (callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT purpose_id, purpose_name, display_order, is_active, created_at + FROM visit_purpose_types + WHERE is_active = TRUE + ORDER BY display_order ASC, purpose_id ASC` + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 방문 목적 추가 + */ +const createVisitPurpose = async (purposeData, callback) => { + try { + const db = await getDb(); + const { purpose_name, display_order = 0, is_active = true } = purposeData; + + const [result] = await db.query( + `INSERT INTO visit_purpose_types (purpose_name, display_order, is_active) + VALUES (?, ?, ?)`, + [purpose_name, display_order, is_active] + ); + + callback(null, result.insertId); + } catch (err) { + callback(err); + } +}; + +/** + * 방문 목적 수정 + */ +const updateVisitPurpose = async (purposeId, purposeData, callback) => { + try { + const db = await getDb(); + const { purpose_name, display_order, is_active } = purposeData; + + const [result] = await db.query( + `UPDATE visit_purpose_types + SET purpose_name = ?, display_order = ?, is_active = ? + WHERE purpose_id = ?`, + [purpose_name, display_order, is_active, purposeId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 방문 목적 삭제 + */ +const deleteVisitPurpose = async (purposeId, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `DELETE FROM visit_purpose_types WHERE purpose_id = ?`, + [purposeId] + ); + callback(null, result); + } catch (err) { + callback(err); + } +}; + +// ==================== 안전교육 기록 관리 ==================== + +/** + * 안전교육 기록 생성 + */ +const createTrainingRecord = async (trainingData, callback) => { + try { + const db = await getDb(); + const { + request_id, + trainer_id, + training_date, + training_start_time, + training_end_time = null, + training_topics = null + } = trainingData; + + const [result] = await db.query( + `INSERT INTO safety_training_records + (request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics) + VALUES (?, ?, ?, ?, ?, ?)`, + [request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics] + ); + + callback(null, result.insertId); + } catch (err) { + callback(err); + } +}; + +/** + * 특정 출입 신청의 안전교육 기록 조회 + */ +const getTrainingRecordByRequestId = async (requestId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT + str.training_id, str.request_id, str.trainer_id, str.training_date, + str.training_start_time, str.training_end_time, str.training_topics, + str.signature_data, str.completed_at, str.created_at, str.updated_at, + u.username as trainer_name, u.name as trainer_full_name + FROM safety_training_records str + INNER JOIN users u ON str.trainer_id = u.user_id + WHERE str.request_id = ?`, + [requestId] + ); + + callback(null, rows[0]); + } catch (err) { + callback(err); + } +}; + +/** + * 안전교육 기록 수정 + */ +const updateTrainingRecord = async (trainingId, trainingData, callback) => { + try { + const db = await getDb(); + const { + training_date, + training_start_time, + training_end_time, + training_topics + } = trainingData; + + const [result] = await db.query( + `UPDATE safety_training_records + SET training_date = ?, training_start_time = ?, training_end_time = ?, + training_topics = ?, updated_at = NOW() + WHERE training_id = ?`, + [training_date, training_start_time, training_end_time, training_topics, trainingId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 안전교육 완료 (서명 포함) + */ +const completeTraining = async (trainingId, signatureData, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `UPDATE safety_training_records + SET signature_data = ?, completed_at = NOW(), updated_at = NOW() + WHERE training_id = ?`, + [signatureData, trainingId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 안전교육 목록 조회 (날짜별 필터) + */ +const getTrainingRecords = async (filters = {}, callback) => { + try { + const db = await getDb(); + let query = ` + SELECT + str.training_id, str.request_id, str.trainer_id, str.training_date, + str.training_start_time, str.training_end_time, str.training_topics, + str.completed_at, str.created_at, str.updated_at, + u.username as trainer_name, u.name as trainer_full_name, + vr.visitor_company, vr.visitor_count, vr.visit_date + FROM safety_training_records str + INNER JOIN users u ON str.trainer_id = u.user_id + INNER JOIN workplace_visit_requests vr ON str.request_id = vr.request_id + WHERE 1=1 + `; + + const params = []; + + if (filters.training_date) { + query += ` AND str.training_date = ?`; + params.push(filters.training_date); + } + + if (filters.start_date && filters.end_date) { + query += ` AND str.training_date BETWEEN ? AND ?`; + params.push(filters.start_date, filters.end_date); + } + + if (filters.trainer_id) { + query += ` AND str.trainer_id = ?`; + params.push(filters.trainer_id); + } + + query += ` ORDER BY str.training_date DESC, str.training_start_time DESC`; + + const [rows] = await db.query(query, params); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +module.exports = { + // 출입 신청 + createVisitRequest, + getAllVisitRequests, + getVisitRequestById, + updateVisitRequest, + deleteVisitRequest, + approveVisitRequest, + rejectVisitRequest, + updateVisitRequestStatus, + + // 방문 목적 + getAllVisitPurposes, + getActiveVisitPurposes, + createVisitPurpose, + updateVisitPurpose, + deleteVisitPurpose, + + // 안전교육 + createTrainingRecord, + getTrainingRecordByRequestId, + updateTrainingRecord, + completeTraining, + getTrainingRecords +}; diff --git a/api.hyungi.net/models/workplaceModel.js b/api.hyungi.net/models/workplaceModel.js index e1e4506..52d809c 100644 --- a/api.hyungi.net/models/workplaceModel.js +++ b/api.hyungi.net/models/workplaceModel.js @@ -35,7 +35,7 @@ const getAllCategories = async (callback) => { try { const db = await getDb(); const [rows] = await db.query( - `SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at + `SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at FROM workplace_categories ORDER BY display_order ASC, category_id ASC` ); @@ -52,7 +52,7 @@ const getActiveCategories = async (callback) => { try { const db = await getDb(); const [rows] = await db.query( - `SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at + `SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at FROM workplace_categories WHERE is_active = TRUE ORDER BY display_order ASC, category_id ASC` @@ -70,7 +70,7 @@ const getCategoryById = async (categoryId, callback) => { try { const db = await getDb(); const [rows] = await db.query( - `SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at + `SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at FROM workplace_categories WHERE category_id = ?`, [categoryId] @@ -91,14 +91,15 @@ const updateCategory = async (categoryId, category, callback) => { category_name, description, display_order, - is_active + is_active, + layout_image } = category; const [result] = await db.query( `UPDATE workplace_categories - SET category_name = ?, description = ?, display_order = ?, is_active = ?, updated_at = NOW() + SET category_name = ?, description = ?, display_order = ?, is_active = ?, layout_image = ?, updated_at = NOW() WHERE category_id = ?`, - [category_name, description, display_order, is_active, categoryId] + [category_name, description, display_order, is_active, layout_image, categoryId] ); callback(null, result); @@ -135,14 +136,16 @@ const createWorkplace = async (workplace, callback) => { category_id = null, workplace_name, description = null, - is_active = true + is_active = true, + workplace_purpose = null, + display_priority = 0 } = workplace; const [result] = await db.query( `INSERT INTO workplaces - (category_id, workplace_name, description, is_active) - VALUES (?, ?, ?, ?)`, - [category_id, workplace_name, description, is_active] + (category_id, workplace_name, description, is_active, workplace_purpose, display_priority) + VALUES (?, ?, ?, ?, ?, ?)`, + [category_id, workplace_name, description, is_active, workplace_purpose, display_priority] ); callback(null, result.insertId); @@ -158,12 +161,12 @@ const getAllWorkplaces = async (callback) => { try { const db = await getDb(); const [rows] = await db.query( - `SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, - w.created_at, w.updated_at, + `SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority, + w.workplace_purpose, w.display_priority, w.created_at, w.updated_at, wc.category_name FROM workplaces w LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id - ORDER BY wc.display_order ASC, w.workplace_id DESC` + ORDER BY wc.display_order ASC, w.display_priority ASC, w.workplace_id DESC` ); callback(null, rows); } catch (err) { @@ -178,7 +181,7 @@ const getActiveWorkplaces = async (callback) => { try { const db = await getDb(); const [rows] = await db.query( - `SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, + `SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority, w.created_at, w.updated_at, wc.category_name FROM workplaces w @@ -199,7 +202,7 @@ const getWorkplacesByCategory = async (categoryId, callback) => { try { const db = await getDb(); const [rows] = await db.query( - `SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, + `SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority, w.created_at, w.updated_at, wc.category_name FROM workplaces w @@ -221,7 +224,7 @@ const getWorkplaceById = async (workplaceId, callback) => { try { const db = await getDb(); const [rows] = await db.query( - `SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, + `SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority, w.created_at, w.updated_at, wc.category_name FROM workplaces w @@ -245,14 +248,17 @@ const updateWorkplace = async (workplaceId, workplace, callback) => { category_id, workplace_name, description, - is_active + is_active, + workplace_purpose, + display_priority } = workplace; const [result] = await db.query( `UPDATE workplaces - SET category_id = ?, workplace_name = ?, description = ?, is_active = ?, updated_at = NOW() + SET category_id = ?, workplace_name = ?, description = ?, is_active = ?, + workplace_purpose = ?, display_priority = ?, updated_at = NOW() WHERE workplace_id = ?`, - [category_id, workplace_name, description, is_active, workplaceId] + [category_id, workplace_name, description, is_active, workplace_purpose, display_priority, workplaceId] ); callback(null, result); @@ -277,6 +283,134 @@ const deleteWorkplace = async (workplaceId, callback) => { } }; +// ==================== 작업장 지도 영역 관련 ==================== + +/** + * 작업장 지도 영역 생성 + */ +const createMapRegion = async (region, callback) => { + try { + const db = await getDb(); + const { + workplace_id, + category_id, + x_start, + y_start, + x_end, + y_end, + shape = 'rect', + polygon_points = null + } = region; + + const [result] = await db.query( + `INSERT INTO workplace_map_regions + (workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points] + ); + + callback(null, result.insertId); + } catch (err) { + callback(err); + } +}; + +/** + * 카테고리(공장)별 지도 영역 조회 + */ +const getMapRegionsByCategory = async (categoryId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT mr.*, w.workplace_name, w.description + FROM workplace_map_regions mr + INNER JOIN workplaces w ON mr.workplace_id = w.workplace_id + WHERE mr.category_id = ? AND w.is_active = TRUE + ORDER BY mr.region_id ASC`, + [categoryId] + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 작업장별 지도 영역 조회 + */ +const getMapRegionByWorkplace = async (workplaceId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT * FROM workplace_map_regions WHERE workplace_id = ?`, + [workplaceId] + ); + callback(null, rows[0]); + } catch (err) { + callback(err); + } +}; + +/** + * 지도 영역 수정 + */ +const updateMapRegion = async (regionId, region, callback) => { + try { + const db = await getDb(); + const { + x_start, + y_start, + x_end, + y_end, + shape, + polygon_points + } = region; + + const [result] = await db.query( + `UPDATE workplace_map_regions + SET x_start = ?, y_start = ?, x_end = ?, y_end = ?, shape = ?, polygon_points = ?, updated_at = NOW() + WHERE region_id = ?`, + [x_start, y_start, x_end, y_end, shape, polygon_points, regionId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 지도 영역 삭제 + */ +const deleteMapRegion = async (regionId, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `DELETE FROM workplace_map_regions WHERE region_id = ?`, + [regionId] + ); + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 작업장 영역 일괄 삭제 (카테고리별) + */ +const deleteMapRegionsByCategory = async (categoryId, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `DELETE FROM workplace_map_regions WHERE category_id = ?`, + [categoryId] + ); + callback(null, result); + } catch (err) { + callback(err); + } +}; + module.exports = { // 카테고리 createCategory, @@ -293,5 +427,13 @@ module.exports = { getWorkplacesByCategory, getWorkplaceById, updateWorkplace, - deleteWorkplace + deleteWorkplace, + + // 지도 영역 + createMapRegion, + getMapRegionsByCategory, + getMapRegionByWorkplace, + updateMapRegion, + deleteMapRegion, + deleteMapRegionsByCategory }; diff --git a/api.hyungi.net/routes/attendanceRoutes.js b/api.hyungi.net/routes/attendanceRoutes.js index dd29b57..a5c7393 100644 --- a/api.hyungi.net/routes/attendanceRoutes.js +++ b/api.hyungi.net/routes/attendanceRoutes.js @@ -34,4 +34,10 @@ router.get('/vacation-balance/:worker_id', AttendanceController.getWorkerVacatio // 월별 근태 통계 router.get('/monthly-stats', AttendanceController.getMonthlyAttendanceStats); +// 출근 체크 목록 조회 (아침용, 휴가 정보 포함) +router.get('/checkin-list', AttendanceController.getCheckinList); + +// 출근 체크 일괄 저장 +router.post('/checkins', AttendanceController.saveCheckins); + module.exports = router; diff --git a/api.hyungi.net/routes/dailyWorkReportRoutes.js b/api.hyungi.net/routes/dailyWorkReportRoutes.js index c7658ae..17efb14 100644 --- a/api.hyungi.net/routes/dailyWorkReportRoutes.js +++ b/api.hyungi.net/routes/dailyWorkReportRoutes.js @@ -70,6 +70,9 @@ router.get('/stats', dailyWorkReportController.getWorkReportStats); // 📝 일일 작업보고서 생성 (누적 방식 - 덮어쓰기 없음!) router.post('/', dailyWorkReportController.createDailyWorkReport); +// 📝 TBM 기반 작업보고서 생성 +router.post('/from-tbm', dailyWorkReportController.createFromTbm); + // 📊 일일 작업보고서 조회 (날짜별 - 경로 파라미터) router.get('/date/:date', dailyWorkReportController.getDailyWorkReportsByDate); diff --git a/api.hyungi.net/routes/tbmRoutes.js b/api.hyungi.net/routes/tbmRoutes.js index 86dd404..8e433c0 100644 --- a/api.hyungi.net/routes/tbmRoutes.js +++ b/api.hyungi.net/routes/tbmRoutes.js @@ -2,70 +2,76 @@ const express = require('express'); const router = express.Router(); const TbmController = require('../controllers/tbmController'); -const { authenticateToken } = require('../middlewares/auth'); +const { requireAuth } = require('../middlewares/auth'); // ==================== TBM 세션 관련 ==================== // TBM 세션 생성 -router.post('/sessions', authenticateToken, TbmController.createSession); +router.post('/sessions', requireAuth, TbmController.createSession); + +// 작업보고서가 작성되지 않은 TBM 팀 배정 조회 (구체적인 경로이므로 먼저 정의) +router.get('/sessions/incomplete-reports', requireAuth, TbmController.getIncompleteWorkReports); // 특정 날짜의 TBM 세션 목록 조회 -router.get('/sessions/date/:date', authenticateToken, TbmController.getSessionsByDate); +router.get('/sessions/date/:date', requireAuth, TbmController.getSessionsByDate); // TBM 세션 상세 조회 -router.get('/sessions/:sessionId', authenticateToken, TbmController.getSessionById); +router.get('/sessions/:sessionId', requireAuth, TbmController.getSessionById); // TBM 세션 수정 -router.put('/sessions/:sessionId', authenticateToken, TbmController.updateSession); +router.put('/sessions/:sessionId', requireAuth, TbmController.updateSession); // TBM 세션 완료 처리 -router.post('/sessions/:sessionId/complete', authenticateToken, TbmController.completeSession); +router.post('/sessions/:sessionId/complete', requireAuth, TbmController.completeSession); // ==================== 팀 구성 관련 ==================== // 팀원 추가 (단일) -router.post('/sessions/:sessionId/team', authenticateToken, TbmController.addTeamMember); +router.post('/sessions/:sessionId/team', requireAuth, TbmController.addTeamMember); // 팀 구성 일괄 추가 -router.post('/sessions/:sessionId/team/batch', authenticateToken, TbmController.addTeamMembers); +router.post('/sessions/:sessionId/team/batch', requireAuth, TbmController.addTeamMembers); // TBM 세션의 팀 구성 조회 -router.get('/sessions/:sessionId/team', authenticateToken, TbmController.getTeamMembers); +router.get('/sessions/:sessionId/team', requireAuth, TbmController.getTeamMembers); + +// 팀원 전체 삭제 (수정 시 사용) - 더 구체적인 경로이므로 먼저 정의 +router.delete('/sessions/:sessionId/team/clear', requireAuth, TbmController.clearAllTeamMembers); // 팀원 제거 -router.delete('/sessions/:sessionId/team/:workerId', authenticateToken, TbmController.removeTeamMember); +router.delete('/sessions/:sessionId/team/:workerId', requireAuth, TbmController.removeTeamMember); // ==================== 안전 체크리스트 관련 ==================== // 모든 안전 체크 항목 조회 -router.get('/safety-checks', authenticateToken, TbmController.getAllSafetyChecks); +router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks); // TBM 세션의 안전 체크 기록 조회 -router.get('/sessions/:sessionId/safety', authenticateToken, TbmController.getSafetyRecords); +router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords); // 안전 체크 일괄 저장 -router.post('/sessions/:sessionId/safety', authenticateToken, TbmController.saveSafetyRecords); +router.post('/sessions/:sessionId/safety', requireAuth, TbmController.saveSafetyRecords); // ==================== 작업 인계 관련 ==================== // 작업 인계 생성 -router.post('/handovers', authenticateToken, TbmController.createHandover); +router.post('/handovers', requireAuth, TbmController.createHandover); // 작업 인계 확인 -router.post('/handovers/:handoverId/confirm', authenticateToken, TbmController.confirmHandover); +router.post('/handovers/:handoverId/confirm', requireAuth, TbmController.confirmHandover); // 특정 날짜의 작업 인계 목록 조회 -router.get('/handovers/date/:date', authenticateToken, TbmController.getHandoversByDate); +router.get('/handovers/date/:date', requireAuth, TbmController.getHandoversByDate); // 나에게 온 미확인 인계 건 조회 -router.get('/handovers/pending', authenticateToken, TbmController.getMyPendingHandovers); +router.get('/handovers/pending', requireAuth, TbmController.getMyPendingHandovers); // ==================== 통계 및 리포트 ==================== // TBM 통계 조회 -router.get('/statistics/tbm', authenticateToken, TbmController.getTbmStatistics); +router.get('/statistics/tbm', requireAuth, TbmController.getTbmStatistics); // 리더별 TBM 진행 현황 조회 -router.get('/statistics/leaders', authenticateToken, TbmController.getLeaderStatistics); +router.get('/statistics/leaders', requireAuth, TbmController.getLeaderStatistics); module.exports = router; diff --git a/api.hyungi.net/routes/userRoutes.js b/api.hyungi.net/routes/userRoutes.js index 580ee3a..daf9c8d 100644 --- a/api.hyungi.net/routes/userRoutes.js +++ b/api.hyungi.net/routes/userRoutes.js @@ -128,4 +128,10 @@ router.put('/:id/status', userController.updateUserStatus); // 🗑️ 사용자 삭제 router.delete('/:id', userController.deleteUser); +// 📄 사용자 페이지 접근 권한 조회 +router.get('/:id/page-access', userController.getUserPageAccess); + +// 🔐 사용자 페이지 접근 권한 업데이트 +router.put('/:id/page-access', userController.updateUserPageAccess); + module.exports = router; diff --git a/api.hyungi.net/routes/vacationBalanceRoutes.js b/api.hyungi.net/routes/vacationBalanceRoutes.js new file mode 100644 index 0000000..c2ab2b7 --- /dev/null +++ b/api.hyungi.net/routes/vacationBalanceRoutes.js @@ -0,0 +1,31 @@ +/** + * vacationBalanceRoutes.js + * 휴가 잔액 관련 라우트 + */ + +const express = require('express'); +const router = express.Router(); +const vacationBalanceController = require('../controllers/vacationBalanceController'); + +// 모든 작업자의 휴가 잔액 조회 (특정 연도) +router.get('/year/:year', vacationBalanceController.getAllByYear); + +// 특정 작업자의 휴가 잔액 조회 (특정 연도) +router.get('/worker/:workerId/year/:year', vacationBalanceController.getByWorkerAndYear); + +// 작업자의 사용 가능한 휴가 일수 조회 +router.get('/worker/:workerId/year/:year/available', vacationBalanceController.getAvailableDays); + +// 근속년수 기반 연차 자동 계산 및 생성 (관리자만) +router.post('/auto-calculate', vacationBalanceController.autoCalculateAndCreate); + +// 휴가 잔액 생성 (관리자만) +router.post('/', vacationBalanceController.createBalance); + +// 휴가 잔액 수정 (관리자만) +router.put('/:id', vacationBalanceController.updateBalance); + +// 휴가 잔액 삭제 (관리자만) +router.delete('/:id', vacationBalanceController.deleteBalance); + +module.exports = router; diff --git a/api.hyungi.net/routes/vacationRequestRoutes.js b/api.hyungi.net/routes/vacationRequestRoutes.js new file mode 100644 index 0000000..7ca3212 --- /dev/null +++ b/api.hyungi.net/routes/vacationRequestRoutes.js @@ -0,0 +1,34 @@ +/** + * vacationRequestRoutes.js + * 휴가 신청 관련 라우트 + */ + +const express = require('express'); +const router = express.Router(); +const vacationRequestController = require('../controllers/vacationRequestController'); + +// 휴가 신청 생성 +router.post('/', vacationRequestController.createRequest); + +// 휴가 신청 목록 조회 +router.get('/', vacationRequestController.getAllRequests); + +// 대기 중인 휴가 신청 목록 (관리자용) +router.get('/pending', vacationRequestController.getPendingRequests); + +// 특정 휴가 신청 조회 +router.get('/:id', vacationRequestController.getRequestById); + +// 휴가 신청 수정 +router.put('/:id', vacationRequestController.updateRequest); + +// 휴가 신청 삭제 +router.delete('/:id', vacationRequestController.deleteRequest); + +// 휴가 신청 승인 +router.patch('/:id/approve', vacationRequestController.approveRequest); + +// 휴가 신청 거부 +router.patch('/:id/reject', vacationRequestController.rejectRequest); + +module.exports = router; diff --git a/api.hyungi.net/routes/vacationTypeRoutes.js b/api.hyungi.net/routes/vacationTypeRoutes.js new file mode 100644 index 0000000..b39673a --- /dev/null +++ b/api.hyungi.net/routes/vacationTypeRoutes.js @@ -0,0 +1,31 @@ +/** + * vacationTypeRoutes.js + * 휴가 유형 관련 라우트 + */ + +const express = require('express'); +const router = express.Router(); +const vacationTypeController = require('../controllers/vacationTypeController'); + +// 모든 활성 휴가 유형 조회 +router.get('/', vacationTypeController.getAllTypes); + +// 시스템 기본 휴가 유형 조회 +router.get('/system', vacationTypeController.getSystemTypes); + +// 특별 휴가 유형 조회 +router.get('/special', vacationTypeController.getSpecialTypes); + +// 휴가 유형 우선순위 일괄 업데이트 (관리자만) +router.put('/priorities', vacationTypeController.updatePriorities); + +// 특별 휴가 유형 생성 (관리자만) +router.post('/', vacationTypeController.createType); + +// 휴가 유형 수정 (관리자만) +router.put('/:id', vacationTypeController.updateType); + +// 특별 휴가 유형 삭제 (관리자만, 시스템 기본 휴가는 삭제 불가) +router.delete('/:id', vacationTypeController.deleteType); + +module.exports = router; diff --git a/api.hyungi.net/routes/visitRequestRoutes.js b/api.hyungi.net/routes/visitRequestRoutes.js new file mode 100644 index 0000000..5d48d80 --- /dev/null +++ b/api.hyungi.net/routes/visitRequestRoutes.js @@ -0,0 +1,66 @@ +const express = require('express'); +const router = express.Router(); +const visitRequestController = require('../controllers/visitRequestController'); +const { verifyToken } = require('../middlewares/authMiddleware'); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(verifyToken); + +// ==================== 출입 신청 관리 ==================== + +// 출입 신청 생성 +router.post('/requests', visitRequestController.createVisitRequest); + +// 출입 신청 목록 조회 (필터: status, visit_date, start_date, end_date, requester_id, category_id) +router.get('/requests', visitRequestController.getAllVisitRequests); + +// 출입 신청 상세 조회 +router.get('/requests/:id', visitRequestController.getVisitRequestById); + +// 출입 신청 수정 +router.put('/requests/:id', visitRequestController.updateVisitRequest); + +// 출입 신청 삭제 +router.delete('/requests/:id', visitRequestController.deleteVisitRequest); + +// 출입 신청 승인 +router.put('/requests/:id/approve', visitRequestController.approveVisitRequest); + +// 출입 신청 반려 +router.put('/requests/:id/reject', visitRequestController.rejectVisitRequest); + +// ==================== 방문 목적 관리 ==================== + +// 모든 방문 목적 조회 +router.get('/purposes', visitRequestController.getAllVisitPurposes); + +// 활성 방문 목적만 조회 +router.get('/purposes/active', visitRequestController.getActiveVisitPurposes); + +// 방문 목적 추가 +router.post('/purposes', visitRequestController.createVisitPurpose); + +// 방문 목적 수정 +router.put('/purposes/:id', visitRequestController.updateVisitPurpose); + +// 방문 목적 삭제 +router.delete('/purposes/:id', visitRequestController.deleteVisitPurpose); + +// ==================== 안전교육 기록 관리 ==================== + +// 안전교육 기록 생성 +router.post('/training', visitRequestController.createTrainingRecord); + +// 안전교육 기록 목록 조회 (필터: training_date, start_date, end_date, trainer_id) +router.get('/training', visitRequestController.getTrainingRecords); + +// 특정 출입 신청의 안전교육 기록 조회 +router.get('/training/request/:requestId', visitRequestController.getTrainingRecordByRequestId); + +// 안전교육 기록 수정 +router.put('/training/:id', visitRequestController.updateTrainingRecord); + +// 안전교육 완료 (서명 포함) +router.post('/training/:id/complete', visitRequestController.completeTraining); + +module.exports = router; diff --git a/api.hyungi.net/services/attendanceService.js b/api.hyungi.net/services/attendanceService.js index 2795692..a27261a 100644 --- a/api.hyungi.net/services/attendanceService.js +++ b/api.hyungi.net/services/attendanceService.js @@ -248,6 +248,77 @@ const getMonthlyAttendanceStatsService = async (year, month, workerId = null) => } }; +/** + * 출근 체크 목록 조회 (휴가 정보 포함) + */ +const getCheckinListService = async (date) => { + if (!date) { + throw new ValidationError('날짜가 필요합니다', { + required: ['date'], + received: { date } + }); + } + + logger.info('출근 체크 목록 조회 요청', { date }); + + try { + const checkinList = await AttendanceModel.getCheckinList(date); + logger.info('출근 체크 목록 조회 성공', { date, count: checkinList.length }); + return checkinList; + } catch (error) { + logger.error('출근 체크 목록 조회 실패', { date, error: error.message }); + throw new DatabaseError('출근 체크 목록 조회 중 데이터베이스 오류가 발생했습니다'); + } +}; + +/** + * 출근 체크 일괄 저장 + */ +const saveCheckinsService = async (date, checkins) => { + if (!date || !checkins || !Array.isArray(checkins)) { + throw new ValidationError('날짜와 출근 체크 목록이 필요합니다', { + required: ['date', 'checkins'], + received: { date, checkins: checkins ? `Array[${checkins.length}]` : null } + }); + } + + logger.info('출근 체크 일괄 저장 요청', { date, count: checkins.length }); + + try { + const results = []; + + for (const checkin of checkins) { + const { worker_id, is_present } = checkin; + + if (!worker_id || is_present === undefined) { + logger.warn('출근 체크 데이터 누락', { checkin }); + continue; + } + + const result = await AttendanceModel.upsertCheckin({ + worker_id, + record_date: date, + is_present + }); + + results.push({ + worker_id, + record_id: result, + is_present + }); + } + + logger.info('출근 체크 일괄 저장 성공', { date, saved: results.length }); + return { + saved_count: results.length, + results + }; + } catch (error) { + logger.error('출근 체크 일괄 저장 실패', { date, error: error.message }); + throw new DatabaseError('출근 체크 저장 중 데이터베이스 오류가 발생했습니다'); + } +}; + module.exports = { getDailyAttendanceStatusService, getDailyAttendanceRecordsService, @@ -257,5 +328,7 @@ module.exports = { getAttendanceTypesService, getVacationTypesService, getWorkerVacationBalanceService, - getMonthlyAttendanceStatsService + getMonthlyAttendanceStatsService, + getCheckinListService, + saveCheckinsService }; diff --git a/api.hyungi.net/utils/hangulToRoman.js b/api.hyungi.net/utils/hangulToRoman.js index 5d93d9f..fcc5ae0 100644 --- a/api.hyungi.net/utils/hangulToRoman.js +++ b/api.hyungi.net/utils/hangulToRoman.js @@ -122,19 +122,29 @@ function convertToRoman(text) { /** * 사용자명 생성 (중복 확인 및 처리) * @param {string} koreanName - 한글 이름 - * @param {object} knex - Knex 인스턴스 + * @param {object} db - Database connection (mysql2 pool or knex) * @returns {Promise} 고유한 username */ -async function generateUniqueUsername(koreanName, knex) { +async function generateUniqueUsername(koreanName, db) { const baseUsername = hangulToRoman(koreanName); let username = baseUsername; let counter = 1; // 중복 확인 while (true) { - const existing = await knex('users') - .where('username', username) - .first(); + let existing; + + // mysql2 pool 또는 knex 모두 지원 + if (typeof db === 'function') { + // Knex + existing = await db('users') + .where('username', username) + .first(); + } else { + // mysql2 pool + const [rows] = await db.query('SELECT username FROM users WHERE username = ?', [username]); + existing = rows[0]; + } if (!existing) { break; // 중복 없음 diff --git a/web-ui/css/admin-settings.css b/web-ui/css/admin-settings.css index 4d6924e..5b20ed3 100644 --- a/web-ui/css/admin-settings.css +++ b/web-ui/css/admin-settings.css @@ -675,3 +675,100 @@ flex-direction: column; } } + +/* 페이지 권한 관리 스타일 */ +.page-access-list { + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--bg-secondary); +} + +.page-access-category { + margin-bottom: var(--space-4); +} + +.page-access-category:last-child { + margin-bottom: 0; +} + +.page-access-category-title { + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--text-secondary); + margin-bottom: var(--space-2); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-light); + text-transform: uppercase; +} + +.page-access-item { + display: flex; + align-items: center; + padding: var(--space-2); + border-radius: var(--radius-sm); + transition: var(--transition-normal); +} + +.page-access-item:hover { + background: var(--bg-hover); +} + +.page-access-item label { + display: flex; + align-items: center; + cursor: pointer; + flex: 1; + margin: 0; +} + +.page-access-item input[type="checkbox"] { + margin-right: var(--space-2); + width: 18px; + height: 18px; + cursor: pointer; +} + +.page-access-item .page-name { + font-size: var(--text-sm); + color: var(--text-primary); + font-weight: var(--font-medium); +} + +/* 권한 관리 버튼 스타일 */ +.action-btn.permissions { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; +} + +.action-btn.permissions:hover { + background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); +} + +/* 페이지 권한 모달 사용자 정보 */ +.page-access-user-info { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4); + background: var(--bg-secondary); + border-radius: var(--radius-lg); + margin-bottom: var(--space-4); +} + +.page-access-user-info h3 { + margin: 0; + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.page-access-user-info p { + margin: 0; + font-size: var(--text-sm); + color: var(--text-secondary); +} diff --git a/web-ui/css/annual-vacation-overview.css b/web-ui/css/annual-vacation-overview.css new file mode 100644 index 0000000..3d1730f --- /dev/null +++ b/web-ui/css/annual-vacation-overview.css @@ -0,0 +1,348 @@ +/** + * annual-vacation-overview.css + * 연간 연차 현황 페이지 스타일 + */ + +.page-container { + min-height: 100vh; + background: var(--color-bg-primary); +} + +.main-content { + padding: 2rem 0; +} + +.content-wrapper { + max-width: 1400px; + margin: 0 auto; + padding: 0 2rem; +} + +/* 페이지 헤더 */ +.page-header { + margin-bottom: 2rem; +} + +.page-title { + font-size: 2rem; + font-weight: 700; + color: var(--color-text-primary); + margin-bottom: 0.5rem; +} + +.page-description { + font-size: 1rem; + color: var(--color-text-secondary); + margin: 0; +} + +/* 필터 섹션 */ +.filter-section { + margin-bottom: 2rem; +} + +.filter-controls { + display: flex; + gap: 1rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 200px; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.form-select { + padding: 0.625rem 1rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: white; + font-size: 0.875rem; + color: var(--color-text-primary); + transition: all 0.2s; +} + +.form-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* 탭 네비게이션 */ +.tabs-section { + margin-bottom: 2rem; +} + +.tabs-nav { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid var(--color-border); + padding: 0; + margin: 0; +} + +.tab-btn { + padding: 1rem 2rem; + border: none; + background: none; + color: var(--color-text-secondary); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + position: relative; + transition: all 0.2s; + border-radius: 8px 8px 0 0; +} + +.tab-btn:hover { + color: var(--color-primary); + background: rgba(59, 130, 246, 0.05); +} + +.tab-btn.active { + color: var(--color-primary); + background: white; +} + +.tab-btn.active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 2px; + background: var(--color-primary); +} + +/* 탭 컨텐츠 */ +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* 월 선택 컨트롤 */ +.month-controls { + display: flex; + gap: 1rem; + align-items: center; +} + +.month-controls .form-select { + min-width: 120px; +} + +/* 차트 섹션 */ +.chart-section { + margin-bottom: 2rem; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.card-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.chart-controls { + display: flex; + gap: 0.5rem; +} + +.btn-outline { + background: white; + border: 1px solid var(--color-border); + color: var(--color-text-secondary); +} + +.btn-outline:hover { + background: var(--color-bg-secondary); + border-color: var(--color-primary); + color: var(--color-primary); +} + +.btn-outline.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +.chart-container { + position: relative; + height: 500px; + padding: 1.5rem; +} + +/* 테이블 섹션 */ +.table-section { + margin-bottom: 2rem; +} + +.table-responsive { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.data-table thead { + background: var(--color-bg-secondary); +} + +.data-table th { + padding: 1rem; + text-align: left; + font-weight: 600; + color: var(--color-text-primary); + border-bottom: 2px solid var(--color-border); + white-space: nowrap; +} + +.data-table tbody tr { + border-bottom: 1px solid var(--color-border); + transition: background 0.2s; +} + +.data-table tbody tr:hover { + background: var(--color-bg-secondary); +} + +.data-table td { + padding: 1rem; + color: var(--color-text-primary); +} + +.loading-state { + padding: 3rem 1rem !important; + text-align: center; +} + +.loading-state .spinner { + margin: 0 auto 1rem; + width: 40px; + height: 40px; + border: 4px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.loading-state p { + margin: 0; + color: var(--color-text-secondary); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* 사용률 프로그레스 바 */ +.usage-rate-cell { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.progress-bar { + flex: 1; + height: 8px; + background: var(--color-bg-secondary); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.progress-fill.low { + background: linear-gradient(90deg, #10b981 0%, #059669 100%); +} + +.progress-fill.medium { + background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); +} + +.progress-fill.high { + background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); +} + +.usage-rate-text { + font-weight: 600; + min-width: 45px; + text-align: right; +} + +/* 반응형 */ +@media (max-width: 768px) { + .content-wrapper { + padding: 0 1rem; + } + + .page-title { + font-size: 1.5rem; + } + + .tabs-nav { + flex-direction: column; + gap: 0; + } + + .tab-btn { + border-radius: 0; + } + + .filter-controls { + flex-direction: column; + align-items: stretch; + } + + .form-group { + width: 100%; + min-width: auto; + } + + .chart-container { + height: 400px; + } + + .card-header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .chart-controls { + width: 100%; + } + + .chart-controls button { + flex: 1; + } +} diff --git a/web-ui/css/daily-work-report.css b/web-ui/css/daily-work-report.css index e935cba..656af04 100644 --- a/web-ui/css/daily-work-report.css +++ b/web-ui/css/daily-work-report.css @@ -809,3 +809,202 @@ .confirm-btn:active { transform: translateY(0) scale(0.98); } + +/* ================================================ + 저장 결과 모달 스타일 + ================================================ */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 1rem; +} + +.modal-container.result-modal { + background: white; + border-radius: 12px; + max-width: 500px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +.modal-header { + padding: 1.5rem; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.modal-close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6b7280; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background 0.2s; +} + +.modal-close-btn:hover { + background: #f3f4f6; +} + +.modal-body { + padding: 2rem 1.5rem; +} + +.result-icon { + text-align: center; + font-size: 3rem; + margin-bottom: 1rem; +} + +.result-title { + text-align: center; + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.result-title.success { + color: #10b981; +} + +.result-title.error { + color: #ef4444; +} + +.result-title.warning { + color: #f59e0b; +} + +.result-message { + text-align: center; + color: #6b7280; + margin-bottom: 1rem; +} + +.result-details { + margin-top: 1.5rem; + padding: 1rem; + background: #f9fafb; + border-radius: 8px; +} + +.result-details h4 { + margin: 0 0 0.5rem 0; + font-size: 0.875rem; + font-weight: 600; + color: #374151; +} + +.result-details ul { + margin: 0; + padding-left: 1.5rem; +} + +.result-details li { + margin-bottom: 0.25rem; + color: #6b7280; +} + +.result-details p { + margin: 0; + color: #6b7280; +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid #e5e7eb; + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +/* ================================================================= + 일괄제출 버튼 스타일 + ================================================================= */ + +.batch-submit-container { + padding: 1rem; + background: #f9fafb; + border-top: 2px solid #e5e7eb; + display: flex; + justify-content: center; + align-items: center; +} + +.btn-batch-submit { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: none; + padding: 0.875rem 2rem; + font-size: 1rem; + font-weight: 600; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3); + min-width: 280px; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn-batch-submit:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + box-shadow: 0 6px 12px rgba(59, 130, 246, 0.4); + transform: translateY(-2px); +} + +.btn-batch-submit:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3); +} + +.btn-batch-submit:disabled { + background: #9ca3af; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +/* 수동입력 섹션 강조 */ +.manual-input-section { + border: 2px solid #f59e0b; + border-radius: 8px; + margin-bottom: 2rem; + box-shadow: 0 4px 6px rgba(245, 158, 11, 0.1); +} + +.manual-input-section .tbm-session-header { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); +} + +/* TBM 세션 그룹 간격 조정 */ +.tbm-session-group:not(.manual-input-section) { + margin-bottom: 1.5rem; +} diff --git a/web-ui/css/vacation-allocation.css b/web-ui/css/vacation-allocation.css new file mode 100644 index 0000000..6e3a315 --- /dev/null +++ b/web-ui/css/vacation-allocation.css @@ -0,0 +1,472 @@ +/** + * vacation-allocation.css + * 휴가 발생 입력 페이지 스타일 + */ + +.page-container { + min-height: 100vh; + background: var(--color-bg-primary); +} + +.main-content { + padding: 2rem 0; +} + +.content-wrapper { + max-width: 1400px; + margin: 0 auto; + padding: 0 2rem; +} + +/* 페이지 헤더 */ +.page-header { + margin-bottom: 2rem; +} + +.page-title { + font-size: 2rem; + font-weight: 700; + color: var(--color-text-primary); + margin-bottom: 0.5rem; +} + +.page-description { + font-size: 1rem; + color: var(--color-text-secondary); + margin: 0; +} + +/* 탭 네비게이션 */ +.tab-navigation { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + border-bottom: 2px solid var(--color-border); +} + +.tab-button { + padding: 1rem 2rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s; + position: relative; + bottom: -2px; +} + +.tab-button:hover { + color: var(--color-primary); + background: var(--color-bg-secondary); +} + +.tab-button.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* 탭 콘텐츠 */ +.tab-content { + display: none; +} + +.tab-content.active { + display: block; + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 폼 섹션 */ +.form-section { + margin-bottom: 2rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--color-border); +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.required { + color: #ef4444; +} + +.form-select, +.form-input { + padding: 0.625rem 1rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: white; + font-size: 0.875rem; + color: var(--color-text-primary); + transition: all 0.2s; +} + +.form-select:focus, +.form-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-input[type="number"] { + max-width: 200px; +} + +.form-group small { + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +/* 자동 계산 섹션 */ +.auto-calculate-section { + background: var(--color-bg-secondary); + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.section-header h3 { + font-size: 1rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.alert { + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.alert-info { + background: #dbeafe; + color: #1e40af; + border: 1px solid #93c5fd; +} + +.alert-warning { + background: #fef3c7; + color: #92400e; + border: 1px solid #fde68a; +} + +.alert-success { + background: #d1fae5; + color: #065f46; + border: 1px solid #6ee7b7; +} + +/* 폼 액션 버튼 */ +.form-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; +} + +/* 기존 데이터 섹션 */ +.existing-data-section { + margin-top: 2rem; +} + +.existing-data-section h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 1rem; +} + +/* 미리보기 섹션 */ +.preview-section { + margin-top: 2rem; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + max-height: 0; + } + to { + opacity: 1; + max-height: 1000px; + } +} + +.preview-section h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 1rem; +} + +/* 테이블 */ +.table-responsive { + overflow-x: auto; + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.data-table thead { + background: var(--color-bg-secondary); +} + +.data-table th { + padding: 1rem; + text-align: left; + font-weight: 600; + color: var(--color-text-primary); + border-bottom: 2px solid var(--color-border); + white-space: nowrap; +} + +.data-table tbody tr { + border-bottom: 1px solid var(--color-border); + transition: background 0.2s; +} + +.data-table tbody tr:hover { + background: var(--color-bg-secondary); +} + +.data-table td { + padding: 1rem; + color: var(--color-text-primary); +} + +.loading-state { + padding: 3rem 1rem !important; + text-align: center; +} + +.loading-state .spinner { + margin: 0 auto 1rem; + width: 40px; + height: 40px; + border: 4px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-state p { + margin: 0; + color: var(--color-text-secondary); +} + +/* 액션 버튼 */ +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.btn-icon { + padding: 0.5rem; + min-width: auto; + font-size: 1rem; +} + +/* 배지 */ +.badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-info { + background: #dbeafe; + color: #1e40af; +} + +.badge-success { + background: #d1fae5; + color: #065f46; +} + +.badge-warning { + background: #fef3c7; + color: #92400e; +} + +.badge-error { + background: #fee2e2; + color: #991b1b; +} + +/* 모달 */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: white; + border-radius: 12px; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.modal-header h3 { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.modal-close:hover { + background: var(--color-bg-secondary); + color: var(--color-text-primary); +} + +.modal-body { + padding: 1.5rem; +} + +/* 반응형 */ +@media (max-width: 768px) { + .content-wrapper { + padding: 0 1rem; + } + + .page-title { + font-size: 1.5rem; + } + + .tab-navigation { + overflow-x: auto; + } + + .tab-button { + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + white-space: nowrap; + } + + .form-row { + grid-template-columns: 1fr; + } + + .form-actions { + flex-direction: column; + } + + .form-actions button { + width: 100%; + } + + .section-header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .modal-content { + width: 95%; + max-height: 95vh; + } +} diff --git a/web-ui/js/admin-settings.js b/web-ui/js/admin-settings.js index 3faf812..9caa11e 100644 --- a/web-ui/js/admin-settings.js +++ b/web-ui/js/admin-settings.js @@ -241,6 +241,11 @@ function renderUsersTable() { + ${user.role !== 'Admin' && user.role !== 'admin' ? ` + + ` : ''} @@ -344,17 +349,25 @@ function openAddUserModal() { function editUser(userId) { const user = users.find(u => u.user_id === userId); if (!user) return; - + currentEditingUser = user; - + if (elements.modalTitle) { elements.modalTitle.textContent = '사용자 정보 수정'; } - + + // 역할 이름을 HTML select option value로 변환 + const roleToValueMap = { + 'Admin': 'admin', + 'System Admin': 'admin', + 'User': 'user', + 'Guest': 'user' + }; + // 폼에 데이터 채우기 if (elements.userNameInput) elements.userNameInput.value = user.name || ''; if (elements.userIdInput) elements.userIdInput.value = user.username || ''; - if (elements.userRoleSelect) elements.userRoleSelect.value = user.role || ''; + if (elements.userRoleSelect) elements.userRoleSelect.value = roleToValueMap[user.role] || 'user'; if (elements.userEmailInput) elements.userEmailInput.value = user.email || ''; if (elements.userPhoneInput) elements.userPhoneInput.value = user.phone || ''; @@ -403,24 +416,26 @@ async function saveUser() { const formData = { name: elements.userNameInput?.value, username: elements.userIdInput?.value, - role: elements.userRoleSelect?.value, + role: elements.userRoleSelect?.value, // HTML select value는 이미 'admin' 또는 'user' email: elements.userEmailInput?.value, phone: elements.userPhoneInput?.value }; - + + console.log('저장할 데이터:', formData); + // 유효성 검사 if (!formData.name || !formData.username || !formData.role) { showToast('필수 항목을 모두 입력해주세요.', 'error'); return; } - + // 비밀번호 처리 if (!currentEditingUser && elements.userPasswordInput?.value) { formData.password = elements.userPasswordInput.value; } else if (currentEditingUser && elements.userPasswordInput?.value) { formData.password = elements.userPasswordInput.value; } - + let response; if (currentEditingUser) { // 수정 @@ -429,17 +444,17 @@ async function saveUser() { // 생성 response = await window.apiCall('/users', 'POST', formData); } - + if (response.success || response.user_id) { const action = currentEditingUser ? '수정' : '생성'; showToast(`사용자가 성공적으로 ${action}되었습니다.`, 'success'); - + closeUserModal(); await loadUsers(); } else { throw new Error(response.message || '사용자 저장에 실패했습니다.'); } - + } catch (error) { console.error('사용자 저장 오류:', error); showToast(`사용자 저장 중 오류가 발생했습니다: ${error.message}`, 'error'); @@ -532,3 +547,325 @@ window.deleteUser = deleteUser; window.toggleUserStatus = toggleUserStatus; window.closeUserModal = closeUserModal; window.closeDeleteModal = closeDeleteModal; + +// ========== 페이지 권한 관리 ========== // +let allPages = []; +let userPageAccess = []; + +// 모든 페이지 목록 로드 +async function loadAllPages() { + try { + const response = await apiCall('/pages'); + allPages = response.data || response || []; + console.log('📄 페이지 목록 로드:', allPages.length, '개'); + } catch (error) { + console.error('❌ 페이지 목록 로드 오류:', error); + allPages = []; + } +} + +// 사용자의 페이지 권한 로드 +async function loadUserPageAccess(userId) { + try { + const response = await apiCall(`/users/${userId}/page-access`); + userPageAccess = response.data?.pageAccess || []; + console.log(`👤 사용자 ${userId} 페이지 권한 로드:`, userPageAccess.length, '개'); + } catch (error) { + console.error('❌ 사용자 페이지 권한 로드 오류:', error); + userPageAccess = []; + } +} + +// 페이지 권한 체크박스 렌더링 +function renderPageAccessList(userRole) { + const pageAccessList = document.getElementById('pageAccessList'); + const pageAccessGroup = document.getElementById('pageAccessGroup'); + + if (!pageAccessList || !pageAccessGroup) return; + + // Admin 사용자는 권한 설정 불필요 + if (userRole === 'admin') { + pageAccessGroup.style.display = 'none'; + return; + } + + pageAccessGroup.style.display = 'block'; + + // 카테고리별로 페이지 그룹화 + const pagesByCategory = { + 'work': [], + 'admin': [], + 'common': [], + 'profile': [] + }; + + allPages.forEach(page => { + const category = page.category || 'common'; + if (pagesByCategory[category]) { + pagesByCategory[category].push(page); + } + }); + + const categoryNames = { + 'common': '공통', + 'work': '작업', + 'admin': '관리', + 'profile': '프로필' + }; + + // HTML 생성 + let html = ''; + + Object.keys(pagesByCategory).forEach(category => { + const pages = pagesByCategory[category]; + if (pages.length === 0) return; + + const catName = categoryNames[category] || category; + html += '
'; + html += '
' + catName + '
'; + + pages.forEach(page => { + // 프로필과 대시보드는 모든 사용자가 접근 가능하므로 체크박스 비활성화 + const isAlwaysAccessible = page.page_key === 'dashboard' || page.page_key.startsWith('profile.'); + const isChecked = userPageAccess.find(p => p.page_id === page.id && p.can_access === 1) || isAlwaysAccessible; + + html += '
'; + }); + + html += '
'; + }); + + pageAccessList.innerHTML = html; +} + +// 페이지 권한 저장 +async function savePageAccess(userId) { + try { + const checkboxes = document.querySelectorAll('.page-access-checkbox:not([disabled])'); + const pageAccessData = []; + + checkboxes.forEach(checkbox => { + pageAccessData.push({ + page_id: parseInt(checkbox.dataset.pageId), + can_access: checkbox.checked ? 1 : 0 + }); + }); + + console.log('📤 페이지 권한 저장:', userId, pageAccessData); + + await apiCall(`/users/${userId}/page-access`, 'PUT', { + pageAccess: pageAccessData + }); + + console.log('✅ 페이지 권한 저장 완료'); + } catch (error) { + console.error('❌ 페이지 권한 저장 오류:', error); + throw error; + } +} + +// editUser 함수를 수정하여 페이지 권한 로드 추가 +const originalEditUser = window.editUser; +window.editUser = async function(userId) { + // 페이지 목록이 없으면 로드 + if (allPages.length === 0) { + await loadAllPages(); + } + + // 원래 editUser 함수 실행 + if (originalEditUser) { + originalEditUser(userId); + } + + // 사용자의 페이지 권한 로드 + await loadUserPageAccess(userId); + + // 사용자 정보 가져오기 + const user = users.find(u => u.user_id === userId); + if (!user) return; + + // 페이지 권한 체크박스 렌더링 + const roleToValueMap = { + 'Admin': 'admin', + 'System Admin': 'admin', + 'User': 'user', + 'Guest': 'user' + }; + const userRole = roleToValueMap[user.role] || 'user'; + renderPageAccessList(userRole); +}; + +// saveUser 함수를 수정하여 페이지 권한 저장 추가 +const originalSaveUser = window.saveUser; +window.saveUser = async function() { + try { + // 원래 saveUser 함수 실행 + if (originalSaveUser) { + await originalSaveUser(); + } + + // 사용자 편집 시에만 페이지 권한 저장 + if (currentEditingUser && currentEditingUser.user_id) { + const userRole = document.getElementById('userRole')?.value; + + // Admin이 아닌 경우에만 페이지 권한 저장 + if (userRole !== 'admin') { + await savePageAccess(currentEditingUser.user_id); + } + } + + } catch (error) { + console.error('❌ 저장 오류:', error); + throw error; + } +}; + + + +// ========== 페이지 권한 관리 모달 ========== // +let currentPageAccessUser = null; + +// 페이지 권한 관리 모달 열기 +async function managePageAccess(userId) { + try { + // 페이지 목록이 없으면 로드 + if (allPages.length === 0) { + await loadAllPages(); + } + + // 사용자 정보 가져오기 + const user = users.find(u => u.user_id === userId); + if (!user) { + showToast('사용자를 찾을 수 없습니다.', 'error'); + return; + } + + currentPageAccessUser = user; + + // 사용자의 페이지 권한 로드 + await loadUserPageAccess(userId); + + // 모달 정보 업데이트 + const userName = user.name || user.username; + document.getElementById('pageAccessModalTitle').textContent = userName + ' - 페이지 권한 관리'; + document.getElementById('pageAccessUserName').textContent = userName; + document.getElementById('pageAccessUserRole').textContent = getRoleName(user.role); + document.getElementById('pageAccessUserAvatar').textContent = userName.charAt(0); + + // 페이지 권한 체크박스 렌더링 + renderPageAccessModalList(); + + // 모달 표시 + document.getElementById('pageAccessModal').style.display = 'flex'; + } catch (error) { + console.error('❌ 페이지 권한 관리 모달 오류:', error); + showToast('페이지 권한 관리를 열 수 없습니다.', 'error'); + } +} + +// 페이지 권한 모달 닫기 +function closePageAccessModal() { + document.getElementById('pageAccessModal').style.display = 'none'; + currentPageAccessUser = null; +} + +// 페이지 권한 체크박스 렌더링 (모달용) +function renderPageAccessModalList() { + const pageAccessList = document.getElementById('pageAccessModalList'); + if (!pageAccessList) return; + + // 카테고리별로 페이지 그룹화 + const pagesByCategory = { + 'work': [], + 'admin': [], + 'common': [], + 'profile': [] + }; + + allPages.forEach(page => { + const category = page.category || 'common'; + if (pagesByCategory[category]) { + pagesByCategory[category].push(page); + } + }); + + const categoryNames = { + 'common': '공통', + 'work': '작업', + 'admin': '관리', + 'profile': '프로필' + }; + + // HTML 생성 + let html = ''; + + Object.keys(pagesByCategory).forEach(category => { + const pages = pagesByCategory[category]; + if (pages.length === 0) return; + + const catName = categoryNames[category] || category; + html += '
'; + html += '
' + catName + '
'; + + pages.forEach(page => { + // 프로필과 대시보드는 모든 사용자가 접근 가능 + const isAlwaysAccessible = page.page_key === 'dashboard' || page.page_key.startsWith('profile.'); + const isChecked = userPageAccess.find(p => p.page_id === page.id && p.can_access === 1) || isAlwaysAccessible; + + html += '
'; + }); + + html += '
'; + }); + + pageAccessList.innerHTML = html; +} + +// 페이지 권한 저장 (모달용) +async function savePageAccessFromModal() { + if (!currentPageAccessUser) { + showToast('사용자 정보가 없습니다.', 'error'); + return; + } + + try { + await savePageAccess(currentPageAccessUser.user_id); + showToast('페이지 권한이 저장되었습니다.', 'success'); + + // 캐시 삭제 (사용자가 다시 로그인하거나 페이지 새로고침 필요) + localStorage.removeItem('userPageAccess'); + + closePageAccessModal(); + } catch (error) { + console.error('❌ 페이지 권한 저장 오류:', error); + showToast('페이지 권한 저장에 실패했습니다.', 'error'); + } +} + +// 전역 함수로 등록 +window.managePageAccess = managePageAccess; +window.closePageAccessModal = closePageAccessModal; + +// 저장 버튼 이벤트 리스너 +document.addEventListener('DOMContentLoaded', () => { + const saveBtn = document.getElementById('savePageAccessBtn'); + if (saveBtn) { + saveBtn.addEventListener('click', savePageAccessFromModal); + } +}); diff --git a/web-ui/js/annual-vacation-overview.js b/web-ui/js/annual-vacation-overview.js new file mode 100644 index 0000000..04b22ef --- /dev/null +++ b/web-ui/js/annual-vacation-overview.js @@ -0,0 +1,412 @@ +/** + * annual-vacation-overview.js + * 연간 연차 현황 페이지 로직 (2-탭 구조) + */ + +import { API_BASE_URL } from './api-config.js'; + +// 전역 변수 +let annualUsageChart = null; +let currentYear = new Date().getFullYear(); +let vacationRequests = []; + +/** + * 페이지 초기화 + */ +document.addEventListener('DOMContentLoaded', async () => { + // 관리자 권한 체크 + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id); + + if (!isAdmin) { + alert('관리자만 접근할 수 있습니다'); + window.location.href = '/pages/dashboard.html'; + return; + } + + initializeYearSelector(); + initializeMonthSelector(); + initializeEventListeners(); + await loadAnnualUsageData(); +}); + +/** + * 연도 선택 초기화 + */ +function initializeYearSelector() { + const yearSelect = document.getElementById('yearSelect'); + const currentYear = new Date().getFullYear(); + + // 최근 5년, 현재 연도, 다음 연도 + for (let year = currentYear - 5; year <= currentYear + 1; year++) { + const option = document.createElement('option'); + option.value = year; + option.textContent = `${year}년`; + if (year === currentYear) { + option.selected = true; + } + yearSelect.appendChild(option); + } +} + +/** + * 월 선택 초기화 + */ +function initializeMonthSelector() { + const monthSelect = document.getElementById('monthSelect'); + const currentMonth = new Date().getMonth() + 1; + + // 현재 월을 기본 선택 + monthSelect.value = currentMonth; +} + +/** + * 이벤트 리스너 초기화 + */ +function initializeEventListeners() { + // 탭 전환 + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const tabName = e.target.dataset.tab; + switchTab(tabName); + }); + }); + + // 조회 버튼 + document.getElementById('refreshBtn').addEventListener('click', async () => { + await loadAnnualUsageData(); + const activeTab = document.querySelector('.tab-btn.active').dataset.tab; + if (activeTab === 'monthlyDetails') { + await loadMonthlyDetails(); + } + }); + + // 연도 변경 시 자동 조회 + document.getElementById('yearSelect').addEventListener('change', async () => { + await loadAnnualUsageData(); + const activeTab = document.querySelector('.tab-btn.active').dataset.tab; + if (activeTab === 'monthlyDetails') { + await loadMonthlyDetails(); + } + }); + + // 월 선택 변경 시 + document.getElementById('monthSelect').addEventListener('change', loadMonthlyDetails); + + // 엑셀 다운로드 + document.getElementById('exportExcelBtn').addEventListener('click', exportToExcel); +} + +/** + * 탭 전환 + */ +function switchTab(tabName) { + // 탭 버튼 활성화 + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('active'); + if (btn.dataset.tab === tabName) { + btn.classList.add('active'); + } + }); + + // 탭 콘텐츠 활성화 + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.remove('active'); + }); + + if (tabName === 'annualUsage') { + document.getElementById('annualUsageTab').classList.add('active'); + } else if (tabName === 'monthlyDetails') { + document.getElementById('monthlyDetailsTab').classList.add('active'); + loadMonthlyDetails(); + } +} + +/** + * 연간 사용 데이터 로드 (탭 1) + */ +async function loadAnnualUsageData() { + const year = document.getElementById('yearSelect').value; + + try { + const token = localStorage.getItem('token'); + + // 해당 연도의 모든 승인된 휴가 신청 조회 + const response = await fetch( + `${API_BASE_URL}/api/vacation-requests?start_date=${year}-01-01&end_date=${year}-12-31&status=approved`, + { + headers: { + 'Authorization': `Bearer ${token}` + } + } + ); + + if (!response.ok) { + throw new Error('휴가 데이터를 불러오는데 실패했습니다'); + } + + const result = await response.json(); + vacationRequests = result.data || []; + + // 월별로 집계 + const monthlyData = aggregateMonthlyUsage(vacationRequests); + + // 잔여 일수 계산 (올해 총 부여 - 사용) + const remainingDays = await calculateRemainingDays(year); + + updateAnnualUsageChart(monthlyData, remainingDays); + } catch (error) { + console.error('연간 사용 데이터 로드 오류:', error); + showToast('데이터를 불러오는데 실패했습니다', 'error'); + } +} + +/** + * 월별 사용 일수 집계 + */ +function aggregateMonthlyUsage(requests) { + const monthlyUsage = Array(12).fill(0); // 1월~12월 + + requests.forEach(req => { + const startDate = new Date(req.start_date); + const endDate = new Date(req.end_date); + const daysUsed = req.days_used || 0; + + // 간단한 집계: 시작일의 월에 모든 일수를 할당 + // (더 정교한 계산이 필요하면 일자별로 쪼개야 함) + const month = startDate.getMonth(); // 0-11 + monthlyUsage[month] += daysUsed; + }); + + return monthlyUsage; +} + +/** + * 잔여 일수 계산 + */ +async function calculateRemainingDays(year) { + try { + const token = localStorage.getItem('token'); + + // 전체 작업자의 휴가 잔액 조회 + const response = await fetch(`${API_BASE_URL}/api/vacation-balances/year/${year}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + return 0; + } + + const result = await response.json(); + const balances = result.data || []; + + // 전체 잔여 일수 합계 + const totalRemaining = balances.reduce((sum, item) => sum + (item.remaining_days || 0), 0); + return totalRemaining; + } catch (error) { + console.error('잔여 일수 계산 오류:', error); + return 0; + } +} + +/** + * 연간 사용 차트 업데이트 + */ +function updateAnnualUsageChart(monthlyData, remainingDays) { + const ctx = document.getElementById('annualUsageChart'); + + // 기존 차트 삭제 + if (annualUsageChart) { + annualUsageChart.destroy(); + } + + const labels = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월', '잔여']; + const data = [...monthlyData, remainingDays]; + + annualUsageChart = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [{ + label: '일수', + data: data, + backgroundColor: data.map((_, idx) => + idx === 12 ? 'rgba(16, 185, 129, 0.8)' : 'rgba(59, 130, 246, 0.8)' + ), + borderColor: data.map((_, idx) => + idx === 12 ? 'rgba(16, 185, 129, 1)' : 'rgba(59, 130, 246, 1)' + ), + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + callbacks: { + label: function(context) { + return `${context.parsed.y}일`; + } + } + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + stepSize: 5 + } + } + } + } + }); +} + +/** + * 월별 상세 기록 로드 (탭 2) + */ +async function loadMonthlyDetails() { + const year = document.getElementById('yearSelect').value; + const month = document.getElementById('monthSelect').value; + + try { + const token = localStorage.getItem('token'); + + // 해당 월의 모든 휴가 신청 조회 (승인된 것만) + const startDate = `${year}-${String(month).padStart(2, '0')}-01`; + const lastDay = new Date(year, month, 0).getDate(); + const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`; + + const response = await fetch( + `${API_BASE_URL}/api/vacation-requests?start_date=${startDate}&end_date=${endDate}&status=approved`, + { + headers: { + 'Authorization': `Bearer ${token}` + } + } + ); + + if (!response.ok) { + throw new Error('월별 데이터를 불러오는데 실패했습니다'); + } + + const result = await response.json(); + const monthlyRequests = result.data || []; + + updateMonthlyTable(monthlyRequests); + } catch (error) { + console.error('월별 상세 기록 로드 오류:', error); + showToast('데이터를 불러오는데 실패했습니다', 'error'); + } +} + +/** + * 월별 테이블 업데이트 + */ +function updateMonthlyTable(requests) { + const tbody = document.getElementById('monthlyTableBody'); + + if (requests.length === 0) { + tbody.innerHTML = ` + + +

데이터가 없습니다

+ + + `; + return; + } + + tbody.innerHTML = requests.map(req => { + const statusText = req.status === 'approved' ? '승인' : req.status === 'pending' ? '대기' : '거부'; + const statusClass = req.status === 'approved' ? 'success' : req.status === 'pending' ? 'warning' : 'danger'; + + return ` + + ${req.worker_name} + ${req.vacation_type_name} + ${req.start_date} + ${req.end_date} + ${req.days_used}일 + ${req.reason || '-'} + ${statusText} + + `; + }).join(''); +} + +/** + * 엑셀 다운로드 + */ +function exportToExcel() { + const year = document.getElementById('yearSelect').value; + const month = document.getElementById('monthSelect').value; + const tbody = document.getElementById('monthlyTableBody'); + + // 테이블에 데이터가 없으면 중단 + if (!tbody.querySelector('tr:not(.loading-state)')) { + showToast('다운로드할 데이터가 없습니다', 'warning'); + return; + } + + // CSV 형식으로 데이터 생성 + const headers = ['작업자명', '휴가유형', '시작일', '종료일', '사용일수', '사유', '상태']; + const rows = Array.from(tbody.querySelectorAll('tr:not(.loading-state)')).map(tr => { + const cells = tr.querySelectorAll('td'); + return Array.from(cells).map(cell => { + // badge 클래스가 있으면 텍스트만 추출 + const badge = cell.querySelector('.badge'); + return badge ? badge.textContent : cell.textContent; + }); + }); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.join(',')) + ].join('\n'); + + // BOM 추가 (한글 깨짐 방지) + const BOM = '\uFEFF'; + const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', `월별_연차_상세_${year}_${month}월.csv`); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + showToast('엑셀 파일이 다운로드되었습니다', 'success'); +} + +/** + * 토스트 메시지 표시 + */ +function showToast(message, type = 'info') { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('show'); + }, 10); + + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => { + container.removeChild(toast); + }, 300); + }, 3000); +} diff --git a/web-ui/js/api-config.js b/web-ui/js/api-config.js index 34aa063..95b4ce2 100644 --- a/web-ui/js/api-config.js +++ b/web-ui/js/api-config.js @@ -243,4 +243,7 @@ setInterval(() => { alert('세션이 만료되었습니다. 다시 로그인해주세요.'); redirectToLogin(); } -}, config.app.tokenRefreshInterval); // 5분마다 확인 \ No newline at end of file +}, config.app.tokenRefreshInterval); // 5분마다 확인 + +// ES6 모듈 export +export { API_URL as API_BASE_URL }; \ No newline at end of file diff --git a/web-ui/js/auth-check.js b/web-ui/js/auth-check.js index c3224b0..8b146f1 100644 --- a/web-ui/js/auth-check.js +++ b/web-ui/js/auth-check.js @@ -14,10 +14,105 @@ function getUser() { function clearAuthData() { localStorage.removeItem('token'); localStorage.removeItem('user'); + localStorage.removeItem('userPageAccess'); // 페이지 권한 캐시도 삭제 +} + +/** + * 현재 페이지의 page_key를 URL 경로로부터 추출 + * 예: /pages/work/tbm.html -> work.tbm + * /pages/admin/accounts.html -> admin.accounts + * /pages/dashboard.html -> dashboard + */ +function getCurrentPageKey() { + const path = window.location.pathname; + + // /pages/로 시작하는지 확인 + if (!path.startsWith('/pages/')) { + return null; + } + + // /pages/ 이후 경로 추출 + const pagePath = path.substring(7); // '/pages/' 제거 + + // .html 제거 + const withoutExt = pagePath.replace('.html', ''); + + // 슬래시를 점으로 변환 + const pageKey = withoutExt.replace(/\//g, '.'); + + return pageKey; +} + +/** + * 사용자의 페이지 접근 권한 확인 (캐시 활용) + */ +async function checkPageAccess(pageKey) { + const currentUser = getUser(); + + // Admin은 모든 페이지 접근 가능 + if (currentUser.role === 'Admin' || currentUser.role === 'System Admin') { + return true; + } + + // 프로필 페이지는 모든 사용자 접근 가능 + if (pageKey && pageKey.startsWith('profile.')) { + return true; + } + + // 대시보드는 모든 사용자 접근 가능 + if (pageKey === 'dashboard') { + return true; + } + + try { + // 캐시된 권한 확인 + const cached = localStorage.getItem('userPageAccess'); + let accessiblePages = null; + + if (cached) { + const cacheData = JSON.parse(cached); + // 캐시가 5분 이내인 경우 사용 + if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) { + accessiblePages = cacheData.pages; + } + } + + // 캐시가 없으면 API 호출 + if (!accessiblePages) { + const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + console.error('페이지 권한 조회 실패:', response.status); + return false; + } + + const data = await response.json(); + accessiblePages = data.data.pageAccess || []; + + // 캐시 저장 + localStorage.setItem('userPageAccess', JSON.stringify({ + pages: accessiblePages, + timestamp: Date.now() + })); + } + + // 해당 페이지에 대한 접근 권한 확인 + const pageAccess = accessiblePages.find(p => p.page_key === pageKey); + return pageAccess && pageAccess.can_access === 1; + } catch (error) { + console.error('페이지 권한 체크 오류:', error); + return false; + } } // 즉시 실행 함수로 스코프를 보호하고 로직을 실행 -(function() { +(async function() { if (!isLoggedIn()) { console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.'); clearAuthData(); // 만약을 위해 한번 더 정리 @@ -26,7 +121,7 @@ function clearAuthData() { } const currentUser = getUser(); - + // 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우) if (!currentUser || !currentUser.username) { console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.'); @@ -38,6 +133,25 @@ function clearAuthData() { const userRole = currentUser.role || currentUser.access_level || '사용자'; console.log(`✅ ${currentUser.username}(${userRole})님 인증 성공.`); + // 페이지 접근 권한 체크 (Admin은 건너뛰기) + if (currentUser.role !== 'Admin' && currentUser.role !== 'System Admin') { + const pageKey = getCurrentPageKey(); + + if (pageKey) { + console.log(`🔍 페이지 권한 체크: ${pageKey}`); + const hasAccess = await checkPageAccess(pageKey); + + if (!hasAccess) { + console.error(`🚫 페이지 접근 권한이 없습니다: ${pageKey}`); + alert('이 페이지에 접근할 권한이 없습니다.'); + window.location.href = '/pages/dashboard.html'; + return; + } + + console.log(`✅ 페이지 접근 권한 확인됨: ${pageKey}`); + } + } + // 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함. // 전역 변수 할당(window.currentUser) 제거. })(); \ No newline at end of file diff --git a/web-ui/js/daily-work-report.js b/web-ui/js/daily-work-report.js index 1acba6c..5884ab1 100644 --- a/web-ui/js/daily-work-report.js +++ b/web-ui/js/daily-work-report.js @@ -80,7 +80,18 @@ async function loadIncompleteTbms() { throw new Error(response.message || '미완료 TBM 조회 실패'); } - incompleteTbms = response.data || []; + let data = response.data || []; + + // 사용자 권한 확인 및 필터링 + const user = getUser(); + if (user && user.role !== 'Admin' && user.access_level !== 'system') { + // 일반 사용자: 자신이 생성한 세션만 표시 + const userId = user.user_id; + data = data.filter(tbm => tbm.created_by === userId); + } + // 관리자는 모든 데이터 표시 + + incompleteTbms = data; renderTbmWorkList(); } catch (error) { console.error('미완료 TBM 로드 오류:', error); @@ -88,6 +99,14 @@ async function loadIncompleteTbms() { } } +/** + * 사용자 정보 가져오기 (auth-check.js와 동일한 로직) + */ +function getUser() { + const user = localStorage.getItem('user'); + return user ? JSON.parse(user) : null; +} + /** * TBM 작업 목록 렌더링 (세션별 그룹화) */ @@ -120,11 +139,42 @@ function renderTbmWorkList() { `; + // 수동 입력 섹션 먼저 추가 (맨 위) + html += ` +
+
+ 수동 입력 + TBM에 없는 작업을 추가로 입력할 수 있습니다 +
+
+ + + + + + + + + + + + + + + + + + +
작업자날짜프로젝트공정작업작업장소작업시간
(시간)
부적합
(시간)
부적합 원인제출
+
+
+ `; + // 각 TBM 세션별로 테이블 생성 Object.keys(groupedTbms).forEach(key => { const group = groupedTbms[key]; html += ` -
+
TBM 세션 ${formatDate(group.session_date)} @@ -150,7 +200,7 @@ function renderTbmWorkList() { ${group.items.map(tbm => { const index = tbm.originalIndex; return ` - +
${tbm.worker_name || '작업자'} @@ -202,41 +252,17 @@ function renderTbmWorkList() {
+
+ +
`; }); - // 수동 입력 섹션 추가 - html += ` -
-
- 수동 입력 - TBM에 없는 작업을 추가로 입력할 수 있습니다 -
-
- - - - - - - - - - - - - - - - - - -
작업자날짜프로젝트공정작업작업장소작업시간
(시간)
부적합
(시간)
부적합 원인제출
-
-
- `; - container.innerHTML = html; } @@ -286,13 +312,20 @@ window.submitTbmWorkReport = async function(index) { return; } + // 날짜를 YYYY-MM-DD 형식으로 변환 + const reportDate = tbm.session_date instanceof Date + ? tbm.session_date.toISOString().split('T')[0] + : (typeof tbm.session_date === 'string' && tbm.session_date.includes('T') + ? tbm.session_date.split('T')[0] + : tbm.session_date); + const reportData = { tbm_assignment_id: tbm.assignment_id, tbm_session_id: tbm.session_id, worker_id: tbm.worker_id, project_id: tbm.project_id, work_type_id: tbm.work_type_id, - report_date: tbm.session_date, + report_date: reportDate, start_time: null, end_time: null, total_hours: totalHours, @@ -301,6 +334,9 @@ window.submitTbmWorkReport = async function(index) { work_status_id: errorHours > 0 ? 2 : 1 }; + console.log('🔍 TBM 제출 데이터:', JSON.stringify(reportData, null, 2)); + console.log('🔍 tbm 객체:', tbm); + try { const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData); @@ -325,6 +361,155 @@ window.submitTbmWorkReport = async function(index) { } }; +/** + * TBM 세션 일괄제출 + */ +window.batchSubmitTbmSession = async function(sessionKey) { + // 해당 세션의 모든 항목 가져오기 + const sessionRows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"]`); + + if (sessionRows.length === 0) { + showMessage('제출할 항목이 없습니다.', 'error'); + return; + } + + // 1단계: 모든 항목 검증 + const validationErrors = []; + const itemsToSubmit = []; + + sessionRows.forEach((row, rowIndex) => { + const index = parseInt(row.getAttribute('data-index')); + const tbm = incompleteTbms[index]; + + const totalHours = parseFloat(document.getElementById(`totalHours_${index}`)?.value); + const errorHours = parseFloat(document.getElementById(`errorHours_${index}`)?.value) || 0; + const errorTypeId = document.getElementById(`errorType_${index}`)?.value; + + // 검증 + if (!totalHours || totalHours <= 0) { + validationErrors.push(`${tbm.worker_name}: 작업시간 미입력`); + return; + } + + if (errorHours > totalHours) { + validationErrors.push(`${tbm.worker_name}: 부적합 시간이 총 작업시간 초과`); + return; + } + + if (errorHours > 0 && !errorTypeId) { + validationErrors.push(`${tbm.worker_name}: 부적합 원인 미선택`); + return; + } + + // 검증 통과한 항목 저장 + const reportDate = tbm.session_date instanceof Date + ? tbm.session_date.toISOString().split('T')[0] + : (typeof tbm.session_date === 'string' && tbm.session_date.includes('T') + ? tbm.session_date.split('T')[0] + : tbm.session_date); + + itemsToSubmit.push({ + index, + tbm, + data: { + tbm_assignment_id: tbm.assignment_id, + tbm_session_id: tbm.session_id, + worker_id: tbm.worker_id, + project_id: tbm.project_id, + work_type_id: tbm.work_type_id, + report_date: reportDate, + start_time: null, + end_time: null, + total_hours: totalHours, + error_hours: errorHours, + error_type_id: errorTypeId || null, + work_status_id: errorHours > 0 ? 2 : 1 + } + }); + }); + + // 검증 실패가 하나라도 있으면 전체 중단 + if (validationErrors.length > 0) { + showSaveResultModal( + 'error', + '일괄제출 검증 실패', + '모든 항목이 유효해야 제출할 수 있습니다.', + validationErrors + ); + return; + } + + // 2단계: 모든 항목 제출 + const submitBtn = event.target; + submitBtn.disabled = true; + submitBtn.textContent = '제출 중...'; + + const results = { + success: [], + failed: [] + }; + + try { + for (const item of itemsToSubmit) { + try { + const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', item.data); + + if (response.success) { + results.success.push(item.tbm.worker_name); + } else { + results.failed.push(`${item.tbm.worker_name}: ${response.message}`); + } + } catch (error) { + results.failed.push(`${item.tbm.worker_name}: ${error.message}`); + } + } + + // 결과 표시 + const totalCount = itemsToSubmit.length; + const successCount = results.success.length; + const failedCount = results.failed.length; + + if (failedCount === 0) { + // 모두 성공 + showSaveResultModal( + 'success', + '일괄제출 완료', + `${totalCount}건의 작업보고서가 모두 성공적으로 제출되었습니다.`, + results.success.map(name => `✓ ${name}`) + ); + } else if (successCount === 0) { + // 모두 실패 + showSaveResultModal( + 'error', + '일괄제출 실패', + `${totalCount}건의 작업보고서가 모두 실패했습니다.`, + results.failed.map(msg => `✗ ${msg}`) + ); + } else { + // 일부 성공, 일부 실패 + const details = [ + ...results.success.map(name => `✓ ${name} - 성공`), + ...results.failed.map(msg => `✗ ${msg}`) + ]; + showSaveResultModal( + 'warning', + '일괄제출 부분 완료', + `성공: ${successCount}건 / 실패: ${failedCount}건`, + details + ); + } + + // 목록 새로고침 + await loadIncompleteTbms(); + } catch (error) { + console.error('일괄제출 오류:', error); + showSaveResultModal('error', '일괄제출 오류', error.message); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = `📤 이 세션 일괄제출 (${sessionRows.length}건)`; + } +}; + /** * 수동 작업 추가 */ @@ -1075,15 +1260,23 @@ function showSaveResultModal(type, title, message, details = null) { `; // 상세 정보가 있으면 추가 - if (details && details.length > 0) { - content += ` -
-

상세 정보:

-
    - ${details.map(detail => `
  • ${detail}
  • `).join('')} -
-
- `; + if (details) { + if (Array.isArray(details) && details.length > 0) { + content += ` +
+

상세 정보:

+
    + ${details.map(detail => `
  • ${detail}
  • `).join('')} +
+
+ `; + } else if (typeof details === 'string') { + content += ` +
+

${details}

+
+ `; + } } titleElement.textContent = '저장 결과'; @@ -1114,6 +1307,9 @@ function closeSaveResultModal() { document.removeEventListener('keydown', closeSaveResultModal); } +// 전역에서 접근 가능하도록 window에 할당 +window.closeSaveResultModal = closeSaveResultModal; + // 단계 이동 function goToStep(stepNumber) { for (let i = 1; i <= 3; i++) { diff --git a/web-ui/js/equipment-management.js b/web-ui/js/equipment-management.js index 644b5ef..413ce1d 100644 --- a/web-ui/js/equipment-management.js +++ b/web-ui/js/equipment-management.js @@ -8,9 +8,31 @@ let currentEquipment = null; // 페이지 로드 시 초기화 document.addEventListener('DOMContentLoaded', async () => { + // axios 설정이 완료될 때까지 대기 + await waitForAxiosConfig(); await loadInitialData(); }); +// axios 설정 대기 함수 +function waitForAxiosConfig() { + return new Promise((resolve) => { + const check = setInterval(() => { + if (axios.defaults.baseURL) { + clearInterval(check); + resolve(); + } + }, 50); + // 최대 5초 대기 + setTimeout(() => { + clearInterval(check); + if (!axios.defaults.baseURL) { + console.error('⚠️ Axios 설정 시간 초과'); + } + resolve(); + }, 5000); + }); +} + // 초기 데이터 로드 async function loadInitialData() { try { @@ -28,7 +50,7 @@ async function loadInitialData() { // 설비 목록 로드 async function loadEquipments() { try { - const response = await axios.get('/api/equipments'); + const response = await axios.get('/equipments'); if (response.data.success) { equipments = response.data.data; renderEquipmentList(); @@ -42,7 +64,7 @@ async function loadEquipments() { // 작업장 목록 로드 async function loadWorkplaces() { try { - const response = await axios.get('/api/workplaces'); + const response = await axios.get('/workplaces'); if (response.data.success) { workplaces = response.data.data; populateWorkplaceFilters(); @@ -56,7 +78,7 @@ async function loadWorkplaces() { // 설비 유형 목록 로드 async function loadEquipmentTypes() { try { - const response = await axios.get('/api/equipments/types'); + const response = await axios.get('/equipments/types'); if (response.data.success) { equipmentTypes = response.data.data; populateTypeFilter(); @@ -220,7 +242,7 @@ function openEquipmentModal(equipmentId = null) { // 설비 데이터 로드 (수정용) async function loadEquipmentData(equipmentId) { try { - const response = await axios.get(`/api/equipments/${equipmentId}`); + const response = await axios.get(`/equipments/${equipmentId}`); if (response.data.success) { const equipment = response.data.data; @@ -281,10 +303,10 @@ async function saveEquipment() { let response; if (equipmentId) { // 수정 - response = await axios.put(`/api/equipments/${equipmentId}`, equipmentData); + response = await axios.put(`/equipments/${equipmentId}`, equipmentData); } else { // 추가 - response = await axios.post('/api/equipments', equipmentData); + response = await axios.post('/equipments', equipmentData); } if (response.data.success) { @@ -318,7 +340,7 @@ async function deleteEquipment(equipmentId) { } try { - const response = await axios.delete(`/api/equipments/${equipmentId}`); + const response = await axios.delete(`/equipments/${equipmentId}`); if (response.data.success) { alert('설비가 삭제되었습니다.'); await loadEquipments(); diff --git a/web-ui/js/load-navbar.js b/web-ui/js/load-navbar.js index c1edcf7..f15358d 100644 --- a/web-ui/js/load-navbar.js +++ b/web-ui/js/load-navbar.js @@ -17,37 +17,95 @@ const ROLE_NAMES = { * 네비게이션 바 DOM을 사용자 정보와 역할에 맞게 수정하는 프로세서입니다. * @param {Document} doc - 파싱된 HTML 문서 객체 */ -function processNavbarDom(doc) { +async function processNavbarDom(doc) { const currentUser = getUser(); if (!currentUser) return; - // 1. 역할 기반 메뉴 필터링 - filterMenuByRole(doc, currentUser.role); + // 1. 역할 및 페이지 권한 기반 메뉴 필터링 + await filterMenuByPageAccess(doc, currentUser); // 2. 사용자 정보 채우기 populateUserInfo(doc, currentUser); } /** - * 사용자 역할에 따라 메뉴 항목을 필터링합니다. + * 사용자의 페이지 접근 권한에 따라 메뉴 항목을 필터링합니다. * @param {Document} doc - 파싱된 HTML 문서 객체 - * @param {string} userRole - 현재 사용자의 역할 + * @param {object} currentUser - 현재 사용자 객체 */ -function filterMenuByRole(doc, userRole) { - // 대소문자 구분 없이 처리 - const userRoleLower = (userRole || '').toLowerCase(); +async function filterMenuByPageAccess(doc, currentUser) { + const userRole = (currentUser.role || '').toLowerCase(); - const selectors = [ - { role: 'admin', selector: '.admin-only' }, - { role: 'system', selector: '.system-only' }, - { role: 'leader', selector: '.leader-only' }, - ]; + // Admin은 모든 메뉴 표시 + if (userRole === 'admin' || userRole === 'system') { + return; + } - selectors.forEach(({ role, selector }) => { - if (userRoleLower !== role && userRoleLower !== 'system') { - doc.querySelectorAll(selector).forEach(el => el.remove()); + try { + // 사용자의 페이지 접근 권한 조회 + const cached = localStorage.getItem('userPageAccess'); + let accessiblePages = null; + + if (cached) { + const cacheData = JSON.parse(cached); + // 캐시가 5분 이내인 경우 사용 + if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) { + accessiblePages = cacheData.pages; + } } - }); + + // 캐시가 없으면 API 호출 + if (!accessiblePages) { + const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + console.error('페이지 권한 조회 실패:', response.status); + return; + } + + const data = await response.json(); + accessiblePages = data.data.pageAccess || []; + + // 캐시 저장 + localStorage.setItem('userPageAccess', JSON.stringify({ + pages: accessiblePages, + timestamp: Date.now() + })); + } + + // 접근 가능한 페이지 키 목록 + const accessiblePageKeys = accessiblePages + .filter(p => p.can_access === 1) + .map(p => p.page_key); + + // 메뉴 항목에 data-page-key 속성이 있으면 해당 권한 체크 + const menuItems = doc.querySelectorAll('[data-page-key]'); + menuItems.forEach(item => { + const pageKey = item.getAttribute('data-page-key'); + + // 대시보드와 프로필 페이지는 모든 사용자 접근 가능 + if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) { + return; + } + + // 권한이 없으면 메뉴 항목 제거 + if (!accessiblePageKeys.includes(pageKey)) { + item.remove(); + } + }); + + // Admin 전용 메뉴는 무조건 제거 + doc.querySelectorAll('.admin-only').forEach(el => el.remove()); + + } catch (error) { + console.error('메뉴 필터링 오류:', error); + } } /** diff --git a/web-ui/js/modern-dashboard.js b/web-ui/js/modern-dashboard.js index 32c108e..a86bb60 100644 --- a/web-ui/js/modern-dashboard.js +++ b/web-ui/js/modern-dashboard.js @@ -34,18 +34,18 @@ const elements = { userName: document.getElementById('userName'), userRole: document.getElementById('userRole'), userInitial: document.getElementById('userInitial'), - selectedDate: document.getElementById('selectedDate'), - refreshBtn: document.getElementById('refreshBtn'), + selectedDate: document.getElementById('selectedDate'), // 작업장 현황으로 교체되어 없을 수 있음 + refreshBtn: document.getElementById('refreshBtn'), // 작업장 현황으로 교체되어 없을 수 있음 logoutBtn: document.getElementById('logoutBtn'), - + // 요약 카드 todayWorkers: document.getElementById('todayWorkers'), totalHours: document.getElementById('totalHours'), activeProjects: document.getElementById('activeProjects'), errorCount: document.getElementById('errorCount'), - + // 컨테이너 - workStatusContainer: document.getElementById('workStatusContainer'), + workStatusContainer: document.getElementById('workStatusContainer'), // 작업장 현황으로 교체되어 없을 수 있음 workersContainer: document.getElementById('workersContainer'), toastContainer: document.getElementById('toastContainer') }; @@ -84,15 +84,19 @@ async function initializeDashboard() { // 시간 업데이트 시작 updateCurrentTime(); setInterval(updateCurrentTime, 1000); - - // 날짜 설정 - elements.selectedDate.value = selectedDate; - + + // 날짜 설정 (요소가 있을 때만) + if (elements.selectedDate) { + elements.selectedDate.value = selectedDate; + } + // 이벤트 리스너 설정 setupEventListeners(); - - // 데이터 로드 - await loadDashboardData(); + + // 데이터 로드 (작업 현황 컨테이너가 있을 때만) + if (elements.workStatusContainer) { + await loadDashboardData(); + } // 관리자 권한 확인 checkAdminAccess(); @@ -154,18 +158,22 @@ function updateCurrentTime() { // ========== 이벤트 리스너 ========== // function setupEventListeners() { - // 날짜 변경 - elements.selectedDate.addEventListener('change', (e) => { - selectedDate = e.target.value; - loadDashboardData(); - }); - - // 새로고침 버튼 - elements.refreshBtn.addEventListener('click', () => { - loadDashboardData(); - showToast('데이터를 새로고침했습니다.', 'success'); - }); - + // 날짜 변경 (요소가 있을 때만) + if (elements.selectedDate) { + elements.selectedDate.addEventListener('change', (e) => { + selectedDate = e.target.value; + loadDashboardData(); + }); + } + + // 새로고침 버튼 (요소가 있을 때만) + if (elements.refreshBtn) { + elements.refreshBtn.addEventListener('click', () => { + loadDashboardData(); + showToast('데이터를 새로고침했습니다.', 'success'); + }); + } + // 로그아웃 버튼 (navbar 컴포넌트가 이미 처리하므로 버튼이 있을 때만) if (elements.logoutBtn) { elements.logoutBtn.addEventListener('click', () => { @@ -747,19 +755,31 @@ async function checkTbmPageAccess() { return; } - console.log('🛠️ TBM 페이지 권한 확인 중...'); + const tbmQuickAction = document.getElementById('tbmQuickAction'); + if (!tbmQuickAction) { + console.log('⚠️ TBM 빠른 작업 버튼 요소를 찾을 수 없습니다'); + return; + } - // 사용자의 페이지 접근 권한 조회 + console.log('🛠️ TBM 페이지 권한 확인 중...', { role: currentUser.role, access_level: currentUser.access_level }); + + // Admin은 모든 페이지 접근 가능 + if (currentUser.role === 'Admin' || currentUser.role === 'System Admin' || currentUser.access_level === 'admin' || currentUser.access_level === 'system') { + tbmQuickAction.style.display = 'block'; + console.log('✅ Admin 사용자 - TBM 빠른 작업 버튼 표시'); + return; + } + + // 일반 사용자는 페이지 접근 권한 조회 const response = await window.apiCall(`/users/${currentUser.user_id}/page-access`); if (response && response.success) { const pageAccess = response.data?.pageAccess || []; - // 'tbm' 페이지 접근 권한 확인 - const tbmPage = pageAccess.find(p => p.page_key === 'tbm'); - const tbmQuickAction = document.getElementById('tbmQuickAction'); + // 'work.tbm' 페이지 접근 권한 확인 (마이그레이션에서 work.tbm으로 등록함) + const tbmPage = pageAccess.find(p => p.page_key === 'work.tbm'); - if (tbmPage && tbmPage.can_access && tbmQuickAction) { + if (tbmPage && tbmPage.can_access) { tbmQuickAction.style.display = 'block'; console.log('✅ TBM 페이지 접근 권한 있음 - 빠른 작업 버튼 표시'); } else { diff --git a/web-ui/js/safety-management.js b/web-ui/js/safety-management.js new file mode 100644 index 0000000..fcd2ff3 --- /dev/null +++ b/web-ui/js/safety-management.js @@ -0,0 +1,447 @@ +// 안전관리 대시보드 JavaScript + +let currentStatus = 'pending'; +let requests = []; +let currentRejectRequestId = null; + +// ==================== Toast 알림 ==================== + +function showToast(message, type = 'info', duration = 3000) { + const toastContainer = document.getElementById('toastContainer') || createToastContainer(); + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + + const iconMap = { + success: '✅', + error: '❌', + warning: '⚠️', + info: 'ℹ️' + }; + + toast.innerHTML = ` + ${iconMap[type] || 'ℹ️'} + ${message} + `; + + toastContainer.appendChild(toast); + + setTimeout(() => toast.classList.add('show'), 10); + + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +function createToastContainer() { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 10px; + `; + + document.body.appendChild(container); + + if (!document.getElementById('toastStyles')) { + const style = document.createElement('style'); + style.id = 'toastStyles'; + style.textContent = ` + .toast { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + opacity: 0; + transform: translateX(100px); + transition: all 0.3s ease; + min-width: 250px; + max-width: 400px; + } + .toast.show { + opacity: 1; + transform: translateX(0); + } + .toast-success { border-left: 4px solid #10b981; } + .toast-error { border-left: 4px solid #ef4444; } + .toast-warning { border-left: 4px solid #f59e0b; } + .toast-info { border-left: 4px solid #3b82f6; } + .toast-icon { font-size: 20px; } + .toast-message { font-size: 14px; color: #374151; } + `; + document.head.appendChild(style); + } + + return container; +} + +// ==================== 초기화 ==================== + +document.addEventListener('DOMContentLoaded', async () => { + await loadRequests(); + updateStats(); +}); + +// ==================== 데이터 로드 ==================== + +/** + * 출입 신청 목록 로드 + */ +async function loadRequests() { + try { + const filters = currentStatus === 'all' ? {} : { status: currentStatus }; + const queryString = new URLSearchParams(filters).toString(); + + const response = await window.apiCall(`/workplace-visits/requests?${queryString}`, 'GET'); + + if (response && response.success) { + requests = response.data || []; + renderRequestTable(); + updateStats(); + } + } catch (error) { + console.error('출입 신청 목록 로드 오류:', error); + showToast('출입 신청 목록을 불러오는데 실패했습니다.', 'error'); + } +} + +/** + * 통계 업데이트 + */ +async function updateStats() { + try { + const response = await window.apiCall('/workplace-visits/requests', 'GET'); + + if (response && response.success) { + const allRequests = response.data || []; + + const stats = { + pending: allRequests.filter(r => r.status === 'pending').length, + approved: allRequests.filter(r => r.status === 'approved').length, + training_completed: allRequests.filter(r => r.status === 'training_completed').length, + rejected: allRequests.filter(r => r.status === 'rejected').length + }; + + document.getElementById('statPending').textContent = stats.pending; + document.getElementById('statApproved').textContent = stats.approved; + document.getElementById('statTrainingCompleted').textContent = stats.training_completed; + document.getElementById('statRejected').textContent = stats.rejected; + } + } catch (error) { + console.error('통계 업데이트 오류:', error); + } +} + +/** + * 테이블 렌더링 + */ +function renderRequestTable() { + const container = document.getElementById('requestTableContainer'); + + if (requests.length === 0) { + container.innerHTML = ` +
+
📭
+

출입 신청이 없습니다

+

현재 ${getStatusText(currentStatus)} 상태의 신청이 없습니다.

+
+ `; + return; + } + + let html = ` + + + + + + + + + + + + + + + + `; + + requests.forEach(req => { + const statusText = { + 'pending': '승인 대기', + 'approved': '승인됨', + 'rejected': '반려됨', + 'training_completed': '교육 완료' + }[req.status] || req.status; + + html += ` + + + + + + + + + + + + `; + }); + + html += ` + +
신청일신청자방문자인원방문 작업장방문 일시목적상태작업
${new Date(req.created_at).toLocaleDateString()}${req.requester_full_name || req.requester_name}${req.visitor_company}${req.visitor_count}명${req.category_name} - ${req.workplace_name}${req.visit_date} ${req.visit_time}${req.purpose_name}${statusText} +
+ + ${req.status === 'pending' ? ` + + + ` : ''} + ${req.status === 'approved' ? ` + + ` : ''} +
+
+ `; + + container.innerHTML = html; +} + +/** + * 상태 텍스트 변환 + */ +function getStatusText(status) { + const map = { + 'pending': '승인 대기', + 'approved': '승인 완료', + 'rejected': '반려', + 'training_completed': '교육 완료', + 'all': '전체' + }; + return map[status] || status; +} + +// ==================== 탭 전환 ==================== + +/** + * 탭 전환 + */ +async function switchTab(status) { + currentStatus = status; + + // 탭 활성화 상태 변경 + document.querySelectorAll('.status-tab').forEach(tab => { + if (tab.dataset.status === status) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + await loadRequests(); +} + +// ==================== 상세보기 ==================== + +/** + * 상세보기 모달 열기 + */ +async function viewDetail(requestId) { + try { + const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET'); + + if (response && response.success) { + const req = response.data; + const statusText = { + 'pending': '승인 대기', + 'approved': '승인됨', + 'rejected': '반려됨', + 'training_completed': '교육 완료' + }[req.status] || req.status; + + let html = ` +
+
신청 번호
+
#${req.request_id}
+ +
신청일
+
${new Date(req.created_at).toLocaleString()}
+ +
신청자
+
${req.requester_full_name || req.requester_name}
+ +
방문자 소속
+
${req.visitor_company}
+ +
방문 인원
+
${req.visitor_count}명
+ +
방문 구역
+
${req.category_name}
+ +
방문 작업장
+
${req.workplace_name}
+ +
방문 날짜
+
${req.visit_date}
+ +
방문 시간
+
${req.visit_time}
+ +
방문 목적
+
${req.purpose_name}
+ +
상태
+
${statusText}
+
+ `; + + if (req.notes) { + html += ` +
+ 비고:
+ ${req.notes} +
+ `; + } + + if (req.rejection_reason) { + html += ` +
+ 반려 사유:
+ ${req.rejection_reason} +
+ `; + } + + if (req.approved_by) { + html += ` +
+ 처리 정보:
+ 처리자: ${req.approver_name || 'Unknown'}
+ 처리 시간: ${new Date(req.approved_at).toLocaleString()} +
+ `; + } + + document.getElementById('detailContent').innerHTML = html; + document.getElementById('detailModal').style.display = 'flex'; + } + } catch (error) { + console.error('상세 정보 로드 오류:', error); + showToast('상세 정보를 불러오는데 실패했습니다.', 'error'); + } +} + +/** + * 상세보기 모달 닫기 + */ +function closeDetailModal() { + document.getElementById('detailModal').style.display = 'none'; +} + +// ==================== 승인/반려 ==================== + +/** + * 승인 처리 + */ +async function approveRequest(requestId) { + if (!confirm('이 출입 신청을 승인하시겠습니까?')) { + return; + } + + try { + const response = await window.apiCall(`/workplace-visits/requests/${requestId}/approve`, 'PUT'); + + if (response && response.success) { + showToast('출입 신청이 승인되었습니다.', 'success'); + await loadRequests(); + updateStats(); + } else { + throw new Error(response?.message || '승인 실패'); + } + } catch (error) { + console.error('승인 처리 오류:', error); + showToast(error.message || '승인 처리 중 오류가 발생했습니다.', 'error'); + } +} + +/** + * 반려 모달 열기 + */ +function openRejectModal(requestId) { + currentRejectRequestId = requestId; + document.getElementById('rejectionReason').value = ''; + document.getElementById('rejectModal').style.display = 'flex'; +} + +/** + * 반려 모달 닫기 + */ +function closeRejectModal() { + currentRejectRequestId = null; + document.getElementById('rejectModal').style.display = 'none'; +} + +/** + * 반려 확정 + */ +async function confirmReject() { + const reason = document.getElementById('rejectionReason').value.trim(); + + if (!reason) { + showToast('반려 사유를 입력해주세요.', 'warning'); + return; + } + + try { + const response = await window.apiCall( + `/workplace-visits/requests/${currentRejectRequestId}/reject`, + 'PUT', + { rejection_reason: reason } + ); + + if (response && response.success) { + showToast('출입 신청이 반려되었습니다.', 'success'); + closeRejectModal(); + await loadRequests(); + updateStats(); + } else { + throw new Error(response?.message || '반려 실패'); + } + } catch (error) { + console.error('반려 처리 오류:', error); + showToast(error.message || '반려 처리 중 오류가 발생했습니다.', 'error'); + } +} + +// ==================== 안전교육 진행 ==================== + +/** + * 안전교육 진행 페이지로 이동 + */ +function startTraining(requestId) { + window.location.href = `/pages/admin/safety-training-conduct.html?request_id=${requestId}`; +} + +// 전역 함수로 노출 +window.showToast = showToast; +window.switchTab = switchTab; +window.viewDetail = viewDetail; +window.closeDetailModal = closeDetailModal; +window.approveRequest = approveRequest; +window.openRejectModal = openRejectModal; +window.closeRejectModal = closeRejectModal; +window.confirmReject = confirmReject; +window.startTraining = startTraining; diff --git a/web-ui/js/safety-training-conduct.js b/web-ui/js/safety-training-conduct.js new file mode 100644 index 0000000..a7bd2d1 --- /dev/null +++ b/web-ui/js/safety-training-conduct.js @@ -0,0 +1,553 @@ +// 안전교육 진행 페이지 JavaScript + +let requestId = null; +let requestData = null; +let canvas = null; +let ctx = null; +let isDrawing = false; +let hasSignature = false; +let savedSignatures = []; // 저장된 서명 목록 + +// ==================== Toast 알림 ==================== + +function showToast(message, type = 'info', duration = 3000) { + const toastContainer = document.getElementById('toastContainer') || createToastContainer(); + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + + const iconMap = { + success: '✅', + error: '❌', + warning: '⚠️', + info: 'ℹ️' + }; + + toast.innerHTML = ` + ${iconMap[type] || 'ℹ️'} + ${message} + `; + + toastContainer.appendChild(toast); + + setTimeout(() => toast.classList.add('show'), 10); + + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +function createToastContainer() { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 10px; + `; + + document.body.appendChild(container); + + if (!document.getElementById('toastStyles')) { + const style = document.createElement('style'); + style.id = 'toastStyles'; + style.textContent = ` + .toast { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + opacity: 0; + transform: translateX(100px); + transition: all 0.3s ease; + min-width: 250px; + max-width: 400px; + } + .toast.show { + opacity: 1; + transform: translateX(0); + } + .toast-success { border-left: 4px solid #10b981; } + .toast-error { border-left: 4px solid #ef4444; } + .toast-warning { border-left: 4px solid #f59e0b; } + .toast-info { border-left: 4px solid #3b82f6; } + .toast-icon { font-size: 20px; } + .toast-message { font-size: 14px; color: #374151; } + `; + document.head.appendChild(style); + } + + return container; +} + +// ==================== 초기화 ==================== + +document.addEventListener('DOMContentLoaded', async () => { + // URL 파라미터에서 request_id 가져오기 + const urlParams = new URLSearchParams(window.location.search); + requestId = urlParams.get('request_id'); + + if (!requestId) { + showToast('출입 신청 ID가 없습니다.', 'error'); + setTimeout(() => { + window.location.href = '/pages/admin/safety-management.html'; + }, 2000); + return; + } + + // 서명 캔버스 초기화 + initSignatureCanvas(); + + // 현재 날짜 표시 + const today = new Date().toLocaleDateString('ko-KR'); + document.getElementById('signatureDate').textContent = today; + + // 출입 신청 정보 로드 + await loadRequestInfo(); +}); + +// ==================== 출입 신청 정보 로드 ==================== + +/** + * 출입 신청 정보 로드 + */ +async function loadRequestInfo() { + try { + const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET'); + + if (response && response.success) { + requestData = response.data; + + // 상태 확인 - 승인됨 상태만 진행 가능 + if (requestData.status !== 'approved') { + showToast('이미 처리되었거나 승인되지 않은 신청입니다.', 'error'); + setTimeout(() => { + window.location.href = '/pages/admin/safety-management.html'; + }, 2000); + return; + } + + renderRequestInfo(); + } else { + throw new Error(response?.message || '정보를 불러올 수 없습니다.'); + } + } catch (error) { + console.error('출입 신청 정보 로드 오류:', error); + showToast('출입 신청 정보를 불러오는데 실패했습니다.', 'error'); + } +} + +/** + * 출입 신청 정보 렌더링 + */ +function renderRequestInfo() { + const container = document.getElementById('requestInfo'); + + // 날짜 포맷 변환 + const visitDate = new Date(requestData.visit_date); + const formattedDate = visitDate.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'short' + }); + + const html = ` +
+
신청 번호
+
#${requestData.request_id}
+
+
+
신청자
+
${requestData.requester_full_name || requestData.requester_name}
+
+
+
방문자 소속
+
${requestData.visitor_company}
+
+
+
방문 인원
+
${requestData.visitor_count}명
+
+
+
방문 작업장
+
${requestData.category_name} - ${requestData.workplace_name}
+
+
+
방문 일시
+
${formattedDate} ${requestData.visit_time}
+
+
+
방문 목적
+
${requestData.purpose_name}
+
+ `; + + container.innerHTML = html; +} + +// ==================== 서명 캔버스 ==================== + +/** + * 서명 캔버스 초기화 + */ +function initSignatureCanvas() { + canvas = document.getElementById('signatureCanvas'); + ctx = canvas.getContext('2d'); + + // 캔버스 크기 설정 + const container = canvas.parentElement; + canvas.width = container.clientWidth - 4; // border 제외 + canvas.height = 300; + + // 그리기 설정 + ctx.strokeStyle = '#000'; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // 마우스 이벤트 + canvas.addEventListener('mousedown', startDrawing); + canvas.addEventListener('mousemove', draw); + canvas.addEventListener('mouseup', stopDrawing); + canvas.addEventListener('mouseout', stopDrawing); + + // 터치 이벤트 (모바일, Apple Pencil) + canvas.addEventListener('touchstart', handleTouchStart, { passive: false }); + canvas.addEventListener('touchmove', handleTouchMove, { passive: false }); + canvas.addEventListener('touchend', stopDrawing); + canvas.addEventListener('touchcancel', stopDrawing); + + // Pointer Events (Apple Pencil 최적화) + if (window.PointerEvent) { + canvas.addEventListener('pointerdown', handlePointerDown); + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerup', stopDrawing); + canvas.addEventListener('pointercancel', stopDrawing); + } +} + +/** + * 그리기 시작 (마우스) + */ +function startDrawing(e) { + isDrawing = true; + hasSignature = true; + document.getElementById('signaturePlaceholder').style.display = 'none'; + + const rect = canvas.getBoundingClientRect(); + ctx.beginPath(); + ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top); +} + +/** + * 그리기 (마우스) + */ +function draw(e) { + if (!isDrawing) return; + + const rect = canvas.getBoundingClientRect(); + ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top); + ctx.stroke(); +} + +/** + * 그리기 중지 + */ +function stopDrawing() { + isDrawing = false; + ctx.beginPath(); +} + +/** + * 터치 시작 처리 + */ +function handleTouchStart(e) { + e.preventDefault(); + isDrawing = true; + hasSignature = true; + document.getElementById('signaturePlaceholder').style.display = 'none'; + + const touch = e.touches[0]; + const rect = canvas.getBoundingClientRect(); + ctx.beginPath(); + ctx.moveTo(touch.clientX - rect.left, touch.clientY - rect.top); +} + +/** + * 터치 이동 처리 + */ +function handleTouchMove(e) { + if (!isDrawing) return; + e.preventDefault(); + + const touch = e.touches[0]; + const rect = canvas.getBoundingClientRect(); + ctx.lineTo(touch.clientX - rect.left, touch.clientY - rect.top); + ctx.stroke(); +} + +/** + * Pointer 시작 처리 (Apple Pencil) + */ +function handlePointerDown(e) { + isDrawing = true; + hasSignature = true; + document.getElementById('signaturePlaceholder').style.display = 'none'; + + const rect = canvas.getBoundingClientRect(); + ctx.beginPath(); + ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top); +} + +/** + * Pointer 이동 처리 (Apple Pencil) + */ +function handlePointerMove(e) { + if (!isDrawing) return; + + const rect = canvas.getBoundingClientRect(); + ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top); + ctx.stroke(); +} + +/** + * 서명 지우기 + */ +function clearSignature() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + hasSignature = false; + document.getElementById('signaturePlaceholder').style.display = 'block'; +} + +/** + * 서명을 Base64로 변환 + */ +function getSignatureBase64() { + if (!hasSignature) { + return null; + } + return canvas.toDataURL('image/png'); +} + +/** + * 현재 서명 저장 + */ +function saveSignature() { + if (!hasSignature) { + showToast('서명이 없습니다. 이름과 서명을 작성해주세요.', 'warning'); + return; + } + + const signatureImage = getSignatureBase64(); + const now = new Date(); + + savedSignatures.push({ + id: Date.now(), + image: signatureImage, + timestamp: now.toLocaleString('ko-KR') + }); + + // 서명 카운트 업데이트 + document.getElementById('signatureCount').textContent = savedSignatures.length; + + // 캔버스 초기화 + clearSignature(); + + // 저장된 서명 목록 렌더링 + renderSavedSignatures(); + + // 교육 완료 버튼 활성화 + updateCompleteButton(); + + showToast('서명이 저장되었습니다.', 'success'); +} + +/** + * 저장된 서명 목록 렌더링 + */ +function renderSavedSignatures() { + const container = document.getElementById('savedSignatures'); + + if (savedSignatures.length === 0) { + container.innerHTML = ''; + return; + } + + let html = '

저장된 서명 목록

'; + + savedSignatures.forEach((sig, index) => { + html += ` +
+ 서명 ${index + 1} +
+
방문자 ${index + 1}
+
저장 시간: ${sig.timestamp}
+
+
+ +
+
+ `; + }); + + container.innerHTML = html; +} + +/** + * 서명 삭제 + */ +function deleteSignature(signatureId) { + if (!confirm('이 서명을 삭제하시겠습니까?')) { + return; + } + + savedSignatures = savedSignatures.filter(sig => sig.id !== signatureId); + + // 서명 카운트 업데이트 + document.getElementById('signatureCount').textContent = savedSignatures.length; + + // 목록 다시 렌더링 + renderSavedSignatures(); + + // 교육 완료 버튼 상태 업데이트 + updateCompleteButton(); + + showToast('서명이 삭제되었습니다.', 'success'); +} + +/** + * 교육 완료 버튼 활성화/비활성화 + */ +function updateCompleteButton() { + const completeBtn = document.getElementById('completeBtn'); + + // 체크리스트와 서명이 모두 있어야 활성화 + const checkboxes = document.querySelectorAll('input[name="safety-check"]'); + const checkedItems = Array.from(checkboxes).filter(cb => cb.checked); + const allChecked = checkedItems.length === checkboxes.length; + const hasSignatures = savedSignatures.length > 0; + + completeBtn.disabled = !(allChecked && hasSignatures); +} + +// ==================== 교육 완료 처리 ==================== + +/** + * 교육 완료 처리 + */ +async function completeTraining() { + // 체크리스트 검증 + const checkboxes = document.querySelectorAll('input[name="safety-check"]'); + const checkedItems = Array.from(checkboxes).filter(cb => cb.checked); + + if (checkedItems.length !== checkboxes.length) { + showToast('모든 안전교육 항목을 체크해주세요.', 'warning'); + return; + } + + // 서명 검증 + if (savedSignatures.length === 0) { + showToast('최소 1명 이상의 서명이 필요합니다.', 'warning'); + return; + } + + // 확인 + if (!confirm(`${savedSignatures.length}명의 방문자 안전교육을 완료하시겠습니까?\n완료 후에는 수정할 수 없습니다.`)) { + return; + } + + try { + // 교육 항목 수집 + const trainingItems = checkedItems.map(cb => cb.value).join(', '); + + // API 호출 + const userData = localStorage.getItem('user'); + const currentUser = userData ? JSON.parse(userData) : null; + + if (!currentUser) { + showToast('로그인 정보를 찾을 수 없습니다.', 'error'); + return; + } + + // 현재 시간 + const now = new Date(); + const currentTime = now.toTimeString().split(' ')[0]; // HH:MM:SS + const trainingDate = now.toISOString().split('T')[0]; // YYYY-MM-DD + + // 각 서명에 대해 개별적으로 API 호출 + let successCount = 0; + for (let i = 0; i < savedSignatures.length; i++) { + const sig = savedSignatures[i]; + + const payload = { + request_id: requestId, + conducted_by: currentUser.user_id, + training_date: trainingDate, + training_start_time: currentTime, + training_end_time: currentTime, + training_items: trainingItems, + visitor_name: `방문자 ${i + 1}`, // 순번으로 구분 + signature_image: sig.image, + notes: `교육 완료 - ${checkedItems.length}개 항목 (${i + 1}/${savedSignatures.length})` + }; + + const response = await window.apiCall( + '/workplace-visits/training', + 'POST', + payload + ); + + if (response && response.success) { + successCount++; + } else { + console.error(`서명 ${i + 1} 저장 실패:`, response); + } + } + + if (successCount === savedSignatures.length) { + showToast(`${successCount}명의 안전교육이 완료되었습니다.`, 'success'); + setTimeout(() => { + window.location.href = '/pages/admin/safety-management.html'; + }, 1500); + } else if (successCount > 0) { + showToast(`${successCount}/${savedSignatures.length}명의 교육만 저장되었습니다.`, 'warning'); + } else { + throw new Error('교육 완료 처리 실패'); + } + } catch (error) { + console.error('교육 완료 처리 오류:', error); + showToast(error.message || '교육 완료 처리 중 오류가 발생했습니다.', 'error'); + } +} + +/** + * 뒤로 가기 + */ +function goBack() { + if (hasSignature || document.querySelector('input[name="safety-check"]:checked')) { + if (!confirm('작성 중인 내용이 있습니다. 정말 나가시겠습니까?')) { + return; + } + } + window.location.href = '/pages/admin/safety-management.html'; +} + +// 전역 함수로 노출 +window.showToast = showToast; +window.clearSignature = clearSignature; +window.saveSignature = saveSignature; +window.deleteSignature = deleteSignature; +window.updateCompleteButton = updateCompleteButton; +window.completeTraining = completeTraining; +window.goBack = goBack; diff --git a/web-ui/js/tbm.js b/web-ui/js/tbm.js index 6ad0aaa..6e32ef0 100644 --- a/web-ui/js/tbm.js +++ b/web-ui/js/tbm.js @@ -8,10 +8,67 @@ let allProjects = []; let allWorkTypes = []; let allTasks = []; let allSafetyChecks = []; +let allWorkplaces = []; +let allWorkplaceCategories = []; +let currentUser = null; let currentSessionId = null; let selectedWorkers = new Set(); let currentTab = 'tbm-input'; +// 새로운 TBM 입력 방식 관련 변수 +let workerTaskList = []; // [{worker_id, worker_name, job_type, tasks: [{task_line_id, project_id, ...}]}] +let selectedWorkersInModal = new Set(); // 모달에서 선택된 작업자 ID 세트 +let currentEditingTaskLine = null; // 현재 편집 중인 작업 라인 정보 {workerIndex, taskIndex} +let selectedCategory = null; +let selectedWorkplace = null; +let selectedCategoryName = ''; +let selectedWorkplaceName = ''; +let isBulkMode = false; // 일괄 설정 모드인지 여부 +let bulkSelectedWorkers = new Set(); // 일괄 설정에서 선택된 작업자 인덱스 + +// ==================== 유틸리티 함수 ==================== + +/** + * 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환 + */ +function getTodayKST() { + const now = new Date(); + // 한국 시간대로 변환 (UTC+9) + const kstOffset = 9 * 60; // 9시간을 분 단위로 + const utc = now.getTime() + (now.getTimezoneOffset() * 60000); // UTC 시간 + const kstTime = new Date(utc + (kstOffset * 60000)); // KST 시간 + + const year = kstTime.getFullYear(); + const month = String(kstTime.getMonth() + 1).padStart(2, '0'); + const day = String(kstTime.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +/** + * ISO 날짜 문자열을 YYYY-MM-DD 형식으로 변환 + * @param {string} dateString - ISO 형식 날짜 문자열 또는 YYYY-MM-DD 형식 + * @returns {string} YYYY-MM-DD 형식 날짜 + */ +function formatDate(dateString) { + if (!dateString) return ''; + + // 이미 YYYY-MM-DD 형식이면 그대로 반환 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + return dateString; + } + + // ISO 형식 또는 다른 형식이면 변환 + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +// ==================== 페이지 초기화 ==================== + // 페이지 초기화 document.addEventListener('DOMContentLoaded', async () => { console.log('🛠️ TBM 관리 페이지 초기화'); @@ -28,8 +85,8 @@ document.addEventListener('DOMContentLoaded', async () => { return; } - // 오늘 날짜 설정 - const today = new Date().toISOString().split('T')[0]; + // 오늘 날짜 설정 (서울 시간대 기준) + const today = getTodayKST(); document.getElementById('tbmDate').value = today; document.getElementById('sessionDate').value = today; @@ -55,6 +112,11 @@ function setupEventListeners() { // 초기 데이터 로드 async function loadInitialData() { try { + // 현재 로그인한 사용자 정보 가져오기 + const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}'); + currentUser = userInfo; + console.log('👤 로그인 사용자:', currentUser); + // 작업자 목록 로드 const workersResponse = await window.apiCall('/workers?limit=1000'); if (workersResponse) { @@ -64,11 +126,13 @@ async function loadInitialData() { console.log('✅ 작업자 목록 로드:', allWorkers.length + '명'); } - // 프로젝트 목록 로드 + // 프로젝트 목록 로드 (활성 프로젝트만) const projectsResponse = await window.apiCall('/projects?is_active=1'); if (projectsResponse) { - allProjects = Array.isArray(projectsResponse) ? projectsResponse : (projectsResponse.data || []); - console.log('✅ 프로젝트 목록 로드:', allProjects.length + '개'); + const projects = Array.isArray(projectsResponse) ? projectsResponse : (projectsResponse.data || []); + // 활성 프로젝트만 필터링 (is_active가 1 또는 true인 경우) + allProjects = projects.filter(p => p.is_active === 1 || p.is_active === true || p.is_active === '1'); + console.log('✅ 프로젝트 목록 로드:', allProjects.length + '개 (활성)'); populateProjectSelect(); } @@ -93,6 +157,20 @@ async function loadInitialData() { console.log('✅ 작업 목록 로드:', allTasks.length + '개'); } + // 작업장 목록 로드 + const workplacesResponse = await window.apiCall('/workplaces?is_active=true'); + if (workplacesResponse && workplacesResponse.success) { + allWorkplaces = workplacesResponse.data || []; + console.log('✅ 작업장 목록 로드:', allWorkplaces.length + '개'); + } + + // 작업장 카테고리 로드 + const categoriesResponse = await window.apiCall('/workplaces/categories/active/list'); + if (categoriesResponse && categoriesResponse.success) { + allWorkplaceCategories = categoriesResponse.data || []; + console.log('✅ 작업장 카테고리 로드:', allWorkplaceCategories.length + '개'); + } + } catch (error) { console.error('❌ 초기 데이터 로드 오류:', error); showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error'); @@ -138,7 +216,7 @@ window.switchTbmTab = switchTbmTab; // 오늘의 TBM만 로드 (TBM 입력 탭용) async function loadTodayOnlyTbm() { - const today = new Date().toISOString().split('T')[0]; + const today = getTodayKST(); try { const response = await window.apiCall(`/tbm/sessions/date/${today}`); @@ -192,7 +270,7 @@ function displayTodayTbmSessions() { // 오늘 TBM 로드 (TBM 관리 탭용) async function loadTodayTbm() { - const today = new Date().toISOString().split('T')[0]; + const today = getTodayKST(); document.getElementById('tbmDate').value = today; await loadTbmSessionsByDate(today); } @@ -272,15 +350,20 @@ function createSessionCard(session) { 'cancelled': '취소' }[session.status] || ''; + // 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시) + const leaderDisplay = session.leader_name + ? `${session.leader_name} (${session.leader_job_type || '작업자'})` + : `${session.created_by_name || '작업 책임자'} (관리자)`; + return `

- ${session.leader_name || '팀장 미지정'} + ${leaderDisplay}

- ${session.session_date} | ${session.leader_job_type || ''} + ${formatDate(session.session_date)}

${statusBadge} @@ -327,12 +410,6 @@ function createSessionCard(session) { - - ` : ''}
@@ -342,43 +419,67 @@ function createSessionCard(session) { // 새 TBM 모달 열기 function openNewTbmModal() { currentSessionId = null; + workerTaskList = []; // 작업자 목록 초기화 + document.getElementById('modalTitle').textContent = '새 TBM 시작'; document.getElementById('sessionId').value = ''; document.getElementById('tbmForm').reset(); - const today = new Date().toISOString().split('T')[0]; + const today = getTodayKST(); document.getElementById('sessionDate').value = today; - // 팀장 목록 로드 - populateLeaderSelect(); - populateProjectSelect(); - populateWorkTypeSelect(); - - // 작업 드롭다운 초기화 - const taskSelect = document.getElementById('taskId'); - if (taskSelect) { - taskSelect.innerHTML = ''; - taskSelect.disabled = true; + // 입력자 자동 설정 (readonly) + if (currentUser && currentUser.worker_id) { + const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id); + if (worker) { + document.getElementById('leaderName').value = `${worker.worker_name} (${worker.job_type || ''})`; + document.getElementById('leaderId').value = worker.worker_id; + } + } else if (currentUser && currentUser.name) { + // 관리자: 관리자로 표시 + document.getElementById('leaderName').value = `${currentUser.name} (관리자)`; + document.getElementById('leaderId').value = ''; } + // 작업자 목록 UI 초기화 + renderWorkerTaskList(); + document.getElementById('tbmModal').style.display = 'flex'; document.body.style.overflow = 'hidden'; } window.openNewTbmModal = openNewTbmModal; -// 팀장 선택 드롭다운 채우기 +// 입력자 선택 드롭다운 채우기 function populateLeaderSelect() { const leaderSelect = document.getElementById('leaderId'); if (!leaderSelect) return; - const leaders = allWorkers.filter(w => - w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin' - ); + // 로그인한 사용자가 작업자와 연결되어 있는지 확인 + if (currentUser && currentUser.worker_id) { + // 작업자와 연결된 경우: 자동으로 선택하고 비활성화 + const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id); + if (worker) { + leaderSelect.innerHTML = ``; + leaderSelect.disabled = true; + console.log('✅ 입력자 자동 설정:', worker.worker_name); + } else { + // 작업자를 찾을 수 없는 경우 + leaderSelect.innerHTML = ''; + leaderSelect.disabled = true; + } + } else { + // 관리자 계정 (worker_id가 없음): 드롭다운으로 선택 가능 + const leaders = allWorkers.filter(w => + w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin' + ); - leaderSelect.innerHTML = '' + - leaders.map(w => ` - - `).join(''); + leaderSelect.innerHTML = '' + + leaders.map(w => ` + + `).join(''); + leaderSelect.disabled = false; + console.log('✅ 관리자: 입력자 선택 가능'); + } } // 프로젝트 선택 드롭다운 채우기 @@ -403,6 +504,17 @@ function populateWorkTypeSelect() { `).join(''); } +// 작업장 선택 드롭다운 채우기 +function populateWorkplaceSelect() { + const workLocationSelect = document.getElementById('workLocation'); + if (!workLocationSelect) return; + + workLocationSelect.innerHTML = '' + + allWorkplaces.map(wp => ` + + `).join(''); +} + // 작업(Task) 선택 드롭다운 채우기 (공정 선택 시 호출) function loadTasksByWorkType() { const workTypeId = document.getElementById('workTypeId').value; @@ -441,55 +553,141 @@ function closeTbmModal() { } window.closeTbmModal = closeTbmModal; -// TBM 세션 저장 +// TBM 세션 저장 (작업자별 상세 정보 포함) async function saveTbmSession() { - const workTypeId = document.getElementById('workTypeId').value; - const taskId = document.getElementById('taskId').value; + console.log('💾 TBM 저장 시작...'); + + let leaderId = parseInt(document.getElementById('leaderId').value); + + // 관리자 계정인 경우 leader_id를 null로 설정 + if (!leaderId || isNaN(leaderId)) { + if (!currentUser.worker_id) { + console.log('📝 관리자 계정: leader_id를 NULL로 설정'); + leaderId = null; + } else { + console.error('❌ 입력자 설정 오류'); + showToast('입력자 정보가 올바르지 않습니다.', 'error'); + return; + } + } const sessionData = { session_date: document.getElementById('sessionDate').value, - leader_id: parseInt(document.getElementById('leaderId').value), - project_id: document.getElementById('projectId').value || null, - work_type_id: workTypeId ? parseInt(workTypeId) : null, - task_id: taskId ? parseInt(taskId) : null, - work_location: document.getElementById('workLocation').value || null, - work_description: document.getElementById('workDescription').value || null, - safety_notes: document.getElementById('safetyNotes').value || null, - start_time: document.getElementById('startTime').value || null + leader_id: leaderId }; - if (!sessionData.session_date || !sessionData.leader_id) { - showToast('TBM 날짜와 팀장을 선택해주세요.', 'error'); + console.log('📅 세션 데이터:', sessionData); + console.log('👥 작업자 리스트:', workerTaskList); + console.log('👤 현재 사용자:', currentUser); + + if (!sessionData.session_date) { + console.error('❌ 날짜 누락'); + showToast('TBM 날짜를 확인해주세요.', 'error'); return; } - if (!sessionData.work_type_id || !sessionData.task_id) { - showToast('공정과 작업을 선택해주세요.', 'error'); + if (workerTaskList.length === 0) { + console.error('❌ 작업자 리스트가 비어있음'); + showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error'); return; } + // 필수 항목 검증 (공정, 작업, 작업장) + let hasError = false; + for (const workerData of workerTaskList) { + for (const taskLine of workerData.tasks) { + if (!taskLine.work_type_id || !taskLine.task_id || !taskLine.workplace_id) { + showToast(`${workerData.worker_name}의 공정, 작업, 작업장을 모두 선택해주세요.`, 'error'); + hasError = true; + break; + } + } + if (hasError) break; + } + if (hasError) return; + + // 작업자-작업 데이터를 평평하게 변환 + const members = []; + for (const workerData of workerTaskList) { + for (const taskLine of workerData.tasks) { + members.push({ + worker_id: workerData.worker_id, + project_id: taskLine.project_id || null, + work_type_id: taskLine.work_type_id, + task_id: taskLine.task_id, + workplace_category_id: taskLine.workplace_category_id || null, + workplace_id: taskLine.workplace_id, + work_detail: taskLine.work_detail || null, + is_present: taskLine.is_present !== undefined ? taskLine.is_present : true + }); + } + } + + console.log('📤 전송할 팀 데이터:', members); + try { - const response = await window.apiCall('/tbm/sessions', 'POST', sessionData); + const editingSessionId = document.getElementById('sessionId').value; - if (response && response.success) { - showToast('TBM 세션이 생성되었습니다.', 'success'); - closeTbmModal(); + if (editingSessionId) { + // 수정 모드: 기존 팀원 삭제 후 재등록 + console.log('📝 TBM 수정 모드:', editingSessionId); - const createdSessionId = response.data.session_id; + // 기존 팀원 삭제 + await window.apiCall(`/tbm/sessions/${editingSessionId}/team/clear`, 'DELETE'); - // 목록 새로고침 - if (currentTab === 'tbm-input') { - await loadTodayOnlyTbm(); + // 새 팀원 일괄 추가 + const teamResponse = await window.apiCall( + `/tbm/sessions/${editingSessionId}/team/batch`, + 'POST', + { members } + ); + + if (teamResponse && teamResponse.success) { + showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success'); + closeTbmModal(); + + // 목록 새로고침 + if (currentTab === 'tbm-input') { + await loadTodayOnlyTbm(); + } else { + await loadTbmSessionsByDate(sessionData.session_date); + } } else { - await loadTbmSessionsByDate(sessionData.session_date); + throw new Error(teamResponse.message || '팀원 수정에 실패했습니다.'); } - - // 팀 구성 모달 열기 - setTimeout(() => { - openTeamCompositionModal(createdSessionId); - }, 500); } else { - throw new Error(response.message || '저장에 실패했습니다.'); + // 생성 모드: 새 TBM 세션 생성 + console.log('✨ TBM 생성 모드'); + + const response = await window.apiCall('/tbm/sessions', 'POST', sessionData); + + if (response && response.success) { + const createdSessionId = response.data.session_id; + console.log('✅ TBM 세션 생성 완료:', createdSessionId); + + // 작업자 일괄 추가 + const teamResponse = await window.apiCall( + `/tbm/sessions/${createdSessionId}/team/batch`, + 'POST', + { members } + ); + + if (teamResponse && teamResponse.success) { + showToast(`TBM이 생성되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success'); + closeTbmModal(); + + // 목록 새로고침 + if (currentTab === 'tbm-input') { + await loadTodayOnlyTbm(); + } else { + await loadTbmSessionsByDate(sessionData.session_date); + } + } else { + throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.'); + } + } else { + throw new Error(response.message || '저장에 실패했습니다.'); + } } } catch (error) { console.error('❌ TBM 세션 저장 오류:', error); @@ -498,46 +696,1059 @@ async function saveTbmSession() { } window.saveTbmSession = saveTbmSession; -// 팀 구성 모달 열기 -async function openTeamCompositionModal(sessionId) { - currentSessionId = sessionId; - selectedWorkers.clear(); +// ==================== 작업자 관리 ==================== - // 기존 팀 구성 로드 - try { - const response = await window.apiCall(`/tbm/sessions/${sessionId}/team`); - if (response && response.success) { - response.data.forEach(member => { - selectedWorkers.add(member.worker_id); - }); - } - } catch (error) { - console.error('팀 구성 조회 오류:', error); +// UUID 생성 함수 +function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +// 작업자 카드 리스트 렌더링 +function renderWorkerTaskList() { + const listContainer = document.getElementById('workerTaskList'); + const emptyState = document.getElementById('workerListEmpty'); + + if (workerTaskList.length === 0) { + if (emptyState) emptyState.style.display = 'flex'; + listContainer.innerHTML = ''; + return; } - // 작업자 선택 그리드 생성 - const grid = document.getElementById('workerSelectionGrid'); - grid.innerHTML = allWorkers.map(worker => ` -
+ + +
@@ -163,12 +171,45 @@ + + +
- + diff --git a/web-ui/pages/admin/attendance-report-comparison.html b/web-ui/pages/admin/attendance-report-comparison.html new file mode 100644 index 0000000..08a9178 --- /dev/null +++ b/web-ui/pages/admin/attendance-report-comparison.html @@ -0,0 +1,493 @@ + + + + + + 출퇴근-작업보고서 대조 | (주)테크니컬코리아 + + + + + + + + + + + + +
+
+ + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+

대조 결과

+

출퇴근 기록과 작업보고서의 시간을 비교합니다

+
+
+
+ +
+
+
+
+
+
+ + + + + + + + diff --git a/web-ui/pages/admin/codes.html b/web-ui/pages/admin/codes.html index ec336ff..4fda212 100644 --- a/web-ui/pages/admin/codes.html +++ b/web-ui/pages/admin/codes.html @@ -41,6 +41,12 @@ 작업장 관리 + + + + +