Compare commits

...

53 Commits

Author SHA1 Message Date
Hyungi Ahn
2b1c7bfb88 feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가)
- 출근/근태 시스템 개선 (연차 조회, 근무현황)
- 작업분석 대분류 그룹화 및 마이그레이션 스크립트
- 모바일 네비게이션 UI 추가
- NAS 배포 도구 및 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:41:01 +09:00
Hyungi Ahn
1548253f56 fix: 작업 분석에서 공정(대분류)으로 올바르게 분류
문제: work_type_id에 task_id가 저장된 경우 공정 분류가 안됨
- work_type_id=10 → 실제로는 task "노즐 용접" (공정: Vessel)

해결:
- API에서 task_id인 경우 해당 task의 work_type_id로 공정 조회
- getRecentWork, getProjectWorkTypeRawData 쿼리 수정
- 프론트엔드는 API 결과의 work_type_name 직접 사용

공정(대분류): Base(구조물), Vessel(용기), Piping Assembly(배관), 작업대기, 휴무, 시설설비

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:29:08 +09:00
Hyungi Ahn
0ea253befd fix: 작업자별 현황 테이블도 대분류로 그룹화
- renderWorkReportTable 함수에서 getMajorCategory() 사용
- 작업내용을 대분류(Base제작, 용기제작, 파이핑 등)로 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:25:26 +09:00
Hyungi Ahn
bea0fec4f1 fix: 작업 분석 페이지 작업내용을 대분류로 그룹화
- getMajorCategory() 함수 추가
- work_type_id를 대분류(Base제작, 용기제작, 파이핑, 작업대기, 휴무, 시설설비, 기타)로 매핑
- 개별 작업유형 대신 대분류 기준으로 집계
- 세부 분류는 다른 분석에서 처리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:23:29 +09:00
Hyungi Ahn
665a5b1b7d refactor: 작업보고서 조회 페이지 삭제 및 출근체크 버그 수정
- report-view.html 및 관련 파일 삭제 (리소스 최적화)
  - work-report-calendar.js/css
  - modules/calendar/* (CalendarState, CalendarAPI, CalendarView)
  - report-viewer-*.js (미사용)
  - daily-report-viewer.js/css (미사용)
- 사이드바에서 작업보고서 조회 링크 제거
- 출근체크 페이지: 날짜 변경 시 자동 새로고침 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 09:44:19 +09:00
Hyungi Ahn
170adcc149 refactor: 코드 관리 페이지 삭제 및 프론트엔드 모듈화
- codes.html, code-management.js 삭제 (tasks.html에서 동일 기능 제공)
- 사이드바에서 코드 관리 링크 제거
- daily-work-report, tbm, workplace-management JS 모듈 분리
- common/security.js 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 06:42:12 +09:00
Hyungi Ahn
36f110c90a fix: 보안 취약점 수정 및 XSS 방지 적용
## 백엔드 보안 수정
- 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거
- SQL Injection 방지를 위한 화이트리스트 검증 추가
- 인증 미적용 API 라우트에 requireAuth 미들웨어 적용
- CSRF 보호 미들웨어 구현 (csrf.js)
- 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js)
- 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js)

## 프론트엔드 XSS 방지
- api-base.js에 전역 escapeHtml() 함수 추가
- 17개 주요 JS 파일에 escapeHtml 적용:
  - tbm.js, daily-patrol.js, daily-work-report.js
  - task-management.js, workplace-status.js
  - equipment-detail.js, equipment-management.js
  - issue-detail.js, issue-report.js
  - vacation-common.js, worker-management.js
  - safety-report-list.js, nonconformity-list.js
  - project-management.js, workplace-management.js

## 정리
- 백업 폴더 및 빈 파일 삭제
- SECURITY_GUIDE.md 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 06:33:10 +09:00
Hyungi Ahn
7c38c555f5 fix: 출근체크/근무현황 페이지 버그 수정
- workers API 기본 limit 10 → 100 변경 (작업자 누락 문제 해결)
- 작업자 필터 조건 수정 (status='active' + employment_status 체크)
- 근태 기록 저장 시 컬럼명 불일치 수정 (attendance_type_id)
- 근무현황 페이지에 저장 상태 표시 추가 (✓저장됨)
- 디버그 로그 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:58:30 +09:00
Hyungi Ahn
b8ccde7f17 feat: 알림 시스템 및 시설설비 관리 기능 구현
- 알림 시스템 구축 (navbar 알림 아이콘, 드롭다운)
- 알림 수신자 설정 기능 (계정관리 페이지)
- 시설설비 관리 페이지 추가 (수리 워크플로우)
- 수리 신청 → 접수 → 처리중 → 완료 상태 관리
- 사이드바 메뉴 구조 개선 (공장 관리 카테고리)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 15:56:57 +09:00
Hyungi Ahn
d1aec517a6 feat: 임시 이동 설비 현황 표시 기능 추가
- 대시보드 하단에 임시 이동된 설비 카드 섹션 추가
- 작업장 모달에 '이동 설비' 탭 추가
  - 이 작업장으로 이동해 온 설비 표시
  - 다른 곳으로 이동한 설비 표시
- 설비 마커에 이동 상태 색상 구분 (주황색 점선 + 깜빡임)
- 원위치 복귀 기능
- 사이드바 기본값을 접힌 상태로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 14:30:25 +09:00
Hyungi Ahn
4d83f10b07 feat: 설비 상세 패널 및 임시 이동 기능 구현
- 설비 마커 클릭 시 슬라이드 패널로 상세 정보 표시
- 설비 사진 업로드/삭제 기능
- 설비 임시 이동 기능 (3단계 지도 기반 선택)
  - Step 1: 공장 선택
  - Step 2: 레이아웃 지도에서 작업장 선택
  - Step 3: 상세 지도에서 위치 선택
- 설비 외부 반출/반입 기능
- 설비 수리 신청 기능 (기존 신고 시스템 연동)
- DB 마이그레이션 추가 (사진, 임시이동, 외부반출 테이블)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 13:45:56 +09:00
Hyungi Ahn
90d3e32992 feat: 일일순회점검 시스템 구축 및 관리 기능 개선
- 일일순회점검 시스템 신규 구현
  - DB 테이블: patrol_checklist_items, daily_patrol_sessions, patrol_check_records, workplace_items, item_types
  - API: /api/patrol/* 엔드포인트
  - 프론트엔드: 지도 기반 작업장 점검 UI

- 설비 관리 기능 개선
  - 구매 관련 필드 추가 (구매일, 가격, 공급업체 등)
  - 설비 코드 자동 생성 (TKP-XXX 형식)

- 작업장 관리 개선
  - 레이아웃 이미지 업로드 기능
  - 마커 위치 저장 기능

- 부서 관리 기능 추가
- 사이드바 네비게이션 카테고리 재구성
- 이미지 401 오류 수정 (정적 파일 경로 처리)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:41:41 +09:00
Hyungi Ahn
2e9d24faf2 feat: 일간작업장 점검 카테고리 추가
- 사이드바에 '일간작업장 점검' 카테고리 신설
- 일일 출퇴근을 근태 관리에서 일간작업장 점검으로 이동
- CODING_GUIDE.md 페이지 구조 업데이트
- docs/README.md 트리 구조 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:13:13 +09:00
Hyungi Ahn
359d5dd7dd docs: 문서 구조 정리 및 정리 체크리스트 생성
- docs/README.md 현재 페이지 구조에 맞게 업데이트
- docs/CLEANUP_TODO.md 삭제/통합 권장 항목 정리
- 삭제 권장: update-logs/, refactoring/, 오래된 SQL 파일들
- 통합 권장: 개발로그/ → 개발 log/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:37:36 +09:00
Hyungi Ahn
c42c9f4fa3 fix: 부적합 제출 버그 수정 및 UI 개선
- 부적합 API 호출 형식 수정 (카테고리/아이템 추가 시)
- 부적합 저장 시 내부 플래그 제거 후 백엔드 전송
- 기본 부적합 객체 구조 수정 (category_id, item_id 추가)
- 날씨 API 시간대 수정 (UTC → KST 변환)
- 신고 카테고리 관리 페이지 추가 (/pages/admin/issue-categories.html)
- 부적합 입력 UI 개선 (대분류→소분류 캐스케이딩 선택)
- 저장된 부적합 분리 표시 및 수정/삭제 기능
- 디버깅 로그 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:31:26 +09:00
Hyungi Ahn
4b158de1eb refactor: 전체 페이지 이모지 제거 및 사이드바 레이아웃 수정
- 모든 페이지에서 이모지 제거 (CODING_GUIDE 준수)
  - admin/ (9개), safety/ (7개), work/ (4개)
  - attendance/ (8개), profile/ (2개)
- 사이드바 CSS에 누락된 컨테이너 클래스 추가
  - work-report-container, analysis-container, dashboard-main
  - 사이드바 토글 시 메인 콘텐츠 정상 반응하도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:09:37 +09:00
Hyungi Ahn
09b3cf8e65 docs: 문서 구조 정리 및 페이지 검토 보고서 작성
- docs/README.md 전면 개편 (문서 인덱스, 구조 설명)
- 31개 페이지 종합 검토 보고서 작성
- 이모지 사용, 사이드바 불일치, 인라인 스타일 등 문제점 식별

주요 발견:
- 이모지 300개+ 사용 (CODING_GUIDE 위반)
- 27/31 페이지에서 이모지 사용
- admin/ 6개 페이지 사이드바 HTML 직접 작성 (중복)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:42:00 +09:00
Hyungi Ahn
5f1791443a fix: 헤더 레이아웃 - 브랜드 왼쪽, 계정 오른쪽 배치
- max-width 제거하고 width: 100%로 변경
- justify-content: space-between으로 양쪽 끝 배치

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:31:57 +09:00
Hyungi Ahn
9998d9df96 fix: 헤더/사이드바 레이아웃 개선 및 템플릿 표준화
- 헤더를 fixed로 변경하고 z-index를 200으로 높여 사이드바와 겹침 방지
- 대시보드에서 빠른 작업 섹션 제거 (사이드바로 대체)
- 모든 템플릿(4개)에 사이드바 네비게이션 추가
- 템플릿 README에 사이드바 설명 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:30:47 +09:00
Hyungi Ahn
74d3a78aa3 feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성
  - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동
  - common/ → attendance/: 근태/휴가 관련 페이지 이동
  - admin/ 정리: safety-* 파일들을 safety/로 이동

- 사이드바 네비게이션 메뉴 구현
  - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리
  - 접기/펼치기 기능 및 상태 저장
  - 관리자 전용 메뉴 자동 표시/숨김

- 날씨 API 연동 (기상청 단기예보)
  - TBM 및 navbar에 현재 날씨 표시
  - weatherService.js 추가

- 안전 체크리스트 확장
  - 기본/날씨별/작업별 체크 유형 추가
  - checklist-manage.html 페이지 추가

- 이슈 신고 시스템 구현
  - workIssueController, workIssueModel, workIssueRoutes 추가

- DB 마이그레이션 파일 추가 (실행 대기)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:27:22 +09:00
Hyungi Ahn
b6485e3140 feat: 대시보드 작업장 현황 지도 구현
- 실시간 작업장 현황을 지도로 시각화
- 작업장 관리 페이지에서 정의한 구역 정보 활용
- 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 <noreply@anthropic.com>
2026-01-29 15:46:47 +09:00
Hyungi Ahn
e1227a69fe feat: 설비 관리 시스템 구축
## 주요 기능
- 설비 등록/수정/삭제 기능
- 작업장별 설비 연결
- 작업장 지도에서 설비 위치 정의
- 필터링 및 검색 기능

## 백엔드
- equipments 테이블 생성 (마이그레이션)
- 설비 API (모델, 컨트롤러, 라우트) 구현
- workplaces 테이블에 layout_image 컬럼 추가

## 프론트엔드
- 설비 관리 페이지 (equipments.html)
- 설비 관리 JavaScript (equipment-management.js)
- 작업장 지도 모달 개선

## 버그 수정
- 카테고리/작업장 이미지 보존 로직 개선 (null 처리)
- 작업장 레이아웃 이미지 업로드 경로 수정 (public/uploads → uploads)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 09:22:57 +09:00
Hyungi Ahn
9c98c44d8a fix: TBM 작업보고서 저장 버그 수정
문제:
1. dailyWorkReportController에서 DailyWorkReportModel(대문자) 사용 → dailyWorkReportModel(소문자)로 수정
2. daily_work_reports INSERT 쿼리에 work_hours 필드 누락
3. 에러 로그에 스택 트레이스 추가

해결:
- 변수명 통일 (dailyWorkReportModel 사용)
- INSERT 쿼리에 work_hours 필드 추가 (TBM에서는 total_hours와 동일)
- 에러 핸들링 개선 (스택 트레이스 로깅 추가)

영향받는 파일:
- api.hyungi.net/controllers/dailyWorkReportController.js (line 878, 887-894)
- api.hyungi.net/models/dailyWorkReportModel.js (line 1242-1266)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 13:56:12 +09:00
Hyungi Ahn
397485e150 feat: 작업보고서 시간 입력 UX 개선 - 터치 최적화
작업보고서 작성 페이지의 시간 입력을 모바일/터치 환경에 최적화

주요 변경사항:
- 기존 number input → 큰 버튼 기반 팝오버 방식으로 전환
- 퀵 선택 버튼 5개 (30분, 1시간, 2시간, 4시간, 8시간)
- ±30분 미세 조정 버튼 추가
- 터치 타겟 최소 48-64px로 확대
- "8시간 30분" 형식으로 직관적 표시
- TBM 작업보고 및 수동 입력 모두 적용

기술 구현:
- Hidden input + display div 패턴으로 폼 호환성 유지
- 팝오버 오버레이 with ESC/클릭 외부 닫기
- CSS 애니메이션 추가
- 캐시 버스팅 (CSS v9, JS v24)

문서:
- 개발 로그: 개발 log/2026-01-27-time-input-ux-improvement.md
- 사용자 가이드: docs/guides/work-report-time-input-guide.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 13:29:38 +09:00
Hyungi Ahn
ad7088d840 refactor: 작업자 관리 페이지를 카드에서 테이블 형식으로 변경
## 변경 사항
- UI 형식: 카드 그리드 → 엑셀 스타일 테이블
- 더 많은 정보를 한눈에 볼 수 있음
- 공간 활용 효율성 향상

## HTML 변경 (workers.html)
- 테이블 구조 추가
  - 컬럼: 상태, 이름, 직책, 전화번호, 이메일, 입사일, 부서, 계정, 현장직, 등록일, 관리
  - tbody id="workersGrid" 유지 (기존 코드 호환성)

## JavaScript 변경 (worker-management.js)
- renderWorkers() 함수 리팩토링
  - 카드 HTML 생성 → 테이블 행 생성
  - 상태 배지: 현장직(초록), 사무직(노랑), 퇴사(빨강)
  - 아바타 아이콘 유지 (이름 첫 글자)
  - 아이콘 버튼으로 편집/상태변경/삭제 기능

## CSS 변경 (admin-pages.css)
- 테이블 내 버튼 스타일 추가
  - .data-table .btn-icon
  - hover 효과 및 transform

## 유지된 기능
- 검색 및 필터링
- 정렬
- 통계 표시
- 편집/삭제/상태 변경
- Empty state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:24:33 +09:00
Hyungi Ahn
45f80e206b fix: 관리 페이지 탭 active 스타일 개선
## 문제
- 선택된 탭이 파란색 배경으로 가득 채워져 보기 안 좋음
- 모든 관리 페이지(프로젝트, 작업자, 작업장, 작업 등)에서 동일한 문제

## 해결
- .tab-btn.active 스타일 변경
  - Before: 전체 배경 파란색 (background: var(--color-primary))
  - After: 연한 배경 + 하단 border 강조
    - color: var(--color-primary) (텍스트 파란색)
    - background: var(--color-primary-light) (연한 배경)
    - border-bottom: 3px solid var(--color-primary) (하단 강조)
    - font-weight: 600 (글자 굵게)

## 영향
- 모든 관리 페이지의 탭 UI 개선
- 가독성 향상

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:20:20 +09:00
Hyungi Ahn
1fc9dff69f feat: 작업 관리 페이지에 공정 관리 기능 추가
## 추가 기능
- 공정 추가/수정/삭제 기능 구현
- 공정 관리 모달 UI 추가
- 공정 탭에 편집 버튼 추가 (✏️)

## UI 변경
- 상단에 "공정 추가" 버튼 추가
- 공정 모달: 공정명, 카테고리, 설명 입력 필드
- 각 공정 탭에 편집 아이콘 표시

## JavaScript 함수
- openWorkTypeModal(): 공정 추가 모달 열기
- editWorkType(workTypeId): 공정 수정 모달 열기
- saveWorkType(): 공정 저장 (POST/PUT)
- deleteWorkType(): 공정 삭제 (연결된 작업 확인)
- closeWorkTypeModal(): 모달 닫기

## 검증 로직
- 연결된 작업이 있는 공정은 삭제 불가
- 필수 필드(공정명) 검증

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:18:14 +09:00
Hyungi Ahn
6ff5c443be fix: taskModel DB 연결 및 API 응답 형식 수정
- taskModel.js: ../db/connection → ../dbPool로 수정
- dailyWorkReportController.js: getWorkTypes 응답 형식 표준화
  - res.json(data) → res.json({ success: true, data, message })

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:15:19 +09:00
Hyungi Ahn
566a38562c fix: work-types API 경로 수정
- /api/tools/work-types → /api/daily-work-reports/work-types
- task-management.js와 tbm.js에서 올바른 엔드포인트 사용
- API 서버 재시작으로 /api/tasks 라우트 활성화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:09:16 +09:00
Hyungi Ahn
7acb835c39 feat: 작업 관리 시스템 및 TBM 공정/작업 통합
## Backend Changes
- Create tasks table with work_type_id FK to work_types
- Add taskModel, taskController, taskRoutes for task CRUD
- Update tbmModel to support work_type_id and task_id
- Add migrations for tasks table and TBM integration

## Frontend Changes
- Create task management admin page (tasks.html, task-management.js)
- Update TBM modal to include work type (공정) and task (작업) selection
- Add cascading dropdown: work type → task selection
- Display work type and task info in TBM session cards
- Update sidebar navigation in all admin pages

## Database Schema
- tasks: task_id, work_type_id, task_name, description, is_active
- tbm_sessions: add work_type_id, task_id columns with FKs
- Foreign keys maintain referential integrity with work_types and tasks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:06:43 +09:00
Hyungi Ahn
9c636bf6ad refactor: TBM 페이지를 탭 기반 UI로 개선
- TBM 입력 탭: 오늘의 TBM 목록 + 새 TBM 시작 버튼
- TBM 관리 탭: 전체 TBM 기록 + 날짜 필터링
- 탭 전환 로직 추가
- 각 탭별 통계 표시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:35:52 +09:00
Hyungi Ahn
f27728b168 feat: 작업장 관리 기능 추가 (공장-작업장 계층 구조)
- 공장(카테고리) 및 작업장 CRUD API 구현
- 탭 기반 UI로 공장별 작업장 필터링
- 터치 최적화된 관리자 페이지
- DB 테이블: workplace_categories, workplaces
- 관리자 메뉴에 작업장 관리 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:31:58 +09:00
Hyungi Ahn
ffabcaf579 fix: 대시보드 카드 명칭 변경 (프로젝트 관리 → 기본 정보 관리)
관리 페이지 진입점의 명칭을 더 포괄적으로 변경:
- "프로젝트 관리" → "기본 정보 관리"
- 프로젝트, 작업자, 코드 관리를 포함하는 의미

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:10:38 +09:00
Hyungi Ahn
485ce7d276 docs: 2026-01-26 개발 로그 업데이트
관리 페이지 UI/UX 개선 및 네비게이션 단순화 작업 내용 기록:
- 2단 레이아웃 구현 (사이드바 + 메인)
- 코드 관리 페이지 탭 디자인 개선
- admin/index.html 제거 및 네비게이션 단순화
- 관리 페이지 표준화 문서 작성

커밋 해시: ca33736, cbf1ad9, f3386a5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:08:50 +09:00
Hyungi Ahn
f3386a54c7 refactor: admin/index.html 제거 및 네비게이션 단순화
## 변경사항
- 중간 허브 페이지(admin/index.html) 제거
- 대시보드에서 프로젝트 관리로 직접 연결
- 관리 페이지 사이드바 백링크를 대시보드로 변경

## 상세 내용
### 제거된 파일
- web-ui/pages/admin/index.html (작업 관리 허브 페이지)

### 네비게이션 변경
- dashboard.html: "작업 관리" → "프로젝트 관리" 링크 변경
  * /pages/admin/index.html → /pages/admin/projects.html
- 관리 페이지 사이드바 백링크 수정:
  * projects.html: "작업관리로 ◀" → "대시보드로 🏠"
  * workers.html: "작업관리로 ◀" → "대시보드로 🏠"
  * codes.html: "작업관리로 ◀" → "대시보드로 🏠"

### 개선 효과
- 네비게이션 단계 축소 (2단계 → 1단계)
- 사용자 경험 개선 (불필요한 중간 페이지 제거)
- 관리 페이지 간 이동은 사이드바로 유지

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:07:34 +09:00
Hyungi Ahn
cbf1ad9dad refactor: 프로젝트 카드 레이아웃 및 메타 정보 표시 개선
## 변경사항
- 프로젝트 카드 메타 정보를 key-value 형식으로 재구성
- 빈 값은 '-'로 표시하여 일관성 향상
- 버튼 텍스트 추가 (✏️ 수정, 🗑️ 삭제)
- 메인 콘텐츠 영역 최대폭 1600px 제한 및 중앙 정렬
- 그리드 정렬 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:59:42 +09:00
Hyungi Ahn
ca33736ed4 feat: 관리 페이지(Admin Pages) UI 개선 및 표준화
## 주요 변경사항
- 프로젝트/작업자/코드 관리 페이지 2단 레이아웃(사이드바+메인) 적용
- 통일된 3열 카드 그리드 레이아웃 구현
- 코드 관리 페이지 탭 및 카드 디자인 개선
- 관리 페이지 표준 가이드 문서 작성

## 세부 내용
### HTML 구조 개선
- `.page-container` flexbox 레이아웃으로 변경
- 240px 고정폭 사이드바 네비게이션 추가
- 페이지 헤더를 카드 형태로 분리

### CSS 개선
- admin-pages.css 신규 생성 (v7)
- 3열 그리드 레이아웃 (repeat(3, 1fr))
- 카드 높이 통일 (프로젝트/작업자: 420px, 코드: 최소 200px)
- 반응형 디자인 (1200px: 2열, 768px: 1열)

### 코드 관리 페이지 특화
- 탭 네비게이션 스타일 개선
- 상태별/심각도별 컬러 보더 적용
- 해결 가이드 섹션 스타일링
- 아이콘 48x48 둥근 박스 디자인

### 문서화
- ADMIN_PAGE_STANDARD.md 생성
- HTML 템플릿, CSS 클래스, 파일 명명 규칙 정의
- 3가지 페이지 타입(카드 그리드/테이블/탭) 표준화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:57:43 +09:00
Hyungi Ahn
35aa4a840e fix: 로그인 실패 시 500 에러 → 401 에러로 수정
## 문제
- 로그인 실패 시 401 에러를 반환해야 하는데 500 에러 반환
- 원인: ApiError(utils/errorHandler.js)와 AppError(utils/errors.js) 클래스 불일치
- errorHandler 미들웨어가 ApiError를 인식하지 못해 500으로 변환

## 수정사항
1. authController.js:
   - ApiError 대신 AuthenticationError, ValidationError 사용
   - 로그인 실패 → AuthenticationError 던짐 (401)
   - 유효성 검증 실패 → ValidationError 던짐 (400)

2. db/connection.js 추가:
   - TBM 모델의 콜백 방식 DB 쿼리 지원
   - dbPool을 래핑하여 레거시 코드 호환

3. routes.js:
   - TBM 라우트 임시 비활성화 (db/connection 볼륨 마운트 문제)
   - Docker 볼륨 재설정 후 재활성화 예정

## 테스트 결과
```bash
# Before: 500 Internal Server Error
# After: 401 Unauthorized

curl -X POST http://localhost:20005/api/auth/login \
  -d '{"username":"wrong","password":"wrong"}'

# Response:
{
  "success": false,
  "error": {
    "message": "아이디 또는 비밀번호가 올바르지 않습니다.",
    "code": "AUTHENTICATION_ERROR"  // ← 정상!
  }
}
```

## TODO
- [ ] Docker 볼륨 설정에 db 디렉토리 전체 추가
- [ ] TBM 라우트 재활성화
- [ ] 중복 에러 처리 시스템 통합 (ApiError 제거)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:42:00 +09:00
Hyungi Ahn
4ee07bc95c docs: 문서 작성 표준 가이드 추가
프로젝트 전체 문서 작성 규칙 및 템플릿 정의

## 주요 내용
- 문서 작성 5대 원칙 (명확성, 일관성, 완전성, 접근성, 유지보수성)
- 문서 구조 표준 (필수 섹션, 섹션 순서)
- 문서 유형별 가이드 (배포, 기능 명세, API, 트러블슈팅)
- 작성 규칙 (마크다운 스타일, 명명 규칙, 코드 예시, 표, 다이어그램)
- 예시 템플릿 (배포 가이드, API 문서)
- 문서 검토 체크리스트

## 문서 유형별 표준 구조

### 배포/설치 가이드:
1. 문서 개요 → 2. 목차 → 3. 시스템 개요 → 4. 배포 전 확인사항
→ 5. 배포 절차 → 6. 기능 명세 → 7. API 명세 → 8. 프론트엔드 구현
→ 9. 테스트 가이드 → 10. 문제 해결 → 11. 변경 이력

### API 문서:
1. 문서 개요 → 2. 목차 → 3. API 개요 → 4. 인증 방식
→ 5. 엔드포인트 목록 → 6. 엔드포인트 상세 → 7. 에러 코드
→ 8. 예시 코드 → 9. 변경 이력

## 적용 대상
- 모든 신규 문서는 이 가이드를 따름
- 기존 문서는 점진적으로 표준화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 10:16:47 +09:00
Hyungi Ahn
94bccf3b67 docs: TBM 배포 가이드 전면 개편 (v2.0)
기존 중구난방이던 문서를 체계적으로 재구성

## 주요 변경사항
- 목차 추가 (9개 섹션)
- 시스템 아키텍처 다이어그램 추가
- 배포 전 확인사항 섹션 신설
- 단계별 배포 절차 상세화
- 기능 명세 및 사용자 시나리오 추가
- API 명세 예시 코드 포함
- 프론트엔드 구현 세부 함수 코드 추가
- 테스트 가이드 확대 (API, 웹, 성능)
- 문제 해결 섹션 강화 (5개 카테고리)
- 데이터베이스 스키마 명시

## 문서 구조
1. 시스템 개요
2. 배포 전 확인사항
3. 배포 절차
4. 기능 명세
5. API 명세
6. 프론트엔드 구현
7. 테스트 가이드
8. 문제 해결
9. 변경 이력

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 10:14:17 +09:00
Hyungi Ahn
67e9c0886d feat: TBM 빠른 작업 배너 추가 (페이지 권한 기반)
- 대시보드에 TBM 관리 빠른 작업 카드 추가
- 페이지 접근 권한 기반으로 표시/숨김 처리
- 오렌지 그라데이션 배경으로 시각적 구분
- checkTbmPageAccess() 함수로 사용자 권한 확인

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 15:52:00 +09:00
Hyungi Ahn
480206912b feat: TBM 시스템 완성 - 작업 인계, 상세보기, 작업보고서 연동
## 주요 기능 추가

### 1. 작업 인계 시스템 (반차/조퇴 시)
- **인계 모달** (`handoverModal`)
  - 인계 사유 선택 (반차/조퇴/긴급/기타)
  - 인수자 (다른 팀장) 선택
  - 인계 날짜/시간 입력
  - 인계할 팀원 선택 (체크박스)
  - 인계 내용 메모

- **API 연동**
  - POST /api/tbm/handovers (인계 요청 생성)
  - 세션 정보와 팀 구성 자동 조회
  - from_leader_id 자동 설정

- **UI 개선**
  - TBM 카드에 "📤 인계" 버튼 추가
  - 인계할 팀원 목록 자동 로드
  - 현재 팀장 제외한 리더만 표시

### 2. TBM 상세보기 모달
- **상세 정보 표시** (`detailModal`)
  - 기본 정보 (팀장, 날짜, 프로젝트, 작업 장소, 작업 내용)
  - 안전 특이사항 (노란색 강조)
  - 팀 구성 (그리드 레이아웃)
  - 안전 체크리스트 (카테고리별 그룹화)

- **안전 체크 시각화**
  - / 아이콘으로 체크 상태 표시
  - 체크됨: 초록색 배경
  - 미체크: 빨간색 배경
  - 카테고리별 구분 (PPE/EQUIPMENT/ENVIRONMENT/EMERGENCY)

- **병렬 API 호출**
  - Promise.all로 세션/팀/안전체크 동시 조회
  - 로딩 성능 최적화

### 3. 작업 보고서와 TBM 연동
- **TBM 팀 구성 자동 불러오기**
  - `loadTbmTeamForDate()` 함수 추가
  - 선택한 날짜의 TBM 세션 자동 조회
  - 진행중(draft) 세션 우선 선택
  - 팀 구성 정보 자동 로드

- **작업자 자동 선택**
  - TBM에서 구성한 팀원 자동 선택
  - 선택된 작업자 시각적 표시 (.selected 클래스)
  - 다음 단계 버튼 자동 활성화

- **안내 메시지**
  - "🛠️ TBM 팀 구성 자동 적용" 알림
  - 자동 선택된 팀원 수 표시
  - 파란색 강조 스타일

### 4. UI/UX 개선
- TBM 카드 버튼 레이아웃 개선 (flex-wrap)
- 인계 버튼 오렌지색 (#f59e0b)
- 모달 스크롤 가능 (max-height: 70vh)
- 반응형 그리드 (auto-fill, minmax)

## 기술 구현

### 함수 추가
- `viewTbmSession()`: 상세보기 (병렬 API 호출)
- `openHandoverModal()`: 인계 모달 (팀 구성 자동 로드)
- `saveHandover()`: 인계 저장 (worker_ids JSON array)
- `loadTbmTeamForDate()`: TBM 팀 구성 조회
- `closeDetailModal()`, `closeHandoverModal()`: 모달 닫기

### 수정 함수
- `populateWorkerGrid()`: TBM 연동 추가 (async/await)
- `displayTbmSessions()`: 인계 버튼 추가

## 파일 변경사항
- web-ui/pages/work/tbm.html (모달 2개 추가, 약 110줄)
- web-ui/js/tbm.js (함수 추가, 약 250줄 증가)
- web-ui/js/daily-work-report.js (TBM 연동, 약 60줄 추가)

## 사용 시나리오

### 시나리오 1: TBM → 작업보고서
1. 아침 TBM에서 팀 구성 (예: 5명 선택)
2. 작업 보고서 작성 시 날짜 선택
3. **자동으로 5명 선택됨** 
4. 바로 작업 내역 입력 가능

### 시나리오 2: 조퇴 시 인계
1. TBM 카드에서 "📤 인계" 클릭
2. 사유 선택 (조퇴), 인수자 선택
3. 인계할 팀원 선택 (기본 전체 선택)
4. 인계 요청 → DB 저장

### 시나리오 3: TBM 상세 확인
1. TBM 카드 클릭
2. 기본 정보, 팀 구성, 안전 체크 한눈에 확인
3. 안전 체크 완료 여부 시각적 확인

## 데이터 흐름

```
TBM 시작
  ↓
팀 구성 저장 (tbm_team_assignments)
  ↓
작업 보고서 작성 시
  ↓
GET /api/tbm/sessions/date/:date
  ↓
GET /api/tbm/sessions/:id/team
  ↓
팀원 자동 선택
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 15:46:02 +09:00
Hyungi Ahn
f8138685a1 feat: TBM JavaScript 로직 구현 완료
## 주요 기능

### 1. TBM 세션 관리
- 날짜별 TBM 세션 목록 조회
- 새 TBM 세션 생성
- TBM 세션 완료 처리
- 세션 상태별 표시 (진행중/완료/취소)

### 2. 팀 구성 관리
- 작업자 선택 그리드 UI
- 전체 선택/해제 기능
- 선택된 작업자 실시간 표시
- 팀원 일괄 추가

### 3. 안전 체크리스트
- 카테고리별 체크리스트 표시
  - PPE (개인 보호 장비)
  - EQUIPMENT (장비 점검)
  - ENVIRONMENT (작업 환경)
  - EMERGENCY (비상 대응)
- 필수/선택 항목 구분
- 체크 상태 저장

### 4. UI/UX
- 모달 기반 인터페이스
- 토스트 알림
- 실시간 통계 표시 (총 세션, 완료 세션)
- 반응형 그리드 레이아웃

## 구현 상세

### 전역 상태 관리
- allSessions: TBM 세션 목록
- allWorkers: 작업자 목록
- allProjects: 프로젝트 목록
- allSafetyChecks: 안전 체크리스트
- selectedWorkers: 선택된 작업자 (Set)

### API 연동
- GET /api/tbm/sessions/date/:date
- POST /api/tbm/sessions
- POST /api/tbm/sessions/:id/team/batch
- GET /api/tbm/sessions/:id/safety
- POST /api/tbm/sessions/:id/safety
- POST /api/tbm/sessions/:id/complete

### 주요 함수
- loadTbmSessionsByDate(): 날짜별 세션 조회
- saveTbmSession(): TBM 세션 생성
- saveTeamComposition(): 팀 구성 저장
- saveSafetyChecklist(): 안전 체크 저장
- completeTbmSession(): TBM 완료 처리

## 파일
- web-ui/js/tbm.js (신규, 약 600줄)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 15:41:23 +09:00
Hyungi Ahn
4d0c4c0801 feat: TBM 시스템 구축 및 페이지 권한 관리 기능 추가
## 주요 변경사항

### 1. TBM (Tool Box Meeting) 시스템 구축
- **데이터베이스 스키마** (5개 테이블 생성)
  - tbm_sessions: TBM 세션 관리
  - tbm_team_assignments: 팀 구성 관리
  - tbm_safety_checks: 안전 체크리스트 마스터 (17개 항목)
  - tbm_safety_records: 안전 체크 기록
  - team_handovers: 작업 인계 관리

- **API 엔드포인트** (17개)
  - TBM 세션 CRUD
  - 팀 구성 관리
  - 안전 체크리스트
  - 작업 인계
  - 통계 및 리포트

- **프론트엔드**
  - TBM 관리 페이지 (/pages/work/tbm.html)
  - 모달 기반 UI (세션 생성, 팀 구성, 안전 체크)

### 2. 페이지 권한 관리 시스템
- 페이지별 접근 권한 설정 기능
- 관리자 페이지 (/pages/admin/page-access.html)
- 사용자별 페이지 권한 부여/회수
- TBM 페이지 등록 및 권한 연동

### 3. 네비게이션 role 표시 버그 수정
- load-navbar.js: case-insensitive role 매칭 적용
- JWT의 "Admin" role이 "관리자"로 정상 표시
- admin-only 메뉴 항목 정상 표시

### 4. 대시보드 개선
- 작업 현황 테이블 가독성 향상
- 고대비 색상 및 명확한 구분선 적용
- 이모지 제거 및 SVG 아이콘 적용

### 5. 문서화
- TBM 배포 가이드 작성 (docs/TBM_DEPLOYMENT_GUIDE.md)
- 데이터베이스 스키마 상세 기록
- 배포 절차 및 체크리스트 제공

## 기술 스택
- Backend: Node.js, Express, MySQL
- Frontend: Vanilla JavaScript, HTML5, CSS3
- Database: MySQL (InnoDB)

## 파일 변경사항

### 신규 파일
- api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js
- api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js
- api.hyungi.net/models/tbmModel.js
- api.hyungi.net/models/pageAccessModel.js
- api.hyungi.net/controllers/tbmController.js
- api.hyungi.net/controllers/pageAccessController.js
- api.hyungi.net/routes/tbmRoutes.js
- web-ui/pages/work/tbm.html
- web-ui/pages/admin/page-access.html
- web-ui/js/page-access-management.js
- docs/TBM_DEPLOYMENT_GUIDE.md

### 수정 파일
- api.hyungi.net/config/routes.js (TBM 라우트 추가)
- web-ui/js/load-navbar.js (role 매칭 버그 수정)
- web-ui/pages/admin/workers.html (HTML 구조 수정)
- web-ui/pages/dashboard.html (이모지 제거)
- web-ui/css/design-system.css (색상 팔레트 추가)
- web-ui/css/modern-dashboard.css (가독성 개선)
- web-ui/js/modern-dashboard.js (SVG 아이콘 적용)

## 배포 시 주의사항
⚠️ 본 서버 배포 시 반드시 마이그레이션 실행 필요:
```bash
npm run db:migrate
```

상세한 배포 절차는 docs/TBM_DEPLOYMENT_GUIDE.md 참조

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 15:38:17 +09:00
Hyungi Ahn
0ec099b493 feat: UI 표준화 Phase 1 - 네비게이션/헤더 통일
## 주요 변경사항

### 1. Design System 색상 업데이트
- 하늘색 계열 primary 색상으로 변경 (#0ea5e9, #38bdf8, #7dd3fc)
- CSS 변수 추가: --header-gradient

### 2. Navbar 컴포넌트 표준화
- 50개 이상의 하드코딩 값을 CSS 변수로 변경
- 모든 페이지에서 동일한 헤더 스타일 적용

### 3. 중복 코드 제거 (102줄)
- dashboard.html: 50줄 → 2줄 (navbar 컴포넌트로 교체)
- work/report-view.html: 54줄 → 2줄 (navbar 컴포넌트로 교체)
- modern-dashboard.css: 중복 헤더 스타일 제거
- project-management.css: 중복 헤더 스타일 제거

### 4. 표준 레이아웃 템플릿 생성
- dashboard-layout.html (대시보드용)
- work-layout.html (작업 페이지용)
- admin-layout.html (관리자 페이지용)
- simple-layout.html (프로필/설정용)
- templates/README.md (사용 가이드)

### 5. 누락된 design-system.css 추가
- work/report-view.html
- work/analysis.html
- admin/accounts.html

### 6. ES6 Module 문법 수정
- load-navbar.js: type="module" 추가
- modern-dashboard.js: navbar 엘리먼트 안전 처리

## 문서 업데이트
- CODING_GUIDE.md: 표준 컴포넌트 사용법 추가
- 개발 log/2026-01-20-ui-standardization-phase1.md: 상세 작업 로그

## 영향
- 수정: 10개 파일
- 신규: 6개 파일 (템플릿 5개 + 로그 1개)
- 코드 감소: -102줄

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 14:08:54 +09:00
Hyungi Ahn
6b7f9d4627 fix: 로그인 후 리다이렉트 경로를 새 대시보드로 수정
변경사항:
- authController.js: 로그인 후 /pages/dashboard.html로 리다이렉트
- config.js: 모든 대시보드 경로를 /pages/dashboard.html로 통일
- work/report-view.html: 대시보드 버튼 경로 수정

이제 로그인하면 올바른 경로로 이동합니다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 10:50:54 +09:00
Hyungi Ahn
73e5eff7bd docs: 페이지 구조 개편 문서화
- CODING_GUIDE에 새 페이지 구조 및 네이밍 규칙 추가
- 상세한 개편 과정 문서 작성 (2026-01-20-page-restructure.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 10:46:10 +09:00
Hyungi Ahn
a6ab9e395d refactor: 페이지 구조 대대적 개편 - 명확한 폴더 구조 및 파일명 개선
## 주요 변경사항

### 1. 미사용 페이지 아카이브 (24개)
- admin 폴더 전체 (8개) → .archived-admin/
- 분석 페이지 (5개) → .archived-*
- 공통 페이지 (5개) → .archived-*
- 대시보드 페이지 (2개) → .archived-*
- 기타 (4개) → .archived-*

### 2. 새로운 폴더 구조
```
pages/
├── dashboard.html          (메인 대시보드)
├── work/                   (작업 관련)
│   ├── report-create.html  (작업보고서 작성)
│   ├── report-view.html    (작업보고서 조회)
│   └── analysis.html       (작업 분석)
├── admin/                  (관리 기능)
│   ├── index.html          (관리 메뉴 허브)
│   ├── projects.html       (프로젝트 관리)
│   ├── workers.html        (작업자 관리)
│   ├── codes.html          (코드 관리)
│   └── accounts.html       (계정 관리)
└── profile/                (프로필)
    ├── info.html           (내 정보)
    └── password.html       (비밀번호 변경)
```

### 3. 파일명 개선
- group-leader.html → dashboard.html
- daily-work-report.html → work/report-create.html
- daily-work-report-viewer.html → work/report-view.html
- work-analysis.html → work/analysis.html
- work-management.html → admin/index.html
- project-management.html → admin/projects.html
- worker-management.html → admin/workers.html
- code-management.html → admin/codes.html
- my-profile.html → profile/info.html
- change-password.html → profile/password.html
- admin-settings.html → admin/accounts.html

### 4. 내부 링크 전면 수정
- navbar.html 프로필 메뉴 링크 업데이트
- dashboard.html 빠른 작업 링크 업데이트
- admin/* 페이지 간 링크 업데이트
- load-navbar.js 대시보드 경로 수정

영향받는 파일: 39개

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 10:44:34 +09:00
Hyungi Ahn
33e9e2afec fix: 대시보드 버튼을 메인 대시보드(그룹장 대시보드)로 통일
모든 역할의 사용자가 group-leader.html로 이동하도록 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 08:54:47 +09:00
Hyungi Ahn
05a9de8d2f fix: 대시보드 버튼을 역할별 공통 대시보드로 연결
변경사항:
- navbar의 대시보드 버튼이 개인 대시보드가 아닌 역할별 공통 대시보드로 이동하도록 수정
- Admin/System → /pages/dashboard/system.html
- 그룹장 → /pages/dashboard/group-leader.html
- 일반 사용자 → /pages/dashboard/user.html

수정된 파일:
- web-ui/components/navbar.html
- web-ui/js/load-navbar.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 08:53:29 +09:00
Hyungi Ahn
e8829a0bc7 docs: UI/UX 디자인 가이드 추가
- 이모지 사용 금지 정책 추가
- 모던하고 깔끔한 디자인 원칙 명시
- 색상 가이드 및 컴포넌트 구조 설명

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 08:52:40 +09:00
Hyungi Ahn
4f0af62d8c style: 네비게이션 색상 및 레이아웃 개선
변경사항:
1. navbar 배경색을 하늘색 계열로 변경 (#0ea5e9, #38bdf8, #7dd3fc)
2. 대시보드 버튼을 header에 눈에 띄게 추가
3. work-management.css의 navbar 관련 중복 스타일 제거하여 레이아웃 충돌 해결

수정된 파일:
- web-ui/components/navbar.html
- web-ui/css/work-management.css

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 08:51:49 +09:00
Hyungi Ahn
8a5480177b fix: 관리 페이지 네비게이션 구조 표준화
- 모든 관리 페이지에서 navbar-container를 work-report-container 내부로 이동
- design-system.css 임포트 추가하여 일관된 navbar 스타일 적용
- daily-work-report.html의 원래 구조를 표준으로 채택

변경된 파일:
- web-ui/pages/management/code-management.html
- web-ui/pages/management/project-management.html
- web-ui/pages/management/work-management.html
- web-ui/pages/management/worker-management.html

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 08:48:35 +09:00
872 changed files with 437292 additions and 17146 deletions

View File

@@ -56,6 +56,246 @@ docker-compose up -d # 수동 실행
---
## 🎨 UI/UX 디자인 가이드
### 디자인 원칙
- **모던하고 깔끔한 디자인**: 이모지 사용 지양, 아이콘 또는 심플한 텍스트 사용
- **일관성**: 모든 페이지에서 동일한 디자인 시스템 적용
- **컴포넌트 재사용**: navbar, footer 등은 컴포넌트로 관리
### 이모지 사용 금지
**금지**:
```html
<!-- 나쁜 예 -->
<button>📊 대시보드</button>
<h1>🔧 작업 관리</h1>
```
**권장**:
```html
<!-- 좋은 예 - 아이콘 라이브러리 사용 또는 심플한 텍스트 -->
<button class="btn-primary">
<i class="icon-dashboard"></i>
대시보드
</button>
<h1>작업 관리</h1>
```
### 색상 가이드
- **Primary**: 하늘색 계열 (`#0ea5e9`, `#38bdf8`, `#7dd3fc`)
- **기본 배경**: 흰색/밝은 회색 계열
- **텍스트**: `#1f2937` (dark gray)
- **보조 텍스트**: `#6b7280` (medium gray)
### 컴포넌트 구조
- **네비게이션**: `web-ui/components/navbar.html` 참조
- **일관된 헤더**: 모든 페이지에서 `<div id="navbar-container"></div>` 사용
- **CSS 로딩 순서**: `design-system.css` → 페이지별 CSS
### 페이지 구조 (2026-02-03 현행)
```
web-ui/pages/
├── dashboard.html # 메인 대시보드 (작업장 현황 지도 포함)
├── work/ # 작업 관리 (현장 입력/생산)
│ ├── tbm.html # TBM(Tool Box Meeting) 관리
│ ├── report-create.html # 작업보고서 작성
│ ├── report-view.html # 작업보고서 조회
│ ├── nonconformity.html # 부적합 현황
│ └── analysis.html # 작업 분석
├── [일간작업장 점검] # 일간작업장 점검 (사이드바 카테고리)
│ ├── attendance/checkin.html # 출근 체크
│ └── attendance/work-status.html # 근무 현황 (휴가/연장근무)
├── safety/ # 안전 관리 페이지
│ ├── report.html # 신고 (공통)
│ ├── report-status.html # 안전신고 현황
│ ├── issue-detail.html # 이슈 상세
│ ├── visit-request.html # 출입 신청
│ ├── management.html # 안전 관리 (출입 승인)
│ └── checklist-manage.html # 안전 체크리스트 관리
├── attendance/ # 근태 관리 페이지
│ ├── monthly.html # 월별 출퇴근 현황
│ ├── vacation-request.html # 휴가 신청
│ ├── vacation-management.html # 휴가 관리 (통합)
│ ├── vacation-approval.html # 휴가 승인 관리
│ ├── vacation-input.html # 휴가 직접 입력
│ ├── vacation-allocation.html # 휴가 발생 입력
│ └── annual-overview.html # 연간 연차 현황
├── admin/ # 시스템 관리 페이지
│ ├── accounts.html # 계정 관리
│ ├── page-access.html # 페이지 접근 권한 관리
│ ├── workers.html # 작업자 관리
│ ├── projects.html # 프로젝트 관리
│ ├── tasks.html # 작업 관리
│ ├── workplaces.html # 작업장 관리 (지도 구역 설정)
│ ├── equipments.html # 설비 관리
│ ├── codes.html # 코드 관리
│ ├── issue-categories.html # 신고 카테고리 관리
│ └── attendance-report.html # 출퇴근-작업보고서 대조
├── profile/ # 사용자 프로필
│ ├── info.html # 내 정보
│ └── password.html # 비밀번호 변경
└── .archived-*/ # 미사용 페이지 보관
```
**폴더 분류 기준** (2026-02-03 변경):
- `work/`: 현장 입력/생산 활동 (TBM, 작업보고서, 부적합)
- `[일간작업장 점검]`: 일일 작업장 점검 관련 (사이드바 카테고리)
- `safety/`: 안전 관리/분석 (신고, 출입)
- `attendance/`: 근태/휴가 관리
- `admin/`: 시스템 관리 (관리자 전용)
- `profile/`: 개인 설정 페이지
**네이밍 규칙**:
- 메인 페이지: 단일 명사 (`dashboard.html`)
- 관리 페이지: 복수형 명사 (`projects.html`, `workers.html`)
- 기능 페이지: 동사-명사 또는 명사 (`report-create.html`, `daily.html`)
- 폴더명: 단수형, 소문자 (`work/`, `safety/`, `attendance/`, `admin/`, `profile/`)
**네비게이션 구조**:
- 1차: `dashboard.html` (메인 진입점, 작업장 현황 지도)
- 2차: 사이드 메뉴 또는 빠른 작업 카드를 통한 각 기능 페이지 이동
- 모든 페이지: navbar를 통해 profile, 로그아웃 가능
### 대기 중인 DB 마이그레이션
페이지 구조 변경에 따른 DB 마이그레이션이 필요합니다:
```bash
cd /Users/hyungiahn/Documents/code/TK-FB-Project/api.hyungi.net
npx knex migrate:latest
```
- 마이그레이션 파일: `db/migrations/20260202200000_reorganize_pages.js`
- 내용: pages 테이블 경로 업데이트, role_default_pages 테이블 생성
### 표준 컴포넌트 (2026-01-20 업데이트)
#### 네비게이션 헤더
모든 페이지는 표준 navbar 컴포넌트를 사용합니다:
```html
<!-- HTML에 컨테이너 추가 -->
<div id="navbar-container"></div>
<!-- 스크립트로 로드 -->
<script src="/js/load-navbar.js"></script>
```
**특징**:
- 자동으로 사용자 정보 표시 (이름, 역할)
- 프로필 메뉴 (내 프로필, 비밀번호 변경, 로그아웃)
- 관리자 전용 메뉴 자동 표시/숨김
- 현재 시각 실시간 표시
- 대시보드 버튼
#### CSS 변수 시스템
모든 스타일은 `design-system.css`의 CSS 변수를 사용합니다:
```css
/* 색상 - 하늘색 계열 primary */
var(--primary-500) /* 기본 하늘색: #0ea5e9 */
var(--primary-400) /* 밝은 하늘색: #38bdf8 */
var(--header-gradient) /* 헤더 그라디언트 */
/* 간격 */
var(--space-2) /* 8px */
var(--space-4) /* 16px */
var(--space-6) /* 24px */
/* 타이포그래피 */
var(--text-sm) /* 14px */
var(--text-base) /* 16px */
var(--font-medium) /* 500 */
/* 기타 */
var(--radius-md) /* 8px 둥근 모서리 */
var(--shadow-md) /* 그림자 */
var(--transition-fast) /* 150ms */
```
**금지**: 하드코딩된 색상 값 사용 (`#0ea5e9` 대신 `var(--primary-500)` 사용)
#### 페이지 레이아웃 템플릿
`web-ui/templates/` 디렉토리에 4가지 표준 템플릿 제공:
1. **dashboard-layout.html**: 메인 대시보드, 통계 페이지
2. **work-layout.html**: 작업 관련 페이지 (보고서, 분석)
3. **admin-layout.html**: 관리자 페이지 (테이블, CRUD)
4. **simple-layout.html**: 프로필, 설정 등 단순 페이지
새 페이지 생성 시:
```bash
# 템플릿 복사
cp web-ui/templates/work-layout.html web-ui/pages/work/new-page.html
# 내용 수정
# - <title> 변경
# - 페이지별 CSS/JS 추가
# - 콘텐츠 작성
```
상세한 사용법은 `web-ui/templates/README.md` 참조.
---
## 🔐 페이지 접근 권한 관리
### 권한 체크 방식
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
<!-- 관리자 전용 페이지 -->
<a href="/pages/admin/safety-management.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>안전관리</h3>
</div>
</a>
<!-- 모든 사용자 접근 가능 -->
<a href="/pages/work/visit-request.html" class="quick-action-card">
<div class="action-content">
<h3>출입 신청</h3>
</div>
</a>
```
### 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).
- **응답 포맷**:
@@ -74,6 +314,96 @@ Synology NAS(MySQL 8.0)의 `Strict Mode`로 인해 `db.execute()` 사용 시 `In
---
## 🕐 시간대(Timezone) 처리 가이드
### 핵심 원칙
이 프로젝트는 **한국 시간(KST, UTC+9)** 기준으로 운영됩니다.
### 문제 상황
서버가 UTC 시간대로 설정된 경우, `NOW()`, `CURRENT_TIMESTAMP`, `new Date()`를 사용하면 **한국 시간과 9시간 차이**가 발생합니다.
```
예시: 한국 시간 2026-02-03 08:00 AM에 신고 등록
- UTC 시간: 2026-02-02 11:00 PM
- DB 저장: 2026-02-02 (잘못된 날짜!)
```
### 해결 방법
#### 백엔드 (Node.js)
**공용 유틸리티 사용** (`utils/dateUtils.js`):
```javascript
const { getKoreaDatetime, getKoreaDateString } = require('../utils/dateUtils');
// 비즈니스 날짜 저장 시
const reportDate = getKoreaDatetime(); // '2026-02-03 08:00:00'
await db.query('INSERT INTO reports (report_date, ...) VALUES (?, ...)', [reportDate, ...]);
// 날짜만 필요한 경우
const today = getKoreaDateString(); // '2026-02-03'
```
**제공되는 함수들**:
| 함수 | 반환값 | 용도 |
|------|--------|------|
| `getKoreaDatetime()` | `'2026-02-03 08:00:00'` | DB DATETIME 저장 |
| `getKoreaDateString()` | `'2026-02-03'` | DB DATE 저장 |
| `getKoreaTimeString()` | `'08:00:00'` | DB TIME 저장 |
| `getKoreaYear()` | `2026` | 연도 |
| `getKoreaMonth()` | `2` | 월 (1-12) |
| `toKoreaDatetime(date)` | `'2026-02-03 08:00:00'` | Date 객체 변환 |
#### 프론트엔드 (JavaScript)
```javascript
// 로컬 시간대 기준 날짜 문자열
function getLocalDateString() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// ❌ 잘못된 방법 (UTC 변환됨)
const today = new Date().toISOString().split('T')[0];
// ✅ 올바른 방법 (로컬 시간)
const today = getLocalDateString();
```
### 컬럼별 적용 기준
| 컬럼 유형 | 처리 방법 | 비고 |
|-----------|-----------|------|
| `created_at`, `updated_at` | `NOW()` 또는 `CURRENT_TIMESTAMP` 사용 가능 | 감사용 메타데이터, UTC로 저장해도 무방 |
| `report_date`, `session_date` | **반드시 `getKoreaDatetime()` 사용** | 비즈니스 날짜, 사용자에게 표시됨 |
| `visit_date`, `attendance_date` | **반드시 한국 시간 기준** | 필터링/조회에 사용됨 |
| API 응답 `timestamp` | `new Date().toISOString()` 사용 가능 | 디버깅용 |
### 마이그레이션 주의사항
새 테이블 생성 시 비즈니스 날짜 컬럼은 **default 값을 사용하지 말고** 애플리케이션에서 명시적으로 설정:
```javascript
// ❌ 잘못된 방법
table.datetime('report_date').defaultTo(knex.fn.now());
// ✅ 올바른 방법
table.datetime('report_date').notNullable(); // default 없음
// 애플리케이션에서 getKoreaDatetime()으로 값 설정
```
### 기존 데이터 보정 (필요시)
UTC로 잘못 저장된 데이터를 한국 시간으로 보정:
```sql
-- 주의: 백업 후 실행
UPDATE work_issue_reports
SET report_date = DATE_ADD(report_date, INTERVAL 9 HOUR)
WHERE report_date < '2026-02-03';
```
---
## 🧪 테스트 가이드 (Jest)
### 중요도

View File

@@ -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`를 리팩토링함.

View File

@@ -51,13 +51,63 @@ function setupMiddlewares(app) {
app.use(express.static(path.join(__dirname, '../public')));
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Rate Limiting (필요시 활성화)
// const rateLimit = require('express-rate-limit');
// const limiter = rateLimit({
// windowMs: 15 * 60 * 1000, // 15분
// max: 100 // IP당 최대 100 요청
// });
// app.use('/api/', limiter);
// Rate Limiting - API 요청 제한
const rateLimit = require('express-rate-limit');
// 일반 API 요청 제한
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 1000, // IP당 최대 1000 요청 (일괄 처리 지원)
message: {
success: false,
error: '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.',
code: 'RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false,
// 인증된 사용자는 더 많은 요청 허용
skip: (req) => {
// Authorization 헤더가 있으면 Rate Limit 완화
return req.headers.authorization && req.headers.authorization.startsWith('Bearer ');
}
});
// 로그인 시도 제한 (브루트포스 방지)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 10, // IP당 최대 10회 로그인 시도
message: {
success: false,
error: '로그인 시도 횟수를 초과했습니다. 15분 후 다시 시도해주세요.',
code: 'LOGIN_RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false
});
// Rate limiter 적용
app.use('/api/', apiLimiter);
app.use('/api/auth/login', loginLimiter);
logger.info('Rate Limiting 설정 완료');
// CSRF Protection (선택적 - 필요 시 주석 해제)
// const { verifyCsrfToken, getCsrfToken } = require('../middlewares/csrf');
//
// CSRF 토큰 발급 엔드포인트
// app.get('/api/csrf-token', getCsrfToken);
//
// CSRF 검증 미들웨어 (로그인 등 일부 경로 제외)
// app.use('/api/', verifyCsrfToken({
// ignorePaths: [
// '/api/auth/login',
// '/api/auth/register',
// '/api/health',
// '/api/csrf-token'
// ]
// }));
//
// logger.info('CSRF Protection 설정 완료');
logger.info('미들웨어 설정 완료');
}

View File

@@ -40,6 +40,19 @@ function setupRoutes(app) {
const attendanceRoutes = require('../routes/attendanceRoutes');
const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes');
const pageAccessRoutes = require('../routes/pageAccessRoutes');
const workplaceRoutes = require('../routes/workplaceRoutes');
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');
const workIssueRoutes = require('../routes/workIssueRoutes');
const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
const notificationRecipientRoutes = require('../routes/notificationRecipientRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -54,10 +67,18 @@ function setupRoutes(app) {
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: 100, // 최대 100
max: 1000, // 최대 1000회 (기존 100회에서 대폭 증가)
message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
standardHeaders: true,
legacyHeaders: false
legacyHeaders: false,
// 관리자 및 시스템 계정은 rate limit 제외
skip: (req) => {
// 인증된 사용자 정보 확인
if (req.user && (req.user.access_level === 'system' || req.user.access_level === 'admin')) {
return true; // rate limit 건너뛰기
}
return false;
}
});
// 모든 API 요청에 활동 로거 적용
@@ -72,9 +93,6 @@ function setupRoutes(app) {
// Health check
app.use('/api/health', healthRoutes);
// 일반 API에 속도 제한 적용
app.use('/api/', apiLimiter);
// 인증이 필요 없는 공개 경로 목록
const publicPaths = [
'/api/auth/login',
@@ -89,10 +107,13 @@ function setupRoutes(app) {
'/api/setup/migrate-existing-data',
'/api/setup/check-data-status',
'/api/monthly-status/calendar',
'/api/monthly-status/daily-details'
'/api/monthly-status/daily-details',
'/api/migrate-work-type-id', // 임시 마이그레이션 - 실행 후 삭제!
'/api/diagnose-work-type-id', // 임시 진단 - 실행 후 삭제!
'/api/test-analysis' // 임시 분석 테스트 - 실행 후 삭제!
];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
app.use('/api/*', (req, res, next) => {
const isPublicPath = publicPaths.some(path => {
return req.originalUrl === path ||
@@ -109,6 +130,9 @@ function setupRoutes(app) {
verifyToken(req, res, next);
});
// 인증 후 일반 API에 속도 제한 적용 (인증된 사용자 정보로 skip 판단)
app.use('/api/', apiLimiter);
// 인증된 사용자만 접근 가능한 라우트들
app.use('/api/issue-reports', dailyIssueReportRoutes);
app.use('/api/issue-types', issueTypeRoutes);
@@ -126,7 +150,20 @@ function setupRoutes(app) {
app.use('/api/projects', projectRoutes);
app.use('/api/tools', toolsRoute);
app.use('/api/users', userRoutes);
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/work-issues', workIssueRoutes); // 문제 신고 시스템
app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
app.use('/api/notifications', notificationRoutes); // 알림 시스템
app.use('/api/notification-recipients', notificationRecipientRoutes); // 알림 수신자 설정
app.use('/api', uploadBgRoutes);
// Swagger API 문서

View File

@@ -86,6 +86,15 @@ const helmetOptions = {
*/
permittedCrossDomainPolicies: {
permittedPolicies: 'none'
},
/**
* Cross-Origin-Resource-Policy
* 크로스 오리진 리소스 공유 설정
* 이미지 등 정적 파일을 다른 포트에서 로드할 수 있도록 허용
*/
crossOriginResourcePolicy: {
policy: 'cross-origin'
}
};

View File

@@ -38,6 +38,20 @@ const getDailyAttendanceRecords = asyncHandler(async (req, res) => {
});
});
/**
* 기간별 근태 기록 조회 (월별 조회용)
*/
const getAttendanceRecordsByRange = asyncHandler(async (req, res) => {
const { start_date, end_date, worker_id } = req.query;
const data = await attendanceService.getAttendanceRecordsByRangeService(start_date, end_date, worker_id);
res.json({
success: true,
data,
message: '근태 기록을 성공적으로 조회했습니다'
});
});
/**
* 근태 기록 생성/업데이트
*/
@@ -154,14 +168,45 @@ 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,
getAttendanceRecordsByRange,
upsertAttendanceRecord,
processVacation,
approveOvertime,
getAttendanceTypes,
getVacationTypes,
getWorkerVacationBalance,
getMonthlyAttendanceStats
getMonthlyAttendanceStats,
getCheckinList,
saveCheckins
};

View File

@@ -2,7 +2,8 @@ const { getDb } = require('../dbPool');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const authService = require('../services/auth.service');
const { ApiError, asyncHandler } = require('../utils/errorHandler');
const { asyncHandler } = require('../utils/errorHandler');
const { AuthenticationError, ValidationError } = require('../utils/errors');
const { validateSchema, schemas } = require('../utils/validator');
const login = asyncHandler(async (req, res) => {
@@ -12,18 +13,18 @@ const login = asyncHandler(async (req, res) => {
// 유효성 검사
if (!username || !password) {
throw new ApiError('사용자명과 비밀번호를 입력해주세요.', 400);
throw new ValidationError('사용자명과 비밀번호를 입력해주세요.');
}
const result = await authService.loginService(username, password, ipAddress, userAgent);
if (!result.success) {
throw new ApiError(result.error, result.status || 400);
throw new AuthenticationError(result.error);
}
// 로그인 성공 후, 모든 권한을 그룹장 대시보드로 통일
// 로그인 성공 후, 메인 대시보드로 리다이렉트
const user = result.data.user;
const redirectUrl = '/pages/dashboard/group-leader.html'; // 모든 사용자를 그룹장 대시보드로 리다이렉트
const redirectUrl = '/pages/dashboard.html'; // 메인 대시보드로 리다이렉트
// 새로운 응답 포맷터 사용
res.auth(user, result.data.token, redirectUrl, '로그인 성공');

View File

@@ -479,12 +479,19 @@ const getWorkTypes = (req, res) => {
if (err) {
console.error('작업 유형 조회 오류:', err);
return res.status(500).json({
error: '작업 유형 조회 중 오류가 발생했습니다.',
details: err.message
success: false,
error: {
message: '작업 유형 조회 중 오류가 발생했습니다.',
code: 'DATABASE_ERROR'
}
});
}
console.log(`📋 작업 유형 조회 결과: ${data.length}`);
res.json(data);
res.json({
success: true,
data: data,
message: '작업 유형 조회 성공'
});
});
};
@@ -820,6 +827,74 @@ const getAccumulatedReports = (req, res) => {
});
};
/**
* TBM 배정 기반 작업보고서 생성
*/
const createFromTbm = async (req, res) => {
try {
const {
tbm_assignment_id,
tbm_session_id,
worker_id,
project_id,
work_type_id,
report_date,
start_time,
end_time,
total_hours,
error_hours,
error_type_id,
work_status_id
} = req.body;
// 필수 필드 검증
if (!tbm_assignment_id || !tbm_session_id || !worker_id || !report_date || !total_hours) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다. (assignment_id, session_id, worker_id, report_date, total_hours)'
});
}
// regular_hours 계산
const regular_hours = total_hours - (error_hours || 0);
const reportData = {
tbm_assignment_id,
tbm_session_id,
worker_id,
project_id,
work_type_id,
report_date,
start_time,
end_time,
total_hours,
error_hours: error_hours || 0,
regular_hours,
work_status_id: work_status_id || (error_hours > 0 ? 2 : 1), // error_hours가 있으면 상태 2 (부적합)
error_type_id,
created_by: req.user.user_id
};
const result = await dailyWorkReportModel.createFromTbmAssignment(reportData);
res.status(201).json({
success: true,
message: '작업보고서가 생성되었습니다.',
data: result
});
} catch (err) {
console.error('TBM 작업보고서 생성 오류:', err);
console.error('Error stack:', err.stack);
res.status(500).json({
success: false,
message: 'TBM 작업보고서 생성 중 오류가 발생했습니다.',
error: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
}
};
// 모든 컨트롤러 함수 내보내기 (리팩토링된 함수 위주로 재구성)
module.exports = {
// 📝 V2 핵심 CRUD 함수
@@ -827,6 +902,7 @@ module.exports = {
getDailyWorkReports,
updateWorkReport,
removeDailyWorkReport,
createFromTbm,
// 📊 V2 통계 및 요약 함수
getWorkReportStats,

View File

@@ -0,0 +1,241 @@
// controllers/departmentController.js
const departmentModel = require('../models/departmentModel');
const departmentController = {
// 모든 부서 조회
async getAll(req, res) {
try {
const { active_only } = req.query;
const departments = active_only === 'true'
? await departmentModel.getActive()
: await departmentModel.getAll();
res.json({
success: true,
data: departments
});
} catch (error) {
console.error('부서 목록 조회 오류:', error);
res.status(500).json({
success: false,
error: '부서 목록을 불러오는데 실패했습니다.'
});
}
},
// 부서 상세 조회
async getById(req, res) {
try {
const { id } = req.params;
const department = await departmentModel.getById(id);
if (!department) {
return res.status(404).json({
success: false,
error: '부서를 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: department
});
} catch (error) {
console.error('부서 조회 오류:', error);
res.status(500).json({
success: false,
error: '부서 정보를 불러오는데 실패했습니다.'
});
}
},
// 부서 생성
async create(req, res) {
try {
const { department_name, parent_id, description, is_active, display_order } = req.body;
if (!department_name) {
return res.status(400).json({
success: false,
error: '부서명은 필수입니다.'
});
}
const departmentId = await departmentModel.create({
department_name,
parent_id,
description,
is_active,
display_order
});
const newDepartment = await departmentModel.getById(departmentId);
res.status(201).json({
success: true,
message: '부서가 생성되었습니다.',
data: newDepartment
});
} catch (error) {
console.error('부서 생성 오류:', error);
res.status(500).json({
success: false,
error: '부서 생성에 실패했습니다.'
});
}
},
// 부서 수정
async update(req, res) {
try {
const { id } = req.params;
const { department_name, parent_id, description, is_active, display_order } = req.body;
if (!department_name) {
return res.status(400).json({
success: false,
error: '부서명은 필수입니다.'
});
}
// 자기 자신을 상위 부서로 지정하는 것 방지
if (parent_id && parseInt(parent_id) === parseInt(id)) {
return res.status(400).json({
success: false,
error: '자기 자신을 상위 부서로 지정할 수 없습니다.'
});
}
const updated = await departmentModel.update(id, {
department_name,
parent_id,
description,
is_active,
display_order
});
if (!updated) {
return res.status(404).json({
success: false,
error: '부서를 찾을 수 없습니다.'
});
}
const updatedDepartment = await departmentModel.getById(id);
res.json({
success: true,
message: '부서 정보가 수정되었습니다.',
data: updatedDepartment
});
} catch (error) {
console.error('부서 수정 오류:', error);
res.status(500).json({
success: false,
error: '부서 수정에 실패했습니다.'
});
}
},
// 부서 삭제
async delete(req, res) {
try {
const { id } = req.params;
await departmentModel.delete(id);
res.json({
success: true,
message: '부서가 삭제되었습니다.'
});
} catch (error) {
console.error('부서 삭제 오류:', error);
res.status(400).json({
success: false,
error: error.message || '부서 삭제에 실패했습니다.'
});
}
},
// 부서별 작업자 조회
async getWorkers(req, res) {
try {
const { id } = req.params;
const workers = await departmentModel.getWorkersByDepartment(id);
res.json({
success: true,
data: workers
});
} catch (error) {
console.error('부서 작업자 조회 오류:', error);
res.status(500).json({
success: false,
error: '작업자 목록을 불러오는데 실패했습니다.'
});
}
},
// 작업자 부서 이동
async moveWorker(req, res) {
try {
const { workerId, departmentId } = req.body;
if (!workerId || !departmentId) {
return res.status(400).json({
success: false,
error: '작업자 ID와 부서 ID가 필요합니다.'
});
}
await departmentModel.moveWorker(workerId, departmentId);
res.json({
success: true,
message: '작업자 부서가 변경되었습니다.'
});
} catch (error) {
console.error('작업자 부서 이동 오류:', error);
res.status(500).json({
success: false,
error: '작업자 부서 변경에 실패했습니다.'
});
}
},
// 여러 작업자 부서 일괄 이동
async moveWorkers(req, res) {
try {
const { workerIds, departmentId } = req.body;
if (!workerIds || !Array.isArray(workerIds) || workerIds.length === 0) {
return res.status(400).json({
success: false,
error: '이동할 작업자를 선택하세요.'
});
}
if (!departmentId) {
return res.status(400).json({
success: false,
error: '대상 부서를 선택하세요.'
});
}
const count = await departmentModel.moveWorkers(workerIds, departmentId);
res.json({
success: true,
message: `${count}명의 작업자 부서가 변경되었습니다.`
});
} catch (error) {
console.error('작업자 일괄 이동 오류:', error);
res.status(500).json({
success: false,
error: '작업자 부서 변경에 실패했습니다.'
});
}
}
};
module.exports = departmentController;

View File

@@ -0,0 +1,945 @@
// controllers/equipmentController.js
const EquipmentModel = require('../models/equipmentModel');
const imageUploadService = require('../services/imageUploadService');
const EquipmentController = {
// CREATE - 설비 생성
createEquipment: async (req, res) => {
try {
const equipmentData = req.body;
// 필수 필드 검증
if (!equipmentData.equipment_code || !equipmentData.equipment_name) {
return res.status(400).json({
success: false,
message: '설비 코드와 설비명은 필수입니다.'
});
}
// 설비 코드 중복 확인
EquipmentModel.checkDuplicateCode(equipmentData.equipment_code, null, (error, isDuplicate) => {
if (error) {
console.error('설비 코드 중복 확인 오류:', error);
return res.status(500).json({
success: false,
message: '설비 코드 중복 확인 중 오류가 발생했습니다.'
});
}
if (isDuplicate) {
return res.status(409).json({
success: false,
message: '이미 사용 중인 설비 코드입니다.'
});
}
// 설비 생성
EquipmentModel.create(equipmentData, (error, result) => {
if (error) {
console.error('설비 생성 오류:', error);
return res.status(500).json({
success: false,
message: '설비 생성 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '설비가 성공적으로 생성되었습니다.',
data: result
});
});
});
} catch (error) {
console.error('설비 생성 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ ALL - 모든 설비 조회 (필터링 가능)
getAllEquipments: (req, res) => {
try {
const filters = {
workplace_id: req.query.workplace_id,
equipment_type: req.query.equipment_type,
status: req.query.status,
search: req.query.search
};
EquipmentModel.getAll(filters, (error, results) => {
if (error) {
console.error('설비 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// READ ONE - 특정 설비 조회
getEquipmentById: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getById(equipmentId, (error, result) => {
if (error) {
console.error('설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 조회 중 오류가 발생했습니다.'
});
}
if (!result) {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: result
});
});
} catch (error) {
console.error('설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ BY WORKPLACE - 특정 작업장의 설비 조회
getEquipmentsByWorkplace: (req, res) => {
try {
const workplaceId = req.params.workplaceId;
EquipmentModel.getByWorkplace(workplaceId, (error, results) => {
if (error) {
console.error('작업장 설비 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// READ ACTIVE - 활성 설비만 조회
getActiveEquipments: (req, res) => {
try {
EquipmentModel.getActive((error, results) => {
if (error) {
console.error('활성 설비 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// UPDATE - 설비 수정
updateEquipment: async (req, res) => {
try {
const equipmentId = req.params.id;
const equipmentData = req.body;
// 필수 필드 검증
if (!equipmentData.equipment_code || !equipmentData.equipment_name) {
return res.status(400).json({
success: false,
message: '설비 코드와 설비명은 필수입니다.'
});
}
// 설비 존재 확인
EquipmentModel.getById(equipmentId, (error, existingEquipment) => {
if (error) {
console.error('설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 조회 중 오류가 발생했습니다.'
});
}
if (!existingEquipment) {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
// 설비 코드 중복 확인 (자신 제외)
EquipmentModel.checkDuplicateCode(equipmentData.equipment_code, equipmentId, (error, isDuplicate) => {
if (error) {
console.error('설비 코드 중복 확인 오류:', error);
return res.status(500).json({
success: false,
message: '설비 코드 중복 확인 중 오류가 발생했습니다.'
});
}
if (isDuplicate) {
return res.status(409).json({
success: false,
message: '이미 사용 중인 설비 코드입니다.'
});
}
// 설비 수정
EquipmentModel.update(equipmentId, equipmentData, (error, result) => {
if (error) {
console.error('설비 수정 오류:', error);
return res.status(500).json({
success: false,
message: '설비 수정 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 성공적으로 수정되었습니다.',
data: result
});
});
});
});
} catch (error) {
console.error('설비 수정 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// UPDATE MAP POSITION - 지도상 위치 업데이트
updateMapPosition: (req, res) => {
try {
const equipmentId = req.params.id;
const positionData = {
map_x_percent: req.body.map_x_percent,
map_y_percent: req.body.map_y_percent,
map_width_percent: req.body.map_width_percent,
map_height_percent: req.body.map_height_percent
};
// workplace_id가 있으면 포함 (설비를 다른 작업장으로 이동 가능)
if (req.body.workplace_id !== undefined) {
positionData.workplace_id = req.body.workplace_id;
}
EquipmentModel.updateMapPosition(equipmentId, positionData, (error, result) => {
if (error) {
console.error('설비 위치 업데이트 오류:', error);
return res.status(500).json({
success: false,
message: '설비 위치 업데이트 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비 위치가 성공적으로 업데이트되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 위치 업데이트 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// DELETE - 설비 삭제
deleteEquipment: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.delete(equipmentId, (error, result) => {
if (error) {
console.error('설비 삭제 오류:', error);
return res.status(500).json({
success: false,
message: '설비 삭제 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 성공적으로 삭제되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 삭제 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET EQUIPMENT TYPES - 사용 중인 설비 유형 목록 조회
getEquipmentTypes: (req, res) => {
try {
EquipmentModel.getEquipmentTypes((error, results) => {
if (error) {
console.error('설비 유형 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// GET NEXT EQUIPMENT CODE - 다음 관리번호 자동 생성
getNextEquipmentCode: (req, res) => {
try {
const prefix = req.query.prefix || 'TKP';
EquipmentModel.getNextEquipmentCode(prefix, (error, nextCode) => {
if (error) {
console.error('다음 관리번호 조회 오류:', error);
return res.status(500).json({
success: false,
message: '다음 관리번호 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: {
next_code: nextCode,
prefix: prefix
}
});
});
} catch (error) {
console.error('다음 관리번호 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 사진 관리
// ==========================================
// ADD PHOTO - 설비 사진 추가
addPhoto: async (req, res) => {
try {
const equipmentId = req.params.id;
const { photo_base64, description, display_order } = req.body;
if (!photo_base64) {
return res.status(400).json({
success: false,
message: '사진 데이터가 필요합니다.'
});
}
// Base64 이미지를 파일로 저장
const photoPath = await imageUploadService.saveBase64Image(
photo_base64,
'equipment',
'equipments'
);
if (!photoPath) {
return res.status(500).json({
success: false,
message: '사진 저장에 실패했습니다.'
});
}
// DB에 사진 정보 저장
const photoData = {
photo_path: photoPath,
description: description || null,
display_order: display_order || 0,
uploaded_by: req.user?.user_id || null
};
EquipmentModel.addPhoto(equipmentId, photoData, (error, result) => {
if (error) {
console.error('사진 정보 저장 오류:', error);
return res.status(500).json({
success: false,
message: '사진 정보 저장 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '사진이 성공적으로 추가되었습니다.',
data: result
});
});
} catch (error) {
console.error('사진 추가 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET PHOTOS - 설비 사진 조회
getPhotos: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getPhotos(equipmentId, (error, results) => {
if (error) {
console.error('사진 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// DELETE PHOTO - 설비 사진 삭제
deletePhoto: async (req, res) => {
try {
const photoId = req.params.photoId;
EquipmentModel.deletePhoto(photoId, async (error, result) => {
if (error) {
if (error.message === 'Photo not found') {
return res.status(404).json({
success: false,
message: '사진을 찾을 수 없습니다.'
});
}
console.error('사진 삭제 오류:', error);
return res.status(500).json({
success: false,
message: '사진 삭제 중 오류가 발생했습니다.'
});
}
// 파일 시스템에서 사진 삭제
if (result.photo_path) {
await imageUploadService.deleteFile(result.photo_path);
}
res.json({
success: true,
message: '사진이 성공적으로 삭제되었습니다.',
data: { photo_id: photoId }
});
});
} catch (error) {
console.error('사진 삭제 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 임시 이동
// ==========================================
// MOVE TEMPORARILY - 설비 임시 이동
moveTemporarily: (req, res) => {
try {
const equipmentId = req.params.id;
const moveData = {
target_workplace_id: req.body.target_workplace_id,
target_x_percent: req.body.target_x_percent,
target_y_percent: req.body.target_y_percent,
target_width_percent: req.body.target_width_percent,
target_height_percent: req.body.target_height_percent,
from_workplace_id: req.body.from_workplace_id,
from_x_percent: req.body.from_x_percent,
from_y_percent: req.body.from_y_percent,
reason: req.body.reason,
moved_by: req.user?.user_id || null
};
if (!moveData.target_workplace_id || moveData.target_x_percent === undefined || moveData.target_y_percent === undefined) {
return res.status(400).json({
success: false,
message: '이동할 작업장과 위치가 필요합니다.'
});
}
EquipmentModel.moveTemporarily(equipmentId, moveData, (error, result) => {
if (error) {
console.error('설비 이동 오류:', error);
return res.status(500).json({
success: false,
message: '설비 이동 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 임시 이동되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 이동 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// RETURN TO ORIGINAL - 설비 원위치 복귀
returnToOriginal: (req, res) => {
try {
const equipmentId = req.params.id;
const userId = req.user?.user_id || null;
EquipmentModel.returnToOriginal(equipmentId, userId, (error, result) => {
if (error) {
if (error.message === 'Equipment not found') {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
console.error('설비 복귀 오류:', error);
return res.status(500).json({
success: false,
message: '설비 복귀 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 원위치로 복귀되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 복귀 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET TEMPORARILY MOVED - 임시 이동된 설비 목록
getTemporarilyMoved: (req, res) => {
try {
EquipmentModel.getTemporarilyMoved((error, results) => {
if (error) {
console.error('임시 이동 설비 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// GET MOVE LOGS - 설비 이동 이력 조회
getMoveLogs: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getMoveLogs(equipmentId, (error, results) => {
if (error) {
console.error('이동 이력 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 외부 반출/반입
// ==========================================
// EXPORT EQUIPMENT - 설비 외부 반출
exportEquipment: (req, res) => {
try {
const equipmentId = req.params.id;
const exportData = {
equipment_id: equipmentId,
export_date: req.body.export_date,
expected_return_date: req.body.expected_return_date,
destination: req.body.destination,
reason: req.body.reason,
notes: req.body.notes,
is_repair: req.body.is_repair || false,
exported_by: req.user?.user_id || null
};
EquipmentModel.exportEquipment(exportData, (error, result) => {
if (error) {
console.error('설비 반출 오류:', error);
return res.status(500).json({
success: false,
message: '설비 반출 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '설비가 외부로 반출되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 반출 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// RETURN EQUIPMENT - 설비 반입 (외부에서 복귀)
returnEquipment: (req, res) => {
try {
const logId = req.params.logId;
const returnData = {
return_date: req.body.return_date,
new_status: req.body.new_status || 'active',
notes: req.body.notes,
returned_by: req.user?.user_id || null
};
EquipmentModel.returnEquipment(logId, returnData, (error, result) => {
if (error) {
if (error.message === 'Export log not found') {
return res.status(404).json({
success: false,
message: '반출 기록을 찾을 수 없습니다.'
});
}
console.error('설비 반입 오류:', error);
return res.status(500).json({
success: false,
message: '설비 반입 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 반입되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 반입 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET EXTERNAL LOGS - 설비 외부 반출 이력 조회
getExternalLogs: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getExternalLogs(equipmentId, (error, results) => {
if (error) {
console.error('반출 이력 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// GET EXPORTED EQUIPMENTS - 현재 외부 반출 중인 설비 목록
getExportedEquipments: (req, res) => {
try {
EquipmentModel.getExportedEquipments((error, results) => {
if (error) {
console.error('반출 중 설비 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 수리 신청
// ==========================================
// CREATE REPAIR REQUEST - 수리 신청
createRepairRequest: async (req, res) => {
try {
const equipmentId = req.params.id;
const { photo_base64_list, description, item_id, workplace_id } = req.body;
// 사진 저장 (있는 경우)
let photoPaths = [];
if (photo_base64_list && photo_base64_list.length > 0) {
for (const base64 of photo_base64_list) {
const path = await imageUploadService.saveBase64Image(base64, 'repair', 'issues');
if (path) photoPaths.push(path);
}
}
const requestData = {
equipment_id: equipmentId,
item_id: item_id || null,
workplace_id: workplace_id || null,
description: description || null,
photo_paths: photoPaths.length > 0 ? photoPaths : null,
reported_by: req.user?.user_id || null
};
EquipmentModel.createRepairRequest(requestData, (error, result) => {
if (error) {
if (error.message === '설비 수리 카테고리가 없습니다') {
return res.status(400).json({
success: false,
message: error.message
});
}
console.error('수리 신청 오류:', error);
return res.status(500).json({
success: false,
message: '수리 신청 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '수리 신청이 접수되었습니다.',
data: result
});
});
} catch (error) {
console.error('수리 신청 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET REPAIR HISTORY - 설비 수리 이력 조회
getRepairHistory: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getRepairHistory(equipmentId, (error, results) => {
if (error) {
console.error('수리 이력 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// GET REPAIR CATEGORIES - 설비 수리 항목 목록 조회
getRepairCategories: (req, res) => {
try {
EquipmentModel.getRepairCategories((error, results) => {
if (error) {
console.error('수리 항목 조회 오류:', error);
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: '서버 오류가 발생했습니다.'
});
}
},
// ADD REPAIR CATEGORY - 새 수리 항목 추가
addRepairCategory: (req, res) => {
try {
const { item_name } = req.body;
if (!item_name || !item_name.trim()) {
return res.status(400).json({
success: false,
message: '수리 유형 이름을 입력하세요.'
});
}
EquipmentModel.addRepairCategory(item_name.trim(), (error, result) => {
if (error) {
console.error('수리 항목 추가 오류:', error);
return res.status(500).json({
success: false,
message: '수리 항목 추가 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: result.isNew ? '새 수리 유형이 추가되었습니다.' : '기존 수리 유형을 사용합니다.',
data: result
});
});
} catch (error) {
console.error('수리 항목 추가 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
}
};
module.exports = EquipmentController;

View File

@@ -0,0 +1,165 @@
// controllers/notificationController.js
const notificationModel = require('../models/notificationModel');
const notificationController = {
// 읽지 않은 알림 조회
async getUnread(req, res) {
try {
const userId = req.user?.id || null;
const notifications = await notificationModel.getUnread(userId);
res.json({
success: true,
data: notifications
});
} catch (error) {
console.error('읽지 않은 알림 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 조회 중 오류가 발생했습니다.'
});
}
},
// 전체 알림 조회
async getAll(req, res) {
try {
const userId = req.user?.id || null;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const result = await notificationModel.getAll(userId, page, limit);
res.json({
success: true,
data: result.notifications,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: Math.ceil(result.total / result.limit)
}
});
} catch (error) {
console.error('알림 목록 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 조회 중 오류가 발생했습니다.'
});
}
},
// 읽지 않은 알림 개수
async getUnreadCount(req, res) {
try {
const userId = req.user?.id || null;
const count = await notificationModel.getUnreadCount(userId);
res.json({
success: true,
data: { count }
});
} catch (error) {
console.error('알림 개수 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 개수 조회 중 오류가 발생했습니다.'
});
}
},
// 알림 읽음 처리
async markAsRead(req, res) {
try {
const { id } = req.params;
const success = await notificationModel.markAsRead(id);
res.json({
success,
message: success ? '알림을 읽음 처리했습니다.' : '알림을 찾을 수 없습니다.'
});
} catch (error) {
console.error('알림 읽음 처리 오류:', error);
res.status(500).json({
success: false,
message: '알림 처리 중 오류가 발생했습니다.'
});
}
},
// 모든 알림 읽음 처리
async markAllAsRead(req, res) {
try {
const userId = req.user?.id || null;
const count = await notificationModel.markAllAsRead(userId);
res.json({
success: true,
message: `${count}개의 알림을 읽음 처리했습니다.`,
data: { count }
});
} catch (error) {
console.error('전체 읽음 처리 오류:', error);
res.status(500).json({
success: false,
message: '알림 처리 중 오류가 발생했습니다.'
});
}
},
// 알림 삭제
async delete(req, res) {
try {
const { id } = req.params;
const success = await notificationModel.delete(id);
res.json({
success,
message: success ? '알림을 삭제했습니다.' : '알림을 찾을 수 없습니다.'
});
} catch (error) {
console.error('알림 삭제 오류:', error);
res.status(500).json({
success: false,
message: '알림 삭제 중 오류가 발생했습니다.'
});
}
},
// 알림 생성 (시스템용)
async create(req, res) {
try {
const { type, title, message, link_url, user_id } = req.body;
if (!title) {
return res.status(400).json({
success: false,
message: '알림 제목은 필수입니다.'
});
}
const notificationId = await notificationModel.create({
user_id,
type,
title,
message,
link_url,
created_by: req.user?.id
});
res.json({
success: true,
message: '알림이 생성되었습니다.',
data: { notification_id: notificationId }
});
} catch (error) {
console.error('알림 생성 오류:', error);
res.status(500).json({
success: false,
message: '알림 생성 중 오류가 발생했습니다.'
});
}
}
};
module.exports = notificationController;

View File

@@ -0,0 +1,91 @@
// controllers/notificationRecipientController.js
const notificationRecipientModel = require('../models/notificationRecipientModel');
const notificationRecipientController = {
// 알림 유형 목록
getTypes: async (req, res) => {
try {
const types = notificationRecipientModel.getTypes();
res.json({ success: true, data: types });
} catch (error) {
console.error('알림 유형 조회 오류:', error);
res.status(500).json({ success: false, error: '알림 유형 조회 실패' });
}
},
// 전체 수신자 목록 (유형별 그룹화)
getAll: async (req, res) => {
try {
console.log('🔔 알림 수신자 목록 조회 시작');
const recipients = await notificationRecipientModel.getAll();
console.log('✅ 알림 수신자 목록 조회 완료:', recipients);
res.json({ success: true, data: recipients });
} catch (error) {
console.error('❌ 수신자 목록 조회 오류:', error.message);
console.error('❌ 스택:', error.stack);
res.status(500).json({ success: false, error: '수신자 목록 조회 실패', detail: error.message });
}
},
// 유형별 수신자 조회
getByType: async (req, res) => {
try {
const { type } = req.params;
const recipients = await notificationRecipientModel.getByType(type);
res.json({ success: true, data: recipients });
} catch (error) {
console.error('수신자 조회 오류:', error);
res.status(500).json({ success: false, error: '수신자 조회 실패' });
}
},
// 수신자 추가
add: async (req, res) => {
try {
const { notification_type, user_id } = req.body;
if (!notification_type || !user_id) {
return res.status(400).json({ success: false, error: '알림 유형과 사용자 ID가 필요합니다.' });
}
await notificationRecipientModel.add(notification_type, user_id, req.user?.user_id);
res.json({ success: true, message: '수신자가 추가되었습니다.' });
} catch (error) {
console.error('수신자 추가 오류:', error);
res.status(500).json({ success: false, error: '수신자 추가 실패' });
}
},
// 수신자 제거
remove: async (req, res) => {
try {
const { type, userId } = req.params;
await notificationRecipientModel.remove(type, userId);
res.json({ success: true, message: '수신자가 제거되었습니다.' });
} catch (error) {
console.error('수신자 제거 오류:', error);
res.status(500).json({ success: false, error: '수신자 제거 실패' });
}
},
// 유형별 수신자 일괄 설정
setRecipients: async (req, res) => {
try {
const { type } = req.params;
const { user_ids } = req.body;
if (!Array.isArray(user_ids)) {
return res.status(400).json({ success: false, error: 'user_ids 배열이 필요합니다.' });
}
await notificationRecipientModel.setRecipients(type, user_ids, req.user?.user_id);
res.json({ success: true, message: '수신자가 설정되었습니다.' });
} catch (error) {
console.error('수신자 설정 오류:', error);
res.status(500).json({ success: false, error: '수신자 설정 실패' });
}
}
};
module.exports = notificationRecipientController;

View File

@@ -0,0 +1,200 @@
// controllers/pageAccessController.js
const PageAccessModel = require('../models/pageAccessModel');
const PageAccessController = {
// 사용자의 페이지 권한 조회
getUserPageAccess: (req, res) => {
const userId = parseInt(req.params.userId);
if (isNaN(userId)) {
return res.status(400).json({
success: false,
message: '유효하지 않은 사용자 ID입니다.'
});
}
PageAccessModel.getUserPageAccess(userId, (err, results) => {
if (err) {
console.error('페이지 권한 조회 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 권한 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
// 모든 페이지 목록 조회
getAllPages: (req, res) => {
PageAccessModel.getAllPages((err, results) => {
if (err) {
console.error('페이지 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
// 페이지 권한 부여
grantPageAccess: (req, res) => {
const userId = parseInt(req.params.userId);
const { pageId } = req.body;
const grantedBy = req.user.user_id;
if (isNaN(userId) || !pageId) {
return res.status(400).json({
success: false,
message: '필수 파라미터가 누락되었습니다.'
});
}
PageAccessModel.grantPageAccess(userId, pageId, grantedBy, (err, result) => {
if (err) {
console.error('페이지 권한 부여 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 권한 부여 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '페이지 권한이 부여되었습니다.',
data: result
});
});
},
// 페이지 권한 회수
revokePageAccess: (req, res) => {
const userId = parseInt(req.params.userId);
const pageId = parseInt(req.params.pageId);
if (isNaN(userId) || isNaN(pageId)) {
return res.status(400).json({
success: false,
message: '유효하지 않은 파라미터입니다.'
});
}
PageAccessModel.revokePageAccess(userId, pageId, (err, result) => {
if (err) {
console.error('페이지 권한 회수 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 권한 회수 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '페이지 권한이 회수되었습니다.',
data: result
});
});
},
// 사용자 페이지 권한 일괄 설정
setUserPageAccess: (req, res) => {
const userId = parseInt(req.params.userId);
const { pageIds } = req.body;
const grantedBy = req.user.user_id;
if (isNaN(userId)) {
return res.status(400).json({
success: false,
message: '유효하지 않은 사용자 ID입니다.'
});
}
if (!Array.isArray(pageIds)) {
return res.status(400).json({
success: false,
message: 'pageIds는 배열이어야 합니다.'
});
}
PageAccessModel.setUserPageAccess(userId, pageIds, grantedBy, (err, result) => {
if (err) {
console.error('페이지 권한 설정 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 권한 설정 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '페이지 권한이 설정되었습니다.',
data: result
});
});
},
// 특정 페이지 접근 권한 확인
checkPageAccess: (req, res) => {
const userId = req.user.user_id;
const { pageKey } = req.params;
if (!pageKey) {
return res.status(400).json({
success: false,
message: '페이지 키가 필요합니다.'
});
}
PageAccessModel.checkPageAccess(userId, pageKey, (err, result) => {
if (err) {
console.error('페이지 접근 권한 확인 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 접근 권한 확인 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: result
});
});
},
// 계정이 있는 사용자 목록 조회 (권한 관리용)
getUsersWithAccounts: (req, res) => {
PageAccessModel.getUsersWithAccounts((err, results) => {
if (err) {
console.error('사용자 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '사용자 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
}
};
module.exports = PageAccessController;

View File

@@ -0,0 +1,796 @@
// patrolController.js
// 일일순회점검 시스템 컨트롤러
const PatrolModel = require('../models/patrolModel');
const PatrolController = {
// ==================== 순회점검 세션 ====================
// 세션 시작/조회
getOrCreateSession: async (req, res) => {
try {
const { patrol_date, patrol_time, category_id } = req.body;
const inspectorId = req.user.user_id;
if (!patrol_date || !patrol_time || !category_id) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
const session = await PatrolModel.getOrCreateSession(patrol_date, patrol_time, category_id, inspectorId);
res.json({ success: true, data: session });
} catch (error) {
console.error('세션 생성/조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 세션 상세 조회
getSession: async (req, res) => {
try {
const { sessionId } = req.params;
const session = await PatrolModel.getSession(sessionId);
if (!session) {
return res.status(404).json({ success: false, message: '세션을 찾을 수 없습니다.' });
}
res.json({ success: true, data: session });
} catch (error) {
console.error('세션 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 세션 목록 조회
getSessions: async (req, res) => {
try {
const { patrol_date, patrol_time, category_id, status, limit } = req.query;
const sessions = await PatrolModel.getSessions({
patrol_date,
patrol_time,
category_id,
status,
limit
});
res.json({ success: true, data: sessions });
} catch (error) {
console.error('세션 목록 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 세션 완료
completeSession: async (req, res) => {
try {
const { sessionId } = req.params;
await PatrolModel.completeSession(sessionId);
res.json({ success: true, message: '순회점검이 완료되었습니다.' });
} catch (error) {
console.error('세션 완료 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 세션 메모 업데이트
updateSessionNotes: async (req, res) => {
try {
const { sessionId } = req.params;
const { notes } = req.body;
await PatrolModel.updateSessionNotes(sessionId, notes);
res.json({ success: true, message: '메모가 저장되었습니다.' });
} catch (error) {
console.error('메모 저장 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 체크리스트 항목 ====================
// 체크리스트 항목 조회
getChecklistItems: async (req, res) => {
try {
const { category_id, workplace_id } = req.query;
const items = await PatrolModel.getChecklistItems(category_id, workplace_id);
// 카테고리별로 그룹화
const grouped = {};
items.forEach(item => {
if (!grouped[item.check_category]) {
grouped[item.check_category] = [];
}
grouped[item.check_category].push(item);
});
res.json({ success: true, data: { items, grouped } });
} catch (error) {
console.error('체크리스트 항목 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크리스트 항목 추가
createChecklistItem: async (req, res) => {
try {
const itemId = await PatrolModel.createChecklistItem(req.body);
res.json({ success: true, data: { item_id: itemId }, message: '항목이 추가되었습니다.' });
} catch (error) {
console.error('항목 추가 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크리스트 항목 수정
updateChecklistItem: async (req, res) => {
try {
const { itemId } = req.params;
await PatrolModel.updateChecklistItem(itemId, req.body);
res.json({ success: true, message: '항목이 수정되었습니다.' });
} catch (error) {
console.error('항목 수정 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크리스트 항목 삭제
deleteChecklistItem: async (req, res) => {
try {
const { itemId } = req.params;
await PatrolModel.deleteChecklistItem(itemId);
res.json({ success: true, message: '항목이 삭제되었습니다.' });
} catch (error) {
console.error('항목 삭제 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 체크 기록 ====================
// 작업장별 체크 기록 조회
getCheckRecords: async (req, res) => {
try {
const { sessionId } = req.params;
const { workplace_id } = req.query;
const records = await PatrolModel.getCheckRecords(sessionId, workplace_id);
res.json({ success: true, data: records });
} catch (error) {
console.error('체크 기록 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크 기록 저장
saveCheckRecord: async (req, res) => {
try {
const { sessionId } = req.params;
const { workplace_id, check_item_id, is_checked, check_result, note } = req.body;
if (!workplace_id || !check_item_id) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
await PatrolModel.saveCheckRecord(sessionId, workplace_id, check_item_id, is_checked, check_result, note);
res.json({ success: true, message: '저장되었습니다.' });
} catch (error) {
console.error('체크 기록 저장 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크 기록 일괄 저장
saveCheckRecords: async (req, res) => {
try {
const { sessionId } = req.params;
const { workplace_id, records } = req.body;
if (!workplace_id || !records || !Array.isArray(records)) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
await PatrolModel.saveCheckRecords(sessionId, workplace_id, records);
res.json({ success: true, message: '저장되었습니다.' });
} catch (error) {
console.error('체크 기록 일괄 저장 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 작업장 물품 현황 ====================
// 작업장 물품 조회
getWorkplaceItems: async (req, res) => {
try {
const { workplaceId } = req.params;
const { include_inactive } = req.query;
const items = await PatrolModel.getWorkplaceItems(workplaceId, include_inactive !== 'true');
res.json({ success: true, data: items });
} catch (error) {
console.error('물품 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 물품 추가
createWorkplaceItem: async (req, res) => {
try {
const { workplaceId } = req.params;
const data = { ...req.body, workplace_id: workplaceId, created_by: req.user.user_id };
const itemId = await PatrolModel.createWorkplaceItem(data);
res.json({ success: true, data: { item_id: itemId }, message: '물품이 추가되었습니다.' });
} catch (error) {
console.error('물품 추가 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 물품 수정
updateWorkplaceItem: async (req, res) => {
try {
const { itemId } = req.params;
await PatrolModel.updateWorkplaceItem(itemId, req.body, req.user.user_id);
res.json({ success: true, message: '물품이 수정되었습니다.' });
} catch (error) {
console.error('물품 수정 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 물품 삭제
deleteWorkplaceItem: async (req, res) => {
try {
const { itemId } = req.params;
const { permanent } = req.query;
if (permanent === 'true') {
await PatrolModel.hardDeleteWorkplaceItem(itemId);
} else {
await PatrolModel.deleteWorkplaceItem(itemId, req.user.user_id);
}
res.json({ success: true, message: '물품이 삭제되었습니다.' });
} catch (error) {
console.error('물품 삭제 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 물품 유형 ====================
// 물품 유형 목록
getItemTypes: async (req, res) => {
try {
const types = await PatrolModel.getItemTypes();
res.json({ success: true, data: types });
} catch (error) {
console.error('물품 유형 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 대시보드/통계 ====================
// 오늘 순회점검 현황
getTodayStatus: async (req, res) => {
try {
const { category_id } = req.query;
const status = await PatrolModel.getTodayPatrolStatus(category_id);
res.json({ success: true, data: status });
} catch (error) {
console.error('오늘 현황 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 작업장별 점검 현황
getWorkplaceCheckStatus: async (req, res) => {
try {
const { sessionId } = req.params;
const status = await PatrolModel.getWorkplaceCheckStatus(sessionId);
res.json({ success: true, data: status });
} catch (error) {
console.error('작업장별 점검 현황 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 작업장 상세 정보 (통합) ====================
// 작업장 상세 정보 조회 (시설물, 안전신고, 부적합, 출입, TBM)
getWorkplaceDetail: async (req, res) => {
try {
const { workplaceId } = req.params;
const { date } = req.query; // 기본: 오늘
const targetDate = date || new Date().toISOString().slice(0, 10);
const { getDb } = require('../dbPool');
const db = await getDb();
// 1. 작업장 기본 정보 (카테고리 지도 이미지 포함)
const [workplaceInfo] = await db.query(`
SELECT w.*, wc.category_name, wc.layout_image as category_layout_image
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.workplace_id = ?
`, [workplaceId]);
if (!workplaceInfo.length) {
return res.status(404).json({ success: false, message: '작업장을 찾을 수 없습니다.' });
}
// 2. 설비 현황 (해당 작업장 - 원래 위치 또는 현재 위치)
let equipments = [];
try {
const [eqResult] = await db.query(`
SELECT e.equipment_id, e.equipment_name, e.equipment_code, e.equipment_type,
e.status, e.notes, e.workplace_id,
e.map_x_percent, e.map_y_percent, e.map_width_percent, e.map_height_percent,
e.is_temporarily_moved, e.current_workplace_id,
e.current_map_x_percent, e.current_map_y_percent,
e.current_map_width_percent, e.current_map_height_percent,
e.moved_at,
ow.workplace_name as original_workplace_name,
cw.workplace_name as current_workplace_name,
CASE
WHEN e.status IN ('maintenance', 'repair_needed', 'repair_external') THEN 1
WHEN e.is_temporarily_moved = 1 THEN 1
ELSE 0
END as needs_attention
FROM equipments e
LEFT JOIN workplaces ow ON e.workplace_id = ow.workplace_id
LEFT JOIN workplaces cw ON e.current_workplace_id = cw.workplace_id
WHERE (e.workplace_id = ? OR e.current_workplace_id = ?)
AND e.status != 'inactive'
ORDER BY needs_attention DESC, e.equipment_name
`, [workplaceId, workplaceId]);
equipments = eqResult;
} catch (eqError) {
console.log('설비 조회 스킵 (테이블 없음 또는 오류):', eqError.message);
}
// 3. 수리 요청 현황 (미완료) - 테이블 존재 여부 확인 후 조회
let repairRequests = [];
try {
const [repairResult] = await db.query(`
SELECT er.request_id, er.request_date, er.repair_category, er.description,
er.priority, er.status, e.equipment_name, e.equipment_code
FROM equipment_repair_requests er
JOIN equipments e ON er.equipment_id = e.equipment_id
WHERE e.workplace_id = ? AND er.status NOT IN ('completed', 'cancelled')
ORDER BY
CASE er.priority WHEN 'emergency' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END,
er.request_date DESC
LIMIT 10
`, [workplaceId]);
repairRequests = repairResult;
} catch (repairError) {
console.log('수리요청 조회 스킵 (테이블 없음 또는 오류):', repairError.message);
}
// 4. 안전 신고 및 부적합 사항 - 테이블 존재 여부 확인 후 조회
let workIssues = [];
try {
const [issueResult] = await db.query(`
SELECT wi.report_id, wi.issue_type, wi.title, wi.description,
wi.status, wi.severity, wi.created_at, wi.resolved_at,
wic.category_name, wic.issue_type as category_type,
u.name as reporter_name
FROM work_issue_reports wi
LEFT JOIN issue_report_categories wic ON wi.category_id = wic.category_id
LEFT JOIN users u ON wi.reporter_id = u.user_id
WHERE wi.workplace_id = ?
AND wi.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY wi.created_at DESC
LIMIT 20
`, [workplaceId]);
workIssues = issueResult;
} catch (issueError) {
console.log('신고 조회 스킵 (테이블 없음 또는 오류):', issueError.message);
}
// 5. 오늘의 출입 기록 (해당 공장 카테고리)
const categoryId = workplaceInfo[0].category_id;
let visitRecords = [];
try {
const [visitResult] = await db.query(`
SELECT vr.request_id, vr.visitor_name, vr.visitor_company, vr.visit_purpose,
vr.visit_date, vr.visit_time_from, vr.visit_time_to, vr.status,
vr.vehicle_number, vr.companion_count,
vp.purpose_name, u.name as requester_name
FROM workplace_visit_requests vr
LEFT JOIN visit_purpose_types vp ON vr.purpose_id = vp.purpose_id
LEFT JOIN users u ON vr.requester_id = u.user_id
WHERE vr.category_id = ? AND vr.visit_date = ? AND vr.status = 'approved'
ORDER BY vr.visit_time_from
`, [categoryId, targetDate]);
visitRecords = visitResult;
} catch (visitError) {
console.log('출입기록 조회 스킵 (테이블 없음 또는 오류):', visitError.message);
}
// 6. 오늘의 TBM 세션 (해당 공장 카테고리)
let tbmSessions = [];
try {
const [tbmResult] = await db.query(`
SELECT ts.session_id, ts.session_date, ts.work_location, ts.status,
ts.work_content, ts.safety_measures, ts.team_size,
t.task_name, wt.name as work_type_name,
u.name as leader_name, w.worker_name as leader_worker_name
FROM tbm_sessions ts
LEFT JOIN tasks t ON ts.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN users u ON ts.leader_id = u.user_id
LEFT JOIN workers w ON ts.leader_worker_id = w.worker_id
WHERE ts.category_id = ? AND ts.session_date = ?
ORDER BY ts.created_at DESC
`, [categoryId, targetDate]);
tbmSessions = tbmResult;
} catch (tbmError) {
console.log('TBM 조회 스킵 (테이블 없음 또는 오류):', tbmError.message);
}
// 7. TBM 팀원 정보 (세션별)
let tbmWithTeams = [];
try {
tbmWithTeams = await Promise.all(tbmSessions.map(async (session) => {
const [team] = await db.query(`
SELECT tta.assignment_id, w.worker_name, w.occupation,
tta.attendance_status, tta.signature_image
FROM tbm_team_assignments tta
JOIN workers w ON tta.worker_id = w.worker_id
WHERE tta.session_id = ?
ORDER BY w.worker_name
`, [session.session_id]);
return { ...session, team };
}));
} catch (teamError) {
console.log('TBM 팀원 조회 스킵:', teamError.message);
tbmWithTeams = tbmSessions.map(s => ({ ...s, team: [] }));
}
// 8. 최근 순회점검 결과 (해당 작업장)
let recentPatrol = [];
try {
const [patrolResult] = await db.query(`
SELECT ps.session_id, ps.patrol_date, ps.patrol_time, ps.status,
ps.notes, u.name as inspector_name,
(SELECT COUNT(*) FROM patrol_check_records pcr
WHERE pcr.session_id = ps.session_id AND pcr.workplace_id = ?) as checked_count,
(SELECT COUNT(*) FROM patrol_check_records pcr
WHERE pcr.session_id = ps.session_id AND pcr.workplace_id = ?
AND pcr.check_result IN ('warning', 'bad')) as issue_count
FROM daily_patrol_sessions ps
LEFT JOIN users u ON ps.inspector_id = u.user_id
WHERE ps.category_id = ? AND ps.patrol_date >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY ps.patrol_date DESC, ps.patrol_time DESC
LIMIT 5
`, [workplaceId, workplaceId, categoryId]);
recentPatrol = patrolResult;
} catch (patrolError) {
console.log('순회점검 조회 스킵 (테이블 없음 또는 오류):', patrolError.message);
}
res.json({
success: true,
data: {
workplace: workplaceInfo[0],
equipments: equipments,
repairRequests: repairRequests,
workIssues: {
safety: workIssues.filter(i => i.category_type === 'safety'),
nonconformity: workIssues.filter(i => i.category_type === 'nonconformity'),
all: workIssues
},
visitRecords: visitRecords,
tbmSessions: tbmWithTeams,
recentPatrol: recentPatrol,
summary: {
equipmentCount: equipments.length,
needsAttention: equipments.filter(e => e.needs_attention).length,
pendingRepairs: repairRequests.length,
openIssues: workIssues.filter(i => i.status !== 'closed').length,
todayVisitors: visitRecords.reduce((sum, v) => sum + 1 + (v.companion_count || 0), 0),
todayTbmSessions: tbmSessions.length
}
}
});
} catch (error) {
console.error('작업장 상세 정보 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 구역 내 등록 물품/시설물 ====================
// 구역 내 물품/시설물 목록 조회
getZoneItems: async (req, res) => {
try {
const { workplaceId } = req.params;
const { getDb } = require('../dbPool');
const db = await getDb();
// 테이블이 없으면 생성
await db.query(`
CREATE TABLE IF NOT EXISTS workplace_zone_items (
item_id INT AUTO_INCREMENT PRIMARY KEY,
workplace_id INT NOT NULL,
item_name VARCHAR(200) NOT NULL COMMENT '물품/시설물 명칭',
item_type VARCHAR(50) DEFAULT 'general' COMMENT '유형 (heavy_equipment, hazardous, storage, general 등)',
description TEXT COMMENT '상세 설명',
x_percent DECIMAL(5,2) NOT NULL COMMENT '영역 시작 X 좌표 (%)',
y_percent DECIMAL(5,2) NOT NULL COMMENT '영역 시작 Y 좌표 (%)',
width_percent DECIMAL(5,2) DEFAULT 10 COMMENT '영역 너비 (%)',
height_percent DECIMAL(5,2) DEFAULT 10 COMMENT '영역 높이 (%)',
color VARCHAR(20) DEFAULT '#3b82f6' COMMENT '표시 색상',
warning_level VARCHAR(20) DEFAULT 'normal' COMMENT '주의 수준 (normal, caution, danger)',
quantity INT DEFAULT 1 COMMENT '수량',
unit VARCHAR(20) DEFAULT '개' COMMENT '단위',
weight_kg DECIMAL(10,2) DEFAULT NULL COMMENT '중량 (kg)',
is_active BOOLEAN DEFAULT TRUE,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_workplace (workplace_id),
INDEX idx_type (item_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='구역 내 등록 물품/시설물'
`);
// 새 컬럼 추가 (없으면)
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_type VARCHAR(20) DEFAULT 'non_project'`);
} catch (e) { /* 이미 존재 */ }
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_id INT NULL`);
} catch (e) { /* 이미 존재 */ }
const [items] = await db.query(`
SELECT zi.*, p.project_name
FROM workplace_zone_items zi
LEFT JOIN projects p ON zi.project_id = p.project_id
WHERE zi.workplace_id = ? AND zi.is_active = TRUE
ORDER BY zi.warning_level DESC, zi.item_name
`, [workplaceId]);
// 사진 테이블 존재 확인 및 사진 조회
try {
for (const item of items) {
const [photos] = await db.query(`
SELECT photo_id, photo_url, created_at
FROM zone_item_photos
WHERE item_id = ?
ORDER BY created_at DESC
`, [item.item_id]);
item.photos = photos || [];
}
} catch (e) {
// 사진 테이블이 없으면 무시
items.forEach(item => item.photos = []);
}
res.json({ success: true, data: items });
} catch (error) {
console.error('구역 물품 목록 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 등록
createZoneItem: async (req, res) => {
try {
const { workplaceId } = req.params;
const { item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id } = req.body;
const createdBy = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
if (!item_name || x_percent === undefined || y_percent === undefined) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
// 테이블에 새 컬럼 추가 (없으면)
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_type VARCHAR(20) DEFAULT 'non_project'`);
} catch (e) { /* 이미 존재 */ }
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_id INT NULL`);
} catch (e) { /* 이미 존재 */ }
const [result] = await db.query(`
INSERT INTO workplace_zone_items
(workplace_id, item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [workplaceId, item_name, item_type || 'working', description, x_percent, y_percent,
width_percent || 5, height_percent || 5, color || '#3b82f6', warning_level || 'good',
project_type || 'non_project', project_id || null, createdBy]);
const newItemId = result.insertId;
// 등록 이력 저장
try {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, new_values, changed_by)
VALUES (?, 'created', ?, ?)
`, [newItemId, JSON.stringify({ item_name, item_type, warning_level, project_type }), createdBy]);
} catch (e) { /* 테이블 없으면 무시 */ }
res.json({
success: true,
data: { item_id: newItemId },
message: '현황이 등록되었습니다.'
});
} catch (error) {
console.error('구역 현황 등록 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 수정
updateZoneItem: async (req, res) => {
try {
const { itemId } = req.params;
const { item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id } = req.body;
const userId = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
// 이력 테이블 생성 (없으면)
await db.query(`
CREATE TABLE IF NOT EXISTS zone_item_history (
history_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NOT NULL,
action_type VARCHAR(20) NOT NULL COMMENT 'created, updated, deleted',
changed_fields TEXT COMMENT '변경된 필드 JSON',
old_values TEXT COMMENT '이전 값 JSON',
new_values TEXT COMMENT '새 값 JSON',
changed_by INT,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_item (item_id),
INDEX idx_date (changed_at)
)
`);
// 기존 데이터 조회 (이력용)
const [oldData] = await db.query(`SELECT * FROM workplace_zone_items WHERE item_id = ?`, [itemId]);
const oldItem = oldData[0];
// 업데이트
await db.query(`
UPDATE workplace_zone_items SET
item_name = COALESCE(?, item_name),
item_type = COALESCE(?, item_type),
description = ?,
x_percent = COALESCE(?, x_percent),
y_percent = COALESCE(?, y_percent),
width_percent = COALESCE(?, width_percent),
height_percent = COALESCE(?, height_percent),
color = COALESCE(?, color),
warning_level = COALESCE(?, warning_level),
project_type = COALESCE(?, project_type),
project_id = ?
WHERE item_id = ?
`, [item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id, itemId]);
// 변경 이력 저장
if (oldItem) {
const changedFields = [];
const oldValues = {};
const newValues = {};
const fieldMap = { item_name, item_type, description, warning_level, project_type, project_id };
for (const [key, newVal] of Object.entries(fieldMap)) {
if (newVal !== undefined && oldItem[key] !== newVal) {
changedFields.push(key);
oldValues[key] = oldItem[key];
newValues[key] = newVal;
}
}
if (changedFields.length > 0) {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, changed_fields, old_values, new_values, changed_by)
VALUES (?, 'updated', ?, ?, ?, ?)
`, [itemId, JSON.stringify(changedFields), JSON.stringify(oldValues), JSON.stringify(newValues), userId]);
}
}
res.json({ success: true, message: '현황이 수정되었습니다.' });
} catch (error) {
console.error('구역 현황 수정 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 사진 업로드
uploadZoneItemPhoto: async (req, res) => {
try {
const { item_id } = req.body;
const { getDb } = require('../dbPool');
const db = await getDb();
if (!req.file) {
return res.status(400).json({ success: false, message: '파일이 없습니다.' });
}
// 사진 테이블 생성 (없으면)
await db.query(`
CREATE TABLE IF NOT EXISTS zone_item_photos (
photo_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NOT NULL,
photo_url VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_item_id (item_id)
)
`);
const photoUrl = `/uploads/${req.file.filename}`;
const [result] = await db.query(
`INSERT INTO zone_item_photos (item_id, photo_url) VALUES (?, ?)`,
[item_id, photoUrl]
);
res.json({
success: true,
data: { photo_id: result.insertId, photo_url: photoUrl },
message: '사진이 업로드되었습니다.'
});
} catch (error) {
console.error('사진 업로드 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 삭제
deleteZoneItem: async (req, res) => {
try {
const { itemId } = req.params;
const userId = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
// 기존 데이터 조회 (이력용)
const [oldData] = await db.query(`SELECT * FROM workplace_zone_items WHERE item_id = ?`, [itemId]);
const oldItem = oldData[0];
// 소프트 삭제
await db.query(`UPDATE workplace_zone_items SET is_active = FALSE WHERE item_id = ?`, [itemId]);
// 삭제 이력 저장
if (oldItem) {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, old_values, changed_by)
VALUES (?, 'deleted', ?, ?)
`, [itemId, JSON.stringify({ item_name: oldItem.item_name, item_type: oldItem.item_type }), userId]);
}
res.json({ success: true, message: '현황이 삭제되었습니다.' });
} catch (error) {
console.error('구역 현황 삭제 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 이력 조회
getZoneItemHistory: async (req, res) => {
try {
const { itemId } = req.params;
const { getDb } = require('../dbPool');
const db = await getDb();
const [history] = await db.query(`
SELECT h.*, u.full_name as changed_by_name
FROM zone_item_history h
LEFT JOIN users u ON h.changed_by = u.user_id
WHERE h.item_id = ?
ORDER BY h.changed_at DESC
LIMIT 50
`, [itemId]);
res.json({ success: true, data: history });
} catch (error) {
console.error('현황 이력 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = PatrolController;

View File

@@ -21,12 +21,7 @@ exports.createProject = asyncHandler(async (req, res) => {
logger.info('프로젝트 생성 요청', { name: projectData.name });
const id = await new Promise((resolve, reject) => {
projectModel.create(projectData, (err, lastID) => {
if (err) reject(new DatabaseError('프로젝트 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
const id = await projectModel.create(projectData);
// 프로젝트 캐시 무효화
await cache.invalidateCache.project();
@@ -44,12 +39,7 @@ exports.createProject = asyncHandler(async (req, res) => {
* 전체 프로젝트 조회
*/
exports.getAllProjects = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
projectModel.getAll((err, data) => {
if (err) reject(new DatabaseError('프로젝트 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
const rows = await projectModel.getAll();
res.json({
success: true,
@@ -62,12 +52,7 @@ exports.getAllProjects = asyncHandler(async (req, res) => {
* 활성 프로젝트만 조회 (작업보고서용)
*/
exports.getActiveProjects = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
projectModel.getActiveProjects((err, data) => {
if (err) reject(new DatabaseError('활성 프로젝트 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
const rows = await projectModel.getActiveProjects();
res.json({
success: true,
@@ -86,12 +71,7 @@ exports.getProjectById = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
}
const row = await new Promise((resolve, reject) => {
projectModel.getById(id, (err, data) => {
if (err) reject(new DatabaseError('프로젝트 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
const row = await projectModel.getById(id);
if (!row) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다');
@@ -116,12 +96,7 @@ exports.updateProject = asyncHandler(async (req, res) => {
const data = { ...req.body, project_id: id };
const changes = await new Promise((resolve, reject) => {
projectModel.update(data, (err, ch) => {
if (err) reject(new DatabaseError('프로젝트 수정 중 오류가 발생했습니다'));
else resolve(ch);
});
});
const changes = await projectModel.update(data);
if (changes === 0) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다');
@@ -149,12 +124,7 @@ exports.removeProject = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
}
const changes = await new Promise((resolve, reject) => {
projectModel.remove(id, (err, ch) => {
if (err) reject(new DatabaseError('프로젝트 삭제 중 오류가 발생했습니다'));
else resolve(ch);
});
});
const changes = await projectModel.remove(id);
if (changes === 0) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다');

View File

@@ -0,0 +1,152 @@
/**
* 작업 관리 컨트롤러
*
* 작업 CRUD API 엔드포인트 핸들러
* (공정=work_types에 속하는 세부 작업)
*
* @author TK-FB-Project
* @since 2026-01-26
*/
const taskModel = require('../models/taskModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
// ==================== 작업 CRUD ====================
/**
* 작업 생성
*/
exports.createTask = asyncHandler(async (req, res) => {
const taskData = req.body;
if (!taskData.task_name) {
throw new ValidationError('작업명은 필수 입력 항목입니다');
}
logger.info('작업 생성 요청', { name: taskData.task_name });
const id = await taskModel.createTask(taskData);
logger.info('작업 생성 성공', { task_id: id });
res.status(201).json({
success: true,
data: { task_id: id },
message: '작업이 성공적으로 생성되었습니다'
});
});
/**
* 전체 작업 조회 (work_type_id 필터 지원)
*/
exports.getAllTasks = asyncHandler(async (req, res) => {
const { work_type_id } = req.query;
let rows;
if (work_type_id) {
// 특정 공정의 활성 작업만 조회
rows = await taskModel.getTasksByWorkType(work_type_id);
} else {
rows = await taskModel.getAllTasks();
}
res.json({
success: true,
data: rows,
message: '작업 목록 조회 성공'
});
});
/**
* 활성 작업만 조회
*/
exports.getActiveTasks = asyncHandler(async (req, res) => {
const rows = await taskModel.getActiveTasks();
res.json({
success: true,
data: rows,
message: '활성 작업 목록 조회 성공'
});
});
/**
* 공정별 작업 조회
*/
exports.getTasksByWorkType = asyncHandler(async (req, res) => {
const workTypeId = req.params.work_type_id || req.query.work_type_id;
if (!workTypeId) {
throw new ValidationError('공정 ID가 필요합니다');
}
const rows = await taskModel.getTasksByWorkType(workTypeId);
res.json({
success: true,
data: rows,
message: '공정별 작업 목록 조회 성공'
});
});
/**
* 단일 작업 조회
*/
exports.getTaskById = asyncHandler(async (req, res) => {
const taskId = req.params.id;
const task = await taskModel.getTaskById(taskId);
if (!task) {
throw new NotFoundError('작업을 찾을 수 없습니다');
}
res.json({
success: true,
data: task,
message: '작업 조회 성공'
});
});
/**
* 작업 수정
*/
exports.updateTask = asyncHandler(async (req, res) => {
const taskId = req.params.id;
const taskData = req.body;
if (!taskData.task_name) {
throw new ValidationError('작업명은 필수 입력 항목입니다');
}
logger.info('작업 수정 요청', { task_id: taskId });
await taskModel.updateTask(taskId, taskData);
logger.info('작업 수정 성공', { task_id: taskId });
res.json({
success: true,
message: '작업이 성공적으로 수정되었습니다'
});
});
/**
* 작업 삭제
*/
exports.deleteTask = asyncHandler(async (req, res) => {
const taskId = req.params.id;
logger.info('작업 삭제 요청', { task_id: taskId });
await taskModel.deleteTask(taskId);
logger.info('작업 삭제 성공', { task_id: taskId });
res.json({
success: true,
message: '작업이 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,893 @@
// controllers/tbmController.js - TBM 시스템 컨트롤러
const TbmModel = require('../models/tbmModel');
const TbmController = {
// ==================== TBM 세션 관련 ====================
/**
* TBM 세션 생성
*/
createSession: (req, res) => {
const sessionData = {
session_date: req.body.session_date,
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,
safety_notes: req.body.safety_notes || null,
start_time: req.body.start_time || null,
created_by: req.user.user_id
};
// 필수 필드 검증 (날짜만 필수, leader_id는 관리자의 경우 null 허용)
if (!sessionData.session_date) {
return res.status(400).json({
success: false,
message: 'TBM 날짜는 필수입니다.'
});
}
TbmModel.createSession(sessionData, (err, result) => {
if (err) {
console.error('TBM 세션 생성 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 생성 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: 'TBM 세션이 생성되었습니다.',
data: {
session_id: result.insertId,
...sessionData
}
});
});
},
/**
* 특정 날짜의 TBM 세션 목록 조회
*/
getSessionsByDate: (req, res) => {
const { date } = req.params;
if (!date) {
return res.status(400).json({
success: false,
message: '날짜 정보가 필요합니다.'
});
}
TbmModel.getSessionsByDate(date, (err, results) => {
if (err) {
console.error('TBM 세션 조회 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* TBM 세션 상세 조회
*/
getSessionById: (req, res) => {
const { sessionId } = req.params;
TbmModel.getSessionById(sessionId, (err, results) => {
if (err) {
console.error('TBM 세션 상세 조회 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 상세 조회 중 오류가 발생했습니다.',
error: err.message
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: 'TBM 세션을 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: results[0]
});
});
},
/**
* TBM 세션 수정
*/
updateSession: (req, res) => {
const { sessionId } = req.params;
const sessionData = {
project_id: req.body.project_id,
work_location: req.body.work_location,
work_description: req.body.work_description,
safety_notes: req.body.safety_notes,
status: req.body.status || 'draft'
};
TbmModel.updateSession(sessionId, sessionData, (err, result) => {
if (err) {
console.error('TBM 세션 수정 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 수정 중 오류가 발생했습니다.',
error: err.message
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: 'TBM 세션을 찾을 수 없습니다.'
});
}
res.json({
success: true,
message: 'TBM 세션이 수정되었습니다.'
});
});
},
/**
* TBM 세션 완료 처리
*/
completeSession: (req, res) => {
const { sessionId } = req.params;
const endTime = req.body.end_time || new Date().toTimeString().slice(0, 8);
TbmModel.completeSession(sessionId, endTime, (err, result) => {
if (err) {
console.error('TBM 세션 완료 처리 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 완료 처리 중 오류가 발생했습니다.',
error: err.message
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: 'TBM 세션을 찾을 수 없습니다.'
});
}
res.json({
success: true,
message: 'TBM 세션이 완료되었습니다.'
});
});
},
// ==================== 팀 구성 관련 ====================
/**
* 팀원 추가 (작업자별 상세 정보 포함)
*/
addTeamMember: (req, res) => {
const assignmentData = {
session_id: req.params.sessionId,
worker_id: req.body.worker_id,
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,
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) {
return res.status(400).json({
success: false,
message: '작업자 ID가 필요합니다.'
});
}
TbmModel.addTeamMember(assignmentData, (err, result) => {
if (err) {
console.error('팀원 추가 오류:', err);
return res.status(500).json({
success: false,
message: '팀원 추가 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '팀원이 추가되었습니다.'
});
});
},
/**
* 팀 구성 일괄 추가
*/
addTeamMembers: (req, res) => {
const { sessionId } = req.params;
const { members } = req.body;
if (!Array.isArray(members) || members.length === 0) {
return res.status(400).json({
success: false,
message: '팀원 목록이 필요합니다.'
});
}
TbmModel.addTeamMembers(sessionId, members, (err, result) => {
if (err) {
console.error('팀 구성 일괄 추가 오류:', err);
return res.status(500).json({
success: false,
message: '팀 구성 추가 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: `${members.length}명의 팀원이 추가되었습니다.`,
data: { count: members.length }
});
});
},
/**
* TBM 세션의 팀 구성 조회
*/
getTeamMembers: (req, res) => {
const { sessionId } = req.params;
TbmModel.getTeamMembers(sessionId, (err, results) => {
if (err) {
console.error('팀 구성 조회 오류:', err);
return res.status(500).json({
success: false,
message: '팀 구성 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 팀원 제거
*/
removeTeamMember: (req, res) => {
const { sessionId, workerId } = req.params;
TbmModel.removeTeamMember(sessionId, workerId, (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: '팀원이 제거되었습니다.'
});
});
},
/**
* 세션의 모든 팀원 삭제 (수정 시 사용)
*/
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 }
});
});
},
// ==================== 안전 체크리스트 관련 ====================
/**
* 모든 안전 체크 항목 조회
*/
getAllSafetyChecks: (req, res) => {
TbmModel.getAllSafetyChecks((err, results) => {
if (err) {
console.error('안전 체크 항목 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 항목 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* TBM 세션의 안전 체크 기록 조회
*/
getSafetyRecords: (req, res) => {
const { sessionId } = req.params;
TbmModel.getSafetyRecords(sessionId, (err, results) => {
if (err) {
console.error('안전 체크 기록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 기록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 안전 체크 일괄 저장
*/
saveSafetyRecords: (req, res) => {
const { sessionId } = req.params;
const { records } = req.body;
if (!Array.isArray(records) || records.length === 0) {
return res.status(400).json({
success: false,
message: '안전 체크 기록이 필요합니다.'
});
}
const checkedBy = req.user.user_id;
TbmModel.saveSafetyRecords(sessionId, records, checkedBy, (err, result) => {
if (err) {
console.error('안전 체크 저장 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 저장 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '안전 체크가 저장되었습니다.',
data: { count: records.length }
});
});
},
// ==================== 필터링된 안전 체크리스트 (확장) ====================
/**
* 세션에 맞는 필터링된 안전 체크 항목 조회
* 기본 + 날씨 + 작업별 체크항목 통합
*/
getFilteredSafetyChecks: async (req, res) => {
const { sessionId } = req.params;
try {
// 날씨 정보 확인 (이미 저장된 경우 사용, 없으면 새로 조회)
const weatherService = require('../services/weatherService');
let weatherRecord = await weatherService.getWeatherRecord(sessionId);
let weatherConditions = [];
if (weatherRecord && weatherRecord.weather_conditions) {
weatherConditions = weatherRecord.weather_conditions;
} else {
// 날씨 정보가 없으면 현재 날씨 조회
const currentWeather = await weatherService.getCurrentWeather();
weatherConditions = await weatherService.determineWeatherConditions(currentWeather);
// 날씨 기록 저장
await weatherService.saveWeatherRecord(sessionId, currentWeather, weatherConditions);
}
TbmModel.getFilteredSafetyChecks(sessionId, weatherConditions, (err, results) => {
if (err) {
console.error('필터링된 안전 체크 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크리스트 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('필터링된 안전 체크 조회 오류:', error);
res.status(500).json({
success: false,
message: '안전 체크리스트 조회 중 오류가 발생했습니다.',
error: error.message
});
}
},
/**
* 현재 날씨 조회
*/
getCurrentWeather: async (req, res) => {
try {
const weatherService = require('../services/weatherService');
const { nx, ny } = req.query;
const weatherData = await weatherService.getCurrentWeather(nx, ny);
const conditions = await weatherService.determineWeatherConditions(weatherData);
const conditionList = await weatherService.getWeatherConditionList();
// 현재 조건의 상세 정보 매핑
const activeConditions = conditionList.filter(c => conditions.includes(c.condition_code));
res.json({
success: true,
data: {
...weatherData,
conditions,
conditionDetails: activeConditions
}
});
} catch (error) {
console.error('날씨 조회 오류:', error);
res.status(500).json({
success: false,
message: '날씨 조회 중 오류가 발생했습니다.',
error: error.message
});
}
},
/**
* 세션 날씨 정보 저장
*/
saveSessionWeather: async (req, res) => {
const { sessionId } = req.params;
const { weatherConditions } = req.body;
try {
const weatherService = require('../services/weatherService');
// 현재 날씨 조회
const weatherData = await weatherService.getCurrentWeather();
const conditions = weatherConditions || await weatherService.determineWeatherConditions(weatherData);
// 저장
await weatherService.saveWeatherRecord(sessionId, weatherData, conditions);
res.json({
success: true,
message: '날씨 정보가 저장되었습니다.',
data: { conditions }
});
} catch (error) {
console.error('날씨 저장 오류:', error);
res.status(500).json({
success: false,
message: '날씨 저장 중 오류가 발생했습니다.',
error: error.message
});
}
},
/**
* 세션 날씨 정보 조회
*/
getSessionWeather: async (req, res) => {
const { sessionId } = req.params;
try {
const weatherService = require('../services/weatherService');
const weatherRecord = await weatherService.getWeatherRecord(sessionId);
if (!weatherRecord) {
return res.status(404).json({
success: false,
message: '날씨 기록이 없습니다.'
});
}
res.json({
success: true,
data: weatherRecord
});
} catch (error) {
console.error('날씨 조회 오류:', error);
res.status(500).json({
success: false,
message: '날씨 조회 중 오류가 발생했습니다.',
error: error.message
});
}
},
/**
* 날씨 조건 목록 조회
*/
getWeatherConditions: async (req, res) => {
try {
const weatherService = require('../services/weatherService');
const conditions = await weatherService.getWeatherConditionList();
res.json({
success: true,
data: conditions
});
} catch (error) {
console.error('날씨 조건 조회 오류:', error);
res.status(500).json({
success: false,
message: '날씨 조건 조회 중 오류가 발생했습니다.',
error: error.message
});
}
},
// ==================== 안전 체크항목 관리 (관리자용) ====================
/**
* 안전 체크 항목 생성
*/
createSafetyCheck: (req, res) => {
const checkData = req.body;
if (!checkData.check_category || !checkData.check_item) {
return res.status(400).json({
success: false,
message: '카테고리와 체크 항목은 필수입니다.'
});
}
TbmModel.createSafetyCheck(checkData, (err, result) => {
if (err) {
console.error('안전 체크 항목 생성 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 항목 생성 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: '안전 체크 항목이 생성되었습니다.',
data: { check_id: result.insertId }
});
});
},
/**
* 안전 체크 항목 수정
*/
updateSafetyCheck: (req, res) => {
const { checkId } = req.params;
const checkData = req.body;
TbmModel.updateSafetyCheck(checkId, checkData, (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: '안전 체크 항목이 수정되었습니다.'
});
});
},
/**
* 안전 체크 항목 삭제 (비활성화)
*/
deleteSafetyCheck: (req, res) => {
const { checkId } = req.params;
TbmModel.deleteSafetyCheck(checkId, (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: '안전 체크 항목이 삭제되었습니다.'
});
});
},
// ==================== 작업 인계 관련 ====================
/**
* 작업 인계 생성
*/
createHandover: (req, res) => {
const handoverData = {
session_id: req.body.session_id,
from_leader_id: req.body.from_leader_id,
to_leader_id: req.body.to_leader_id,
handover_date: req.body.handover_date,
handover_time: req.body.handover_time || null,
reason: req.body.reason,
handover_notes: req.body.handover_notes || null,
worker_ids: req.body.worker_ids || []
};
// 필수 필드 검증
if (!handoverData.session_id || !handoverData.from_leader_id ||
!handoverData.to_leader_id || !handoverData.handover_date || !handoverData.reason) {
return res.status(400).json({
success: false,
message: '필수 정보가 누락되었습니다.'
});
}
TbmModel.createHandover(handoverData, (err, result) => {
if (err) {
console.error('작업 인계 생성 오류:', err);
return res.status(500).json({
success: false,
message: '작업 인계 생성 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: '작업 인계가 생성되었습니다.',
data: { handover_id: result.insertId }
});
});
},
/**
* 작업 인계 확인
*/
confirmHandover: (req, res) => {
const { handoverId } = req.params;
const confirmedBy = req.user.user_id;
TbmModel.confirmHandover(handoverId, confirmedBy, (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: '작업 인계가 확인되었습니다.'
});
});
},
/**
* 특정 날짜의 작업 인계 목록 조회
*/
getHandoversByDate: (req, res) => {
const { date } = req.params;
TbmModel.getHandoversByDate(date, (err, results) => {
if (err) {
console.error('작업 인계 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '작업 인계 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 나에게 온 미확인 인계 건 조회
*/
getMyPendingHandovers: (req, res) => {
// worker_id는 req.user에서 가져옴
const toLeaderId = req.user.worker_id;
if (!toLeaderId) {
return res.status(400).json({
success: false,
message: '작업자 정보를 찾을 수 없습니다.'
});
}
TbmModel.getPendingHandovers(toLeaderId, (err, results) => {
if (err) {
console.error('미확인 인계 건 조회 오류:', err);
return res.status(500).json({
success: false,
message: '미확인 인계 건 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
// ==================== 통계 및 리포트 ====================
/**
* TBM 통계 조회
*/
getTbmStatistics: (req, res) => {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
message: '시작일과 종료일이 필요합니다.'
});
}
TbmModel.getTbmStatistics(startDate, endDate, (err, results) => {
if (err) {
console.error('TBM 통계 조회 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 통계 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 리더별 TBM 진행 현황 조회
*/
getLeaderStatistics: (req, res) => {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
message: '시작일과 종료일이 필요합니다.'
});
}
TbmModel.getLeaderStatistics(startDate, endDate, (err, results) => {
if (err) {
console.error('리더 통계 조회 오류:', err);
return res.status(500).json({
success: false,
message: '리더 통계 조회 중 오류가 발생했습니다.',
error: err.message
});
}
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
});
});
}
};
module.exports = TbmController;

View File

@@ -43,11 +43,17 @@ const getAllUsers = asyncHandler(async (req, res) => {
r.name as role,
u._access_level_old as access_level,
u.is_active,
u.worker_id,
w.worker_name,
w.department_id,
d.department_name,
u.created_at,
u.updated_at,
u.last_login_at as last_login
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN workers w ON u.worker_id = w.worker_id
LEFT JOIN departments d ON w.department_id = d.department_id
ORDER BY u.created_at DESC
`;
@@ -218,16 +224,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, role, role_id, password, worker_id } = 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 && !role && !role_id && !password && worker_id === undefined) {
throw new ValidationError('수정할 필드가 없습니다');
}
@@ -278,18 +284,35 @@ const updateUser = asyncHandler(async (req, res) => {
values.push(email || null);
}
if (phone !== undefined) {
updates.push('phone = ?');
values.push(phone || null);
// 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_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 (role) {
const validRoles = ['admin', 'group_leader', 'worker'];
if (!validRoles.includes(role)) {
throw new ValidationError('유효하지 않은 권한입니다');
if (roleCheck.length === 0) {
throw new ValidationError(`유효하지 않은 권한입니다: ${role}`);
}
updates.push('role = ?, access_level = ?');
values.push(role, role);
updates.push('role_id = ?');
values.push(roleCheck[0].id);
logger.info('role 문자열로 역할 변경', { userId: id, role, role_id: roleCheck[0].id });
}
if (password) {
@@ -297,15 +320,32 @@ 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);
}
// worker_id 업데이트 (null도 허용 - 연결 해제)
if (worker_id !== undefined) {
if (worker_id !== null) {
// worker_id가 유효한지 확인
const [workerCheck] = await db.execute('SELECT worker_id, worker_name FROM workers WHERE worker_id = ?', [worker_id]);
if (workerCheck.length === 0) {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
logger.info('작업자 연결', { userId: id, worker_id, worker_name: workerCheck[0].worker_name });
} else {
logger.info('작업자 연결 해제', { userId: id });
}
updates.push('worker_id = ?');
values.push(worker_id);
}
updates.push('updated_at = NOW()');
values.push(id);
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 +364,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 +498,242 @@ const deleteUser = asyncHandler(async (req, res) => {
}
});
/**
* 사용자 영구 삭제 (Hard Delete)
*/
const permanentDeleteUser = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
// 자기 자신 삭제 방지
if (req.user && req.user.user_id == id) {
throw new ValidationError('자기 자신은 삭제할 수 없습니다');
}
logger.info('사용자 영구 삭제 요청', { userId: id });
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 사용자 존재 확인
const checkQuery = 'SELECT user_id, username FROM users WHERE user_id = ?';
const [users] = await db.execute(checkQuery, [id]);
if (users.length === 0) {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
const username = users[0].username;
// 관련 데이터 삭제 (외래 키 제약 조건 때문에 순서 중요)
// 1. 로그인 로그 삭제
await db.execute('DELETE FROM login_logs WHERE user_id = ?', [id]);
// 2. 페이지 접근 권한 삭제
await db.execute('DELETE FROM user_page_access WHERE user_id = ?', [id]);
// 3. 사용자 삭제
await db.execute('DELETE FROM users WHERE user_id = ?', [id]);
logger.info('사용자 영구 삭제 성공', {
userId: id,
username: username,
deletedBy: req.user.username
});
res.json({
success: true,
data: { user_id: id },
message: `사용자 "${username}"이(가) 영구적으로 삭제되었습니다`
});
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) {
throw error;
}
logger.error('사용자 영구 삭제 실패', { userId: id, error: error.message });
throw new DatabaseError('사용자를 영구 삭제하는데 실패했습니다');
}
});
/**
* 사용자의 페이지 접근 권한 조회
*/
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 {
// 권한 조회: user_page_access에 명시적 권한이 있으면 사용, 없으면 is_default_accessible 사용
const query = `
SELECT
p.id as page_id,
p.page_key,
p.page_name,
p.page_path,
p.category,
p.is_default_accessible,
COALESCE(upa.can_access, p.is_default_accessible) as can_access
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
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('페이지 권한을 업데이트하는데 실패했습니다');
}
});
/**
* 사용자 비밀번호 초기화 (000000)
*/
const resetUserPassword = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 사용자 존재 확인
const [existing] = await db.execute('SELECT user_id, username FROM users WHERE user_id = ?', [id]);
if (existing.length === 0) {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
// 비밀번호를 000000으로 초기화
const hashedPassword = await bcrypt.hash('000000', 10);
await db.execute(
'UPDATE users SET password = ?, password_changed_at = NULL, updated_at = NOW() WHERE user_id = ?',
[hashedPassword, id]
);
logger.info('사용자 비밀번호 초기화 성공', {
userId: id,
username: existing[0].username,
resetBy: req.user.username
});
res.json({
success: true,
message: '비밀번호가 000000으로 초기화되었습니다'
});
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) {
throw error;
}
logger.error('비밀번호 초기화 실패', { userId: id, error: error.message });
throw new DatabaseError('비밀번호 초기화에 실패했습니다');
}
});
module.exports = {
getAllUsers,
getUserById,
createUser,
updateUser,
updateUserStatus,
deleteUser
deleteUser,
permanentDeleteUser,
getUserPageAccess,
updateUserPageAccess,
resetUserPassword
};

View File

@@ -0,0 +1,421 @@
/**
* 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: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 잔액 일괄 저장 (upsert)
* POST /api/vacation-balances/bulk-upsert
*/
async bulkUpsert(req, res) {
try {
const { balances } = req.body;
const created_by = req.user.user_id;
if (!balances || !Array.isArray(balances) || balances.length === 0) {
return res.status(400).json({
success: false,
message: '저장할 데이터가 없습니다'
});
}
const { getDb } = require('../dbPool');
const db = await getDb();
let successCount = 0;
let errorCount = 0;
for (const balance of balances) {
const { worker_id, vacation_type_id, year, total_days, notes } = balance;
if (!worker_id || !vacation_type_id || !year || total_days === undefined) {
errorCount++;
continue;
}
try {
// Upsert 쿼리
const query = `
INSERT INTO vacation_balance_details
(worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
total_days = VALUES(total_days),
notes = VALUES(notes),
updated_at = NOW()
`;
await db.query(query, [worker_id, vacation_type_id, year, total_days, notes || null, created_by]);
successCount++;
} catch (err) {
console.error('휴가 잔액 저장 오류:', err);
errorCount++;
}
}
res.json({
success: true,
message: `${successCount}건 저장 완료${errorCount > 0 ? `, ${errorCount}건 실패` : ''}`,
data: { successCount, errorCount }
});
} catch (error) {
console.error('bulkUpsert 오류:', 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
});
});
};

View File

@@ -0,0 +1,674 @@
/**
* 작업 중 문제 신고 컨트롤러
*/
const workIssueModel = require('../models/workIssueModel');
const imageUploadService = require('../services/imageUploadService');
// ==================== 신고 카테고리 관리 ====================
/**
* 모든 카테고리 조회
*/
exports.getAllCategories = (req, res) => {
workIssueModel.getAllCategories((err, categories) => {
if (err) {
console.error('카테고리 조회 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
}
res.json({ success: true, data: categories });
});
};
/**
* 타입별 카테고리 조회
*/
exports.getCategoriesByType = (req, res) => {
const { type } = req.params;
if (!['nonconformity', 'safety'].includes(type)) {
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
}
workIssueModel.getCategoriesByType(type, (err, categories) => {
if (err) {
console.error('카테고리 조회 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
}
res.json({ success: true, data: categories });
});
};
/**
* 카테고리 생성
*/
exports.createCategory = (req, res) => {
const { category_type, category_name, description, display_order } = req.body;
if (!category_type || !category_name) {
return res.status(400).json({ success: false, error: '카테고리 타입과 이름은 필수입니다.' });
}
workIssueModel.createCategory(
{ category_type, category_name, description, display_order },
(err, categoryId) => {
if (err) {
console.error('카테고리 생성 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 생성 실패' });
}
res.status(201).json({
success: true,
message: '카테고리가 생성되었습니다.',
data: { category_id: categoryId }
});
}
);
};
/**
* 카테고리 수정
*/
exports.updateCategory = (req, res) => {
const { id } = req.params;
const { category_name, description, display_order, is_active } = req.body;
workIssueModel.updateCategory(
id,
{ category_name, description, display_order, is_active },
(err, result) => {
if (err) {
console.error('카테고리 수정 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 수정 실패' });
}
res.json({ success: true, message: '카테고리가 수정되었습니다.' });
}
);
};
/**
* 카테고리 삭제
*/
exports.deleteCategory = (req, res) => {
const { id } = req.params;
workIssueModel.deleteCategory(id, (err, result) => {
if (err) {
console.error('카테고리 삭제 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 삭제 실패' });
}
res.json({ success: true, message: '카테고리가 삭제되었습니다.' });
});
};
// ==================== 사전 정의 항목 관리 ====================
/**
* 카테고리별 항목 조회
*/
exports.getItemsByCategory = (req, res) => {
const { categoryId } = req.params;
workIssueModel.getItemsByCategory(categoryId, (err, items) => {
if (err) {
console.error('항목 조회 실패:', err);
return res.status(500).json({ success: false, error: '항목 조회 실패' });
}
res.json({ success: true, data: items });
});
};
/**
* 모든 항목 조회
*/
exports.getAllItems = (req, res) => {
workIssueModel.getAllItems((err, items) => {
if (err) {
console.error('항목 조회 실패:', err);
return res.status(500).json({ success: false, error: '항목 조회 실패' });
}
res.json({ success: true, data: items });
});
};
/**
* 항목 생성
*/
exports.createItem = (req, res) => {
const { category_id, item_name, description, severity, display_order } = req.body;
if (!category_id || !item_name) {
return res.status(400).json({ success: false, error: '카테고리 ID와 항목명은 필수입니다.' });
}
workIssueModel.createItem(
{ category_id, item_name, description, severity, display_order },
(err, itemId) => {
if (err) {
console.error('항목 생성 실패:', err);
return res.status(500).json({ success: false, error: '항목 생성 실패' });
}
res.status(201).json({
success: true,
message: '항목이 생성되었습니다.',
data: { item_id: itemId }
});
}
);
};
/**
* 항목 수정
*/
exports.updateItem = (req, res) => {
const { id } = req.params;
const { item_name, description, severity, display_order, is_active } = req.body;
workIssueModel.updateItem(
id,
{ item_name, description, severity, display_order, is_active },
(err, result) => {
if (err) {
console.error('항목 수정 실패:', err);
return res.status(500).json({ success: false, error: '항목 수정 실패' });
}
res.json({ success: true, message: '항목이 수정되었습니다.' });
}
);
};
/**
* 항목 삭제
*/
exports.deleteItem = (req, res) => {
const { id } = req.params;
workIssueModel.deleteItem(id, (err, result) => {
if (err) {
console.error('항목 삭제 실패:', err);
return res.status(500).json({ success: false, error: '항목 삭제 실패' });
}
res.json({ success: true, message: '항목이 삭제되었습니다.' });
});
};
// ==================== 문제 신고 관리 ====================
/**
* 신고 생성
*/
exports.createReport = async (req, res) => {
try {
const {
factory_category_id,
workplace_id,
custom_location,
tbm_session_id,
visit_request_id,
issue_category_id,
issue_item_id,
custom_item_name, // 직접 입력한 항목명
additional_description,
photos = []
} = req.body;
const reporter_id = req.user.user_id;
if (!issue_category_id) {
return res.status(400).json({ success: false, error: '신고 카테고리는 필수입니다.' });
}
// 위치 정보 검증 (지도 선택 또는 기타 위치)
if (!factory_category_id && !custom_location) {
return res.status(400).json({ success: false, error: '위치 정보는 필수입니다.' });
}
// 항목 검증 (기존 항목 또는 직접 입력)
if (!issue_item_id && !custom_item_name) {
return res.status(400).json({ success: false, error: '신고 항목은 필수입니다.' });
}
// 직접 입력한 항목이 있으면 DB에 저장
let finalItemId = issue_item_id;
if (custom_item_name && !issue_item_id) {
try {
finalItemId = await new Promise((resolve, reject) => {
workIssueModel.createItem(
{
category_id: issue_category_id,
item_name: custom_item_name,
description: '사용자 직접 입력',
severity: 'medium',
display_order: 999 // 마지막에 표시
},
(err, itemId) => {
if (err) reject(err);
else resolve(itemId);
}
);
});
} catch (itemErr) {
console.error('커스텀 항목 생성 실패:', itemErr);
return res.status(500).json({ success: false, error: '항목 저장 실패' });
}
}
// 사진 저장 (최대 5장)
const photoPaths = {
photo_path1: null,
photo_path2: null,
photo_path3: null,
photo_path4: null,
photo_path5: null
};
for (let i = 0; i < Math.min(photos.length, 5); i++) {
if (photos[i]) {
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
if (savedPath) {
photoPaths[`photo_path${i + 1}`] = savedPath;
}
}
}
const reportData = {
reporter_id,
factory_category_id: factory_category_id || null,
workplace_id: workplace_id || null,
custom_location: custom_location || null,
tbm_session_id: tbm_session_id || null,
visit_request_id: visit_request_id || null,
issue_category_id,
issue_item_id: finalItemId || null,
additional_description: additional_description || null,
...photoPaths
};
workIssueModel.createReport(reportData, (err, reportId) => {
if (err) {
console.error('신고 생성 실패:', err);
return res.status(500).json({ success: false, error: '신고 생성 실패' });
}
res.status(201).json({
success: true,
message: '문제 신고가 등록되었습니다.',
data: { report_id: reportId }
});
});
} catch (error) {
console.error('신고 생성 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 목록 조회
*/
exports.getAllReports = (req, res) => {
const filters = {
status: req.query.status,
category_type: req.query.category_type,
issue_category_id: req.query.issue_category_id,
factory_category_id: req.query.factory_category_id,
workplace_id: req.query.workplace_id,
assigned_user_id: req.query.assigned_user_id,
start_date: req.query.start_date,
end_date: req.query.end_date,
search: req.query.search,
limit: req.query.limit,
offset: req.query.offset
};
// 일반 사용자는 자신의 신고만 조회 (관리자 제외)
const userLevel = req.user.access_level;
if (!['admin', 'system', 'support_team'].includes(userLevel)) {
filters.reporter_id = req.user.user_id;
}
workIssueModel.getAllReports(filters, (err, reports) => {
if (err) {
console.error('신고 목록 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 목록 조회 실패' });
}
res.json({ success: true, data: reports });
});
};
/**
* 신고 상세 조회
*/
exports.getReportById = (req, res) => {
const { id } = req.params;
workIssueModel.getReportById(id, (err, report) => {
if (err) {
console.error('신고 상세 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 상세 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인: 본인, 담당자, 또는 관리자
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isAssignee = report.assigned_user_id === req.user.user_id;
const isManager = ['admin', 'system', 'support_team'].includes(userLevel);
if (!isOwner && !isAssignee && !isManager) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
}
res.json({ success: true, data: report });
});
};
/**
* 신고 수정
*/
exports.updateReport = async (req, res) => {
try {
const { id } = req.params;
// 기존 신고 확인
workIssueModel.getReportById(id, async (err, report) => {
if (err) {
console.error('신고 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isManager = ['admin', 'system'].includes(userLevel);
if (!isOwner && !isManager) {
return res.status(403).json({ success: false, error: '수정 권한이 없습니다.' });
}
// 상태 확인: reported 상태에서만 수정 가능 (관리자 제외)
if (!isManager && report.status !== 'reported') {
return res.status(400).json({ success: false, error: '이미 접수된 신고는 수정할 수 없습니다.' });
}
const {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
photos = []
} = req.body;
// 사진 업데이트 처리
const photoPaths = {};
for (let i = 0; i < Math.min(photos.length, 5); i++) {
if (photos[i]) {
// 기존 사진 삭제
const oldPath = report[`photo_path${i + 1}`];
if (oldPath) {
await imageUploadService.deleteFile(oldPath);
}
// 새 사진 저장
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
if (savedPath) {
photoPaths[`photo_path${i + 1}`] = savedPath;
}
}
}
const updateData = {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
...photoPaths
};
workIssueModel.updateReport(id, updateData, req.user.user_id, (updateErr, result) => {
if (updateErr) {
console.error('신고 수정 실패:', updateErr);
return res.status(500).json({ success: false, error: '신고 수정 실패' });
}
res.json({ success: true, message: '신고가 수정되었습니다.' });
});
});
} catch (error) {
console.error('신고 수정 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 삭제
*/
exports.deleteReport = async (req, res) => {
const { id } = req.params;
workIssueModel.getReportById(id, async (err, report) => {
if (err) {
console.error('신고 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isManager = ['admin', 'system'].includes(userLevel);
if (!isOwner && !isManager) {
return res.status(403).json({ success: false, error: '삭제 권한이 없습니다.' });
}
workIssueModel.deleteReport(id, async (deleteErr, { result, photos }) => {
if (deleteErr) {
console.error('신고 삭제 실패:', deleteErr);
return res.status(500).json({ success: false, error: '신고 삭제 실패' });
}
// 사진 파일 삭제
if (photos) {
const allPhotos = [
photos.photo_path1, photos.photo_path2, photos.photo_path3,
photos.photo_path4, photos.photo_path5,
photos.resolution_photo_path1, photos.resolution_photo_path2
].filter(Boolean);
await imageUploadService.deleteMultipleFiles(allPhotos);
}
res.json({ success: true, message: '신고가 삭제되었습니다.' });
});
});
};
// ==================== 상태 관리 ====================
/**
* 신고 접수
*/
exports.receiveReport = (req, res) => {
const { id } = req.params;
workIssueModel.receiveReport(id, req.user.user_id, (err, result) => {
if (err) {
console.error('신고 접수 실패:', err);
return res.status(400).json({ success: false, error: err.message || '신고 접수 실패' });
}
res.json({ success: true, message: '신고가 접수되었습니다.' });
});
};
/**
* 담당자 배정
*/
exports.assignReport = (req, res) => {
const { id } = req.params;
const { assigned_department, assigned_user_id } = req.body;
if (!assigned_user_id) {
return res.status(400).json({ success: false, error: '담당자는 필수입니다.' });
}
workIssueModel.assignReport(id, {
assigned_department,
assigned_user_id,
assigned_by: req.user.user_id
}, (err, result) => {
if (err) {
console.error('담당자 배정 실패:', err);
return res.status(400).json({ success: false, error: err.message || '담당자 배정 실패' });
}
res.json({ success: true, message: '담당자가 배정되었습니다.' });
});
};
/**
* 처리 시작
*/
exports.startProcessing = (req, res) => {
const { id } = req.params;
workIssueModel.startProcessing(id, req.user.user_id, (err, result) => {
if (err) {
console.error('처리 시작 실패:', err);
return res.status(400).json({ success: false, error: err.message || '처리 시작 실패' });
}
res.json({ success: true, message: '처리가 시작되었습니다.' });
});
};
/**
* 처리 완료
*/
exports.completeReport = async (req, res) => {
try {
const { id } = req.params;
const { resolution_notes, resolution_photos = [] } = req.body;
// 완료 사진 저장
let resolution_photo_path1 = null;
let resolution_photo_path2 = null;
if (resolution_photos[0]) {
resolution_photo_path1 = await imageUploadService.saveBase64Image(resolution_photos[0], 'resolution');
}
if (resolution_photos[1]) {
resolution_photo_path2 = await imageUploadService.saveBase64Image(resolution_photos[1], 'resolution');
}
workIssueModel.completeReport(id, {
resolution_notes,
resolution_photo_path1,
resolution_photo_path2,
resolved_by: req.user.user_id
}, (err, result) => {
if (err) {
console.error('처리 완료 실패:', err);
return res.status(400).json({ success: false, error: err.message || '처리 완료 실패' });
}
res.json({ success: true, message: '처리가 완료되었습니다.' });
});
} catch (error) {
console.error('처리 완료 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 종료
*/
exports.closeReport = (req, res) => {
const { id } = req.params;
workIssueModel.closeReport(id, req.user.user_id, (err, result) => {
if (err) {
console.error('신고 종료 실패:', err);
return res.status(400).json({ success: false, error: err.message || '신고 종료 실패' });
}
res.json({ success: true, message: '신고가 종료되었습니다.' });
});
};
/**
* 상태 변경 이력 조회
*/
exports.getStatusLogs = (req, res) => {
const { id } = req.params;
workIssueModel.getStatusLogs(id, (err, logs) => {
if (err) {
console.error('상태 이력 조회 실패:', err);
return res.status(500).json({ success: false, error: '상태 이력 조회 실패' });
}
res.json({ success: true, data: logs });
});
};
// ==================== 통계 ====================
/**
* 통계 요약
*/
exports.getStatsSummary = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date,
factory_category_id: req.query.factory_category_id
};
workIssueModel.getStatsSummary(filters, (err, stats) => {
if (err) {
console.error('통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};
/**
* 카테고리별 통계
*/
exports.getStatsByCategory = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date
};
workIssueModel.getStatsByCategory(filters, (err, stats) => {
if (err) {
console.error('카테고리별 통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};
/**
* 작업장별 통계
*/
exports.getStatsByWorkplace = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date,
factory_category_id: req.query.factory_category_id
};
workIssueModel.getStatsByWorkplace(filters, (err, stats) => {
if (err) {
console.error('작업장별 통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};

View File

@@ -106,3 +106,70 @@ exports.getSummary = asyncHandler(async (req, res) => {
message: '월간 요약 조회 성공'
});
});
// ========== 부적합 원인 관리 API ==========
/**
* 작업 보고서의 부적합 원인 목록 조회
*/
exports.getReportDefects = asyncHandler(async (req, res) => {
const { reportId } = req.params;
const rows = await workReportService.getReportDefectsService(reportId);
res.json({
success: true,
data: rows,
message: '부적합 원인 조회 성공'
});
});
/**
* 부적합 원인 저장 (전체 교체)
* 기존 부적합 원인을 모두 삭제하고 새로 저장
*/
exports.saveReportDefects = asyncHandler(async (req, res) => {
const { reportId } = req.params;
const { defects } = req.body; // [{ error_type_id, defect_hours, note }]
const result = await workReportService.saveReportDefectsService(reportId, defects);
res.json({
success: true,
data: result,
message: '부적합 원인이 저장되었습니다'
});
});
/**
* 부적합 원인 추가 (단일)
*/
exports.addReportDefect = asyncHandler(async (req, res) => {
const { reportId } = req.params;
const { error_type_id, defect_hours, note } = req.body;
const result = await workReportService.addReportDefectService(reportId, {
error_type_id,
defect_hours,
note
});
res.json({
success: true,
data: result,
message: '부적합 원인이 추가되었습니다'
});
});
/**
* 부적합 원인 삭제
*/
exports.removeReportDefect = asyncHandler(async (req, res) => {
const { defectId } = req.params;
const result = await workReportService.removeReportDefectService(defectId);
res.json({
success: true,
data: result,
message: '부적합 원인이 삭제되었습니다'
});
});

View File

@@ -26,12 +26,7 @@ exports.createWorker = asyncHandler(async (req, res) => {
logger.info('작업자 생성 요청', { name: workerData.worker_name, create_account: createAccount });
const lastID = await new Promise((resolve, reject) => {
workerModel.create(workerData, (err, id) => {
if (err) reject(new DatabaseError('작업자 생성 중 오류가 발생했습니다'));
else resolve(id);
});
});
const lastID = await workerModel.create(workerData);
// 계정 생성 요청이 있으면 users 테이블에 계정 생성
if (createAccount && workerData.worker_name) {
@@ -73,9 +68,9 @@ exports.createWorker = asyncHandler(async (req, res) => {
* 전체 작업자 조회 (캐싱 및 페이지네이션 적용)
*/
exports.getAllWorkers = asyncHandler(async (req, res) => {
const { page = 1, limit = 10, search = '', status = '' } = req.query;
const { page = 1, limit = 100, search = '', status = '', department_id = null } = req.query;
const cacheKey = cache.createKey('workers', 'list', page, limit, search, status);
const cacheKey = cache.createKey('workers', 'list', page, limit, search, status, department_id);
// 캐시에서 조회
const cachedData = await cache.get(cacheKey);
@@ -90,7 +85,7 @@ exports.getAllWorkers = asyncHandler(async (req, res) => {
}
// 최적화된 쿼리 사용
const result = await optimizedQueries.getWorkersPaged(page, limit, search, status);
const result = await optimizedQueries.getWorkersPaged(page, limit, search, status, department_id);
// 캐시에 저장 (5분)
await cache.set(cacheKey, result, cache.TTL.MEDIUM);
@@ -114,12 +109,7 @@ exports.getWorkerById = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
const row = await new Promise((resolve, reject) => {
workerModel.getById(id, (err, data) => {
if (err) reject(new DatabaseError('작업자 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
const row = await workerModel.getById(id);
if (!row) {
throw new NotFoundError('작업자를 찾을 수 없습니다');
@@ -153,61 +143,80 @@ exports.updateWorker = asyncHandler(async (req, res) => {
});
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용)
const currentWorker = await new Promise((resolve, reject) => {
workerModel.getById(id, (err, data) => {
if (err) reject(new DatabaseError('작업자 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
const currentWorker = await workerModel.getById(id);
if (!currentWorker) {
throw new NotFoundError('작업자를 찾을 수 없습니다');
}
// 작업자 정보 업데이트
const changes = await new Promise((resolve, reject) => {
workerModel.update(workerData, (err, affected) => {
if (err) {
console.error('❌ workerModel.update 에러:', err);
reject(new DatabaseError(`작업자 수정 중 오류가 발생했습니다: ${err.message}`));
}
else resolve(affected);
});
});
const changes = await workerModel.update(workerData);
// 계정 생성/해제 처리
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 +225,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
});
});
@@ -233,12 +258,7 @@ exports.removeWorker = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
const changes = await new Promise((resolve, reject) => {
workerModel.remove(id, (err, affected) => {
if (err) reject(new DatabaseError('작업자 삭제 중 오류가 발생했습니다'));
else resolve(affected);
});
});
const changes = await workerModel.remove(id);
if (changes === 0) {
throw new NotFoundError('작업자를 찾을 수 없습니다');

View File

@@ -0,0 +1,575 @@
/**
* 작업장 관리 컨트롤러
*
* 작업장 카테고리(공장) 및 작업장 CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2026-01-26
*/
const workplaceModel = require('../models/workplaceModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
// ==================== 카테고리(공장) 관련 ====================
/**
* 카테고리 생성
*/
exports.createCategory = asyncHandler(async (req, res) => {
const categoryData = req.body;
if (!categoryData.category_name) {
throw new ValidationError('카테고리명은 필수 입력 항목입니다');
}
logger.info('카테고리 생성 요청', { name: categoryData.category_name });
const id = await new Promise((resolve, reject) => {
workplaceModel.createCategory(categoryData, (err, lastID) => {
if (err) reject(new DatabaseError('카테고리 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('카테고리 생성 성공', { category_id: id });
res.status(201).json({
success: true,
data: { category_id: id },
message: '카테고리가 성공적으로 생성되었습니다'
});
});
/**
* 전체 카테고리 조회
*/
exports.getAllCategories = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getAllCategories((err, data) => {
if (err) reject(new DatabaseError('카테고리 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '카테고리 목록 조회 성공'
});
});
/**
* 활성 카테고리만 조회
*/
exports.getActiveCategories = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getActiveCategories((err, data) => {
if (err) reject(new DatabaseError('활성 카테고리 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '활성 카테고리 목록 조회 성공'
});
});
/**
* 단일 카테고리 조회
*/
exports.getCategoryById = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
const category = await new Promise((resolve, reject) => {
workplaceModel.getCategoryById(categoryId, (err, data) => {
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!category) {
throw new NotFoundError('카테고리를 찾을 수 없습니다');
}
res.json({
success: true,
data: category,
message: '카테고리 조회 성공'
});
});
/**
* 카테고리 수정
*/
exports.updateCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
const categoryData = req.body;
if (!categoryData.category_name) {
throw new ValidationError('카테고리명은 필수 입력 항목입니다');
}
logger.info('카테고리 수정 요청', { category_id: categoryId });
// 기존 카테고리 정보 가져오기
const existingCategory = await new Promise((resolve, reject) => {
workplaceModel.getCategoryById(categoryId, (err, data) => {
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!existingCategory) {
throw new NotFoundError('카테고리를 찾을 수 없습니다');
}
// layout_image가 요청에 없거나 null이면 기존 값 보존
const updateData = {
...categoryData,
layout_image: (categoryData.layout_image !== undefined && categoryData.layout_image !== null)
? categoryData.layout_image
: existingCategory.layout_image
};
await new Promise((resolve, reject) => {
workplaceModel.updateCategory(categoryId, updateData, (err, result) => {
if (err) reject(new DatabaseError('카테고리 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('카테고리 수정 성공', { category_id: categoryId });
res.json({
success: true,
message: '카테고리가 성공적으로 수정되었습니다'
});
});
/**
* 카테고리 삭제
*/
exports.deleteCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
logger.info('카테고리 삭제 요청', { category_id: categoryId });
await new Promise((resolve, reject) => {
workplaceModel.deleteCategory(categoryId, (err, result) => {
if (err) reject(new DatabaseError('카테고리 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('카테고리 삭제 성공', { category_id: categoryId });
res.json({
success: true,
message: '카테고리가 성공적으로 삭제되었습니다'
});
});
// ==================== 작업장 관련 ====================
/**
* 작업장 생성
*/
exports.createWorkplace = asyncHandler(async (req, res) => {
const workplaceData = req.body;
if (!workplaceData.workplace_name) {
throw new ValidationError('작업장명은 필수 입력 항목입니다');
}
logger.info('작업장 생성 요청', { name: workplaceData.workplace_name });
const id = await new Promise((resolve, reject) => {
workplaceModel.createWorkplace(workplaceData, (err, lastID) => {
if (err) reject(new DatabaseError('작업장 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('작업장 생성 성공', { workplace_id: id });
res.status(201).json({
success: true,
data: { workplace_id: id },
message: '작업장이 성공적으로 생성되었습니다'
});
});
/**
* 전체 작업장 조회
*/
exports.getAllWorkplaces = asyncHandler(async (req, res) => {
const categoryId = req.query.category_id;
// 카테고리별 필터링
if (categoryId) {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getWorkplacesByCategory(categoryId, (err, data) => {
if (err) reject(new DatabaseError('작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
return res.json({
success: true,
data: rows,
message: '작업장 목록 조회 성공'
});
}
// 전체 조회
const rows = await new Promise((resolve, reject) => {
workplaceModel.getAllWorkplaces((err, data) => {
if (err) reject(new DatabaseError('작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '작업장 목록 조회 성공'
});
});
/**
* 활성 작업장만 조회
*/
exports.getActiveWorkplaces = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getActiveWorkplaces((err, data) => {
if (err) reject(new DatabaseError('활성 작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '활성 작업장 목록 조회 성공'
});
});
/**
* 단일 작업장 조회
*/
exports.getWorkplaceById = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
const workplace = await new Promise((resolve, reject) => {
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!workplace) {
throw new NotFoundError('작업장을 찾을 수 없습니다');
}
res.json({
success: true,
data: workplace,
message: '작업장 조회 성공'
});
});
/**
* 작업장 수정
*/
exports.updateWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
const workplaceData = req.body;
if (!workplaceData.workplace_name) {
throw new ValidationError('작업장명은 필수 입력 항목입니다');
}
logger.info('작업장 수정 요청', { workplace_id: workplaceId });
// 기존 작업장 정보 가져오기
const existingWorkplace = await new Promise((resolve, reject) => {
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!existingWorkplace) {
throw new NotFoundError('작업장을 찾을 수 없습니다');
}
// layout_image가 요청에 없거나 null이면 기존 값 보존
const updateData = {
...workplaceData,
layout_image: (workplaceData.layout_image !== undefined && workplaceData.layout_image !== null)
? workplaceData.layout_image
: existingWorkplace.layout_image
};
await new Promise((resolve, reject) => {
workplaceModel.updateWorkplace(workplaceId, updateData, (err, result) => {
if (err) reject(new DatabaseError('작업장 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 수정 성공', { workplace_id: workplaceId });
res.json({
success: true,
message: '작업장이 성공적으로 수정되었습니다'
});
});
/**
* 작업장 삭제
*/
exports.deleteWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
logger.info('작업장 삭제 요청', { workplace_id: workplaceId });
await new Promise((resolve, reject) => {
workplaceModel.deleteWorkplace(workplaceId, (err, result) => {
if (err) reject(new DatabaseError('작업장 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 삭제 성공', { workplace_id: workplaceId });
res.json({
success: true,
message: '작업장이 성공적으로 삭제되었습니다'
});
});
// ==================== 작업장 지도 영역 관련 ====================
/**
* 카테고리 레이아웃 이미지 업로드
*/
exports.uploadCategoryLayoutImage = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
if (!req.file) {
throw new ValidationError('이미지 파일이 필요합니다');
}
const imagePath = `/uploads/${req.file.filename}`;
logger.info('카테고리 레이아웃 이미지 업로드 요청', { category_id: categoryId, path: imagePath });
// 현재 카테고리 정보 가져오기
const category = await new Promise((resolve, reject) => {
workplaceModel.getCategoryById(categoryId, (err, data) => {
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!category) {
throw new NotFoundError('카테고리를 찾을 수 없습니다');
}
// 카테고리 정보 업데이트 (이미지 경로만 변경)
const updatedData = {
category_name: category.category_name,
description: category.description,
display_order: category.display_order,
is_active: category.is_active,
layout_image: imagePath
};
await new Promise((resolve, reject) => {
workplaceModel.updateCategory(categoryId, updatedData, (err, result) => {
if (err) reject(new DatabaseError('이미지 경로 저장 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('레이아웃 이미지 업로드 성공', { category_id: categoryId });
res.json({
success: true,
data: { image_path: imagePath },
message: '레이아웃 이미지가 성공적으로 업로드되었습니다'
});
});
/**
* 작업장 레이아웃 이미지 업로드
*/
exports.uploadWorkplaceLayoutImage = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
if (!req.file) {
throw new ValidationError('이미지 파일이 필요합니다');
}
const imagePath = `/uploads/${req.file.filename}`;
logger.info('작업장 레이아웃 이미지 업로드 요청', { workplace_id: workplaceId, path: imagePath });
// 현재 작업장 정보 가져오기
const workplace = await new Promise((resolve, reject) => {
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!workplace) {
throw new NotFoundError('작업장을 찾을 수 없습니다');
}
// 작업장 정보 업데이트 (이미지 경로만 변경)
const updatedData = {
workplace_name: workplace.workplace_name,
category_id: workplace.category_id,
description: workplace.description,
workplace_purpose: workplace.workplace_purpose,
display_priority: workplace.display_priority,
is_active: workplace.is_active,
layout_image: imagePath
};
await new Promise((resolve, reject) => {
workplaceModel.updateWorkplace(workplaceId, updatedData, (err, result) => {
if (err) reject(new DatabaseError('이미지 경로 저장 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 레이아웃 이미지 업로드 성공', { workplace_id: workplaceId });
res.json({
success: true,
data: { image_path: imagePath },
message: '작업장 레이아웃 이미지가 성공적으로 업로드되었습니다'
});
});
/**
* 지도 영역 생성
*/
exports.createMapRegion = asyncHandler(async (req, res) => {
const regionData = req.body;
if (!regionData.workplace_id || !regionData.category_id) {
throw new ValidationError('작업장 ID와 카테고리 ID는 필수 입력 항목입니다');
}
logger.info('지도 영역 생성 요청', { workplace_id: regionData.workplace_id });
const id = await new Promise((resolve, reject) => {
workplaceModel.createMapRegion(regionData, (err, lastID) => {
if (err) reject(new DatabaseError('지도 영역 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('지도 영역 생성 성공', { region_id: id });
res.status(201).json({
success: true,
data: { region_id: id },
message: '지도 영역이 성공적으로 생성되었습니다'
});
});
/**
* 카테고리별 지도 영역 조회 (작업장 정보 포함)
*/
exports.getMapRegionsByCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.categoryId;
const rows = await new Promise((resolve, reject) => {
workplaceModel.getMapRegionsByCategory(categoryId, (err, data) => {
if (err) reject(new DatabaseError('지도 영역 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '지도 영역 조회 성공'
});
});
/**
* 작업장별 지도 영역 조회
*/
exports.getMapRegionByWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.workplaceId;
const region = await new Promise((resolve, reject) => {
workplaceModel.getMapRegionByWorkplace(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('지도 영역 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: region,
message: '지도 영역 조회 성공'
});
});
/**
* 지도 영역 수정
*/
exports.updateMapRegion = asyncHandler(async (req, res) => {
const regionId = req.params.id;
const regionData = req.body;
logger.info('지도 영역 수정 요청', { region_id: regionId });
await new Promise((resolve, reject) => {
workplaceModel.updateMapRegion(regionId, regionData, (err, result) => {
if (err) reject(new DatabaseError('지도 영역 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('지도 영역 수정 성공', { region_id: regionId });
res.json({
success: true,
message: '지도 영역이 성공적으로 수정되었습니다'
});
});
/**
* 지도 영역 삭제
*/
exports.deleteMapRegion = asyncHandler(async (req, res) => {
const regionId = req.params.id;
logger.info('지도 영역 삭제 요청', { region_id: regionId });
await new Promise((resolve, reject) => {
workplaceModel.deleteMapRegion(regionId, (err, result) => {
if (err) reject(new DatabaseError('지도 영역 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('지도 영역 삭제 성공', { region_id: regionId });
res.json({
success: true,
message: '지도 영역이 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,17 @@
// db/connection.js - 레거시 콜백 방식 DB 래퍼
const { getDb } = require('../dbPool');
// 콜백 방식 쿼리 래퍼
const query = async (sql, params, callback) => {
try {
const db = await getDb();
const [results] = await db.query(sql, params);
callback(null, results);
} catch (error) {
callback(error);
}
};
module.exports = {
query
};

View File

@@ -0,0 +1,158 @@
/**
* 마이그레이션: TBM (Tool Box Meeting) 시스템
* 작성일: 2026-01-20
*
* 생성 테이블:
* - tbm_sessions: TBM 세션 (아침 미팅 기록)
* - tbm_team_assignments: TBM 팀 구성 (리더가 선택한 작업자들)
* - tbm_safety_checks: TBM 안전 체크리스트
* - tbm_safety_records: TBM 안전 체크 기록
* - team_handovers: 작업 인계 기록 (반차/조퇴 시)
*/
exports.up = async function(knex) {
console.log('⏳ TBM 시스템 테이블 생성 중...');
// 1. TBM 세션 테이블 (아침 미팅)
await knex.schema.createTable('tbm_sessions', (table) => {
table.increments('session_id').primary();
table.date('session_date').notNullable().comment('TBM 날짜');
table.integer('leader_id').notNullable().comment('팀장 worker_id');
table.integer('project_id').nullable().comment('프로젝트 ID');
table.string('work_location', 200).nullable().comment('작업 장소');
table.text('work_description').nullable().comment('작업 내용');
table.text('safety_notes').nullable().comment('안전 관련 특이사항');
table.enum('status', ['draft', 'completed', 'cancelled']).defaultTo('draft').comment('상태');
table.time('start_time').nullable().comment('TBM 시작 시간');
table.time('end_time').nullable().comment('TBM 종료 시간');
table.integer('created_by').notNullable().comment('생성자 user_id');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.index(['session_date', 'leader_id']);
table.foreign('leader_id').references('workers.worker_id');
table.foreign('project_id').references('projects.project_id').onDelete('SET NULL');
table.foreign('created_by').references('users.user_id');
});
console.log('✅ tbm_sessions 테이블 생성 완료');
// 2. TBM 팀 구성 테이블 (리더가 선택한 팀원들)
await knex.schema.createTable('tbm_team_assignments', (table) => {
table.increments('assignment_id').primary();
table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID');
table.integer('worker_id').notNullable().comment('팀원 worker_id');
table.string('assigned_role', 100).nullable().comment('역할/담당');
table.text('work_detail').nullable().comment('세부 작업 내용');
table.boolean('is_present').defaultTo(true).comment('출석 여부');
table.text('absence_reason').nullable().comment('결석 사유');
table.timestamp('assigned_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.unique(['session_id', 'worker_id']);
table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE');
table.foreign('worker_id').references('workers.worker_id');
});
console.log('✅ tbm_team_assignments 테이블 생성 완료');
// 3. TBM 안전 체크리스트 마스터 테이블
await knex.schema.createTable('tbm_safety_checks', (table) => {
table.increments('check_id').primary();
table.string('check_category', 50).notNullable().comment('카테고리 (장비, PPE, 환경 등)');
table.string('check_item', 200).notNullable().comment('체크 항목');
table.text('description').nullable().comment('설명');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_required').defaultTo(true).comment('필수 체크 여부');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.index('check_category');
});
console.log('✅ tbm_safety_checks 테이블 생성 완료');
// 초기 안전 체크리스트 데이터
await knex('tbm_safety_checks').insert([
// PPE (개인 보호 장비)
{ check_category: 'PPE', check_item: '안전모 착용 확인', display_order: 1, is_required: true },
{ check_category: 'PPE', check_item: '안전화 착용 확인', display_order: 2, is_required: true },
{ check_category: 'PPE', check_item: '안전조끼 착용 확인', display_order: 3, is_required: true },
{ check_category: 'PPE', check_item: '안전벨트 착용 확인 (고소작업 시)', display_order: 4, is_required: false },
{ check_category: 'PPE', check_item: '보안경/마스크 착용 확인', display_order: 5, is_required: false },
// 장비 점검
{ check_category: 'EQUIPMENT', check_item: '작업 도구 점검 완료', display_order: 10, is_required: true },
{ check_category: 'EQUIPMENT', check_item: '전동공구 안전 점검', display_order: 11, is_required: true },
{ check_category: 'EQUIPMENT', check_item: '사다리/비계 안전 확인', display_order: 12, is_required: false },
{ check_category: 'EQUIPMENT', check_item: '차량/중장비 점검 완료', display_order: 13, is_required: false },
// 작업 환경
{ check_category: 'ENVIRONMENT', check_item: '작업 장소 정리정돈 확인', display_order: 20, is_required: true },
{ check_category: 'ENVIRONMENT', check_item: '위험 구역 표시 확인', display_order: 21, is_required: true },
{ check_category: 'ENVIRONMENT', check_item: '기상 상태 확인 (우천, 강풍 등)', display_order: 22, is_required: true },
{ check_category: 'ENVIRONMENT', check_item: '작업 동선 안전 확인', display_order: 23, is_required: true },
// 비상 대응
{ check_category: 'EMERGENCY', check_item: '비상연락망 공유 완료', display_order: 30, is_required: true },
{ check_category: 'EMERGENCY', check_item: '소화기 위치 확인', display_order: 31, is_required: true },
{ check_category: 'EMERGENCY', check_item: '응급처치 키트 위치 확인', display_order: 32, is_required: true },
]);
console.log('✅ tbm_safety_checks 초기 데이터 입력 완료');
// 4. TBM 안전 체크 기록 테이블
await knex.schema.createTable('tbm_safety_records', (table) => {
table.increments('record_id').primary();
table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID');
table.integer('check_id').unsigned().notNullable().comment('체크 항목 ID');
table.boolean('is_checked').defaultTo(false).comment('체크 여부');
table.text('notes').nullable().comment('비고/특이사항');
table.integer('checked_by').nullable().comment('체크한 user_id');
table.timestamp('checked_at').nullable().comment('체크 시간');
// 인덱스 및 제약조건
table.unique(['session_id', 'check_id']);
table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE');
table.foreign('check_id').references('tbm_safety_checks.check_id');
table.foreign('checked_by').references('users.user_id');
});
console.log('✅ tbm_safety_records 테이블 생성 완료');
// 5. 작업 인계 테이블 (반차/조퇴 시)
await knex.schema.createTable('team_handovers', (table) => {
table.increments('handover_id').primary();
table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID');
table.integer('from_leader_id').notNullable().comment('인계자 worker_id');
table.integer('to_leader_id').notNullable().comment('인수자 worker_id');
table.date('handover_date').notNullable().comment('인계 날짜');
table.time('handover_time').nullable().comment('인계 시간');
table.enum('reason', ['half_day', 'early_leave', 'emergency', 'other']).notNullable().comment('인계 사유');
table.text('handover_notes').nullable().comment('인계 내용');
table.text('worker_ids').nullable().comment('인계하는 작업자 IDs (JSON array)');
table.boolean('is_confirmed').defaultTo(false).comment('인수 확인 여부');
table.timestamp('confirmed_at').nullable().comment('인수 확인 시간');
table.integer('confirmed_by').nullable().comment('인수 확인자 user_id');
table.timestamp('created_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.index(['session_id', 'handover_date']);
table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE');
table.foreign('from_leader_id').references('workers.worker_id');
table.foreign('to_leader_id').references('workers.worker_id');
table.foreign('confirmed_by').references('users.user_id');
});
console.log('✅ team_handovers 테이블 생성 완료');
console.log('✅ 모든 TBM 시스템 테이블 생성 완료');
};
exports.down = async function(knex) {
console.log('⏳ TBM 시스템 테이블 제거 중...');
await knex.schema.dropTableIfExists('team_handovers');
await knex.schema.dropTableIfExists('tbm_safety_records');
await knex.schema.dropTableIfExists('tbm_safety_checks');
await knex.schema.dropTableIfExists('tbm_team_assignments');
await knex.schema.dropTableIfExists('tbm_sessions');
console.log('✅ 모든 TBM 시스템 테이블 제거 완료');
};

View File

@@ -0,0 +1,33 @@
/**
* 마이그레이션: TBM 페이지 등록
* 작성일: 2026-01-20
*
* pages 테이블에 TBM 페이지 추가
*/
exports.up = async function(knex) {
console.log('⏳ TBM 페이지 등록 중...');
// TBM 페이지 추가
await knex('pages').insert([
{
page_key: 'tbm',
page_name: 'TBM 관리',
page_path: '/pages/work/tbm.html',
category: 'work',
description: 'Tool Box Meeting - 아침 안전 회의 및 팀 구성 관리',
is_admin_only: false,
display_order: 10
}
]);
console.log('✅ TBM 페이지 등록 완료');
};
exports.down = async function(knex) {
console.log('⏳ TBM 페이지 제거 중...');
await knex('pages').where('page_key', 'tbm').del();
console.log('✅ TBM 페이지 제거 완료');
};

View File

@@ -0,0 +1,24 @@
/**
* 작업장 카테고리(공장) 테이블 생성 마이그레이션
* 대분류: 제 1공장, 제 2공장 등
*/
exports.up = function(knex) {
return knex.schema.createTable('workplace_categories', function(table) {
table.increments('category_id').primary().comment('카테고리 ID');
table.string('category_name', 100).notNullable().comment('카테고리명 (예: 제 1공장)');
table.text('description').nullable().comment('설명');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_active').defaultTo(true).comment('활성화 여부');
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
// 인덱스
table.index('is_active');
table.index('display_order');
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('workplace_categories');
};

View File

@@ -0,0 +1,31 @@
/**
* 작업장(작업 구역) 테이블 생성 마이그레이션
* 소분류: 서스작업장, 조립구역 등
*/
exports.up = function(knex) {
return knex.schema.createTable('workplaces', function(table) {
table.increments('workplace_id').primary().comment('작업장 ID');
table.integer('category_id').unsigned().nullable().comment('카테고리 ID (공장)');
table.string('workplace_name', 255).notNullable().comment('작업장명');
table.text('description').nullable().comment('설명');
table.boolean('is_active').defaultTo(true).comment('활성화 여부');
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
// 외래키
table.foreign('category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('SET NULL')
.onUpdate('CASCADE');
// 인덱스
table.index('category_id');
table.index('is_active');
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('workplaces');
};

View File

@@ -0,0 +1,36 @@
/**
* 작업 테이블 생성 (공정=work_types에 속함)
*
* @param {import('knex').Knex} knex
*/
exports.up = function(knex) {
return knex.schema.createTable('tasks', function(table) {
table.increments('task_id').primary().comment('작업 ID');
table.integer('work_type_id').nullable().comment('공정 ID (work_types 참조)');
table.string('task_name', 255).notNullable().comment('작업명');
table.text('description').nullable().comment('작업 설명');
table.boolean('is_active').defaultTo(true).comment('활성화 여부');
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
// 외래키 (work_types 테이블 참조)
table.foreign('work_type_id')
.references('id')
.inTable('work_types')
.onDelete('SET NULL')
.onUpdate('CASCADE');
// 인덱스
table.index('work_type_id');
table.index('is_active');
}).then(() => {
console.log('✅ tasks 테이블 생성 완료');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = function(knex) {
return knex.schema.dropTableIfExists('tasks');
};

View File

@@ -0,0 +1,42 @@
/**
* TBM 세션에 공정/작업 컬럼 추가
*
* @param {import('knex').Knex} knex
*/
exports.up = function(knex) {
return knex.schema.table('tbm_sessions', function(table) {
table.integer('work_type_id').nullable().comment('공정 ID (work_types 참조)');
table.integer('task_id').unsigned().nullable().comment('작업 ID (tasks 참조)');
// 외래키 추가
table.foreign('work_type_id')
.references('id')
.inTable('work_types')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('task_id')
.references('task_id')
.inTable('tasks')
.onDelete('SET NULL')
.onUpdate('CASCADE');
// 인덱스 추가
table.index('work_type_id');
table.index('task_id');
}).then(() => {
console.log('✅ tbm_sessions 테이블에 work_type_id, task_id 컬럼 추가 완료');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = function(knex) {
return knex.schema.table('tbm_sessions', function(table) {
table.dropForeign('work_type_id');
table.dropForeign('task_id');
table.dropColumn('work_type_id');
table.dropColumn('task_id');
});
};

View File

@@ -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');
});
};

View File

@@ -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('시작 시간');
});
};

View File

@@ -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');
});
};

View File

@@ -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');
});
};

View File

@@ -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');
};

View File

@@ -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');
});
};

View File

@@ -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('✅ 페이지 목록 삭제 완료');
};

View File

@@ -0,0 +1,22 @@
/**
* 작업장 테이블에 레이아웃 이미지 컬럼 추가
*
* @author TK-FB-Project
* @since 2026-01-28
*/
exports.up = async function(knex) {
await knex.schema.table('workplaces', (table) => {
table.string('layout_image', 500).nullable().comment('작업장 레이아웃 이미지 경로');
});
console.log('✅ workplaces 테이블에 layout_image 컬럼 추가 완료');
};
exports.down = async function(knex) {
await knex.schema.table('workplaces', (table) => {
table.dropColumn('layout_image');
});
console.log('✅ workplaces 테이블에서 layout_image 컬럼 제거 완료');
};

View File

@@ -0,0 +1,48 @@
/**
* 설비 관리 테이블 생성
*
* @author TK-FB-Project
* @since 2026-01-28
*/
exports.up = async function(knex) {
await knex.schema.createTable('equipments', (table) => {
table.increments('equipment_id').primary().comment('설비 ID');
table.string('equipment_code', 50).notNullable().unique().comment('설비 코드 (예: CNC-01, LATHE-A)');
table.string('equipment_name', 100).notNullable().comment('설비명');
table.string('equipment_type', 50).nullable().comment('설비 유형 (예: CNC, 선반, 밀링 등)');
table.string('model_name', 100).nullable().comment('모델명');
table.string('manufacturer', 100).nullable().comment('제조사');
table.date('installation_date').nullable().comment('설치일');
table.string('serial_number', 100).nullable().comment('시리얼 번호');
table.text('specifications').nullable().comment('사양 정보 (JSON 형태로 저장 가능)');
table.enum('status', ['active', 'maintenance', 'inactive']).defaultTo('active').comment('설비 상태');
table.text('notes').nullable().comment('비고');
// 작업장 연결
table.integer('workplace_id').unsigned().nullable().comment('연결된 작업장 ID');
table.foreign('workplace_id').references('workplace_id').inTable('workplaces').onDelete('SET NULL');
// 지도상 위치 정보 (백분율 기반)
table.decimal('map_x_percent', 5, 2).nullable().comment('지도상 X 위치 (%)');
table.decimal('map_y_percent', 5, 2).nullable().comment('지도상 Y 위치 (%)');
table.decimal('map_width_percent', 5, 2).nullable().comment('지도상 영역 너비 (%)');
table.decimal('map_height_percent', 5, 2).nullable().comment('지도상 영역 높이 (%)');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
// 인덱스
table.index('workplace_id');
table.index('equipment_type');
table.index('status');
});
console.log('✅ equipments 테이블 생성 완료');
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('equipments');
console.log('✅ equipments 테이블 삭제 완료');
};

View File

@@ -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 테이블 삭제 완료');
};

View File

@@ -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('✅ 출퇴근 관리 페이지 삭제 완료');
};

View File

@@ -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');
});
}
};

View File

@@ -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('✅ 휴가 관리 페이지 롤백 완료');
};

View File

@@ -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 테이블 롤백 완료');
};

View File

@@ -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 테이블 롤백 완료');
};

View File

@@ -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('✅ 휴가 관리 페이지 롤백 완료');
};

View File

@@ -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('✅ 출입 신청 및 안전교육 시스템 테이블 삭제 완료');
};

View File

@@ -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('✅ 출입 신청 및 안전관리 페이지 삭제 완료');
};

View File

@@ -0,0 +1,266 @@
/**
* 마이그레이션: 작업 중 문제 신고 시스템
* - 신고 카테고리 테이블 (부적합/안전)
* - 사전 정의 신고 항목 테이블
* - 문제 신고 메인 테이블
* - 상태 변경 이력 테이블
*/
exports.up = async function(knex) {
// 1. 신고 카테고리 테이블 생성
await knex.schema.createTable('issue_report_categories', function(table) {
table.increments('category_id').primary().comment('카테고리 ID');
table.enum('category_type', ['nonconformity', 'safety']).notNullable().comment('카테고리 유형 (부적합/안전)');
table.string('category_name', 100).notNullable().comment('카테고리명');
table.text('description').nullable().comment('카테고리 설명');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index('category_type', 'idx_irc_category_type');
table.index('is_active', 'idx_irc_is_active');
});
// 카테고리 초기 데이터 삽입
await knex('issue_report_categories').insert([
// 부적합 사항
{ category_type: 'nonconformity', category_name: '자재누락', display_order: 1, is_active: true },
{ category_type: 'nonconformity', category_name: '설계미스', display_order: 2, is_active: true },
{ category_type: 'nonconformity', category_name: '입고불량', display_order: 3, is_active: true },
{ category_type: 'nonconformity', category_name: '검사미스', display_order: 4, is_active: true },
{ category_type: 'nonconformity', category_name: '기타 부적합', display_order: 99, is_active: true },
// 안전 관련
{ category_type: 'safety', category_name: '보호구 미착용', display_order: 1, is_active: true },
{ category_type: 'safety', category_name: '위험구역 출입', display_order: 2, is_active: true },
{ category_type: 'safety', category_name: '안전시설 파손', display_order: 3, is_active: true },
{ category_type: 'safety', category_name: '안전수칙 위반', display_order: 4, is_active: true },
{ category_type: 'safety', category_name: '기타 안전', display_order: 99, is_active: true }
]);
// 2. 사전 정의 신고 항목 테이블 생성
await knex.schema.createTable('issue_report_items', function(table) {
table.increments('item_id').primary().comment('항목 ID');
table.integer('category_id').unsigned().notNullable().comment('소속 카테고리 ID');
table.string('item_name', 200).notNullable().comment('신고 항목명');
table.text('description').nullable().comment('항목 설명');
table.enum('severity', ['low', 'medium', 'high', 'critical']).defaultTo('medium').comment('심각도');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.foreign('category_id')
.references('category_id')
.inTable('issue_report_categories')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table.index('category_id', 'idx_iri_category_id');
table.index('is_active', 'idx_iri_is_active');
});
// 사전 정의 항목 초기 데이터 삽입
await knex('issue_report_items').insert([
// 자재누락 (category_id: 1)
{ category_id: 1, item_name: '배관 자재 미입고', severity: 'high', display_order: 1 },
{ category_id: 1, item_name: '피팅류 부족', severity: 'medium', display_order: 2 },
{ category_id: 1, item_name: '밸브류 미입고', severity: 'high', display_order: 3 },
{ category_id: 1, item_name: '가스켓/볼트류 부족', severity: 'low', display_order: 4 },
{ category_id: 1, item_name: '서포트 자재 부족', severity: 'medium', display_order: 5 },
// 설계미스 (category_id: 2)
{ category_id: 2, item_name: '도면 치수 오류', severity: 'high', display_order: 1 },
{ category_id: 2, item_name: '스펙 불일치', severity: 'high', display_order: 2 },
{ category_id: 2, item_name: '누락된 상세도', severity: 'medium', display_order: 3 },
{ category_id: 2, item_name: '간섭 발생', severity: 'critical', display_order: 4 },
// 입고불량 (category_id: 3)
{ category_id: 3, item_name: '외관 불량', severity: 'medium', display_order: 1 },
{ category_id: 3, item_name: '치수 불량', severity: 'high', display_order: 2 },
{ category_id: 3, item_name: '수량 부족', severity: 'medium', display_order: 3 },
{ category_id: 3, item_name: '재질 불일치', severity: 'critical', display_order: 4 },
// 검사미스 (category_id: 4)
{ category_id: 4, item_name: '치수 검사 누락', severity: 'high', display_order: 1 },
{ category_id: 4, item_name: '외관 검사 누락', severity: 'medium', display_order: 2 },
{ category_id: 4, item_name: '용접 검사 누락', severity: 'critical', display_order: 3 },
{ category_id: 4, item_name: '도장 검사 누락', severity: 'medium', display_order: 4 },
// 보호구 미착용 (category_id: 6)
{ category_id: 6, item_name: '안전모 미착용', severity: 'high', display_order: 1 },
{ category_id: 6, item_name: '안전화 미착용', severity: 'high', display_order: 2 },
{ category_id: 6, item_name: '보안경 미착용', severity: 'medium', display_order: 3 },
{ category_id: 6, item_name: '안전대 미착용', severity: 'critical', display_order: 4 },
{ category_id: 6, item_name: '귀마개 미착용', severity: 'low', display_order: 5 },
{ category_id: 6, item_name: '안전장갑 미착용', severity: 'medium', display_order: 6 },
// 위험구역 출입 (category_id: 7)
{ category_id: 7, item_name: '통제구역 무단 출입', severity: 'critical', display_order: 1 },
{ category_id: 7, item_name: '고소 작업 구역 무단 출입', severity: 'critical', display_order: 2 },
{ category_id: 7, item_name: '밀폐공간 무단 진입', severity: 'critical', display_order: 3 },
{ category_id: 7, item_name: '장비 가동 구역 무단 접근', severity: 'high', display_order: 4 },
// 안전시설 파손 (category_id: 8)
{ category_id: 8, item_name: '안전난간 파손', severity: 'high', display_order: 1 },
{ category_id: 8, item_name: '경고 표지판 훼손', severity: 'medium', display_order: 2 },
{ category_id: 8, item_name: '안전망 파손', severity: 'high', display_order: 3 },
{ category_id: 8, item_name: '비상조명 고장', severity: 'medium', display_order: 4 },
{ category_id: 8, item_name: '소화설비 파손', severity: 'critical', display_order: 5 },
// 안전수칙 위반 (category_id: 9)
{ category_id: 9, item_name: '지정 통로 미사용', severity: 'medium', display_order: 1 },
{ category_id: 9, item_name: '고소 작업 안전 미준수', severity: 'critical', display_order: 2 },
{ category_id: 9, item_name: '화기 작업 절차 미준수', severity: 'critical', display_order: 3 },
{ category_id: 9, item_name: '정리정돈 미흡', severity: 'low', display_order: 4 },
{ category_id: 9, item_name: '장비 조작 절차 미준수', severity: 'high', display_order: 5 }
]);
// 3. 문제 신고 메인 테이블 생성
await knex.schema.createTable('work_issue_reports', function(table) {
table.increments('report_id').primary().comment('신고 ID');
// 신고자 정보
table.integer('reporter_id').notNullable().comment('신고자 user_id');
table.datetime('report_date').defaultTo(knex.fn.now()).comment('신고 일시');
// 위치 정보
table.integer('factory_category_id').unsigned().nullable().comment('공장 카테고리 ID (지도 외 위치 시 null)');
table.integer('workplace_id').unsigned().nullable().comment('작업장 ID (지도 외 위치 시 null)');
table.string('custom_location', 200).nullable().comment('기타 위치 (지도 외 선택 시)');
// 작업 연결 정보 (선택적)
table.integer('tbm_session_id').unsigned().nullable().comment('연결된 TBM 세션');
table.integer('visit_request_id').unsigned().nullable().comment('연결된 출입 신청');
// 신고 내용
table.integer('issue_category_id').unsigned().notNullable().comment('신고 카테고리 ID');
table.integer('issue_item_id').unsigned().nullable().comment('사전 정의 신고 항목 ID');
table.text('additional_description').nullable().comment('추가 설명');
// 사진 (최대 5장)
table.string('photo_path1', 255).nullable().comment('사진 1');
table.string('photo_path2', 255).nullable().comment('사진 2');
table.string('photo_path3', 255).nullable().comment('사진 3');
table.string('photo_path4', 255).nullable().comment('사진 4');
table.string('photo_path5', 255).nullable().comment('사진 5');
// 상태 관리
table.enum('status', ['reported', 'received', 'in_progress', 'completed', 'closed'])
.defaultTo('reported')
.comment('상태: 신고→접수→처리중→완료→종료');
// 담당자 배정
table.string('assigned_department', 100).nullable().comment('담당 부서');
table.integer('assigned_user_id').nullable().comment('담당자 user_id');
table.datetime('assigned_at').nullable().comment('배정 일시');
table.integer('assigned_by').nullable().comment('배정자 user_id');
// 처리 정보
table.text('resolution_notes').nullable().comment('처리 내용');
table.string('resolution_photo_path1', 255).nullable().comment('처리 완료 사진 1');
table.string('resolution_photo_path2', 255).nullable().comment('처리 완료 사진 2');
table.datetime('resolved_at').nullable().comment('처리 완료 일시');
table.integer('resolved_by').nullable().comment('처리 완료자 user_id');
// 수정 이력 (JSON)
table.json('modification_history').nullable().comment('수정 이력 추적');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('reporter_id')
.references('user_id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('factory_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');
table.foreign('issue_category_id')
.references('category_id')
.inTable('issue_report_categories')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('issue_item_id')
.references('item_id')
.inTable('issue_report_items')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('assigned_user_id')
.references('user_id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('assigned_by')
.references('user_id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('resolved_by')
.references('user_id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
// 인덱스
table.index('reporter_id', 'idx_wir_reporter_id');
table.index('status', 'idx_wir_status');
table.index('report_date', 'idx_wir_report_date');
table.index(['factory_category_id', 'workplace_id'], 'idx_wir_workplace');
table.index('issue_category_id', 'idx_wir_issue_category');
table.index('assigned_user_id', 'idx_wir_assigned_user');
});
// 4. 상태 변경 이력 테이블 생성
await knex.schema.createTable('work_issue_status_logs', function(table) {
table.increments('log_id').primary().comment('로그 ID');
table.integer('report_id').unsigned().notNullable().comment('신고 ID');
table.string('previous_status', 50).nullable().comment('이전 상태');
table.string('new_status', 50).notNullable().comment('새 상태');
table.integer('changed_by').notNullable().comment('변경자 user_id');
table.text('change_reason').nullable().comment('변경 사유');
table.timestamp('changed_at').defaultTo(knex.fn.now());
table.foreign('report_id')
.references('report_id')
.inTable('work_issue_reports')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table.foreign('changed_by')
.references('user_id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.index('report_id', 'idx_wisl_report_id');
table.index('changed_at', 'idx_wisl_changed_at');
});
console.log('작업 중 문제 신고 시스템 테이블 생성 완료');
};
exports.down = async function(knex) {
// 역순으로 테이블 삭제
await knex.schema.dropTableIfExists('work_issue_status_logs');
await knex.schema.dropTableIfExists('work_issue_reports');
await knex.schema.dropTableIfExists('issue_report_items');
await knex.schema.dropTableIfExists('issue_report_categories');
console.log('작업 중 문제 신고 시스템 테이블 삭제 완료');
};

View File

@@ -0,0 +1,50 @@
/**
* 마이그레이션: 문제 신고 관련 페이지 등록
*/
exports.up = async function(knex) {
// 문제 신고 등록 페이지
await knex('pages').insert({
page_key: 'issue-report',
page_name: '문제 신고',
page_path: '/pages/work/issue-report.html',
category: 'work',
description: '작업 중 문제(부적합/안전) 신고 등록',
is_admin_only: 0,
display_order: 16
});
// 신고 목록 페이지
await knex('pages').insert({
page_key: 'issue-list',
page_name: '신고 목록',
page_path: '/pages/work/issue-list.html',
category: 'work',
description: '문제 신고 목록 조회 및 관리',
is_admin_only: 0,
display_order: 17
});
// 신고 상세 페이지
await knex('pages').insert({
page_key: 'issue-detail',
page_name: '신고 상세',
page_path: '/pages/work/issue-detail.html',
category: 'work',
description: '문제 신고 상세 조회',
is_admin_only: 0,
display_order: 18
});
console.log('✅ 문제 신고 페이지 등록 완료');
};
exports.down = async function(knex) {
await knex('pages').whereIn('page_key', [
'issue-report',
'issue-list',
'issue-detail'
]).delete();
console.log('✅ 문제 신고 페이지 삭제 완료');
};

View File

@@ -0,0 +1,47 @@
/**
* 작업보고서 부적합 상세 테이블 마이그레이션
*
* 기존: error_hours, error_type_id (단일 값)
* 변경: 여러 부적합 원인 + 각 원인별 시간 저장 가능
*/
exports.up = function(knex) {
return knex.schema
// 1. work_report_defects 테이블 생성
.createTable('work_report_defects', function(table) {
table.increments('defect_id').primary();
table.integer('report_id').notNullable()
.comment('daily_work_reports의 id');
table.integer('error_type_id').notNullable()
.comment('error_types의 id (부적합 원인)');
table.decimal('defect_hours', 4, 1).notNullable().defaultTo(0)
.comment('해당 원인의 부적합 시간');
table.text('note').nullable()
.comment('추가 메모');
table.timestamp('created_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('report_id').references('id').inTable('daily_work_reports').onDelete('CASCADE');
table.foreign('error_type_id').references('id').inTable('error_types');
// 인덱스
table.index('report_id');
table.index('error_type_id');
// 같은 보고서에 같은 원인이 중복되지 않도록
table.unique(['report_id', 'error_type_id']);
})
// 2. 기존 데이터 마이그레이션 (error_hours > 0인 경우)
.then(function() {
return knex.raw(`
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, created_at)
SELECT id, error_type_id, error_hours, created_at
FROM daily_work_reports
WHERE error_hours > 0 AND error_type_id IS NOT NULL
`);
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('work_report_defects');
};

View File

@@ -0,0 +1,141 @@
/**
* 안전 체크리스트 확장 마이그레이션
*
* 1. tbm_safety_checks 테이블 확장 (check_type, weather_condition, task_id)
* 2. weather_conditions 테이블 생성 (날씨 조건 코드)
* 3. tbm_weather_records 테이블 생성 (세션별 날씨 기록)
* 4. 초기 날씨별 체크항목 데이터
*
* @since 2026-02-02
*/
exports.up = function(knex) {
return knex.schema
// 1. tbm_safety_checks 테이블 확장
.alterTable('tbm_safety_checks', function(table) {
table.enum('check_type', ['basic', 'weather', 'task']).defaultTo('basic').after('check_category');
table.string('weather_condition', 50).nullable().after('check_type');
table.integer('task_id').unsigned().nullable().after('weather_condition');
// 인덱스 추가
table.index('check_type');
table.index('weather_condition');
table.index('task_id');
})
// 2. weather_conditions 테이블 생성
.createTable('weather_conditions', function(table) {
table.string('condition_code', 50).primary();
table.string('condition_name', 100).notNullable();
table.text('description').nullable();
table.string('icon', 50).nullable();
table.decimal('temp_threshold_min', 4, 1).nullable(); // 최소 기온 기준
table.decimal('temp_threshold_max', 4, 1).nullable(); // 최대 기온 기준
table.decimal('wind_threshold', 4, 1).nullable(); // 풍속 기준 (m/s)
table.decimal('precip_threshold', 5, 1).nullable(); // 강수량 기준 (mm)
table.boolean('is_active').defaultTo(true);
table.integer('display_order').defaultTo(0);
table.timestamp('created_at').defaultTo(knex.fn.now());
})
// 3. tbm_weather_records 테이블 생성
.createTable('tbm_weather_records', function(table) {
table.increments('record_id').primary();
table.integer('session_id').unsigned().notNullable();
table.date('weather_date').notNullable();
table.decimal('temperature', 4, 1).nullable(); // 기온 (섭씨)
table.integer('humidity').nullable(); // 습도 (%)
table.decimal('wind_speed', 4, 1).nullable(); // 풍속 (m/s)
table.decimal('precipitation', 5, 1).nullable(); // 강수량 (mm)
table.string('sky_condition', 50).nullable(); // 하늘 상태
table.string('weather_condition', 50).nullable(); // 주요 날씨 상태
table.json('weather_conditions').nullable(); // 복수 조건 ['rain', 'wind']
table.string('data_source', 50).defaultTo('api'); // 데이터 출처
table.timestamp('fetched_at').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('session_id').references('session_id').inTable('tbm_sessions').onDelete('CASCADE');
// 인덱스
table.index('weather_date');
table.unique(['session_id']);
})
// 4. 초기 데이터 삽입
.then(function() {
// 기존 체크항목을 'basic' 유형으로 업데이트
return knex('tbm_safety_checks').update({ check_type: 'basic' });
})
.then(function() {
// 날씨 조건 코드 삽입
return knex('weather_conditions').insert([
{ condition_code: 'clear', condition_name: '맑음', description: '맑은 날씨', icon: 'sunny', display_order: 1 },
{ condition_code: 'rain', condition_name: '비', description: '비 오는 날씨', icon: 'rainy', precip_threshold: 0.1, display_order: 2 },
{ condition_code: 'snow', condition_name: '눈', description: '눈 오는 날씨', icon: 'snowy', display_order: 3 },
{ condition_code: 'heat', condition_name: '폭염', description: '기온 35도 이상', icon: 'hot', temp_threshold_min: 35, display_order: 4 },
{ condition_code: 'cold', condition_name: '한파', description: '기온 영하 10도 이하', icon: 'cold', temp_threshold_max: -10, display_order: 5 },
{ condition_code: 'wind', condition_name: '강풍', description: '풍속 10m/s 이상', icon: 'windy', wind_threshold: 10, display_order: 6 },
{ condition_code: 'fog', condition_name: '안개', description: '시정 1km 미만', icon: 'foggy', display_order: 7 },
{ condition_code: 'dust', condition_name: '미세먼지', description: '미세먼지 나쁨 이상', icon: 'dusty', display_order: 8 }
]);
})
.then(function() {
// 날씨별 안전 체크항목 삽입
return knex('tbm_safety_checks').insert([
// 비 (rain)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '우의/우산 준비 확인', description: '비 오는 날 우의 또는 우산 준비 여부', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '미끄럼 방지 조치 확인', description: '빗물로 인한 미끄러움 방지 조치', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '전기 작업 중단 여부 확인', description: '우천 시 전기 작업 중단 필요성 확인', is_required: true, display_order: 3 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '배수 상태 확인', description: '작업장 배수 상태 점검', is_required: false, display_order: 4 },
// 눈 (snow)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '제설 작업 완료 확인', description: '작업장 주변 제설 작업 완료 여부', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '동파 방지 조치 확인', description: '배관 및 설비 동파 방지 조치', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '미끄럼 방지 모래/염화칼슘 비치', description: '미끄럼 방지를 위한 모래 또는 염화칼슘 비치', is_required: true, display_order: 3 },
// 폭염 (heat)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '그늘막/휴게소 확보', description: '무더위 휴식을 위한 그늘막 또는 휴게소 확보', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '음료수/식염 포도당 비치', description: '열사병 예방을 위한 음료수 및 염분 보충제 비치', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '무더위 휴식 시간 확보', description: '10~15시 사이 충분한 휴식 시간 확보', is_required: true, display_order: 3 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '작업자 건강 상태 확인', description: '열사병 증상 체크 및 건강 상태 확인', is_required: true, display_order: 4 },
// 한파 (cold)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '방한복/방한장갑 착용 확인', description: '동상 방지를 위한 방한복 및 방한장갑 착용', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '난방시설 가동 확인', description: '휴게 공간 난방시설 가동 상태 확인', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '온열 음료 비치', description: '체온 유지를 위한 따뜻한 음료 비치', is_required: false, display_order: 3 },
// 강풍 (wind)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '고소 작업 중단 여부 확인', description: '강풍 시 고소 작업 중단 필요성 확인', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '자재/장비 결박 확인', description: '바람에 날릴 수 있는 자재 및 장비 고정', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '가설물 안전 점검', description: '가설 구조물 및 비계 안전 상태 점검', is_required: true, display_order: 3 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '크레인 작업 중단 여부 확인', description: '강풍 시 크레인 작업 중단 필요성 확인', is_required: true, display_order: 4 },
// 안개 (fog)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '경광등/조명 확보', description: '시정 확보를 위한 경광등 및 조명 설치', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '차량 운행 주의 안내', description: '안개로 인한 차량 운행 주의 안내', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '작업 구역 표시 강화', description: '시인성 확보를 위한 작업 구역 표시 강화', is_required: false, display_order: 3 },
// 미세먼지 (dust)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '보호 마스크 착용 확인', description: 'KF94 이상 마스크 착용 여부 확인', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '실외 작업 시간 조정', description: '미세먼지 농도에 따른 실외 작업 시간 조정', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '호흡기 질환자 실내 배치', description: '호흡기 질환 작업자 실내 작업 배치', is_required: false, display_order: 3 }
]);
});
};
exports.down = function(knex) {
return knex.schema
.dropTableIfExists('tbm_weather_records')
.dropTableIfExists('weather_conditions')
.then(function() {
return knex.schema.alterTable('tbm_safety_checks', function(table) {
table.dropIndex('check_type');
table.dropIndex('weather_condition');
table.dropIndex('task_id');
table.dropColumn('check_type');
table.dropColumn('weather_condition');
table.dropColumn('task_id');
});
});
};

View File

@@ -0,0 +1,319 @@
/**
* 페이지 구조 재구성 마이그레이션
* - 페이지 경로 업데이트 (safety/, attendance/ 폴더로 이동)
* - 카테고리 재분류
* - 역할별 기본 페이지 권한 테이블 생성
*/
exports.up = async function(knex) {
// 1. 페이지 경로 업데이트 - safety 폴더로 이동된 페이지들
const safetyPageUpdates = [
{
old_key: 'issue-report',
new_key: 'safety.issue_report',
new_path: '/pages/safety/issue-report.html',
new_category: 'safety',
new_name: '이슈 신고'
},
{
old_key: 'issue-list',
new_key: 'safety.issue_list',
new_path: '/pages/safety/issue-list.html',
new_category: 'safety',
new_name: '이슈 목록'
},
{
old_key: 'issue-detail',
new_key: 'safety.issue_detail',
new_path: '/pages/safety/issue-detail.html',
new_category: 'safety',
new_name: '이슈 상세'
},
{
old_key: 'visit-request',
new_key: 'safety.visit_request',
new_path: '/pages/safety/visit-request.html',
new_category: 'safety',
new_name: '방문 요청'
},
{
old_key: 'safety-management',
new_key: 'safety.management',
new_path: '/pages/safety/management.html',
new_category: 'safety',
new_name: '안전 관리'
},
{
old_key: 'safety-training-conduct',
new_key: 'safety.training_conduct',
new_path: '/pages/safety/training-conduct.html',
new_category: 'safety',
new_name: '안전교육 진행'
}
];
// 2. 페이지 경로 업데이트 - attendance 폴더로 이동된 페이지들
const attendancePageUpdates = [
{
old_key: 'daily-attendance',
new_key: 'attendance.daily',
new_path: '/pages/attendance/daily.html',
new_category: 'attendance',
new_name: '일일 출퇴근'
},
{
old_key: 'monthly-attendance',
new_key: 'attendance.monthly',
new_path: '/pages/attendance/monthly.html',
new_category: 'attendance',
new_name: '월간 근태'
},
{
old_key: 'annual-vacation-overview',
new_key: 'attendance.annual_overview',
new_path: '/pages/attendance/annual-overview.html',
new_category: 'attendance',
new_name: '연간 휴가 현황'
},
{
old_key: 'vacation-request',
new_key: 'attendance.vacation_request',
new_path: '/pages/attendance/vacation-request.html',
new_category: 'attendance',
new_name: '휴가 신청'
},
{
old_key: 'vacation-management',
new_key: 'attendance.vacation_management',
new_path: '/pages/attendance/vacation-management.html',
new_category: 'attendance',
new_name: '휴가 관리'
},
{
old_key: 'vacation-allocation',
new_key: 'attendance.vacation_allocation',
new_path: '/pages/attendance/vacation-allocation.html',
new_category: 'attendance',
new_name: '휴가 발생 입력'
}
];
// 3. admin 폴더 내 파일명 변경
const adminPageUpdates = [
{
old_key: 'attendance-report-comparison',
new_key: 'admin.attendance_report',
new_path: '/pages/admin/attendance-report.html',
new_category: 'admin',
new_name: '출퇴근-보고서 대조'
}
];
// 모든 업데이트 실행
const allUpdates = [...safetyPageUpdates, ...attendancePageUpdates, ...adminPageUpdates];
for (const update of allUpdates) {
await knex('pages')
.where('page_key', update.old_key)
.update({
page_key: update.new_key,
page_path: update.new_path,
category: update.new_category,
page_name: update.new_name
});
}
// 4. 안전 체크리스트 관리 페이지 추가 (새로 생성된 페이지)
const existingChecklistPage = await knex('pages')
.where('page_key', 'safety.checklist_manage')
.orWhere('page_key', 'safety-checklist-manage')
.first();
if (!existingChecklistPage) {
await knex('pages').insert({
page_key: 'safety.checklist_manage',
page_name: '안전 체크리스트 관리',
page_path: '/pages/safety/checklist-manage.html',
category: 'safety',
description: '안전 체크리스트 항목 관리',
is_admin_only: 1,
display_order: 50
});
}
// 5. 휴가 승인/직접입력 페이지 추가 (새로 생성된 페이지인 경우)
const vacationPages = [
{
page_key: 'attendance.vacation_approval',
page_name: '휴가 승인 관리',
page_path: '/pages/attendance/vacation-approval.html',
category: 'attendance',
description: '휴가 신청 승인/거부',
is_admin_only: 1,
display_order: 65
},
{
page_key: 'attendance.vacation_input',
page_name: '휴가 직접 입력',
page_path: '/pages/attendance/vacation-input.html',
category: 'attendance',
description: '관리자 휴가 직접 입력',
is_admin_only: 1,
display_order: 66
}
];
for (const page of vacationPages) {
const existing = await knex('pages').where('page_key', page.page_key).first();
if (!existing) {
await knex('pages').insert(page);
}
}
// 6. role_default_pages 테이블 생성 (역할별 기본 페이지 권한)
const tableExists = await knex.schema.hasTable('role_default_pages');
if (!tableExists) {
await knex.schema.createTable('role_default_pages', (table) => {
table.integer('role_id').unsigned().notNullable()
.references('id').inTable('roles').onDelete('CASCADE');
table.integer('page_id').unsigned().notNullable()
.references('id').inTable('pages').onDelete('CASCADE');
table.primary(['role_id', 'page_id']);
table.timestamps(true, true);
});
}
// 7. 기본 역할-페이지 매핑 데이터 삽입
// 역할 조회
const roles = await knex('roles').select('id', 'name');
const pages = await knex('pages').select('id', 'page_key', 'category');
const roleMap = {};
roles.forEach(r => { roleMap[r.name] = r.id; });
const pageMap = {};
pages.forEach(p => { pageMap[p.page_key] = p.id; });
// Worker 역할 기본 페이지 (대시보드, 작업보고서, 휴가신청)
const workerPages = [
'dashboard',
'work.report_create',
'work.report_view',
'attendance.vacation_request'
];
// Leader 역할 기본 페이지 (Worker + TBM, 안전, 근태 일부)
const leaderPages = [
...workerPages,
'work.tbm',
'work.analysis',
'safety.issue_report',
'safety.issue_list',
'attendance.daily',
'attendance.monthly'
];
// SafetyManager 역할 기본 페이지 (Leader + 안전 전체)
const safetyManagerPages = [
...leaderPages,
'safety.issue_detail',
'safety.visit_request',
'safety.management',
'safety.training_conduct',
'safety.checklist_manage'
];
// 역할별 페이지 매핑 삽입
const rolePageMappings = [];
if (roleMap['Worker']) {
workerPages.forEach(pageKey => {
if (pageMap[pageKey]) {
rolePageMappings.push({ role_id: roleMap['Worker'], page_id: pageMap[pageKey] });
}
});
}
if (roleMap['Leader']) {
leaderPages.forEach(pageKey => {
if (pageMap[pageKey]) {
rolePageMappings.push({ role_id: roleMap['Leader'], page_id: pageMap[pageKey] });
}
});
}
if (roleMap['SafetyManager']) {
safetyManagerPages.forEach(pageKey => {
if (pageMap[pageKey]) {
rolePageMappings.push({ role_id: roleMap['SafetyManager'], page_id: pageMap[pageKey] });
}
});
}
// 중복 제거 후 삽입
for (const mapping of rolePageMappings) {
const existing = await knex('role_default_pages')
.where('role_id', mapping.role_id)
.where('page_id', mapping.page_id)
.first();
if (!existing) {
await knex('role_default_pages').insert(mapping);
}
}
console.log('페이지 구조 재구성 완료');
console.log(`- 업데이트된 페이지: ${allUpdates.length}`);
console.log(`- 역할별 기본 페이지 매핑: ${rolePageMappings.length}`);
};
exports.down = async function(knex) {
// 1. role_default_pages 테이블 삭제
await knex.schema.dropTableIfExists('role_default_pages');
// 2. 페이지 경로 원복 - safety → work/admin
const safetyRevert = [
{ new_key: 'safety.issue_report', old_key: 'issue-report', old_path: '/pages/work/issue-report.html', old_category: 'work' },
{ new_key: 'safety.issue_list', old_key: 'issue-list', old_path: '/pages/work/issue-list.html', old_category: 'work' },
{ new_key: 'safety.issue_detail', old_key: 'issue-detail', old_path: '/pages/work/issue-detail.html', old_category: 'work' },
{ new_key: 'safety.visit_request', old_key: 'visit-request', old_path: '/pages/work/visit-request.html', old_category: 'work' },
{ new_key: 'safety.management', old_key: 'safety-management', old_path: '/pages/admin/safety-management.html', old_category: 'admin' },
{ new_key: 'safety.training_conduct', old_key: 'safety-training-conduct', old_path: '/pages/admin/safety-training-conduct.html', old_category: 'admin' },
];
// 3. 페이지 경로 원복 - attendance → common
const attendanceRevert = [
{ new_key: 'attendance.daily', old_key: 'daily-attendance', old_path: '/pages/common/daily-attendance.html', old_category: 'common' },
{ new_key: 'attendance.monthly', old_key: 'monthly-attendance', old_path: '/pages/common/monthly-attendance.html', old_category: 'common' },
{ new_key: 'attendance.annual_overview', old_key: 'annual-vacation-overview', old_path: '/pages/common/annual-vacation-overview.html', old_category: 'common' },
{ new_key: 'attendance.vacation_request', old_key: 'vacation-request', old_path: '/pages/common/vacation-request.html', old_category: 'common' },
{ new_key: 'attendance.vacation_management', old_key: 'vacation-management', old_path: '/pages/common/vacation-management.html', old_category: 'common' },
{ new_key: 'attendance.vacation_allocation', old_key: 'vacation-allocation', old_path: '/pages/common/vacation-allocation.html', old_category: 'common' },
];
// 4. admin 파일명 원복
const adminRevert = [
{ new_key: 'admin.attendance_report', old_key: 'attendance-report-comparison', old_path: '/pages/admin/attendance-report-comparison.html', old_category: 'admin' }
];
const allReverts = [...safetyRevert, ...attendanceRevert, ...adminRevert];
for (const revert of allReverts) {
await knex('pages')
.where('page_key', revert.new_key)
.update({
page_key: revert.old_key,
page_path: revert.old_path,
category: revert.old_category
});
}
// 5. 새로 추가된 페이지 삭제
await knex('pages').whereIn('page_key', [
'safety.checklist_manage',
'attendance.vacation_approval',
'attendance.vacation_input'
]).del();
console.log('페이지 구조 재구성 롤백 완료');
};

View File

@@ -0,0 +1,43 @@
/**
* 작업보고서 부적합에 카테고리/아이템 컬럼 추가
*
* 변경사항:
* 1. work_report_defects 테이블에 category_id, item_id 컬럼 추가
* 2. issue_report_categories, issue_report_items 테이블 참조
*/
exports.up = function(knex) {
return knex.schema
.alterTable('work_report_defects', function(table) {
// 카테고리 ID 추가
table.integer('category_id').unsigned().nullable()
.comment('issue_report_categories의 category_id (직접 입력 시)')
.after('issue_report_id');
// 아이템 ID 추가
table.integer('item_id').unsigned().nullable()
.comment('issue_report_items의 item_id (직접 입력 시)')
.after('category_id');
// 외래키 추가
table.foreign('category_id')
.references('category_id')
.inTable('issue_report_categories')
.onDelete('SET NULL');
table.foreign('item_id')
.references('item_id')
.inTable('issue_report_items')
.onDelete('SET NULL');
});
};
exports.down = function(knex) {
return knex.schema
.alterTable('work_report_defects', function(table) {
table.dropForeign('category_id');
table.dropForeign('item_id');
table.dropColumn('category_id');
table.dropColumn('item_id');
});
};

View File

@@ -0,0 +1,85 @@
/**
* 작업보고서 부적합을 신고 시스템과 연동
*
* 변경사항:
* 1. work_report_defects 테이블에 issue_report_id 컬럼 추가
* 2. error_type_id를 NULL 허용으로 변경 (신고 연동 시 불필요)
* 3. work_issue_reports.report_id (unsigned int)와 타입 일치 필요
*/
exports.up = function(knex) {
return knex.schema
.alterTable('work_report_defects', function(table) {
// 1. issue_report_id 컬럼 추가 (unsigned int로 work_issue_reports.report_id와 타입 일치)
table.integer('issue_report_id').unsigned().nullable()
.comment('work_issue_reports의 report_id (신고된 이슈 연결)')
.after('error_type_id');
// 2. 외래키 추가 (work_issue_reports.report_id 참조)
table.foreign('issue_report_id')
.references('report_id')
.inTable('work_issue_reports')
.onDelete('SET NULL');
// 3. 인덱스 추가
table.index('issue_report_id');
})
// 4. error_type_id를 NULL 허용으로 변경
.then(function() {
return knex.raw(`
ALTER TABLE work_report_defects
MODIFY COLUMN error_type_id INT NULL
COMMENT 'error_types의 id (부적합 원인) - 레거시, issue_report_id 사용 권장'
`);
})
// 5. 유니크 제약 수정 (issue_report_id도 고려)
.then(function() {
// 기존 유니크 제약 삭제
return knex.raw(`
ALTER TABLE work_report_defects
DROP INDEX work_report_defects_report_id_error_type_id_unique
`).catch(() => {
// 인덱스가 없을 수 있음 - 무시
});
})
.then(function() {
// 새 유니크 제약 추가 (report_id + issue_report_id 조합)
return knex.raw(`
ALTER TABLE work_report_defects
ADD UNIQUE INDEX work_report_defects_report_issue_unique (report_id, issue_report_id)
`).catch(() => {
// 이미 존재할 수 있음 - 무시
});
});
};
exports.down = function(knex) {
return knex.schema
.alterTable('work_report_defects', function(table) {
// 외래키 및 인덱스 삭제
table.dropForeign('issue_report_id');
table.dropIndex('issue_report_id');
table.dropColumn('issue_report_id');
})
// error_type_id를 다시 NOT NULL로 변경
.then(function() {
return knex.raw(`
ALTER TABLE work_report_defects
MODIFY COLUMN error_type_id INT NOT NULL
COMMENT 'error_types의 id (부적합 원인)'
`);
})
// 기존 유니크 제약 복원
.then(function() {
return knex.raw(`
ALTER TABLE work_report_defects
DROP INDEX IF EXISTS work_report_defects_report_issue_unique
`).catch(() => {});
})
.then(function() {
return knex.raw(`
ALTER TABLE work_report_defects
ADD UNIQUE INDEX work_report_defects_report_id_error_type_id_unique (report_id, error_type_id)
`).catch(() => {});
});
};

View File

@@ -0,0 +1,23 @@
-- 알림 수신자 설정 테이블
-- 알림 유형별로 지정된 사용자에게만 알림이 전송됨
CREATE TABLE IF NOT EXISTS notification_recipients (
id INT AUTO_INCREMENT PRIMARY KEY,
notification_type ENUM('repair', 'safety', 'nonconformity', 'equipment', 'maintenance', 'system') NOT NULL COMMENT '알림 유형',
user_id INT NOT NULL COMMENT '수신자 ID',
is_active BOOLEAN DEFAULT TRUE COMMENT '활성 여부',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL COMMENT '등록자',
UNIQUE KEY unique_type_user (notification_type, user_id),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
INDEX idx_nr_type (notification_type),
INDEX idx_nr_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림 수신자 설정';
-- 알림 유형 설명:
-- repair: 설비 수리 신청
-- safety: 안전 신고
-- nonconformity: 부적합 신고
-- equipment: 설비 관련
-- maintenance: 정기점검
-- system: 시스템 알림

View File

@@ -0,0 +1,35 @@
/**
* 설비 테이블에 구입처 및 구입가격 컬럼 추가
*
* @author TK-FB-Project
* @since 2026-02-04
*/
exports.up = async function(knex) {
// 컬럼 존재 여부 확인
const hasSupplier = await knex.schema.hasColumn('equipments', 'supplier');
const hasPurchasePrice = await knex.schema.hasColumn('equipments', 'purchase_price');
if (!hasSupplier || !hasPurchasePrice) {
await knex.schema.alterTable('equipments', (table) => {
if (!hasSupplier) {
table.string('supplier', 100).nullable().after('manufacturer').comment('구입처');
}
if (!hasPurchasePrice) {
table.decimal('purchase_price', 15, 0).nullable().after('supplier').comment('구입가격');
}
});
console.log('✅ equipments 테이블에 supplier, purchase_price 컬럼 추가 완료');
} else {
console.log(' supplier, purchase_price 컬럼이 이미 존재합니다. 스킵합니다.');
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('equipments', (table) => {
table.dropColumn('supplier');
table.dropColumn('purchase_price');
});
console.log('✅ equipments 테이블에서 supplier, purchase_price 컬럼 삭제 완료');
};

View File

@@ -0,0 +1,166 @@
/**
* 마이그레이션: 일일순회점검 시스템
* 작성일: 2026-02-04
*
* 생성 테이블:
* - patrol_checklist_items: 순회점검 체크리스트 마스터
* - daily_patrol_sessions: 순회점검 세션 기록
* - patrol_check_records: 순회점검 체크 결과
* - workplace_items: 작업장 물품 현황 (용기, 플레이트 등)
*/
exports.up = async function(knex) {
console.log('⏳ 일일순회점검 시스템 테이블 생성 중...');
// 1. 순회점검 체크리스트 마스터 테이블
await knex.schema.createTable('patrol_checklist_items', (table) => {
table.increments('item_id').primary();
table.integer('workplace_id').unsigned().nullable().comment('특정 작업장 전용 (NULL=공통)');
table.integer('category_id').unsigned().nullable().comment('특정 공장 전용 (NULL=공통)');
table.string('check_category', 50).notNullable().comment('분류 (안전, 정리정돈, 설비 등)');
table.string('check_item', 200).notNullable().comment('점검 항목');
table.text('description').nullable().comment('설명');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_required').defaultTo(true).comment('필수 체크 여부');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.index('workplace_id');
table.index('category_id');
table.index('check_category');
table.foreign('workplace_id').references('workplaces.workplace_id').onDelete('CASCADE');
table.foreign('category_id').references('workplace_categories.category_id').onDelete('CASCADE');
});
console.log('✅ patrol_checklist_items 테이블 생성 완료');
// 초기 순회점검 체크리스트 데이터
await knex('patrol_checklist_items').insert([
// 안전 관련
{ check_category: 'SAFETY', check_item: '소화기 상태 확인', display_order: 1, is_required: true },
{ check_category: 'SAFETY', check_item: '비상구 통로 확보 확인', display_order: 2, is_required: true },
{ check_category: 'SAFETY', check_item: '안전표지판 부착 상태', display_order: 3, is_required: true },
{ check_category: 'SAFETY', check_item: '위험물 관리 상태', display_order: 4, is_required: true },
// 정리정돈
{ check_category: 'ORGANIZATION', check_item: '작업장 정리정돈 상태', display_order: 10, is_required: true },
{ check_category: 'ORGANIZATION', check_item: '통로 장애물 여부', display_order: 11, is_required: true },
{ check_category: 'ORGANIZATION', check_item: '폐기물 처리 상태', display_order: 12, is_required: true },
{ check_category: 'ORGANIZATION', check_item: '자재 적재 상태', display_order: 13, is_required: true },
// 설비
{ check_category: 'EQUIPMENT', check_item: '설비 외관 이상 여부', display_order: 20, is_required: false },
{ check_category: 'EQUIPMENT', check_item: '설비 작동 상태', display_order: 21, is_required: false },
{ check_category: 'EQUIPMENT', check_item: '설비 청결 상태', display_order: 22, is_required: false },
// 환경
{ check_category: 'ENVIRONMENT', check_item: '조명 상태', display_order: 30, is_required: true },
{ check_category: 'ENVIRONMENT', check_item: '환기 상태', display_order: 31, is_required: true },
{ check_category: 'ENVIRONMENT', check_item: '누수/누유 여부', display_order: 32, is_required: true },
]);
console.log('✅ patrol_checklist_items 초기 데이터 입력 완료');
// 2. 순회점검 세션 테이블
await knex.schema.createTable('daily_patrol_sessions', (table) => {
table.increments('session_id').primary();
table.date('patrol_date').notNullable().comment('점검 날짜');
table.enum('patrol_time', ['morning', 'afternoon']).notNullable().comment('점검 시간대');
table.integer('inspector_id').notNullable().comment('순찰자 user_id'); // signed (users.user_id)
table.integer('category_id').unsigned().nullable().comment('공장 ID');
table.enum('status', ['in_progress', 'completed']).defaultTo('in_progress').comment('상태');
table.text('notes').nullable().comment('특이사항');
table.time('started_at').nullable().comment('점검 시작 시간');
table.time('completed_at').nullable().comment('점검 완료 시간');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.unique(['patrol_date', 'patrol_time', 'category_id']);
table.index(['patrol_date', 'patrol_time']);
table.index('inspector_id');
table.foreign('inspector_id').references('users.user_id');
table.foreign('category_id').references('workplace_categories.category_id').onDelete('SET NULL');
});
console.log('✅ daily_patrol_sessions 테이블 생성 완료');
// 3. 순회점검 체크 기록 테이블
await knex.schema.createTable('patrol_check_records', (table) => {
table.increments('record_id').primary();
table.integer('session_id').unsigned().notNullable().comment('순회점검 세션 ID');
table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID');
table.integer('check_item_id').unsigned().notNullable().comment('체크항목 ID');
table.boolean('is_checked').defaultTo(false).comment('체크 여부');
table.enum('check_result', ['good', 'warning', 'bad']).nullable().comment('점검 결과');
table.text('note').nullable().comment('비고');
table.timestamp('checked_at').nullable().comment('체크 시간');
// 인덱스명 길이 제한으로 인해 수동으로 지정
table.unique(['session_id', 'workplace_id', 'check_item_id'], 'pcr_session_wp_item_unique');
table.index(['session_id', 'workplace_id'], 'pcr_session_wp_idx');
table.foreign('session_id').references('daily_patrol_sessions.session_id').onDelete('CASCADE');
table.foreign('workplace_id').references('workplaces.workplace_id').onDelete('CASCADE');
table.foreign('check_item_id').references('patrol_checklist_items.item_id').onDelete('CASCADE');
});
console.log('✅ patrol_check_records 테이블 생성 완료');
// 4. 작업장 물품 현황 테이블
await knex.schema.createTable('workplace_items', (table) => {
table.increments('item_id').primary();
table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID');
table.integer('patrol_session_id').unsigned().nullable().comment('등록한 순회점검 세션');
table.integer('project_id').nullable().comment('관련 프로젝트'); // signed (projects.project_id)
table.enum('item_type', ['container', 'plate', 'material', 'tool', 'other']).notNullable().comment('물품 유형');
table.string('item_name', 100).nullable().comment('물품명/설명');
table.integer('quantity').defaultTo(1).comment('수량');
table.decimal('x_percent', 5, 2).nullable().comment('지도상 X 위치 (%)');
table.decimal('y_percent', 5, 2).nullable().comment('지도상 Y 위치 (%)');
table.decimal('width_percent', 5, 2).nullable().comment('지도상 너비 (%)');
table.decimal('height_percent', 5, 2).nullable().comment('지도상 높이 (%)');
table.boolean('is_active').defaultTo(true).comment('현재 존재 여부');
table.integer('created_by').notNullable().comment('등록자 user_id'); // signed (users.user_id)
table.timestamp('created_at').defaultTo(knex.fn.now());
table.integer('updated_by').nullable().comment('최종 수정자 user_id'); // signed (users.user_id)
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.index(['workplace_id', 'is_active']);
table.index('project_id');
table.foreign('workplace_id').references('workplaces.workplace_id').onDelete('CASCADE');
table.foreign('patrol_session_id').references('daily_patrol_sessions.session_id').onDelete('SET NULL');
table.foreign('project_id').references('projects.project_id').onDelete('SET NULL');
table.foreign('created_by').references('users.user_id');
table.foreign('updated_by').references('users.user_id');
});
console.log('✅ workplace_items 테이블 생성 완료');
// 물품 유형 코드 테이블 (선택적 확장용)
await knex.schema.createTable('item_types', (table) => {
table.string('type_code', 20).primary();
table.string('type_name', 50).notNullable().comment('유형명');
table.string('icon', 10).nullable().comment('아이콘 이모지');
table.string('color', 20).nullable().comment('표시 색상');
table.integer('display_order').defaultTo(0);
table.boolean('is_active').defaultTo(true);
});
await knex('item_types').insert([
{ type_code: 'container', type_name: '용기', icon: '📦', color: '#3b82f6', display_order: 1 },
{ type_code: 'plate', type_name: '플레이트', icon: '🔲', color: '#10b981', display_order: 2 },
{ type_code: 'material', type_name: '자재', icon: '🧱', color: '#f59e0b', display_order: 3 },
{ type_code: 'tool', type_name: '공구/장비', icon: '🔧', color: '#8b5cf6', display_order: 4 },
{ type_code: 'other', type_name: '기타', icon: '📍', color: '#6b7280', display_order: 5 },
]);
console.log('✅ item_types 테이블 생성 및 초기 데이터 완료');
console.log('✅ 모든 일일순회점검 시스템 테이블 생성 완료');
};
exports.down = async function(knex) {
console.log('⏳ 일일순회점검 시스템 테이블 제거 중...');
await knex.schema.dropTableIfExists('item_types');
await knex.schema.dropTableIfExists('workplace_items');
await knex.schema.dropTableIfExists('patrol_check_records');
await knex.schema.dropTableIfExists('daily_patrol_sessions');
await knex.schema.dropTableIfExists('patrol_checklist_items');
console.log('✅ 모든 일일순회점검 시스템 테이블 제거 완료');
};

View File

@@ -0,0 +1,13 @@
-- 설비 테이블 컬럼 추가 (phpMyAdmin용)
-- 현재 구조: equipment_id, factory_id, equipment_name, model, status, purchase_date, description, created_at, updated_at
-- 필요한 컬럼 추가
ALTER TABLE equipments ADD COLUMN equipment_code VARCHAR(50) NULL COMMENT '관리번호' AFTER equipment_id;
ALTER TABLE equipments ADD COLUMN specifications TEXT NULL COMMENT '규격' AFTER model;
ALTER TABLE equipments ADD COLUMN serial_number VARCHAR(100) NULL COMMENT '시리얼번호(S/N)' AFTER specifications;
ALTER TABLE equipments ADD COLUMN supplier VARCHAR(100) NULL COMMENT '구입처' AFTER purchase_date;
ALTER TABLE equipments ADD COLUMN purchase_price DECIMAL(15, 0) NULL COMMENT '구입가격' AFTER supplier;
ALTER TABLE equipments ADD COLUMN manufacturer VARCHAR(100) NULL COMMENT '제조사(메이커)' AFTER purchase_price;
-- equipment_code에 유니크 인덱스 추가
ALTER TABLE equipments ADD UNIQUE INDEX idx_equipment_code (equipment_code);

View File

@@ -0,0 +1,138 @@
-- 설비 관리 전체 설정 스크립트
-- 1. 새 컬럼 추가 (supplier, purchase_price)
-- 2. 65개 설비 데이터 입력
--
-- 실행: mysql -u [user] -p [database] < 20260204_equipment_full_setup.sql
-- ============================================
-- STEP 1: 새 컬럼 추가
-- ============================================
-- 컬럼이 이미 존재하는지 확인 후 추가
SET @dbname = DATABASE();
SET @tablename = 'equipments';
-- supplier 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname
AND table_name = @tablename
AND column_name = 'supplier';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN supplier VARCHAR(100) NULL COMMENT ''구입처'' AFTER manufacturer',
'SELECT ''supplier column already exists''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- purchase_price 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname
AND table_name = @tablename
AND column_name = 'purchase_price';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN purchase_price DECIMAL(15, 0) NULL COMMENT ''구입가격'' AFTER supplier',
'SELECT ''purchase_price column already exists''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT '컬럼 추가 완료' AS status;
-- ============================================
-- STEP 2: 기존 데이터 삭제 (선택사항)
-- ============================================
-- 주의: 기존 데이터가 있으면 삭제됩니다
DELETE FROM equipments WHERE equipment_code LIKE 'TKP-%';
-- ============================================
-- STEP 3: 65개 설비 데이터 입력
-- ============================================
INSERT INTO equipments (equipment_code, equipment_name, model_name, specifications, serial_number, installation_date, supplier, purchase_price, manufacturer, status) VALUES
('TKP-001', 'AIR COMPRESSOR', 'AR10E', '7.5KW(10HP)', 'K603023Y', '2016-06-01', '지티씨', NULL, '경원', 'active'),
('TKP-002', 'TURN TABLE', 'YCT-200T', '220V', NULL, '2016-05-30', '형진종합공구', 3600000, '유체기계', 'active'),
('TKP-003', 'BAND SAW(中)', 'CY300W', '1500W*380V', '20150943', '2016-05-30', '형진종합공구', 4800000, '유림싸이겐', 'active'),
('TKP-004', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2016-05-30', '형진종합공구', 2700000, '렉스', 'active'),
('TKP-005', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2019-05-30', NULL, NULL, '렉스', 'active'),
('TKP-006', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-001', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-007', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-002', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-008', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-003', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-009', 'CO2용접기', 'COD-500A', '500A', '10880', '2016-05-30', '형진종합공구', 2000000, '대성용접기', 'active'),
('TKP-010', 'O2용접기', 'GSORK', '220V', NULL, '2016-05-30', '형진종합공구', 620000, '재현오토닉스', 'active'),
('TKP-011', 'PIPE BEVELLING MACHINE', 'S-200LT_MT(테이블포함)', '2" ~ 8"', 'KR-17030007', '2017-03-29', 'DCS ENG', 12000000, 'DCS ENG', 'active'),
('TKP-012', 'CO2용접기', '500MX', '220/380V,500A', NULL, '2017-08-02', '현대용접기', 1800000, '현대용접기', 'active'),
('TKP-013', '프라즈마', 'Perfect-150AP', '220/380/440V,140A', NULL, '2017-08-02', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-014', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
('TKP-015', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
('TKP-016', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-005', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-017', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-006', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-018', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-007', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-019', '전해연마기', 'ONB-8000VP', '220V/MAX1200W', '8022701', '2018-03-13', '오토기전', 1450000, '메탈브라이트(오토기전)', 'active'),
('TKP-020', '지게차', '50DA-9F', '5000KGS', 'HHKHFV36JJ0000061', '2018-05-10', '현대지게차', 45000000, '현대지게차', 'active'),
('TKP-021', '조방', NULL, '3658*12190', NULL, '2018-05-11', '천우기계공업/삼덕금속', 14200000, '테크니컬코리아', 'active'),
('TKP-022', 'BAND SAW(大)', 'WBS-RC500AN', '3,300kgs / 7.88kw', 'BC50A18-005F001', '2018-05-31', '원공사', 36000000, '원공사', 'active'),
('TKP-023', 'AIR COMPRESSOR', 'AR20E', '0.95Mpa', 'AR020FE358', '2018-06-05', '경원기계', NULL, '경원기계', 'active'),
('TKP-024', 'TURN TABLE', 'YCT-200TA', '220V', NULL, '2018-06-12', '청운종합공구', 3245000, '유체기계', 'active'),
('TKP-025', 'TIG용접기', 'Perfect-500WT', '500A/AC DC', 'ADKAC017B-006', '2018-06-12', '현대용접기', 2400000, '퍼펙트대대', 'active'),
('TKP-026', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAI018B-002', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-027', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-009', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-028', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-001', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-029', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1806077', '2018-07-06', '청운종합공구', 2300000, '대산', 'active'),
('TKP-030', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807028', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
('TKP-031', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807029', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
('TKP-032', '만능탭 드릴링머신', 'SF-TDM32', '1.5KW', NULL, '2018-11-09', '㈜애스앤애프', 2927000, '㈜애스앤에프', 'active'),
('TKP-033', '지게차', '30D-9B', '2850KGS', 'HHKHHN51KK0000864', '2019-03-06', '현대지게차', 29400000, '현대지게차', 'active'),
('TKP-034', '갠츄리크레인', 'CRANE - DHG', '50/10Ton x SP20M x T/L50M x H15M', NULL, '2019-05-09', '유진산업기계', 249000000, '반도호이스트', 'active'),
('TKP-035', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
('TKP-036', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
('TKP-037', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
('TKP-038', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
('TKP-039', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-040', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-041', 'AIR CONDITIONER', '코끼리 냉장고', NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-042', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-043', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-044', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-045', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-046', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-047', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-048', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-049', '자동용접기', NULL, NULL, NULL, '2019-01-01', 'Swage-Lok', 50000000, 'Swage-Lok', 'active'),
('TKP-050', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'NITTO', 'active'),
('TKP-051', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'Key-Yang', 'active'),
('TKP-052', 'Tube Bending M/C', NULL, NULL, NULL, '2020-01-01', NULL, NULL, 'REMS', 'active'),
('TKP-053', 'Unit Test Panel', NULL, NULL, NULL, '2021-01-01', NULL, NULL, NULL, 'active'),
('TKP-054', '고소작업대', NULL, '500 Kg', NULL, '2021-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-055', '용접봉 건조기', '주문제작', '박스형', NULL, '2022-05-04', '진원하이텍', 2300000, '진원하이텍', 'active'),
('TKP-056', 'C&T 가공기', 'MS-CTK469', NULL, NULL, '2022-07-20', 'Swage-Lok', 7347600, 'Swage-Lok', 'active'),
('TKP-057', '테이블형 튜브 벤딩기', 'MS-BTT-K', '1/2", 1/4"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
('TKP-058', '자동용접기 헤드', 'SWS-10H-D-15', '1/2"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
('TKP-059', '천장주행크레인', 'HC-75D-13105', '7.5ton', NULL, '2023-06-09', '에이치앤씨', 22800000, '에이치앤씨', 'active'),
('TKP-060', 'AED', 'CU-SP1 Plus', '저출력심장충격기', NULL, '2023-11-09', '제이메디', 1600000, '제이메디', 'active'),
('TKP-061', '베벨머신', 'S-150', 'O.D 20mm ~ 170mm', NULL, '2023-12-12', 'DCSENG', 16000000, 'DCSENG', 'active'),
('TKP-062', '피막제거기', 'CM4_OD_GC', '최대 폭 48mm, 최대 깊이 15mm, 6"이상', NULL, '2023-12-12', 'DCSENG', 2000000, 'DCSENG', 'active'),
('TKP-063', '피막제거기', 'S-CM4_OD', '최대 폭 48mm, 최대 깊이 15mm, 1" 이상', NULL, '2023-12-12', 'DCSENG', 1200000, 'DCSENG', 'active'),
('TKP-064', '텅스텐 가공기', 'S-TGR', '0.89kg, 0.25~3.2', NULL, '2023-12-12', 'DCSENG', 800000, 'DCSENG', 'active'),
('TKP-065', '전동대차', 'LPM15', '2.0 ton', NULL, '2023-12-20', '두산산업차량', 2800000, '두산산업차량', 'active');
-- ============================================
-- STEP 4: 결과 확인
-- ============================================
SELECT '===== 설비 데이터 입력 완료 =====' AS status;
SELECT COUNT(*) AS total_equipments FROM equipments;
SELECT
SUM(CASE WHEN purchase_price IS NOT NULL THEN purchase_price ELSE 0 END) AS total_purchase_value,
COUNT(CASE WHEN purchase_price IS NOT NULL THEN 1 END) AS equipments_with_price
FROM equipments;
-- 최신 10개 설비 확인
SELECT equipment_code, equipment_name, supplier,
FORMAT(purchase_price, 0) AS purchase_price_formatted,
manufacturer
FROM equipments
ORDER BY equipment_code DESC
LIMIT 10;

View File

@@ -0,0 +1,73 @@
-- 설비 데이터 입력 (실제 테이블 구조에 맞춤)
-- 먼저 20260204_equipment_add_columns.sql 실행 후 이 파일 실행
-- 기존 TKP 데이터 삭제
DELETE FROM equipments WHERE equipment_code LIKE 'TKP-%';
-- 65개 설비 데이터 입력
INSERT INTO equipments (equipment_code, equipment_name, model, specifications, serial_number, purchase_date, supplier, purchase_price, manufacturer, status) VALUES
('TKP-001', 'AIR COMPRESSOR', 'AR10E', '7.5KW(10HP)', 'K603023Y', '2016-06-01', '지티씨', NULL, '경원', 'active'),
('TKP-002', 'TURN TABLE', 'YCT-200T', '220V', NULL, '2016-05-30', '형진종합공구', 3600000, '유체기계', 'active'),
('TKP-003', 'BAND SAW(中)', 'CY300W', '1500W*380V', '20150943', '2016-05-30', '형진종합공구', 4800000, '유림싸이겐', 'active'),
('TKP-004', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2016-05-30', '형진종합공구', 2700000, '렉스', 'active'),
('TKP-005', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2019-05-30', NULL, NULL, '렉스', 'active'),
('TKP-006', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-001', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-007', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-002', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-008', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-003', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-009', 'CO2용접기', 'COD-500A', '500A', '10880', '2016-05-30', '형진종합공구', 2000000, '대성용접기', 'active'),
('TKP-010', 'O2용접기', 'GSORK', '220V', NULL, '2016-05-30', '형진종합공구', 620000, '재현오토닉스', 'active'),
('TKP-011', 'PIPE BEVELLING MACHINE', 'S-200LT_MT(테이블포함)', '2" ~ 8"', 'KR-17030007', '2017-03-29', 'DCS ENG', 12000000, 'DCS ENG', 'active'),
('TKP-012', 'CO2용접기', '500MX', '220/380V,500A', NULL, '2017-08-02', '현대용접기', 1800000, '현대용접기', 'active'),
('TKP-013', '프라즈마', 'Perfect-150AP', '220/380/440V,140A', NULL, '2017-08-02', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-014', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
('TKP-015', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
('TKP-016', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-005', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-017', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-006', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-018', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-007', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-019', '전해연마기', 'ONB-8000VP', '220V/MAX1200W', '8022701', '2018-03-13', '오토기전', 1450000, '메탈브라이트(오토기전)', 'active'),
('TKP-020', '지게차', '50DA-9F', '5000KGS', 'HHKHFV36JJ0000061', '2018-05-10', '현대지게차', 45000000, '현대지게차', 'active'),
('TKP-021', '조방', NULL, '3658*12190', NULL, '2018-05-11', '천우기계공업/삼덕금속', 14200000, '테크니컬코리아', 'active'),
('TKP-022', 'BAND SAW(大)', 'WBS-RC500AN', '3,300kgs / 7.88kw', 'BC50A18-005F001', '2018-05-31', '원공사', 36000000, '원공사', 'active'),
('TKP-023', 'AIR COMPRESSOR', 'AR20E', '0.95Mpa', 'AR020FE358', '2018-06-05', '경원기계', NULL, '경원기계', 'active'),
('TKP-024', 'TURN TABLE', 'YCT-200TA', '220V', NULL, '2018-06-12', '청운종합공구', 3245000, '유체기계', 'active'),
('TKP-025', 'TIG용접기', 'Perfect-500WT', '500A/AC DC', 'ADKAC017B-006', '2018-06-12', '현대용접기', 2400000, '퍼펙트대대', 'active'),
('TKP-026', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAI018B-002', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-027', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-009', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-028', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-001', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-029', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1806077', '2018-07-06', '청운종합공구', 2300000, '대산', 'active'),
('TKP-030', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807028', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
('TKP-031', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807029', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
('TKP-032', '만능탭 드릴링머신', 'SF-TDM32', '1.5KW', NULL, '2018-11-09', '㈜애스앤애프', 2927000, '㈜애스앤에프', 'active'),
('TKP-033', '지게차', '30D-9B', '2850KGS', 'HHKHHN51KK0000864', '2019-03-06', '현대지게차', 29400000, '현대지게차', 'active'),
('TKP-034', '갠츄리크레인', 'CRANE - DHG', '50/10Ton x SP20M x T/L50M x H15M', NULL, '2019-05-09', '유진산업기계', 249000000, '반도호이스트', 'active'),
('TKP-035', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
('TKP-036', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
('TKP-037', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
('TKP-038', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
('TKP-039', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-040', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-041', 'AIR CONDITIONER', '코끼리 냉장고', NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-042', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-043', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-044', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-045', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-046', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-047', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-048', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-049', '자동용접기', NULL, NULL, NULL, '2019-01-01', 'Swage-Lok', 50000000, 'Swage-Lok', 'active'),
('TKP-050', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'NITTO', 'active'),
('TKP-051', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'Key-Yang', 'active'),
('TKP-052', 'Tube Bending M/C', NULL, NULL, NULL, '2020-01-01', NULL, NULL, 'REMS', 'active'),
('TKP-053', 'Unit Test Panel', NULL, NULL, NULL, '2021-01-01', NULL, NULL, NULL, 'active'),
('TKP-054', '고소작업대', NULL, '500 Kg', NULL, '2021-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-055', '용접봉 건조기', '주문제작', '박스형', NULL, '2022-05-04', '진원하이텍', 2300000, '진원하이텍', 'active'),
('TKP-056', 'C&T 가공기', 'MS-CTK469', NULL, NULL, '2022-07-20', 'Swage-Lok', 7347600, 'Swage-Lok', 'active'),
('TKP-057', '테이블형 튜브 벤딩기', 'MS-BTT-K', '1/2", 1/4"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
('TKP-058', '자동용접기 헤드', 'SWS-10H-D-15', '1/2"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
('TKP-059', '천장주행크레인', 'HC-75D-13105', '7.5ton', NULL, '2023-06-09', '에이치앤씨', 22800000, '에이치앤씨', 'active'),
('TKP-060', 'AED', 'CU-SP1 Plus', '저출력심장충격기', NULL, '2023-11-09', '제이메디', 1600000, '제이메디', 'active'),
('TKP-061', '베벨머신', 'S-150', 'O.D 20mm ~ 170mm', NULL, '2023-12-12', 'DCSENG', 16000000, 'DCSENG', 'active'),
('TKP-062', '피막제거기', 'CM4_OD_GC', '최대 폭 48mm, 최대 깊이 15mm, 6"이상', NULL, '2023-12-12', 'DCSENG', 2000000, 'DCSENG', 'active'),
('TKP-063', '피막제거기', 'S-CM4_OD', '최대 폭 48mm, 최대 깊이 15mm, 1" 이상', NULL, '2023-12-12', 'DCSENG', 1200000, 'DCSENG', 'active'),
('TKP-064', '텅스텐 가공기', 'S-TGR', '0.89kg, 0.25~3.2', NULL, '2023-12-12', 'DCSENG', 800000, 'DCSENG', 'active'),
('TKP-065', '전동대차', 'LPM15', '2.0 ton', NULL, '2023-12-20', '두산산업차량', 2800000, '두산산업차량', 'active');

View File

@@ -0,0 +1,78 @@
-- 설비 관리 설정 (phpMyAdmin용 단순 버전)
-- phpMyAdmin에서 가져오기로 실행
-- ============================================
-- STEP 2: 기존 TKP 데이터 삭제
-- ============================================
DELETE FROM equipments WHERE equipment_code LIKE 'TKP-%';
-- ============================================
-- STEP 3: 65개 설비 데이터 입력
-- ============================================
INSERT INTO equipments (equipment_code, equipment_name, model_name, specifications, serial_number, installation_date, supplier, purchase_price, manufacturer, status) VALUES
('TKP-001', 'AIR COMPRESSOR', 'AR10E', '7.5KW(10HP)', 'K603023Y', '2016-06-01', '지티씨', NULL, '경원', 'active'),
('TKP-002', 'TURN TABLE', 'YCT-200T', '220V', NULL, '2016-05-30', '형진종합공구', 3600000, '유체기계', 'active'),
('TKP-003', 'BAND SAW(中)', 'CY300W', '1500W*380V', '20150943', '2016-05-30', '형진종합공구', 4800000, '유림싸이겐', 'active'),
('TKP-004', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2016-05-30', '형진종합공구', 2700000, '렉스', 'active'),
('TKP-005', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2019-05-30', NULL, NULL, '렉스', 'active'),
('TKP-006', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-001', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-007', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-002', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-008', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-003', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-009', 'CO2용접기', 'COD-500A', '500A', '10880', '2016-05-30', '형진종합공구', 2000000, '대성용접기', 'active'),
('TKP-010', 'O2용접기', 'GSORK', '220V', NULL, '2016-05-30', '형진종합공구', 620000, '재현오토닉스', 'active'),
('TKP-011', 'PIPE BEVELLING MACHINE', 'S-200LT_MT(테이블포함)', '2" ~ 8"', 'KR-17030007', '2017-03-29', 'DCS ENG', 12000000, 'DCS ENG', 'active'),
('TKP-012', 'CO2용접기', '500MX', '220/380V,500A', NULL, '2017-08-02', '현대용접기', 1800000, '현대용접기', 'active'),
('TKP-013', '프라즈마', 'Perfect-150AP', '220/380/440V,140A', NULL, '2017-08-02', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-014', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
('TKP-015', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
('TKP-016', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-005', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-017', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-006', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-018', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-007', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-019', '전해연마기', 'ONB-8000VP', '220V/MAX1200W', '8022701', '2018-03-13', '오토기전', 1450000, '메탈브라이트(오토기전)', 'active'),
('TKP-020', '지게차', '50DA-9F', '5000KGS', 'HHKHFV36JJ0000061', '2018-05-10', '현대지게차', 45000000, '현대지게차', 'active'),
('TKP-021', '조방', NULL, '3658*12190', NULL, '2018-05-11', '천우기계공업/삼덕금속', 14200000, '테크니컬코리아', 'active'),
('TKP-022', 'BAND SAW(大)', 'WBS-RC500AN', '3,300kgs / 7.88kw', 'BC50A18-005F001', '2018-05-31', '원공사', 36000000, '원공사', 'active'),
('TKP-023', 'AIR COMPRESSOR', 'AR20E', '0.95Mpa', 'AR020FE358', '2018-06-05', '경원기계', NULL, '경원기계', 'active'),
('TKP-024', 'TURN TABLE', 'YCT-200TA', '220V', NULL, '2018-06-12', '청운종합공구', 3245000, '유체기계', 'active'),
('TKP-025', 'TIG용접기', 'Perfect-500WT', '500A/AC DC', 'ADKAC017B-006', '2018-06-12', '현대용접기', 2400000, '퍼펙트대대', 'active'),
('TKP-026', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAI018B-002', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-027', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-009', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-028', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-001', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-029', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1806077', '2018-07-06', '청운종합공구', 2300000, '대산', 'active'),
('TKP-030', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807028', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
('TKP-031', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807029', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
('TKP-032', '만능탭 드릴링머신', 'SF-TDM32', '1.5KW', NULL, '2018-11-09', '㈜애스앤애프', 2927000, '㈜애스앤에프', 'active'),
('TKP-033', '지게차', '30D-9B', '2850KGS', 'HHKHHN51KK0000864', '2019-03-06', '현대지게차', 29400000, '현대지게차', 'active'),
('TKP-034', '갠츄리크레인', 'CRANE - DHG', '50/10Ton x SP20M x T/L50M x H15M', NULL, '2019-05-09', '유진산업기계', 249000000, '반도호이스트', 'active'),
('TKP-035', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
('TKP-036', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
('TKP-037', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
('TKP-038', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
('TKP-039', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-040', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-041', 'AIR CONDITIONER', '코끼리 냉장고', NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-042', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-043', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-044', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-045', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-046', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-047', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-048', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-049', '자동용접기', NULL, NULL, NULL, '2019-01-01', 'Swage-Lok', 50000000, 'Swage-Lok', 'active'),
('TKP-050', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'NITTO', 'active'),
('TKP-051', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'Key-Yang', 'active'),
('TKP-052', 'Tube Bending M/C', NULL, NULL, NULL, '2020-01-01', NULL, NULL, 'REMS', 'active'),
('TKP-053', 'Unit Test Panel', NULL, NULL, NULL, '2021-01-01', NULL, NULL, NULL, 'active'),
('TKP-054', '고소작업대', NULL, '500 Kg', NULL, '2021-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-055', '용접봉 건조기', '주문제작', '박스형', NULL, '2022-05-04', '진원하이텍', 2300000, '진원하이텍', 'active'),
('TKP-056', 'C&T 가공기', 'MS-CTK469', NULL, NULL, '2022-07-20', 'Swage-Lok', 7347600, 'Swage-Lok', 'active'),
('TKP-057', '테이블형 튜브 벤딩기', 'MS-BTT-K', '1/2", 1/4"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
('TKP-058', '자동용접기 헤드', 'SWS-10H-D-15', '1/2"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
('TKP-059', '천장주행크레인', 'HC-75D-13105', '7.5ton', NULL, '2023-06-09', '에이치앤씨', 22800000, '에이치앤씨', 'active'),
('TKP-060', 'AED', 'CU-SP1 Plus', '저출력심장충격기', NULL, '2023-11-09', '제이메디', 1600000, '제이메디', 'active'),
('TKP-061', '베벨머신', 'S-150', 'O.D 20mm ~ 170mm', NULL, '2023-12-12', 'DCSENG', 16000000, 'DCSENG', 'active'),
('TKP-062', '피막제거기', 'CM4_OD_GC', '최대 폭 48mm, 최대 깊이 15mm, 6"이상', NULL, '2023-12-12', 'DCSENG', 2000000, 'DCSENG', 'active'),
('TKP-063', '피막제거기', 'S-CM4_OD', '최대 폭 48mm, 최대 깊이 15mm, 1" 이상', NULL, '2023-12-12', 'DCSENG', 1200000, 'DCSENG', 'active'),
('TKP-064', '텅스텐 가공기', 'S-TGR', '0.89kg, 0.25~3.2', NULL, '2023-12-12', 'DCSENG', 800000, 'DCSENG', 'active'),
('TKP-065', '전동대차', 'LPM15', '2.0 ton', NULL, '2023-12-20', '두산산업차량', 2800000, '두산산업차량', 'active');

View File

@@ -0,0 +1,17 @@
-- 설비 사진 테이블 생성
-- 실행: docker exec -i tkfb_db mysql -u hyungi -p'your_password' hyungi < db/migrations/20260205001000_create_equipment_photos.sql
CREATE TABLE IF NOT EXISTS equipment_photos (
photo_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT UNSIGNED NOT NULL,
photo_path VARCHAR(255) NOT NULL COMMENT '이미지 경로',
description VARCHAR(200) COMMENT '사진 설명',
display_order INT DEFAULT 0 COMMENT '표시 순서',
uploaded_by INT COMMENT '업로드한 사용자 ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_eq_photos_equipment FOREIGN KEY (equipment_id)
REFERENCES equipments(equipment_id) ON DELETE CASCADE,
CONSTRAINT fk_eq_photos_user FOREIGN KEY (uploaded_by)
REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_eq_photos_equipment_id (equipment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,174 @@
-- 설비 임시이동 필드 추가 및 신고 시스템 연동
-- 실행: docker exec -i tkfb_db mysql -u hyungi -p'your_password' hyungi < db/migrations/20260205002000_add_equipment_move_fields.sql
SET @dbname = DATABASE();
-- ============================================
-- STEP 1: equipments 테이블에 임시이동 필드 추가
-- ============================================
-- current_workplace_id 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_workplace_id';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_workplace_id INT UNSIGNED NULL COMMENT ''현재 임시 위치 - 작업장 ID'' AFTER map_height_percent',
'SELECT ''current_workplace_id already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- current_map_x_percent 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_map_x_percent';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_map_x_percent DECIMAL(5,2) NULL COMMENT ''현재 위치 X%'' AFTER current_workplace_id',
'SELECT ''current_map_x_percent already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- current_map_y_percent 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_map_y_percent';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_map_y_percent DECIMAL(5,2) NULL COMMENT ''현재 위치 Y%'' AFTER current_map_x_percent',
'SELECT ''current_map_y_percent already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- current_map_width_percent 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_map_width_percent';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_map_width_percent DECIMAL(5,2) NULL COMMENT ''현재 위치 너비%'' AFTER current_map_y_percent',
'SELECT ''current_map_width_percent already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- current_map_height_percent 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_map_height_percent';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_map_height_percent DECIMAL(5,2) NULL COMMENT ''현재 위치 높이%'' AFTER current_map_width_percent',
'SELECT ''current_map_height_percent already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- is_temporarily_moved 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'is_temporarily_moved';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN is_temporarily_moved BOOLEAN DEFAULT FALSE COMMENT ''임시 이동 상태'' AFTER current_map_height_percent',
'SELECT ''is_temporarily_moved already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- moved_at 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'moved_at';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN moved_at DATETIME NULL COMMENT ''이동 일시'' AFTER is_temporarily_moved',
'SELECT ''moved_at already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- moved_by 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'moved_by';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN moved_by INT NULL COMMENT ''이동 처리자'' AFTER moved_at',
'SELECT ''moved_by already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Foreign Key: current_workplace_id -> workplaces
SELECT COUNT(*) INTO @fk_exists
FROM information_schema.table_constraints
WHERE table_schema = @dbname AND table_name = 'equipments' AND constraint_name = 'fk_eq_current_workplace';
SET @sql = IF(@fk_exists = 0,
'ALTER TABLE equipments ADD CONSTRAINT fk_eq_current_workplace FOREIGN KEY (current_workplace_id) REFERENCES workplaces(workplace_id) ON DELETE SET NULL',
'SELECT ''fk_eq_current_workplace already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'equipments 임시이동 필드 추가 완료' AS status;
-- ============================================
-- STEP 2: work_issue_reports에 equipment_id 필드 추가
-- ============================================
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'work_issue_reports' AND column_name = 'equipment_id';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE work_issue_reports ADD COLUMN equipment_id INT UNSIGNED NULL COMMENT ''관련 설비 ID'' AFTER visit_request_id',
'SELECT ''equipment_id already exists in work_issue_reports''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Foreign Key
SELECT COUNT(*) INTO @fk_exists
FROM information_schema.table_constraints
WHERE table_schema = @dbname AND table_name = 'work_issue_reports' AND constraint_name = 'fk_wir_equipment';
SET @sql = IF(@fk_exists = 0 AND @col_exists = 0,
'ALTER TABLE work_issue_reports ADD CONSTRAINT fk_wir_equipment FOREIGN KEY (equipment_id) REFERENCES equipments(equipment_id) ON DELETE SET NULL',
'SELECT ''fk_wir_equipment already exists or column not added''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Index
SELECT COUNT(*) INTO @idx_exists
FROM information_schema.statistics
WHERE table_schema = @dbname AND table_name = 'work_issue_reports' AND index_name = 'idx_wir_equipment_id';
SET @sql = IF(@idx_exists = 0 AND @col_exists = 0,
'ALTER TABLE work_issue_reports ADD INDEX idx_wir_equipment_id (equipment_id)',
'SELECT ''idx_wir_equipment_id already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'work_issue_reports equipment_id 추가 완료' AS status;
-- ============================================
-- STEP 3: 설비 수리 카테고리 추가
-- ============================================
INSERT INTO issue_report_categories (category_type, category_name, description, display_order, is_active)
SELECT 'nonconformity', '설비 수리', '설비 고장 및 수리 요청', 10, 1
WHERE NOT EXISTS (
SELECT 1 FROM issue_report_categories WHERE category_name = '설비 수리'
);
-- 설비 수리 카테고리에 기본 항목 추가
SET @category_id = (SELECT category_id FROM issue_report_categories WHERE category_name = '설비 수리' LIMIT 1);
INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order, is_active)
SELECT @category_id, '기계 고장', '기계 작동 불가 또는 이상', 'high', 1, 1
WHERE @category_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM issue_report_items WHERE category_id = @category_id AND item_name = '기계 고장'
);
INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order, is_active)
SELECT @category_id, '부품 교체 필요', '소모품 또는 부품 교체 필요', 'medium', 2, 1
WHERE @category_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM issue_report_items WHERE category_id = @category_id AND item_name = '부품 교체 필요'
);
INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order, is_active)
SELECT @category_id, '정기 점검 필요', '예방 정비 또는 정기 점검', 'low', 3, 1
WHERE @category_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM issue_report_items WHERE category_id = @category_id AND item_name = '정기 점검 필요'
);
INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order, is_active)
SELECT @category_id, '외부 수리 필요', '전문 업체 수리가 필요한 경우', 'high', 4, 1
WHERE @category_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM issue_report_items WHERE category_id = @category_id AND item_name = '외부 수리 필요'
);
SELECT '설비 수리 카테고리 및 항목 추가 완료' AS status;

View File

@@ -0,0 +1,86 @@
-- 설비 외부반출 테이블 생성 및 상태 ENUM 확장
-- 실행: docker exec -i tkfb_db mysql -u hyungi -p'your_password' hyungi < db/migrations/20260205003000_create_equipment_external_logs.sql
SET @dbname = DATABASE();
-- ============================================
-- STEP 1: equipment_external_logs 테이블 생성
-- ============================================
CREATE TABLE IF NOT EXISTS equipment_external_logs (
log_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT UNSIGNED NOT NULL COMMENT '설비 ID',
log_type ENUM('export', 'return') NOT NULL COMMENT '반출/반입',
export_date DATE COMMENT '반출일',
expected_return_date DATE COMMENT '반입 예정일',
actual_return_date DATE COMMENT '실제 반입일',
destination VARCHAR(200) COMMENT '반출처 (수리업체명 등)',
reason TEXT COMMENT '반출 사유',
notes TEXT COMMENT '비고',
exported_by INT COMMENT '반출 담당자',
returned_by INT COMMENT '반입 담당자',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_eel_equipment FOREIGN KEY (equipment_id)
REFERENCES equipments(equipment_id) ON DELETE CASCADE,
CONSTRAINT fk_eel_exported_by FOREIGN KEY (exported_by)
REFERENCES users(user_id) ON DELETE SET NULL,
CONSTRAINT fk_eel_returned_by FOREIGN KEY (returned_by)
REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_eel_equipment_id (equipment_id),
INDEX idx_eel_log_type (log_type),
INDEX idx_eel_export_date (export_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SELECT 'equipment_external_logs 테이블 생성 완료' AS status;
-- ============================================
-- STEP 2: equipments 테이블 status ENUM 확장
-- ============================================
-- 현재 status 컬럼의 ENUM 값 확인 후 확장
-- 기존: active, maintenance, repair_needed, inactive
-- 추가: external (외부 반출), repair_external (수리 외주)
ALTER TABLE equipments
MODIFY COLUMN status ENUM(
'active', -- 정상 가동
'maintenance', -- 점검 중
'repair_needed', -- 수리 필요
'inactive', -- 비활성
'external', -- 외부 반출
'repair_external' -- 수리 외주 (외부 수리)
) DEFAULT 'active' COMMENT '설비 상태';
SELECT 'equipments status ENUM 확장 완료' AS status;
-- ============================================
-- STEP 3: 설비 이동 이력 테이블 생성 (선택)
-- ============================================
CREATE TABLE IF NOT EXISTS equipment_move_logs (
log_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT UNSIGNED NOT NULL COMMENT '설비 ID',
move_type ENUM('temporary', 'return') NOT NULL COMMENT '임시이동/복귀',
from_workplace_id INT UNSIGNED COMMENT '이전 작업장',
to_workplace_id INT UNSIGNED COMMENT '이동 작업장',
from_x_percent DECIMAL(5,2) COMMENT '이전 X좌표',
from_y_percent DECIMAL(5,2) COMMENT '이전 Y좌표',
to_x_percent DECIMAL(5,2) COMMENT '이동 X좌표',
to_y_percent DECIMAL(5,2) COMMENT '이동 Y좌표',
reason TEXT COMMENT '이동 사유',
moved_by INT COMMENT '이동 처리자',
moved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_eml_equipment FOREIGN KEY (equipment_id)
REFERENCES equipments(equipment_id) ON DELETE CASCADE,
CONSTRAINT fk_eml_from_workplace FOREIGN KEY (from_workplace_id)
REFERENCES workplaces(workplace_id) ON DELETE SET NULL,
CONSTRAINT fk_eml_to_workplace FOREIGN KEY (to_workplace_id)
REFERENCES workplaces(workplace_id) ON DELETE SET NULL,
CONSTRAINT fk_eml_moved_by FOREIGN KEY (moved_by)
REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_eml_equipment_id (equipment_id),
INDEX idx_eml_move_type (move_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SELECT 'equipment_move_logs 테이블 생성 완료' AS status;

View File

@@ -0,0 +1,46 @@
-- 알림 시스템 테이블 생성
-- 실행: docker exec -i tkfb_db mysql -u hyungi -p'your_password' hyungi < db/migrations/20260205004000_create_notifications.sql
-- ============================================
-- STEP 1: notifications 테이블 생성
-- ============================================
CREATE TABLE IF NOT EXISTS notifications (
notification_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL COMMENT '특정 사용자에게만 표시 (NULL이면 전체)',
type ENUM('repair', 'safety', 'system', 'equipment', 'maintenance') NOT NULL DEFAULT 'system',
title VARCHAR(200) NOT NULL,
message TEXT,
link_url VARCHAR(500) COMMENT '클릭시 이동할 URL',
reference_type VARCHAR(50) COMMENT '연관 테이블 (equipment_repair_requests 등)',
reference_id INT COMMENT '연관 레코드 ID',
is_read BOOLEAN DEFAULT FALSE,
read_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
INDEX idx_notifications_user (user_id),
INDEX idx_notifications_type (type),
INDEX idx_notifications_is_read (is_read),
INDEX idx_notifications_created (created_at DESC)
);
-- 수리 요청 테이블 수정 (알림 연동을 위해)
-- equipment_repair_requests 테이블이 없으면 생성
CREATE TABLE IF NOT EXISTS equipment_repair_requests (
request_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT UNSIGNED NOT NULL,
repair_type VARCHAR(100) NOT NULL,
description TEXT,
urgency ENUM('low', 'normal', 'high', 'urgent') DEFAULT 'normal',
status ENUM('pending', 'in_progress', 'completed', 'cancelled') DEFAULT 'pending',
requested_by INT,
assigned_to INT NULL,
completed_at DATETIME NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (equipment_id) REFERENCES equipments(equipment_id) ON DELETE CASCADE,
INDEX idx_err_equipment (equipment_id),
INDEX idx_err_status (status),
INDEX idx_err_created (created_at DESC)
);

View File

@@ -0,0 +1,105 @@
/**
* 마이그레이션: TBM 기반 작업보고서의 work_type_id를 task_id로 수정
*
* 문제: TBM에서 작업보고서 생성 시 work_type_id(공정 ID)가 저장됨
* 해결: tbm_team_assignments 테이블의 task_id로 업데이트
*
* 실행: node db/migrations/20260205_fix_work_type_id_data.js
*/
const { getDb } = require('../../dbPool');
async function migrate() {
const db = await getDb();
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...\n');
try {
// 1. 수정 대상 확인 (TBM 기반이면서 work_type_id가 task_id와 다른 경우)
const [checkResult] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id as current_work_type_id,
ta.task_id as correct_task_id,
ta.work_type_id as tbm_work_type_id,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
INNER JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
ORDER BY dwr.report_date DESC
`);
console.log(`📊 수정 대상: ${checkResult.length}개 레코드\n`);
if (checkResult.length === 0) {
console.log('✅ 수정할 데이터가 없습니다.');
return;
}
// 수정 대상 샘플 출력
console.log('📋 수정 대상 샘플 (최대 10개):');
console.log('─'.repeat(80));
checkResult.slice(0, 10).forEach(row => {
console.log(` ID: ${row.id} | ${row.worker_name} | ${row.report_date}`);
console.log(` 현재 work_type_id: ${row.current_work_type_id} → 올바른 task_id: ${row.correct_task_id}`);
});
if (checkResult.length > 10) {
console.log(` ... 외 ${checkResult.length - 10}`);
}
console.log('─'.repeat(80));
// 2. 업데이트 실행
const [updateResult] = await db.query(`
UPDATE daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
SET dwr.work_type_id = ta.task_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
`);
console.log(`\n✅ 업데이트 완료: ${updateResult.affectedRows}개 레코드 수정됨`);
// 3. 수정 결과 확인
const [verifyResult] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id,
ta.task_id,
t.task_name,
wt.name as work_type_name
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE dwr.tbm_assignment_id IS NOT NULL
LIMIT 5
`);
console.log('\n📋 수정 후 샘플 확인:');
console.log('─'.repeat(80));
verifyResult.forEach(row => {
console.log(` ID: ${row.id} | work_type_id: ${row.work_type_id} | task: ${row.task_name || 'N/A'} | 공정: ${row.work_type_name || 'N/A'}`);
});
console.log('─'.repeat(80));
} catch (error) {
console.error('❌ 마이그레이션 실패:', error.message);
throw error;
}
}
// 실행
migrate()
.then(() => {
console.log('\n🎉 마이그레이션 완료!');
process.exit(0);
})
.catch(err => {
console.error('\n💥 마이그레이션 실패:', err);
process.exit(1);
});

View File

@@ -0,0 +1,56 @@
-- ============================================
-- error_types → issue_report_items 마이그레이션
-- 실행 전 반드시 백업하세요!
-- ============================================
-- STEP 1: 현재 상태 확인
-- ============================================
SELECT 'Before Migration' as status;
SELECT error_type_id, COUNT(*) as cnt FROM daily_work_reports WHERE error_type_id IS NOT NULL GROUP BY error_type_id;
-- STEP 2: 매핑 업데이트 실행
-- ============================================
-- 주의: 순서가 중요! (충돌 방지를 위해 큰 숫자부터)
-- 6 (검사불량) → 14 (치수 검사 누락)
UPDATE daily_work_reports SET error_type_id = 14 WHERE error_type_id = 6;
-- 5 (설비고장) → 38 (기계 고장)
UPDATE daily_work_reports SET error_type_id = 38 WHERE error_type_id = 5;
-- 4 (작업불량) → 43 (NDE 불합격)
UPDATE daily_work_reports SET error_type_id = 43 WHERE error_type_id = 4;
-- 3 (입고지연) → 1 (배관 자재 미입고) - 이미 1이므로 충돌 가능, 임시값 사용
UPDATE daily_work_reports SET error_type_id = 99991 WHERE error_type_id = 3;
-- 2 (외주작업 불량) → 10 (외관 불량)
UPDATE daily_work_reports SET error_type_id = 10 WHERE error_type_id = 2;
-- 1 (설계미스) → 6 (도면 치수 오류) - 6은 이미 업데이트됨
UPDATE daily_work_reports SET error_type_id = 6 WHERE error_type_id = 1;
-- 임시값 복원: 99991 → 1
UPDATE daily_work_reports SET error_type_id = 1 WHERE error_type_id = 99991;
-- STEP 3: 마이그레이션 결과 확인
-- ============================================
SELECT 'After Migration' as status;
SELECT
dwr.error_type_id,
iri.item_name,
irc.category_name,
COUNT(*) as cnt
FROM daily_work_reports dwr
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE dwr.error_type_id IS NOT NULL
GROUP BY dwr.error_type_id, iri.item_name, irc.category_name;
-- STEP 4: error_types 테이블 삭제 (선택사항)
-- ============================================
-- 마이그레이션 확인 후 주석 해제하여 실행
-- DROP TABLE IF EXISTS error_types;

View File

@@ -0,0 +1,112 @@
/**
* 마이그레이션: error_types에서 issue_report_items로 전환
*
* 기존 daily_work_reports.error_type_id가 error_types.id를 참조하던 것을
* issue_report_items.item_id를 참조하도록 변경
*
* 기존 error_types 데이터:
* id=1: 설계미스
* id=2: 외주작업 불량
* id=3: 입고지연
*
* 새 issue_report_categories 데이터:
* category_id=1: 자재누락 (nonconformity)
* category_id=2: 설계미스 (nonconformity)
* category_id=3: 입고불량 (nonconformity)
*
* 매핑 전략:
* - error_types.id=1 (설계미스) → issue_report_items에서 '설계미스' 카테고리의 첫 번째 항목
* - error_types.id=2 (외주작업 불량) → issue_report_items에서 '입고불량' 카테고리의 '외관 불량' 항목
* - error_types.id=3 (입고지연) → issue_report_items에서 '자재누락' 카테고리의 첫 번째 항목
*/
exports.up = async function(knex) {
console.log('=== error_type_id 마이그레이션 시작 ===');
// 1. 기존 error_types 데이터와 새 issue_report_items 매핑 테이블 조회
const [categories] = await knex.raw(`
SELECT category_id, category_name
FROM issue_report_categories
WHERE category_type = 'nonconformity'
`);
console.log('부적합 카테고리:', categories);
const [items] = await knex.raw(`
SELECT iri.item_id, iri.item_name, iri.category_id, irc.category_name
FROM issue_report_items iri
JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_type = 'nonconformity'
ORDER BY iri.category_id, iri.display_order
`);
console.log('부적합 항목:', items);
// 2. 매핑 정의 (기존 error_type_id → 새 issue_report_items.item_id)
// 설계미스 카테고리 찾기
const designMissCategory = categories.find(c => c.category_name === '설계미스');
const incomingDefectCategory = categories.find(c => c.category_name === '입고불량');
const materialShortageCategory = categories.find(c => c.category_name === '자재누락');
// 각 카테고리의 첫 번째 항목 찾기
const designMissItem = items.find(i => i.category_id === designMissCategory?.category_id);
const incomingDefectItem = items.find(i => i.category_id === incomingDefectCategory?.category_id);
const materialShortageItem = items.find(i => i.category_id === materialShortageCategory?.category_id);
console.log('매핑 결과:');
console.log(' - 설계미스(1) → item_id:', designMissItem?.item_id);
console.log(' - 외주작업불량(2) → item_id:', incomingDefectItem?.item_id);
console.log(' - 입고지연(3) → item_id:', materialShortageItem?.item_id);
// 3. 기존 데이터 업데이트
if (designMissItem) {
const [result1] = await knex.raw(`
UPDATE daily_work_reports
SET error_type_id = ?
WHERE error_type_id = 1
`, [designMissItem.item_id]);
console.log('설계미스(1) 업데이트:', result1.affectedRows, '건');
}
if (incomingDefectItem) {
const [result2] = await knex.raw(`
UPDATE daily_work_reports
SET error_type_id = ?
WHERE error_type_id = 2
`, [incomingDefectItem.item_id]);
console.log('외주작업불량(2) 업데이트:', result2.affectedRows, '건');
}
if (materialShortageItem) {
const [result3] = await knex.raw(`
UPDATE daily_work_reports
SET error_type_id = ?
WHERE error_type_id = 3
`, [materialShortageItem.item_id]);
console.log('입고지연(3) 업데이트:', result3.affectedRows, '건');
}
// 4. 매핑 안된 나머지 데이터 확인 (4 이상의 error_type_id)
const [unmapped] = await knex.raw(`
SELECT DISTINCT error_type_id, COUNT(*) as cnt
FROM daily_work_reports
WHERE error_type_id IS NOT NULL
AND error_type_id NOT IN (?, ?, ?)
GROUP BY error_type_id
`, [
designMissItem?.item_id || 0,
incomingDefectItem?.item_id || 0,
materialShortageItem?.item_id || 0
]);
if (unmapped.length > 0) {
console.log('⚠️ 매핑되지 않은 error_type_id 발견:', unmapped);
console.log(' 이 데이터는 수동으로 확인 필요');
}
console.log('=== error_type_id 마이그레이션 완료 ===');
};
exports.down = async function(knex) {
// 롤백은 복잡하므로 로그만 출력
console.log('⚠️ 이 마이그레이션은 자동 롤백을 지원하지 않습니다.');
console.log(' 데이터 복구가 필요한 경우 백업에서 복원해주세요.');
};

View File

@@ -0,0 +1,73 @@
-- ============================================
-- error_types → issue_report_items 마이그레이션
-- ============================================
-- STEP 1: 현재 데이터 확인
-- ============================================
-- 기존 error_types 확인
SELECT * FROM error_types;
-- 새 issue_report_categories 확인 (부적합만)
SELECT * FROM issue_report_categories WHERE category_type = 'nonconformity';
-- 새 issue_report_items 확인 (부적합만)
SELECT
iri.item_id,
iri.item_name,
iri.category_id,
irc.category_name
FROM issue_report_items iri
JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_type = 'nonconformity'
ORDER BY irc.display_order, iri.display_order;
-- 현재 daily_work_reports에서 사용 중인 error_type_id 확인
SELECT
error_type_id,
COUNT(*) as cnt,
et.name as old_error_name
FROM daily_work_reports dwr
LEFT JOIN error_types et ON dwr.error_type_id = et.id
WHERE error_type_id IS NOT NULL
GROUP BY error_type_id
ORDER BY error_type_id;
-- STEP 2: 매핑 업데이트 (실제 item_id 확인 후 수정 필요!)
-- ============================================
-- 먼저 위 쿼리로 실제 item_id 값을 확인하세요!
-- 아래는 예시입니다. 실제 값으로 수정해서 사용하세요.
-- 예시: 설계미스(error_type_id=1) → 설계미스 카테고리의 '도면 치수 오류' 항목
-- UPDATE daily_work_reports SET error_type_id = 6 WHERE error_type_id = 1;
-- 예시: 외주작업 불량(error_type_id=2) → 입고불량 카테고리의 '외관 불량' 항목
-- UPDATE daily_work_reports SET error_type_id = 10 WHERE error_type_id = 2;
-- 예시: 입고지연(error_type_id=3) → 자재누락 카테고리의 '배관 자재 미입고' 항목
-- UPDATE daily_work_reports SET error_type_id = 1 WHERE error_type_id = 3;
-- STEP 3: 매핑 검증
-- ============================================
-- 업데이트 후 확인
SELECT
dwr.error_type_id,
iri.item_name,
irc.category_name,
COUNT(*) as cnt
FROM daily_work_reports dwr
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE dwr.error_type_id IS NOT NULL
GROUP BY dwr.error_type_id, iri.item_name, irc.category_name;
-- STEP 4: error_types 테이블 삭제 (매핑 완료 후)
-- ============================================
-- 주의: 반드시 STEP 2, 3 완료 후 실행!
-- DROP TABLE IF EXISTS error_types;

View File

@@ -0,0 +1,13 @@
-- 설비 테이블에 구입처 및 구입가격 컬럼 추가
-- 실행: mysql -u [user] -p [database] < add_equipment_purchase_fields.sql
-- 컬럼 추가
ALTER TABLE equipments
ADD COLUMN supplier VARCHAR(100) NULL COMMENT '구입처' AFTER manufacturer,
ADD COLUMN purchase_price DECIMAL(15, 0) NULL COMMENT '구입가격' AFTER supplier;
-- 확인
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'equipments'
AND COLUMN_NAME IN ('supplier', 'purchase_price');

View File

@@ -0,0 +1,77 @@
-- 설비 데이터 입력
-- 실행 전 먼저 add_equipment_purchase_fields.sql 실행 필요
-- 실행: mysql -u [user] -p [database] < insert_equipment_data.sql
-- 기존 데이터 삭제 (필요시 주석 해제)
-- TRUNCATE TABLE equipments;
INSERT INTO equipments (equipment_code, equipment_name, model_name, specifications, serial_number, installation_date, supplier, purchase_price, manufacturer, status) VALUES
('TKP-001', 'AIR COMPRESSOR', 'AR10E', '7.5KW(10HP)', 'K603023Y', '2016-06-01', '지티씨', NULL, '경원', 'active'),
('TKP-002', 'TURN TABLE', 'YCT-200T', '220V', NULL, '2016-05-30', '형진종합공구', 3600000, '유체기계', 'active'),
('TKP-003', 'BAND SAW(中)', 'CY300W', '1500W*380V', '20150943', '2016-05-30', '형진종합공구', 4800000, '유림싸이겐', 'active'),
('TKP-004', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2016-05-30', '형진종합공구', 2700000, '렉스', 'active'),
('TKP-005', 'BAND SAW(小)', 'XB-180WA', '180(VICE)', NULL, '2019-05-30', NULL, NULL, '렉스', 'active'),
('TKP-006', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-001', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-007', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-002', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-008', 'TIG용접기', 'DAESUNG-500DT', '500A', 'TEAG0168-003', '2016-05-30', '형진종합공구', 2200000, '대성용접기', 'active'),
('TKP-009', 'CO2용접기', 'COD-500A', '500A', '10880', '2016-05-30', '형진종합공구', 2000000, '대성용접기', 'active'),
('TKP-010', 'O2용접기', 'GSORK', '220V', NULL, '2016-05-30', '형진종합공구', 620000, '재현오토닉스', 'active'),
('TKP-011', 'PIPE BEVELLING MACHINE', 'S-200LT_MT(테이블포함)', '2" ~ 8"', 'KR-17030007', '2017-03-29', 'DCS ENG', 12000000, 'DCS ENG', 'active'),
('TKP-012', 'CO2용접기', '500MX', '220/380V,500A', NULL, '2017-08-02', '현대용접기', 1800000, '현대용접기', 'active'),
('TKP-013', '프라즈마', 'Perfect-150AP', '220/380/440V,140A', NULL, '2017-08-02', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-014', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
('TKP-015', '터닝로라', 'JK-5-TR', '5TON/380V', NULL, '2017-09-08', '정일기공', 5700000, '정일기공', 'active'),
('TKP-016', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-005', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-017', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-006', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-018', 'TIG용접기', 'Perfect-500PT', '500A/350A', 'TJAD017B-007', '2017-10-18', '현대용접기', 1600000, '퍼펙트대대', 'active'),
('TKP-019', '전해연마기', 'ONB-8000VP', '220V/MAX1200W', '8022701', '2018-03-13', '오토기전', 1450000, '메탈브라이트(오토기전)', 'active'),
('TKP-020', '지게차', '50DA-9F', '5000KGS', 'HHKHFV36JJ0000061', '2018-05-10', '현대지게차', 45000000, '현대지게차', 'active'),
('TKP-021', '조방', NULL, '3658*12190', NULL, '2018-05-11', '천우기계공업/삼덕금속', 14200000, '테크니컬코리아', 'active'),
('TKP-022', 'BAND SAW(大)', 'WBS-RC500AN', '3,300kgs / 7.88kw', 'BC50A18-005F001', '2018-05-31', '원공사', 36000000, '원공사', 'active'),
('TKP-023', 'AIR COMPRESSOR', 'AR20E', '0.95Mpa', 'AR020FE358', '2018-06-05', '경원기계', NULL, '경원기계', 'active'),
('TKP-024', 'TURN TABLE', 'YCT-200TA', '220V', NULL, '2018-06-12', '청운종합공구', 3245000, '유체기계', 'active'),
('TKP-025', 'TIG용접기', 'Perfect-500WT', '500A/AC DC', 'ADKAC017B-006', '2018-06-12', '현대용접기', 2400000, '퍼펙트대대', 'active'),
('TKP-026', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAI018B-002', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-027', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-009', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-028', 'TIG용접기', 'Perfect-500PT', '500A/DC', 'TAAA018B-001', '2018-06-12', '현대용접기', 1900000, '퍼펙트대대', 'active'),
('TKP-029', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1806077', '2018-07-06', '청운종합공구', 2300000, '대산', 'active'),
('TKP-030', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807028', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
('TKP-031', 'ELECTRIC CHAIN HOIST', 'DSM-2S', '3Ph-60Hz-380V', 'K1807029', '2018-07-10', '청운종합공구', 2300000, '대산', 'active'),
('TKP-032', '만능탭 드릴링머신', 'SF-TDM32', '1.5KW', NULL, '2018-11-09', '㈜애스앤애프', 2927000, '㈜애스앤에프', 'active'),
('TKP-033', '지게차', '30D-9B', '2850KGS', 'HHKHHN51KK0000864', '2019-03-06', '현대지게차', 29400000, '현대지게차', 'active'),
('TKP-034', '갠츄리크레인', 'CRANE - DHG', '50/10Ton x SP20M x T/L50M x H15M', NULL, '2019-05-09', '유진산업기계', 249000000, '반도호이스트', 'active'),
('TKP-035', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
('TKP-036', 'OVER HEAD CRANE', 'CRANE - DHO', '20Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 58000000, '반도호이스트', 'active'),
('TKP-037', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
('TKP-038', 'OVER HEAD CRANE', 'CRANE - DHO', '5Ton x SP24.0M x T/L67M x H11M', NULL, '2019-05-09', '유진산업기계', 29000000, '반도호이스트', 'active'),
('TKP-039', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-040', '고소작업대', NULL, '250 Kg', NULL, '2019-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-041', 'AIR CONDITIONER', '코끼리 냉장고', NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-042', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-043', 'AIR CONDITIONER', NULL, NULL, NULL, '2019-01-01', NULL, NULL, '㈜에스엔에프', 'active'),
('TKP-044', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-045', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-046', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-047', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-048', '용접흄집진기', NULL, '5 HP / 60 m3/Min', NULL, '2019-01-01', NULL, NULL, NULL, 'active'),
('TKP-049', '자동용접기', NULL, NULL, NULL, '2019-01-01', 'Swage-Lok', 50000000, 'Swage-Lok', 'active'),
('TKP-050', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'NITTO', 'active'),
('TKP-051', 'Magnetic Drill', NULL, NULL, NULL, '2020-01-01', '청운종합공구', NULL, 'Key-Yang', 'active'),
('TKP-052', 'Tube Bending M/C', NULL, NULL, NULL, '2020-01-01', NULL, NULL, 'REMS', 'active'),
('TKP-053', 'Unit Test Panel', NULL, NULL, NULL, '2021-01-01', NULL, NULL, NULL, 'active'),
('TKP-054', '고소작업대', NULL, '500 Kg', NULL, '2021-01-01', NULL, NULL, '㈜쓰리제이테크', 'active'),
('TKP-055', '용접봉 건조기', '주문제작', '박스형', NULL, '2022-05-04', '진원하이텍', 2300000, '진원하이텍', 'active'),
('TKP-056', 'C&T 가공기', 'MS-CTK469', NULL, NULL, '2022-07-20', 'Swage-Lok', 7347600, 'Swage-Lok', 'active'),
('TKP-057', '테이블형 튜브 벤딩기', 'MS-BTT-K', '1/2", 1/4"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
('TKP-058', '자동용접기 헤드', 'SWS-10H-D-15', '1/2"', NULL, '2022-06-03', 'Swage-Lok', 20000000, 'Swage-Lok', 'active'),
('TKP-059', '천장주행크레인', 'HC-75D-13105', '7.5ton', NULL, '2023-06-09', '에이치앤씨', 22800000, '에이치앤씨', 'active'),
('TKP-060', 'AED', 'CU-SP1 Plus', '저출력심장충격기', NULL, '2023-11-09', '제이메디', 1600000, '제이메디', 'active'),
('TKP-061', '베벨머신', 'S-150', 'O.D 20mm ~ 170mm', NULL, '2023-12-12', 'DCSENG', 16000000, 'DCSENG', 'active'),
('TKP-062', '피막제거기', 'CM4_OD_GC', '최대 폭 48mm, 최대 깊이 15mm, 6"이상', NULL, '2023-12-12', 'DCSENG', 2000000, 'DCSENG', 'active'),
('TKP-063', '피막제거기', 'S-CM4_OD', '최대 폭 48mm, 최대 깊이 15mm, 1" 이상', NULL, '2023-12-12', 'DCSENG', 1200000, 'DCSENG', 'active'),
('TKP-064', '텅스텐 가공기', 'S-TGR', '0.89kg, 0.25~3.2', NULL, '2023-12-12', 'DCSENG', 800000, 'DCSENG', 'active'),
('TKP-065', '전동대차', 'LPM15', '2.0 ton', NULL, '2023-12-20', '두산산업차량', 2800000, '두산산업차량', 'active');
-- 입력 확인
SELECT COUNT(*) AS total_count FROM equipments;
SELECT equipment_code, equipment_name, supplier, purchase_price, manufacturer FROM equipments ORDER BY equipment_code LIMIT 10;

View File

@@ -0,0 +1,34 @@
-- =====================================================
-- daily_attendance_records 테이블 운영 DB 동기화
-- 실행 전 백업 권장
-- =====================================================
-- 1. is_present 컬럼 추가 (출근 체크용)
ALTER TABLE `daily_attendance_records`
ADD COLUMN IF NOT EXISTS `is_present` TINYINT(1) DEFAULT 1 COMMENT '출근 여부' AFTER `is_overtime_approved`;
-- 기존 데이터는 모두 출근으로 설정
UPDATE `daily_attendance_records` SET `is_present` = 1 WHERE `is_present` IS NULL;
-- 2. created_by 컬럼 추가 (등록자)
ALTER TABLE `daily_attendance_records`
ADD COLUMN IF NOT EXISTS `created_by` INT NULL COMMENT '등록자 user_id' AFTER `is_present`;
-- 기존 데이터는 시스템(1)으로 설정
UPDATE `daily_attendance_records` SET `created_by` = 1 WHERE `created_by` IS NULL;
-- 3. check_in_time, check_out_time 컬럼 추가 (선택사항)
ALTER TABLE `daily_attendance_records`
ADD COLUMN IF NOT EXISTS `check_in_time` TIME NULL COMMENT '출근 시간' AFTER `vacation_type_id`;
ALTER TABLE `daily_attendance_records`
ADD COLUMN IF NOT EXISTS `check_out_time` TIME NULL COMMENT '퇴근 시간' AFTER `check_in_time`;
-- 4. notes 컬럼 추가
ALTER TABLE `daily_attendance_records`
ADD COLUMN IF NOT EXISTS `notes` TEXT NULL COMMENT '비고' AFTER `is_overtime_approved`;
-- =====================================================
-- 확인용 쿼리
-- =====================================================
-- DESCRIBE `daily_attendance_records`;

View File

@@ -22,6 +22,222 @@ const PORT = process.env.PORT || 20005;
// Trust proxy for accurate IP addresses
app.set('trust proxy', 1);
// JSON body parser 미리 적용 (마이그레이션용)
app.use(express.json());
// 임시 분석 테스트 엔드포인트 - 실행 후 삭제!
app.get('/api/test-analysis', async (req, res) => {
try {
const { getDb } = require('./dbPool');
const db = await getDb();
// 수정된 COALESCE 로직 테스트 (tasks 우선)
const [results] = await db.query(`
SELECT
dwr.id,
w.worker_name,
dwr.report_date,
dwr.work_type_id as original_work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
) as resolved_work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name
) as work_type_name,
t.task_name,
wt.name as direct_match_work_type,
wt2.name as task_work_type
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
WHERE w.worker_name LIKE '%조승민%' OR w.worker_name LIKE '%최광욱%'
ORDER BY dwr.report_date DESC
LIMIT 20
`);
res.json({
success: true,
message: 'tasks 테이블 우선 조회 결과',
data: results.map(r => ({
id: r.id,
worker: r.worker_name,
date: r.report_date,
original_id: r.original_work_type_id,
resolved_work_type: r.work_type_name,
task: r.task_name,
note: `원래 ID ${r.original_work_type_id}${r.work_type_name}`
}))
});
} catch (error) {
console.error('테스트 실패:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// 임시 진단 엔드포인트 - 실행 후 삭제!
app.get('/api/diagnose-work-type-id', async (req, res) => {
try {
const { getDb } = require('./dbPool');
const db = await getDb();
// 1. 전체 작업보고서 현황
const [totalStats] = await db.query(`
SELECT
COUNT(*) as total_reports,
COUNT(tbm_assignment_id) as tbm_reports,
COUNT(CASE WHEN tbm_assignment_id IS NULL THEN 1 END) as non_tbm_reports
FROM daily_work_reports
`);
// 2. work_type_id 값 분포 (상위 20개)
const [workTypeDistribution] = await db.query(`
SELECT
dwr.work_type_id,
COUNT(*) as count,
wt.name as if_work_type,
t.task_name as if_task,
wt2.name as task_work_type
FROM daily_work_reports dwr
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
GROUP BY dwr.work_type_id
ORDER BY count DESC
LIMIT 20
`);
// 3. 특정 작업자 데이터 확인 (조승민, 최광욱)
const [workerSamples] = await db.query(`
SELECT
dwr.id,
w.worker_name,
dwr.work_type_id,
dwr.tbm_assignment_id,
wt.name as direct_work_type,
t.task_name,
wt2.name as task_work_type,
dwr.report_date
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
WHERE w.worker_name LIKE '%조승민%' OR w.worker_name LIKE '%최광욱%'
ORDER BY dwr.report_date DESC
LIMIT 20
`);
res.json({
success: true,
data: {
total_stats: totalStats[0],
work_type_distribution: workTypeDistribution,
worker_samples: workerSamples
}
});
} catch (error) {
console.error('진단 실패:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// 임시 마이그레이션 엔드포인트 (인증 없이 실행) - 실행 후 삭제!
app.post('/api/migrate-work-type-id', async (req, res) => {
try {
const { getDb } = require('./dbPool');
const db = await getDb();
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...');
// 1. 수정 대상 확인
const [checkResult] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id as current_work_type_id,
ta.task_id as correct_task_id,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
INNER JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
ORDER BY dwr.report_date DESC
`);
console.log('📊 수정 대상:', checkResult.length, '개 레코드');
if (checkResult.length === 0) {
return res.json({
success: true,
message: '수정할 데이터가 없습니다.',
data: { affected_rows: 0 }
});
}
// 2. 업데이트 실행
const [updateResult] = await db.query(`
UPDATE daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
SET dwr.work_type_id = ta.task_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
`);
console.log('✅ 업데이트 완료:', updateResult.affectedRows, '개 레코드 수정됨');
// 3. 수정 후 확인
const [samples] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id,
t.task_name,
wt.name as work_type_name,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
ORDER BY dwr.report_date DESC
LIMIT 10
`);
res.json({
success: true,
message: updateResult.affectedRows + '개 레코드가 수정되었습니다.',
data: {
affected_rows: updateResult.affectedRows,
before_count: checkResult.length,
samples: samples.map(s => ({
id: s.id,
worker: s.worker_name,
date: s.report_date,
task: s.task_name,
work_type: s.work_type_name
}))
}
});
} catch (error) {
console.error('마이그레이션 실패:', error);
res.status(500).json({
success: false,
error: '마이그레이션 실패: ' + error.message
});
}
});
// 미들웨어 설정
setupMiddlewares(app);

View File

@@ -0,0 +1,201 @@
/**
* CSRF Protection Middleware
*
* Cross-Site Request Forgery 방지를 위한 토큰 기반 보호
*
* 구현 방식:
* 1. 서버에서 CSRF 토큰 생성 및 응답 헤더로 전송
* 2. 클라이언트는 state-changing 요청 시 토큰을 헤더에 포함
* 3. 서버에서 토큰 검증
*
* @author TK-FB-Project
* @since 2026-02-04
*/
const crypto = require('crypto');
const logger = require('../utils/logger');
// 토큰 저장소 (프로덕션에서는 Redis 사용 권장)
const tokenStore = new Map();
// 토큰 유효 시간 (1시간)
const TOKEN_EXPIRY = 60 * 60 * 1000;
// 토큰 정리 주기 (5분)
const CLEANUP_INTERVAL = 5 * 60 * 1000;
/**
* 만료된 토큰 정리
*/
const cleanupExpiredTokens = () => {
const now = Date.now();
for (const [token, data] of tokenStore.entries()) {
if (now > data.expiresAt) {
tokenStore.delete(token);
}
}
};
// 주기적 정리
setInterval(cleanupExpiredTokens, CLEANUP_INTERVAL);
/**
* CSRF 토큰 생성
*
* @param {string} sessionId - 세션 ID 또는 사용자 식별자
* @returns {string} 생성된 CSRF 토큰
*/
const generateToken = (sessionId) => {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + TOKEN_EXPIRY;
tokenStore.set(token, {
sessionId,
expiresAt,
createdAt: Date.now()
});
return token;
};
/**
* CSRF 토큰 검증
*
* @param {string} token - 검증할 토큰
* @param {string} sessionId - 세션 ID
* @returns {boolean} 유효 여부
*/
const validateToken = (token, sessionId) => {
if (!token || !tokenStore.has(token)) {
return false;
}
const data = tokenStore.get(token);
// 만료 체크
if (Date.now() > data.expiresAt) {
tokenStore.delete(token);
return false;
}
// 세션 일치 체크
if (data.sessionId !== sessionId) {
return false;
}
return true;
};
/**
* CSRF 토큰을 응답 헤더에 설정하는 미들웨어
*
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Next middleware
*/
const setCsrfToken = (req, res, next) => {
// 세션 ID 또는 사용자 ID 사용
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
// 새 토큰 생성
const token = generateToken(sessionId);
// 응답 헤더에 토큰 설정
res.setHeader('X-CSRF-Token', token);
// 요청 객체에 저장 (다른 미들웨어에서 사용 가능)
req.csrfToken = token;
next();
};
/**
* CSRF 토큰 검증 미들웨어
* POST, PUT, DELETE, PATCH 요청에 적용
*
* @param {Object} options - 옵션
* @param {string[]} options.ignoreMethods - 검증 제외 메서드 (기본: GET, HEAD, OPTIONS)
* @param {string[]} options.ignorePaths - 검증 제외 경로 (정규식 패턴 가능)
* @returns {Function} Express 미들웨어
*/
const verifyCsrfToken = (options = {}) => {
const {
ignoreMethods = ['GET', 'HEAD', 'OPTIONS'],
ignorePaths = ['/api/auth/login', '/api/auth/register', '/api/health']
} = options;
return (req, res, next) => {
// 제외 메서드 체크
if (ignoreMethods.includes(req.method)) {
return next();
}
// 제외 경로 체크
for (const pattern of ignorePaths) {
if (typeof pattern === 'string' && req.path === pattern) {
return next();
}
if (pattern instanceof RegExp && pattern.test(req.path)) {
return next();
}
}
// 토큰 추출 (헤더 또는 body에서)
const token = req.headers['x-csrf-token'] ||
req.headers['csrf-token'] ||
req.body?._csrf ||
req.query?._csrf;
// 세션 ID
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
// 토큰 검증
if (!validateToken(token, sessionId)) {
logger.warn('CSRF 토큰 검증 실패', {
path: req.path,
method: req.method,
ip: req.ip,
hasToken: !!token
});
return res.status(403).json({
success: false,
error: 'CSRF 토큰이 유효하지 않습니다. 페이지를 새로고침 후 다시 시도해주세요.',
code: 'CSRF_TOKEN_INVALID'
});
}
// 사용된 토큰 제거 (일회성 사용)
tokenStore.delete(token);
// 새 토큰 발급
const newToken = generateToken(sessionId);
res.setHeader('X-CSRF-Token', newToken);
req.csrfToken = newToken;
next();
};
};
/**
* CSRF 토큰 발급 엔드포인트 핸들러
* GET /api/csrf-token
*/
const getCsrfToken = (req, res) => {
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
const token = generateToken(sessionId);
res.json({
success: true,
csrfToken: token,
expiresIn: TOKEN_EXPIRY / 1000 // 초 단위
});
};
module.exports = {
generateToken,
validateToken,
setCsrfToken,
verifyCsrfToken,
getCsrfToken
};

View File

@@ -182,6 +182,10 @@ class WorkAnalysis {
// 최근 작업 현황
async getRecentWork(startDate, endDate, limit = 50) {
// work_type_id 컬럼에는 task_id가 저장됨 (tasks 테이블 우선 조회)
// task_id로 매칭되면 해당 task의 work_type_id로 공정(대분류) 조회
// 매칭 안 되면 직접 work_types 테이블 조회 (레거시 데이터 호환)
// error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
const query = `
SELECT
dwr.id,
@@ -191,12 +195,21 @@ class WorkAnalysis {
dwr.project_id,
p.project_name,
p.job_no,
dwr.work_type_id,
wt.name as work_type_name,
dwr.work_type_id as original_work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
) as work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name
) as work_type_name,
t.task_name as task_name,
dwr.work_status_id,
wst.name as work_status_name,
dwr.error_type_id,
et.name as error_type_name,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
dwr.work_hours,
dwr.created_by,
u.name as created_by_name,
@@ -205,8 +218,11 @@ class WorkAnalysis {
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id
WHERE dwr.report_date BETWEEN ? AND ?
ORDER BY dwr.created_at DESC
@@ -224,11 +240,13 @@ class WorkAnalysis {
project_name: row.project_name || `프로젝트 ${row.project_id}`,
job_no: row.job_no || 'N/A',
work_type_id: row.work_type_id,
work_type_name: row.work_type_name || `작업유형 ${row.work_type_id}`,
work_type_name: row.work_type_name || `작업유형 ${row.original_work_type_id}`,
task_name: row.task_name || null,
work_status_id: row.work_status_id,
work_status_name: row.work_status_name || '정상',
error_type_id: row.error_type_id,
error_type_name: row.error_type_name || null,
error_category_name: row.error_category_name || null,
work_hours: parseFloat(row.work_hours) || 0,
created_by: row.created_by,
created_by_name: row.created_by_name || '미지정',
@@ -279,20 +297,23 @@ class WorkAnalysis {
}
// 에러 분석
// error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
async getErrorAnalysis(startDate, endDate) {
const query = `
SELECT
dwr.error_type_id,
et.name as error_type_name,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
COUNT(*) as error_count,
SUM(dwr.work_hours) as total_hours,
COUNT(DISTINCT dwr.worker_id) as affected_workers,
COUNT(DISTINCT dwr.project_id) as affected_projects
FROM daily_work_reports dwr
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE dwr.report_date BETWEEN ? AND ?
AND dwr.work_status_id = 2
GROUP BY dwr.error_type_id, et.name
GROUP BY dwr.error_type_id, iri.item_name, irc.category_name
ORDER BY error_count DESC
`;
@@ -301,6 +322,7 @@ class WorkAnalysis {
return results.map(row => ({
error_type_id: row.error_type_id,
error_type_name: row.error_type_name || `에러유형 ${row.error_type_id}`,
error_category_name: row.error_category_name || null,
errorCount: parseInt(row.error_count) || 0,
totalHours: parseFloat(row.total_hours) || 0,
affectedworkers: parseInt(row.affected_workers) || 0,
@@ -427,15 +449,25 @@ class WorkAnalysis {
throw new Error(`대시보드 데이터 조회 실패: ${error.message}`);
}
}
// 프로젝트별-작업별 시간 분석용 데이터 조회
// 프로젝트별-작업별 시간 분석용 데이터 조회 (공정/대분류 기준)
async getProjectWorkTypeRawData(startDate, endDate) {
// work_type_id 컬럼에는 task_id가 저장됨 (tasks 테이블 우선 조회)
// task_id로 매칭되면 해당 task의 work_type_id로 공정 조회
// 매칭 안 되면 직접 work_types 조회 (레거시 데이터 호환)
const query = `
SELECT
COALESCE(p.project_id, dwr.project_id) as project_id,
COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name,
COALESCE(p.job_no, 'N/A') as job_no,
dwr.work_type_id,
COALESCE(wt.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
) as work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name,
CONCAT('작업유형 ', dwr.work_type_id)
) as work_type_name,
-- 총 시간
SUM(dwr.work_hours) as total_hours,
@@ -460,9 +492,19 @@ class WorkAnalysis {
FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.project_id, p.project_name, p.job_no, dwr.work_type_id, wt.name
ORDER BY p.project_name, wt.name
GROUP BY dwr.project_id, p.project_name, p.job_no,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
),
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name
)
ORDER BY p.project_name, work_type_name
`;
try {

View File

@@ -13,7 +13,7 @@ class AttendanceModel {
wat.type_code as attendance_type_code,
vt.type_name as vacation_type_name,
vt.type_code as vacation_type_code,
vt.hours_deduction as vacation_hours
vt.deduct_days as vacation_days
FROM daily_attendance_records dar
LEFT JOIN workers w ON dar.worker_id = w.worker_id
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
@@ -201,13 +201,15 @@ class AttendanceModel {
const {
record_date,
worker_id,
total_work_hours,
work_attendance_type_id,
vacation_type_id,
is_overtime_approved,
created_by
total_work_hours = 8,
work_attendance_type_id = 1,
vacation_type_id = null,
is_overtime_approved = false,
created_by = 1
} = recordData;
const attendance_type_id = work_attendance_type_id;
// 기존 기록 확인
const [existing] = await db.execute(
'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?',
@@ -220,14 +222,14 @@ class AttendanceModel {
UPDATE daily_attendance_records
SET
total_work_hours = ?,
work_attendance_type_id = ?,
attendance_type_id = ?,
vacation_type_id = ?,
is_overtime_approved = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [
total_work_hours,
work_attendance_type_id,
attendance_type_id,
vacation_type_id,
is_overtime_approved,
existing[0].id
@@ -238,14 +240,14 @@ class AttendanceModel {
// 생성
const [result] = await db.execute(`
INSERT INTO daily_attendance_records (
record_date, worker_id, total_work_hours, work_attendance_type_id,
record_date, worker_id, total_work_hours, attendance_type_id,
vacation_type_id, is_overtime_approved, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
record_date,
worker_id,
total_work_hours,
work_attendance_type_id,
attendance_type_id,
vacation_type_id,
is_overtime_approved,
created_by
@@ -297,7 +299,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]
);
@@ -315,7 +317,8 @@ class AttendanceModel {
`, [workerId, date]);
const currentHours = parseFloat(workHours[0].total_hours);
const vacationHours = parseFloat(vacationTypeInfo.hours_deduction);
// deduct_days를 시간으로 변환 (1일 = 8시간)
const vacationHours = parseFloat(vacationTypeInfo.deduct_days) * 8;
const totalHours = currentHours + vacationHours;
// 근로 유형 결정
@@ -391,7 +394,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;
}
@@ -429,21 +432,23 @@ class AttendanceModel {
static async getMonthlyAttendanceStats(year, month, workerId = null) {
const db = await getDb();
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
// vacation_types: 1=ANNUAL(연차), 2=HALF_ANNUAL(반차), 3=SICK(병가), 4=SPECIAL(경조사)
let query = `
SELECT
w.worker_id,
w.worker_name,
COUNT(CASE WHEN dar.status = 'complete' THEN 1 END) as regular_days,
COUNT(CASE WHEN dar.status = 'overtime' THEN 1 END) as overtime_days,
COUNT(CASE WHEN dar.status = 'vacation' THEN 1 END) as vacation_days,
COUNT(CASE WHEN dar.status = 'partial' THEN 1 END) as partial_days,
COUNT(CASE WHEN dar.status = 'incomplete' THEN 1 END) as incomplete_days,
SUM(dar.total_work_hours) as total_work_hours,
AVG(dar.total_work_hours) as avg_work_hours
COUNT(CASE WHEN dar.attendance_type_id = 1 AND (dar.is_overtime_approved = 0 OR dar.is_overtime_approved IS NULL) THEN 1 END) as regular_days,
COUNT(CASE WHEN dar.is_overtime_approved = 1 OR dar.total_work_hours > 8 THEN 1 END) as overtime_days,
COUNT(CASE WHEN dar.attendance_type_id = 5 AND dar.vacation_type_id = 1 THEN 1 END) as vacation_days,
COUNT(CASE WHEN dar.vacation_type_id = 2 THEN 1 END) as partial_days,
COUNT(CASE WHEN dar.attendance_type_id = 4 THEN 1 END) as incomplete_days,
COALESCE(SUM(dar.total_work_hours), 0) as total_work_hours,
COALESCE(AVG(dar.total_work_hours), 0) as avg_work_hours
FROM workers w
LEFT JOIN daily_attendance_records dar ON w.worker_id = dar.worker_id
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
WHERE w.is_active = TRUE
WHERE w.employment_status = 'employed'
`;
const params = [year, month];
@@ -458,6 +463,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;

View File

@@ -29,7 +29,21 @@ const getAllWorkStatusTypes = async (callback) => {
const getAllErrorTypes = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query('SELECT id, name, description, severity, solution_guide, created_at, updated_at FROM error_types ORDER BY name ASC');
// issue_report_items에서 부적합(nonconformity) 타입의 항목만 조회
const [rows] = await db.query(`
SELECT
iri.item_id as id,
iri.item_name as name,
iri.description,
iri.severity,
irc.category_name as category,
iri.display_order,
iri.created_at
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_type = 'nonconformity' AND iri.is_active = TRUE
ORDER BY irc.display_order, iri.display_order, iri.item_name ASC
`);
callback(null, rows);
} catch (err) {
console.error('에러 유형 조회 오류:', err);
@@ -301,6 +315,7 @@ const removeSpecificEntry = async (entry_id, deleted_by, callback) => {
/**
* 공통 SELECT 쿼리 부분
* error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
*/
const getSelectQuery = () => `
SELECT
@@ -317,7 +332,8 @@ const getSelectQuery = () => `
p.project_name,
wt.name as work_type_name,
wst.name as work_status_name,
et.name as error_type_name,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
u.name as created_by_name,
dwr.created_at,
dwr.updated_at
@@ -326,7 +342,8 @@ const getSelectQuery = () => `
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id
`;
@@ -873,6 +890,8 @@ const createReportEntries = async ({ report_date, worker_id, entries }) => {
/**
* [V2] 공통 SELECT 쿼리 (새로운 스키마 기준)
* 주의: work_type_id 컬럼에는 실제로 task_id가 저장됨
* error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
*/
const getSelectQueryV2 = () => `
SELECT
@@ -887,17 +906,21 @@ const getSelectQueryV2 = () => `
dwr.created_by,
w.worker_name,
p.project_name,
t.task_name,
wt.name as work_type_name,
wst.name as work_status_name,
et.name as error_type_name,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
u.name as created_by_name,
dwr.created_at
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id
`;
@@ -967,9 +990,9 @@ const updateReportById = async (reportId, updateData) => {
}
}
// updated_by_user_id는 항상 업데이트
// updated_by는 항상 업데이트
if (updateData.updated_by_user_id) {
setClauses.push('updated_by_user_id = ?');
setClauses.push('updated_by = ?');
queryParams.push(updateData.updated_by_user_id);
}
@@ -1209,6 +1232,113 @@ const deleteErrorType = async (id, callback) => {
};
/**
* TBM 기반 작업보고서 생성 및 TBM 세션 완료 처리
* @param {object} reportData - TBM 작업보고서 데이터
* @returns {Promise<object>} 생성 결과
*/
const createFromTbmAssignment = async (reportData) => {
const {
tbm_assignment_id,
tbm_session_id,
worker_id,
project_id,
work_type_id,
report_date,
start_time,
end_time,
total_hours,
error_hours,
regular_hours,
work_status_id,
error_type_id,
created_by
} = reportData;
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// 1. 작업보고서 생성
const sql = `
INSERT INTO daily_work_reports
(tbm_session_id, tbm_assignment_id, report_date, worker_id, project_id, work_type_id,
start_time, end_time, work_hours, total_hours, regular_hours, error_hours,
work_status_id, error_type_id, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
`;
const [result] = await conn.query(sql, [
tbm_session_id,
tbm_assignment_id,
report_date,
worker_id,
project_id,
work_type_id,
start_time || null,
end_time || null,
total_hours, // work_hours는 TBM에서 total_hours와 동일
total_hours,
regular_hours,
error_hours || 0,
work_status_id || 1,
error_type_id || null,
created_by
]);
const reportId = result.insertId;
// 2. TBM 세션의 모든 팀 배정이 작업보고서를 제출했는지 확인
const [assignmentCheck] = await conn.query(`
SELECT
COUNT(*) as total_assignments,
COUNT(dwr.tbm_assignment_id) as completed_assignments
FROM tbm_team_assignments ta
LEFT JOIN daily_work_reports dwr ON ta.assignment_id = dwr.tbm_assignment_id
WHERE ta.session_id = ?
`, [tbm_session_id]);
const { total_assignments, completed_assignments } = assignmentCheck[0];
// 3. 모든 팀원이 작업보고서를 제출했으면 TBM 세션을 완료로 표시
if (total_assignments === completed_assignments) {
await conn.query(`
UPDATE tbm_sessions
SET status = 'completed', updated_at = NOW()
WHERE session_id = ?
`, [tbm_session_id]);
}
await conn.commit();
// 4. 근태 기록 동기화
try {
const AttendanceModel = require('./attendanceModel');
await AttendanceModel.syncWithWorkReports(worker_id, report_date);
} catch (syncErr) {
console.error('근태 기록 동기화 실패 (TBM Report):', syncErr);
}
console.log(`[Model] TBM 작업보고서 생성 완료: report_id=${reportId}, session=${tbm_session_id}, assignment=${tbm_assignment_id}`);
return {
success: true,
report_id: reportId,
tbm_completed: total_assignments === completed_assignments,
completion_status: `${completed_assignments}/${total_assignments} 작업 완료`
};
} catch (err) {
await conn.rollback();
console.error('[Model] TBM 작업보고서 생성 중 오류 발생:', err);
throw new Error('TBM 작업보고서 생성 중 오류가 발생했습니다.');
} finally {
conn.release();
}
};
// 모든 함수 내보내기 (Promise 기반 함수 위주로 재구성)
module.exports = {
// 새로 추가된 V2 함수 (Promise 기반)
@@ -1216,6 +1346,7 @@ module.exports = {
getReportsWithOptions,
updateReportById,
removeReportById,
createFromTbmAssignment,
// Promise 기반으로 리팩토링된 함수
getStatistics,

View File

@@ -0,0 +1,120 @@
// models/departmentModel.js
const { getDb } = require('../dbPool');
const departmentModel = {
// 모든 부서 조회 (계층 구조 포함)
async getAll() {
const db = await getDb();
const [rows] = await db.query(`
SELECT d.*,
p.department_name as parent_name,
(SELECT COUNT(*) FROM workers w WHERE w.department_id = d.department_id AND w.status = 'active') as worker_count
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
ORDER BY d.display_order, d.department_name
`);
return rows;
},
// 활성 부서만 조회
async getActive() {
const db = await getDb();
const [rows] = await db.query(`
SELECT d.*,
p.department_name as parent_name,
(SELECT COUNT(*) FROM workers w WHERE w.department_id = d.department_id AND w.status = 'active') as worker_count
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
WHERE d.is_active = TRUE
ORDER BY d.display_order, d.department_name
`);
return rows;
},
// 부서 ID로 조회
async getById(departmentId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT d.*,
p.department_name as parent_name
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
WHERE d.department_id = ?
`, [departmentId]);
return rows[0];
},
// 부서 생성
async create(data) {
const db = await getDb();
const { department_name, parent_id, description, is_active, display_order } = data;
const [result] = await db.query(`
INSERT INTO departments (department_name, parent_id, description, is_active, display_order)
VALUES (?, ?, ?, ?, ?)
`, [department_name, parent_id || null, description || null, is_active !== false, display_order || 0]);
return result.insertId;
},
// 부서 수정
async update(departmentId, data) {
const db = await getDb();
const { department_name, parent_id, description, is_active, display_order } = data;
const [result] = await db.query(`
UPDATE departments
SET department_name = ?, parent_id = ?, description = ?, is_active = ?, display_order = ?
WHERE department_id = ?
`, [department_name, parent_id || null, description || null, is_active, display_order || 0, departmentId]);
return result.affectedRows > 0;
},
// 부서 삭제
async delete(departmentId) {
const db = await getDb();
// 하위 부서가 있는지 확인
const [children] = await db.query('SELECT COUNT(*) as count FROM departments WHERE parent_id = ?', [departmentId]);
if (children[0].count > 0) {
throw new Error('하위 부서가 있어 삭제할 수 없습니다.');
}
// 소속 작업자가 있는지 확인
const [workers] = await db.query('SELECT COUNT(*) as count FROM workers WHERE department_id = ?', [departmentId]);
if (workers[0].count > 0) {
throw new Error('소속 작업자가 있어 삭제할 수 없습니다. 먼저 작업자를 다른 부서로 이동하세요.');
}
const [result] = await db.query('DELETE FROM departments WHERE department_id = ?', [departmentId]);
return result.affectedRows > 0;
},
// 부서별 작업자 조회
async getWorkersByDepartment(departmentId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT w.*, d.department_name, u.user_id, u.username
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
LEFT JOIN users u ON u.worker_id = w.worker_id
WHERE w.department_id = ?
ORDER BY w.worker_name
`, [departmentId]);
return rows;
},
// 작업자 부서 변경
async moveWorker(workerId, departmentId) {
const db = await getDb();
const [result] = await db.query(`
UPDATE workers SET department_id = ? WHERE worker_id = ?
`, [departmentId, workerId]);
return result.affectedRows > 0;
},
// 여러 작업자 부서 일괄 변경
async moveWorkers(workerIds, departmentId) {
const db = await getDb();
const [result] = await db.query(`
UPDATE workers SET department_id = ? WHERE worker_id IN (?)
`, [departmentId, workerIds]);
return result.affectedRows;
}
};
module.exports = departmentModel;

View File

@@ -0,0 +1,949 @@
// models/equipmentModel.js
const { getDb } = require('../dbPool');
const notificationModel = require('./notificationModel');
const EquipmentModel = {
// CREATE - 설비 생성
create: async (equipmentData, callback) => {
try {
const db = await getDb();
const query = `
INSERT INTO equipments (
equipment_code, equipment_name, equipment_type, model_name,
manufacturer, supplier, purchase_price, installation_date, serial_number, specifications,
status, notes, workplace_id, map_x_percent, map_y_percent,
map_width_percent, map_height_percent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [
equipmentData.equipment_code,
equipmentData.equipment_name,
equipmentData.equipment_type || null,
equipmentData.model_name || null,
equipmentData.manufacturer || null,
equipmentData.supplier || null,
equipmentData.purchase_price || null,
equipmentData.installation_date || null,
equipmentData.serial_number || null,
equipmentData.specifications || null,
equipmentData.status || 'active',
equipmentData.notes || null,
equipmentData.workplace_id || null,
equipmentData.map_x_percent || null,
equipmentData.map_y_percent || null,
equipmentData.map_width_percent || null,
equipmentData.map_height_percent || null
];
const [result] = await db.query(query, values);
callback(null, {
equipment_id: result.insertId,
...equipmentData
});
} catch (error) {
callback(error);
}
},
// READ ALL - 모든 설비 조회 (필터링 옵션 포함)
getAll: async (filters, callback) => {
try {
const db = await getDb();
let query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE 1=1
`;
const values = [];
// 필터링: 작업장 ID
if (filters.workplace_id) {
query += ' AND e.workplace_id = ?';
values.push(filters.workplace_id);
}
// 필터링: 설비 유형
if (filters.equipment_type) {
query += ' AND e.equipment_type = ?';
values.push(filters.equipment_type);
}
// 필터링: 상태
if (filters.status) {
query += ' AND e.status = ?';
values.push(filters.status);
}
// 필터링: 검색어 (설비명, 설비코드)
if (filters.search) {
query += ' AND (e.equipment_name LIKE ? OR e.equipment_code LIKE ?)';
const searchTerm = `%${filters.search}%`;
values.push(searchTerm, searchTerm);
}
query += ' ORDER BY e.equipment_code ASC';
const [rows] = await db.query(query, values);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// READ ONE - 특정 설비 조회
getById: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE e.equipment_id = ?
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows[0]);
} catch (error) {
callback(error);
}
},
// READ BY WORKPLACE - 특정 작업장의 설비 조회
getByWorkplace: async (workplaceId, callback) => {
try {
const db = await getDb();
const query = `
SELECT e.*
FROM equipments e
WHERE e.workplace_id = ?
ORDER BY e.equipment_code ASC
`;
const [rows] = await db.query(query, [workplaceId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// READ ACTIVE - 활성 설비만 조회
getActive: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE e.status = 'active'
ORDER BY e.equipment_code ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// UPDATE - 설비 수정
update: async (equipmentId, equipmentData, callback) => {
try {
const db = await getDb();
const query = `
UPDATE equipments SET
equipment_code = ?,
equipment_name = ?,
equipment_type = ?,
model_name = ?,
manufacturer = ?,
supplier = ?,
purchase_price = ?,
installation_date = ?,
serial_number = ?,
specifications = ?,
status = ?,
notes = ?,
workplace_id = ?,
map_x_percent = ?,
map_y_percent = ?,
map_width_percent = ?,
map_height_percent = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const values = [
equipmentData.equipment_code,
equipmentData.equipment_name,
equipmentData.equipment_type || null,
equipmentData.model_name || null,
equipmentData.manufacturer || null,
equipmentData.supplier || null,
equipmentData.purchase_price || null,
equipmentData.installation_date || null,
equipmentData.serial_number || null,
equipmentData.specifications || null,
equipmentData.status || 'active',
equipmentData.notes || null,
equipmentData.workplace_id || null,
equipmentData.map_x_percent || null,
equipmentData.map_y_percent || null,
equipmentData.map_width_percent || null,
equipmentData.map_height_percent || null,
equipmentId
];
const [result] = await db.query(query, values);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId, ...equipmentData });
} catch (error) {
callback(error);
}
},
// UPDATE MAP POSITION - 지도상 위치 업데이트 (선택적으로 workplace_id도 업데이트)
updateMapPosition: async (equipmentId, positionData, callback) => {
try {
const db = await getDb();
// workplace_id가 포함된 경우 함께 업데이트
const hasWorkplaceId = positionData.workplace_id !== undefined;
const query = hasWorkplaceId ? `
UPDATE equipments SET
workplace_id = ?,
map_x_percent = ?,
map_y_percent = ?,
map_width_percent = ?,
map_height_percent = ?,
updated_at = NOW()
WHERE equipment_id = ?
` : `
UPDATE equipments SET
map_x_percent = ?,
map_y_percent = ?,
map_width_percent = ?,
map_height_percent = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const values = hasWorkplaceId ? [
positionData.workplace_id,
positionData.map_x_percent,
positionData.map_y_percent,
positionData.map_width_percent,
positionData.map_height_percent,
equipmentId
] : [
positionData.map_x_percent,
positionData.map_y_percent,
positionData.map_width_percent,
positionData.map_height_percent,
equipmentId
];
const [result] = await db.query(query, values);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId, ...positionData });
} catch (error) {
callback(error);
}
},
// DELETE - 설비 삭제
delete: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = 'DELETE FROM equipments WHERE equipment_id = ?';
const [result] = await db.query(query, [equipmentId]);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId });
} catch (error) {
callback(error);
}
},
// CHECK DUPLICATE CODE - 설비 코드 중복 확인
checkDuplicateCode: async (equipmentCode, excludeEquipmentId, callback) => {
try {
const db = await getDb();
let query = 'SELECT equipment_id FROM equipments WHERE equipment_code = ?';
const values = [equipmentCode];
if (excludeEquipmentId) {
query += ' AND equipment_id != ?';
values.push(excludeEquipmentId);
}
const [rows] = await db.query(query, values);
callback(null, rows.length > 0);
} catch (error) {
callback(error);
}
},
// GET EQUIPMENT TYPES - 사용 중인 설비 유형 목록 조회
getEquipmentTypes: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT DISTINCT equipment_type
FROM equipments
WHERE equipment_type IS NOT NULL
ORDER BY equipment_type ASC
`;
const [rows] = await db.query(query);
callback(null, rows.map(row => row.equipment_type));
} catch (error) {
callback(error);
}
},
// GET NEXT EQUIPMENT CODE - 다음 관리번호 자동 생성 (TKP-001 형식)
getNextEquipmentCode: async (prefix = 'TKP', callback) => {
try {
const db = await getDb();
// 해당 접두사로 시작하는 가장 큰 번호 찾기
const query = `
SELECT equipment_code
FROM equipments
WHERE equipment_code LIKE ?
ORDER BY equipment_code DESC
LIMIT 1
`;
const [rows] = await db.query(query, [`${prefix}-%`]);
let nextNumber = 1;
if (rows.length > 0) {
// TKP-001 형식에서 숫자 부분 추출
const lastCode = rows[0].equipment_code;
const match = lastCode.match(new RegExp(`^${prefix}-(\\d+)$`));
if (match) {
nextNumber = parseInt(match[1], 10) + 1;
}
}
// 3자리로 패딩 (001, 002, ...)
const nextCode = `${prefix}-${String(nextNumber).padStart(3, '0')}`;
callback(null, nextCode);
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 사진 관리
// ==========================================
// ADD PHOTO - 설비 사진 추가
addPhoto: async (equipmentId, photoData, callback) => {
try {
const db = await getDb();
const query = `
INSERT INTO equipment_photos (
equipment_id, photo_path, description, display_order, uploaded_by
) VALUES (?, ?, ?, ?, ?)
`;
const values = [
equipmentId,
photoData.photo_path,
photoData.description || null,
photoData.display_order || 0,
photoData.uploaded_by || null
];
const [result] = await db.query(query, values);
callback(null, {
photo_id: result.insertId,
equipment_id: equipmentId,
...photoData
});
} catch (error) {
callback(error);
}
},
// GET PHOTOS - 설비 사진 조회
getPhotos: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT ep.*, u.name AS uploaded_by_name
FROM equipment_photos ep
LEFT JOIN users u ON ep.uploaded_by = u.user_id
WHERE ep.equipment_id = ?
ORDER BY ep.display_order ASC, ep.created_at ASC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// DELETE PHOTO - 설비 사진 삭제
deletePhoto: async (photoId, callback) => {
try {
const db = await getDb();
// 먼저 사진 정보 조회 (파일 삭제용)
const [photo] = await db.query(
'SELECT photo_path FROM equipment_photos WHERE photo_id = ?',
[photoId]
);
const query = 'DELETE FROM equipment_photos WHERE photo_id = ?';
const [result] = await db.query(query, [photoId]);
if (result.affectedRows === 0) {
return callback(new Error('Photo not found'));
}
callback(null, { photo_id: photoId, photo_path: photo[0]?.photo_path });
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 임시 이동
// ==========================================
// MOVE TEMPORARILY - 설비 임시 이동
moveTemporarily: async (equipmentId, moveData, callback) => {
try {
const db = await getDb();
// 1. 설비 현재 위치 업데이트
const updateQuery = `
UPDATE equipments SET
current_workplace_id = ?,
current_map_x_percent = ?,
current_map_y_percent = ?,
current_map_width_percent = ?,
current_map_height_percent = ?,
is_temporarily_moved = TRUE,
moved_at = NOW(),
moved_by = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const updateValues = [
moveData.target_workplace_id,
moveData.target_x_percent,
moveData.target_y_percent,
moveData.target_width_percent || null,
moveData.target_height_percent || null,
moveData.moved_by || null,
equipmentId
];
const [result] = await db.query(updateQuery, updateValues);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
// 2. 이동 이력 기록
const logQuery = `
INSERT INTO equipment_move_logs (
equipment_id, move_type,
from_workplace_id, to_workplace_id,
from_x_percent, from_y_percent,
to_x_percent, to_y_percent,
reason, moved_by
) VALUES (?, 'temporary', ?, ?, ?, ?, ?, ?, ?, ?)
`;
await db.query(logQuery, [
equipmentId,
moveData.from_workplace_id || null,
moveData.target_workplace_id,
moveData.from_x_percent || null,
moveData.from_y_percent || null,
moveData.target_x_percent,
moveData.target_y_percent,
moveData.reason || null,
moveData.moved_by || null
]);
callback(null, { equipment_id: equipmentId, moved: true });
} catch (error) {
callback(error);
}
},
// RETURN TO ORIGINAL - 설비 원위치 복귀
returnToOriginal: async (equipmentId, userId, callback) => {
try {
const db = await getDb();
// 1. 현재 임시 위치 정보 조회
const [equipment] = await db.query(
'SELECT current_workplace_id, current_map_x_percent, current_map_y_percent FROM equipments WHERE equipment_id = ?',
[equipmentId]
);
if (!equipment[0]) {
return callback(new Error('Equipment not found'));
}
// 2. 임시 위치 필드 초기화
const updateQuery = `
UPDATE equipments SET
current_workplace_id = NULL,
current_map_x_percent = NULL,
current_map_y_percent = NULL,
current_map_width_percent = NULL,
current_map_height_percent = NULL,
is_temporarily_moved = FALSE,
moved_at = NULL,
moved_by = NULL,
updated_at = NOW()
WHERE equipment_id = ?
`;
await db.query(updateQuery, [equipmentId]);
// 3. 복귀 이력 기록
const logQuery = `
INSERT INTO equipment_move_logs (
equipment_id, move_type,
from_workplace_id, from_x_percent, from_y_percent,
reason, moved_by
) VALUES (?, 'return', ?, ?, ?, '원위치 복귀', ?)
`;
await db.query(logQuery, [
equipmentId,
equipment[0].current_workplace_id,
equipment[0].current_map_x_percent,
equipment[0].current_map_y_percent,
userId || null
]);
callback(null, { equipment_id: equipmentId, returned: true });
} catch (error) {
callback(error);
}
},
// GET TEMPORARILY MOVED - 임시 이동된 설비 목록
getTemporarilyMoved: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w_orig.workplace_name AS original_workplace_name,
w_curr.workplace_name AS current_workplace_name,
u.name AS moved_by_name
FROM equipments e
LEFT JOIN workplaces w_orig ON e.workplace_id = w_orig.workplace_id
LEFT JOIN workplaces w_curr ON e.current_workplace_id = w_curr.workplace_id
LEFT JOIN users u ON e.moved_by = u.user_id
WHERE e.is_temporarily_moved = TRUE
ORDER BY e.moved_at DESC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET MOVE LOGS - 설비 이동 이력 조회
getMoveLogs: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
eml.*,
w_from.workplace_name AS from_workplace_name,
w_to.workplace_name AS to_workplace_name,
u.name AS moved_by_name
FROM equipment_move_logs eml
LEFT JOIN workplaces w_from ON eml.from_workplace_id = w_from.workplace_id
LEFT JOIN workplaces w_to ON eml.to_workplace_id = w_to.workplace_id
LEFT JOIN users u ON eml.moved_by = u.user_id
WHERE eml.equipment_id = ?
ORDER BY eml.moved_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 외부 반출/반입
// ==========================================
// EXPORT EQUIPMENT - 설비 외부 반출
exportEquipment: async (exportData, callback) => {
try {
const db = await getDb();
// 1. 반출 로그 생성
const logQuery = `
INSERT INTO equipment_external_logs (
equipment_id, log_type, export_date, expected_return_date,
destination, reason, notes, exported_by
) VALUES (?, 'export', ?, ?, ?, ?, ?, ?)
`;
const logValues = [
exportData.equipment_id,
exportData.export_date || new Date().toISOString().slice(0, 10),
exportData.expected_return_date || null,
exportData.destination || null,
exportData.reason || null,
exportData.notes || null,
exportData.exported_by || null
];
const [logResult] = await db.query(logQuery, logValues);
// 2. 설비 상태 업데이트
const status = exportData.is_repair ? 'repair_external' : 'external';
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
[status, exportData.equipment_id]
);
callback(null, {
log_id: logResult.insertId,
equipment_id: exportData.equipment_id,
exported: true
});
} catch (error) {
callback(error);
}
},
// RETURN EQUIPMENT - 설비 반입 (외부에서 복귀)
returnEquipment: async (logId, returnData, callback) => {
try {
const db = await getDb();
// 1. 반출 로그 조회
const [logs] = await db.query(
'SELECT equipment_id FROM equipment_external_logs WHERE log_id = ?',
[logId]
);
if (!logs[0]) {
return callback(new Error('Export log not found'));
}
const equipmentId = logs[0].equipment_id;
// 2. 반출 로그 업데이트
await db.query(
`UPDATE equipment_external_logs SET
actual_return_date = ?,
returned_by = ?,
notes = CONCAT(IFNULL(notes, ''), '\n반입: ', IFNULL(?, '')),
updated_at = NOW()
WHERE log_id = ?`,
[
returnData.return_date || new Date().toISOString().slice(0, 10),
returnData.returned_by || null,
returnData.notes || '',
logId
]
);
// 3. 설비 상태 복원
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
[returnData.new_status || 'active', equipmentId]
);
callback(null, {
log_id: logId,
equipment_id: equipmentId,
returned: true
});
} catch (error) {
callback(error);
}
},
// GET EXTERNAL LOGS - 설비 외부 반출 이력 조회
getExternalLogs: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
eel.*,
u_exp.name AS exported_by_name,
u_ret.name AS returned_by_name
FROM equipment_external_logs eel
LEFT JOIN users u_exp ON eel.exported_by = u_exp.user_id
LEFT JOIN users u_ret ON eel.returned_by = u_ret.user_id
WHERE eel.equipment_id = ?
ORDER BY eel.created_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET EXPORTED EQUIPMENTS - 현재 외부 반출 중인 설비 목록
getExportedEquipments: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
eel.export_date,
eel.expected_return_date,
eel.destination,
eel.reason,
u.name AS exported_by_name
FROM equipments e
INNER JOIN (
SELECT equipment_id, MAX(log_id) AS latest_log_id
FROM equipment_external_logs
WHERE actual_return_date IS NULL
GROUP BY equipment_id
) latest ON e.equipment_id = latest.equipment_id
INNER JOIN equipment_external_logs eel ON eel.log_id = latest.latest_log_id
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN users u ON eel.exported_by = u.user_id
WHERE e.status IN ('external', 'repair_external')
ORDER BY eel.export_date DESC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 수리 신청 (work_issue_reports 연동)
// ==========================================
// CREATE REPAIR REQUEST - 수리 신청 (신고 시스템 활용)
createRepairRequest: async (requestData, callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리 ID 조회
const [categories] = await db.query(
"SELECT category_id FROM issue_report_categories WHERE category_name = '설비 수리' LIMIT 1"
);
if (!categories[0]) {
return callback(new Error('설비 수리 카테고리가 없습니다'));
}
const categoryId = categories[0].category_id;
// 항목 ID 조회 (지정된 항목이 없으면 첫번째 항목 사용)
let itemId = requestData.item_id;
if (!itemId) {
const [items] = await db.query(
'SELECT item_id FROM issue_report_items WHERE category_id = ? LIMIT 1',
[categoryId]
);
itemId = items[0]?.item_id;
}
// 사진 경로 분리 (최대 5장)
const photos = requestData.photo_paths || [];
// work_issue_reports에 삽입
const query = `
INSERT INTO work_issue_reports (
reporter_id, issue_category_id, issue_item_id,
workplace_id, equipment_id,
additional_description,
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'reported')
`;
const values = [
requestData.reported_by || null,
categoryId,
itemId,
requestData.workplace_id || null,
requestData.equipment_id,
requestData.description || null,
photos[0] || null,
photos[1] || null,
photos[2] || null,
photos[3] || null,
photos[4] || null
];
const [result] = await db.query(query, values);
// 설비 상태를 repair_needed로 업데이트
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
['repair_needed', requestData.equipment_id]
);
// 알림 생성
try {
await notificationModel.createRepairNotification({
equipment_id: requestData.equipment_id,
equipment_name: requestData.equipment_name || '설비',
repair_type: requestData.repair_type || '일반 수리',
request_id: result.insertId,
created_by: requestData.reported_by
});
} catch (notifError) {
console.error('알림 생성 실패:', notifError);
// 알림 생성 실패해도 수리 신청은 성공으로 처리
}
callback(null, {
report_id: result.insertId,
equipment_id: requestData.equipment_id,
created: true
});
} catch (error) {
callback(error);
}
},
// GET REPAIR HISTORY - 설비 수리 이력 조회
getRepairHistory: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
wir.*,
irc.category_name,
iri.item_name,
u_rep.name AS reported_by_name,
u_res.name AS resolved_by_name,
w.workplace_name
FROM work_issue_reports wir
LEFT JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users u_rep ON wir.reporter_id = u_rep.user_id
LEFT JOIN users u_res ON wir.resolved_by = u_res.user_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE wir.equipment_id = ?
ORDER BY wir.created_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET REPAIR CATEGORIES - 설비 수리 항목 목록 조회
getRepairCategories: async (callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리의 항목들 조회
const query = `
SELECT iri.item_id, iri.item_name, iri.description, iri.severity
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_name = '설비 수리' AND iri.is_active = 1
ORDER BY iri.display_order ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// ADD REPAIR CATEGORY - 새 수리 항목 추가
addRepairCategory: async (itemName, callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리 ID 조회
const [categories] = await db.query(
"SELECT category_id FROM issue_report_categories WHERE category_name = '설비 수리'"
);
if (categories.length === 0) {
return callback(new Error('설비 수리 카테고리가 없습니다.'));
}
const categoryId = categories[0].category_id;
// 중복 확인
const [existing] = await db.query(
'SELECT item_id FROM issue_report_items WHERE category_id = ? AND item_name = ?',
[categoryId, itemName]
);
if (existing.length > 0) {
// 이미 존재하면 해당 ID 반환
return callback(null, { item_id: existing[0].item_id, item_name: itemName, isNew: false });
}
// 다음 display_order 구하기
const [maxOrder] = await db.query(
'SELECT MAX(display_order) as max_order FROM issue_report_items WHERE category_id = ?',
[categoryId]
);
const nextOrder = (maxOrder[0].max_order || 0) + 1;
// 새 항목 추가
const [result] = await db.query(
`INSERT INTO issue_report_items (category_id, item_name, display_order, is_active)
VALUES (?, ?, ?, 1)`,
[categoryId, itemName, nextOrder]
);
callback(null, { item_id: result.insertId, item_name: itemName, isNew: true });
} catch (error) {
callback(error);
}
}
};
module.exports = EquipmentModel;

View File

@@ -1,53 +1,37 @@
const { getDb } = require('../dbPool');
// CREATE
const create = async (type, callback) => {
try {
const create = async (type) => {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO IssueTypes (category, subcategory) VALUES (?, ?)`,
[type.category, type.subcategory]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
return result.insertId;
};
// READ ALL
const getAll = async (callback) => {
try {
const getAll = async () => {
const db = await getDb();
const [rows] = await db.query(`SELECT issue_type_id, category, subcategory FROM IssueTypes ORDER BY category, subcategory`);
callback(null, rows);
} catch (err) {
callback(err);
}
return rows;
};
// UPDATE
const update = async (id, type, callback) => {
try {
const update = async (id, type) => {
const db = await getDb();
const [result] = await db.query(
`UPDATE IssueTypes SET category = ?, subcategory = ? WHERE id = ?`,
[type.category, type.subcategory, id]
);
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
return result.affectedRows;
};
// DELETE
const remove = async (id, callback) => {
try {
const remove = async (id) => {
const db = await getDb();
const [result] = await db.query(`DELETE FROM IssueTypes WHERE id = ?`, [id]);
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
return result.affectedRows;
};
module.exports = {

View File

@@ -0,0 +1,197 @@
// models/notificationModel.js
const { getDb } = require('../dbPool');
// 순환 참조를 피하기 위해 함수 내에서 require
async function getRecipientIds(notificationType) {
const db = await getDb();
const [rows] = await db.query(
`SELECT user_id FROM notification_recipients
WHERE notification_type = ? AND is_active = 1`,
[notificationType]
);
return rows.map(r => r.user_id);
}
const notificationModel = {
// 알림 생성
async create(notificationData) {
const db = await getDb();
const { user_id, type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
const [result] = await db.query(
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[user_id || null, type || 'system', title, message || null, link_url || null, reference_type || null, reference_id || null, created_by || null]
);
return result.insertId;
},
// 읽지 않은 알림 조회 (특정 사용자 또는 전체)
async getUnread(userId = null) {
const db = await getDb();
const [rows] = await db.query(
`SELECT * FROM notifications
WHERE is_read = 0
AND (user_id IS NULL OR user_id = ?)
ORDER BY created_at DESC
LIMIT 50`,
[userId || 0]
);
return rows;
},
// 전체 알림 조회 (페이징)
async getAll(userId = null, page = 1, limit = 20) {
const db = await getDb();
const offset = (page - 1) * limit;
const [rows] = await db.query(
`SELECT * FROM notifications
WHERE (user_id IS NULL OR user_id = ?)
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
[userId || 0, limit, offset]
);
const [[{ total }]] = await db.query(
`SELECT COUNT(*) as total FROM notifications
WHERE (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return { notifications: rows, total, page, limit };
},
// 알림 읽음 처리
async markAsRead(notificationId) {
const db = await getDb();
const [result] = await db.query(
`UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`,
[notificationId]
);
return result.affectedRows > 0;
},
// 모든 알림 읽음 처리
async markAllAsRead(userId = null) {
const db = await getDb();
const [result] = await db.query(
`UPDATE notifications SET is_read = 1, read_at = NOW()
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return result.affectedRows;
},
// 알림 삭제
async delete(notificationId) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM notifications WHERE notification_id = ?`,
[notificationId]
);
return result.affectedRows > 0;
},
// 오래된 알림 삭제 (30일 이상)
async deleteOld(days = 30) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
[days]
);
return result.affectedRows;
},
// 읽지 않은 알림 개수
async getUnreadCount(userId = null) {
const db = await getDb();
const [[{ count }]] = await db.query(
`SELECT COUNT(*) as count FROM notifications
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return count;
},
// 수리 신청 알림 생성 헬퍼 (지정된 수신자에게 전송)
async createRepairNotification(repairData) {
const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData;
// 수리 알림 수신자 목록 가져오기
const recipientIds = await getRecipientIds('repair');
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림 (user_id = null)
return await this.create({
type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`,
message: `${repair_type} 수리가 신청되었습니다.`,
link_url: `/pages/admin/repair-management.html`,
reference_type: 'work_issue_reports',
reference_id: request_id,
created_by
});
}
// 지정된 수신자 각각에게 알림 생성
const results = [];
for (const userId of recipientIds) {
const notificationId = await this.create({
user_id: userId,
type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`,
message: `${repair_type} 수리가 신청되었습니다.`,
link_url: `/pages/admin/repair-management.html`,
reference_type: 'work_issue_reports',
reference_id: request_id,
created_by
});
results.push(notificationId);
}
return results;
},
// 일반 알림 생성 (유형별 지정된 수신자에게 전송)
async createTypedNotification(notificationData) {
const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
// 해당 유형의 수신자 목록 가져오기
const recipientIds = await getRecipientIds(type);
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림
return await this.create({
type,
title,
message,
link_url,
reference_type,
reference_id,
created_by
});
}
// 지정된 수신자 각각에게 알림 생성
const results = [];
for (const userId of recipientIds) {
const notificationId = await this.create({
user_id: userId,
type,
title,
message,
link_url,
reference_type,
reference_id,
created_by
});
results.push(notificationId);
}
return results;
}
};
module.exports = notificationModel;

View File

@@ -0,0 +1,146 @@
// models/notificationRecipientModel.js
const { getDb } = require('../dbPool');
const NOTIFICATION_TYPES = {
repair: '설비 수리',
safety: '안전 신고',
nonconformity: '부적합 신고',
equipment: '설비 관련',
maintenance: '정기점검',
system: '시스템'
};
const notificationRecipientModel = {
// 알림 유형 목록 가져오기
getTypes() {
return NOTIFICATION_TYPES;
},
// 유형별 수신자 목록 조회
async getByType(notificationType) {
const db = await getDb();
const [rows] = await db.query(
`SELECT nr.*, u.username, u.name as user_name, r.name as role
FROM notification_recipients nr
JOIN users u ON nr.user_id = u.user_id
LEFT JOIN roles r ON u.role_id = r.id
WHERE nr.notification_type = ? AND nr.is_active = 1
ORDER BY u.name`,
[notificationType]
);
return rows;
},
// 전체 수신자 목록 조회 (유형별 그룹화)
async getAll() {
const db = await getDb();
const [rows] = await db.query(
`SELECT nr.*, u.username, u.name as user_name, r.name as role
FROM notification_recipients nr
JOIN users u ON nr.user_id = u.user_id
LEFT JOIN roles r ON u.role_id = r.id
WHERE nr.is_active = 1
ORDER BY nr.notification_type, u.name`
);
// 유형별로 그룹화
const grouped = {};
for (const type in NOTIFICATION_TYPES) {
grouped[type] = {
label: NOTIFICATION_TYPES[type],
recipients: []
};
}
rows.forEach(row => {
if (grouped[row.notification_type]) {
grouped[row.notification_type].recipients.push(row);
}
});
return grouped;
},
// 수신자 추가
async add(notificationType, userId, createdBy = null) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO notification_recipients (notification_type, user_id, created_by)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE is_active = 1`,
[notificationType, userId, createdBy]
);
return result.insertId || result.affectedRows > 0;
},
// 수신자 제거 (soft delete)
async remove(notificationType, userId) {
const db = await getDb();
const [result] = await db.query(
`UPDATE notification_recipients SET is_active = 0
WHERE notification_type = ? AND user_id = ?`,
[notificationType, userId]
);
return result.affectedRows > 0;
},
// 수신자 완전 삭제
async delete(notificationType, userId) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM notification_recipients
WHERE notification_type = ? AND user_id = ?`,
[notificationType, userId]
);
return result.affectedRows > 0;
},
// 유형별 수신자 user_id 목록만 가져오기 (알림 생성용)
async getRecipientIds(notificationType) {
const db = await getDb();
const [rows] = await db.query(
`SELECT user_id FROM notification_recipients
WHERE notification_type = ? AND is_active = 1`,
[notificationType]
);
return rows.map(r => r.user_id);
},
// 사용자가 특정 유형의 수신자인지 확인
async isRecipient(notificationType, userId) {
const db = await getDb();
const [[row]] = await db.query(
`SELECT 1 FROM notification_recipients
WHERE notification_type = ? AND user_id = ? AND is_active = 1`,
[notificationType, userId]
);
return !!row;
},
// 유형별 수신자 일괄 설정
async setRecipients(notificationType, userIds, createdBy = null) {
const db = await getDb();
// 기존 수신자 비활성화
await db.query(
`UPDATE notification_recipients SET is_active = 0
WHERE notification_type = ?`,
[notificationType]
);
// 새 수신자 추가
if (userIds && userIds.length > 0) {
const values = userIds.map(userId => [notificationType, userId, createdBy]);
await db.query(
`INSERT INTO notification_recipients (notification_type, user_id, created_by)
VALUES ?
ON DUPLICATE KEY UPDATE is_active = 1`,
[values]
);
}
return true;
}
};
module.exports = notificationRecipientModel;

View File

@@ -0,0 +1,160 @@
// models/pageAccessModel.js
const db = require('../db/connection');
const PageAccessModel = {
// 사용자의 페이지 권한 조회
getUserPageAccess: (userId, callback) => {
const sql = `
SELECT
p.id,
p.page_key,
p.page_name,
p.page_path,
p.category,
p.is_admin_only,
COALESCE(upa.can_access, 0) as can_access,
upa.granted_at,
upa.granted_by,
granter.username as granted_by_username
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
LEFT JOIN users granter ON upa.granted_by = granter.user_id
WHERE p.is_admin_only = 0
ORDER BY p.category, p.display_order
`;
db.query(sql, [userId], callback);
},
// 모든 페이지 목록 조회
getAllPages: (callback) => {
const sql = `
SELECT
id,
page_key,
page_name,
page_path,
category,
description,
is_admin_only,
display_order
FROM pages
WHERE is_admin_only = 0
ORDER BY category, display_order
`;
db.query(sql, callback);
},
// 페이지 권한 부여
grantPageAccess: (userId, pageId, grantedBy, callback) => {
const sql = `
INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at)
VALUES (?, ?, 1, ?, NOW())
ON DUPLICATE KEY UPDATE
can_access = 1,
granted_by = ?,
granted_at = NOW()
`;
db.query(sql, [userId, pageId, grantedBy, grantedBy], callback);
},
// 페이지 권한 회수
revokePageAccess: (userId, pageId, callback) => {
const sql = `
DELETE FROM user_page_access
WHERE user_id = ? AND page_id = ?
`;
db.query(sql, [userId, pageId], callback);
},
// 여러 페이지 권한 일괄 설정
setUserPageAccess: (userId, pageIds, grantedBy, callback) => {
db.beginTransaction((err) => {
if (err) return callback(err);
// 기존 권한 모두 삭제
const deleteSql = 'DELETE FROM user_page_access WHERE user_id = ?';
db.query(deleteSql, [userId], (err) => {
if (err) {
return db.rollback(() => callback(err));
}
// 새 권한이 없으면 커밋하고 종료
if (!pageIds || pageIds.length === 0) {
return db.commit((err) => {
if (err) return db.rollback(() => callback(err));
callback(null, { affectedRows: 0 });
});
}
// 새 권한 추가
const values = pageIds.map(pageId => [userId, pageId, 1, grantedBy]);
const insertSql = `
INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at)
VALUES ?
`;
db.query(insertSql, [values], (err, result) => {
if (err) {
return db.rollback(() => callback(err));
}
db.commit((err) => {
if (err) return db.rollback(() => callback(err));
callback(null, result);
});
});
});
});
},
// 특정 페이지 접근 권한 확인
checkPageAccess: (userId, pageKey, callback) => {
const sql = `
SELECT
COALESCE(upa.can_access, 0) as can_access,
p.is_admin_only
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
WHERE p.page_key = ?
`;
db.query(sql, [userId, pageKey], (err, results) => {
if (err) return callback(err);
if (results.length === 0) return callback(null, { can_access: false });
callback(null, results[0]);
});
},
// 계정이 있는 작업자 목록 조회 (권한 관리용)
getUsersWithAccounts: (callback) => {
const sql = `
SELECT
u.user_id,
u.username,
u.name,
u.role_id,
r.name as role_name,
u.worker_id,
w.worker_name,
w.job_type,
COUNT(upa.page_id) as granted_pages_count
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN workers w ON u.worker_id = w.worker_id
LEFT JOIN user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1
WHERE u.is_active = 1
AND u.role_id IN (4, 5)
GROUP BY u.user_id
ORDER BY w.worker_name, u.username
`;
db.query(sql, callback);
}
};
module.exports = PageAccessModel;

View File

@@ -0,0 +1,358 @@
// patrolModel.js
// 일일순회점검 시스템 모델
const { getDb } = require('../dbPool');
const PatrolModel = {
// ==================== 순회점검 세션 ====================
// 세션 생성 또는 조회
getOrCreateSession: async (patrolDate, patrolTime, categoryId, inspectorId) => {
const db = await getDb();
// 기존 세션 확인
const [existingRows] = await db.query(`
SELECT session_id, status, started_at, completed_at
FROM daily_patrol_sessions
WHERE patrol_date = ? AND patrol_time = ? AND category_id = ?
`, [patrolDate, patrolTime, categoryId]);
if (existingRows.length > 0) {
return existingRows[0];
}
// 새 세션 생성
const [result] = await db.query(`
INSERT INTO daily_patrol_sessions (patrol_date, patrol_time, category_id, inspector_id, started_at)
VALUES (?, ?, ?, ?, CURTIME())
`, [patrolDate, patrolTime, categoryId, inspectorId]);
return {
session_id: result.insertId,
status: 'in_progress',
started_at: new Date().toTimeString().slice(0, 8)
};
},
// 세션 조회
getSession: async (sessionId) => {
const db = await getDb();
const [rows] = await db.query(`
SELECT s.*, u.name AS inspector_name, wc.category_name
FROM daily_patrol_sessions s
LEFT JOIN users u ON s.inspector_id = u.user_id
LEFT JOIN workplace_categories wc ON s.category_id = wc.category_id
WHERE s.session_id = ?
`, [sessionId]);
return rows[0] || null;
},
// 세션 목록 조회
getSessions: async (filters = {}) => {
const db = await getDb();
let query = `
SELECT s.*, u.name AS inspector_name, wc.category_name,
(SELECT COUNT(*) FROM patrol_check_records WHERE session_id = s.session_id AND is_checked = 1) AS checked_count,
(SELECT COUNT(*) FROM patrol_check_records WHERE session_id = s.session_id) AS total_count
FROM daily_patrol_sessions s
LEFT JOIN users u ON s.inspector_id = u.user_id
LEFT JOIN workplace_categories wc ON s.category_id = wc.category_id
WHERE 1=1
`;
const params = [];
if (filters.patrol_date) {
query += ' AND s.patrol_date = ?';
params.push(filters.patrol_date);
}
if (filters.patrol_time) {
query += ' AND s.patrol_time = ?';
params.push(filters.patrol_time);
}
if (filters.category_id) {
query += ' AND s.category_id = ?';
params.push(filters.category_id);
}
if (filters.status) {
query += ' AND s.status = ?';
params.push(filters.status);
}
query += ' ORDER BY s.patrol_date DESC, s.patrol_time DESC';
if (filters.limit) {
query += ' LIMIT ?';
params.push(parseInt(filters.limit));
}
const [rows] = await db.query(query, params);
return rows;
},
// 세션 완료 처리
completeSession: async (sessionId) => {
const db = await getDb();
await db.query(`
UPDATE daily_patrol_sessions
SET status = 'completed', completed_at = CURTIME(), updated_at = NOW()
WHERE session_id = ?
`, [sessionId]);
return true;
},
// 세션 메모 업데이트
updateSessionNotes: async (sessionId, notes) => {
const db = await getDb();
await db.query(`
UPDATE daily_patrol_sessions
SET notes = ?, updated_at = NOW()
WHERE session_id = ?
`, [notes, sessionId]);
return true;
},
// ==================== 체크리스트 항목 ====================
// 체크리스트 항목 조회 (공장/작업장별 필터링)
getChecklistItems: async (categoryId = null, workplaceId = null) => {
const db = await getDb();
let query = `
SELECT *
FROM patrol_checklist_items
WHERE is_active = 1
AND (workplace_id IS NULL OR workplace_id = ?)
AND (category_id IS NULL OR category_id = ?)
ORDER BY check_category, display_order, check_item
`;
const [rows] = await db.query(query, [workplaceId, categoryId]);
return rows;
},
// 체크리스트 항목 CRUD
createChecklistItem: async (data) => {
const db = await getDb();
const [result] = await db.query(`
INSERT INTO patrol_checklist_items (workplace_id, category_id, check_category, check_item, description, display_order, is_required)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [data.workplace_id, data.category_id, data.check_category, data.check_item, data.description, data.display_order || 0, data.is_required !== false]);
return result.insertId;
},
updateChecklistItem: async (itemId, data) => {
const db = await getDb();
const fields = [];
const params = [];
['workplace_id', 'category_id', 'check_category', 'check_item', 'description', 'display_order', 'is_required', 'is_active'].forEach(key => {
if (data[key] !== undefined) {
fields.push(`${key} = ?`);
params.push(data[key]);
}
});
if (fields.length === 0) return false;
params.push(itemId);
await db.query(`UPDATE patrol_checklist_items SET ${fields.join(', ')}, updated_at = NOW() WHERE item_id = ?`, params);
return true;
},
deleteChecklistItem: async (itemId) => {
const db = await getDb();
await db.query('UPDATE patrol_checklist_items SET is_active = 0, updated_at = NOW() WHERE item_id = ?', [itemId]);
return true;
},
// ==================== 체크 기록 ====================
// 작업장별 체크 기록 조회
getCheckRecords: async (sessionId, workplaceId = null) => {
const db = await getDb();
let query = `
SELECT r.*, ci.check_category, ci.check_item, ci.is_required
FROM patrol_check_records r
JOIN patrol_checklist_items ci ON r.check_item_id = ci.item_id
WHERE r.session_id = ?
`;
const params = [sessionId];
if (workplaceId) {
query += ' AND r.workplace_id = ?';
params.push(workplaceId);
}
query += ' ORDER BY ci.check_category, ci.display_order';
const [rows] = await db.query(query, params);
return rows;
},
// 체크 기록 저장 (upsert)
saveCheckRecord: async (sessionId, workplaceId, checkItemId, isChecked, checkResult = null, note = null) => {
const db = await getDb();
await db.query(`
INSERT INTO patrol_check_records (session_id, workplace_id, check_item_id, is_checked, check_result, note, checked_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_checked = VALUES(is_checked),
check_result = VALUES(check_result),
note = VALUES(note),
checked_at = NOW()
`, [sessionId, workplaceId, checkItemId, isChecked, checkResult, note]);
return true;
},
// 여러 체크 기록 일괄 저장
saveCheckRecords: async (sessionId, workplaceId, records) => {
const db = await getDb();
for (const record of records) {
await db.query(`
INSERT INTO patrol_check_records (session_id, workplace_id, check_item_id, is_checked, check_result, note, checked_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_checked = VALUES(is_checked),
check_result = VALUES(check_result),
note = VALUES(note),
checked_at = NOW()
`, [sessionId, workplaceId, record.check_item_id, record.is_checked, record.check_result, record.note]);
}
return true;
},
// ==================== 작업장 물품 현황 ====================
// 작업장 물품 조회
getWorkplaceItems: async (workplaceId, activeOnly = true) => {
const db = await getDb();
let query = `
SELECT wi.*, u.name AS created_by_name, it.type_name, it.icon, it.color
FROM workplace_items wi
LEFT JOIN users u ON wi.created_by = u.user_id
LEFT JOIN item_types it ON wi.item_type = it.type_code
WHERE wi.workplace_id = ?
`;
if (activeOnly) {
query += ' AND wi.is_active = 1';
}
query += ' ORDER BY wi.created_at DESC';
const [rows] = await db.query(query, [workplaceId]);
return rows;
},
// 물품 추가
createWorkplaceItem: async (data) => {
const db = await getDb();
const [result] = await db.query(`
INSERT INTO workplace_items
(workplace_id, patrol_session_id, project_id, item_type, item_name, quantity, x_percent, y_percent, width_percent, height_percent, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
data.workplace_id,
data.patrol_session_id,
data.project_id,
data.item_type,
data.item_name,
data.quantity || 1,
data.x_percent,
data.y_percent,
data.width_percent,
data.height_percent,
data.created_by
]);
return result.insertId;
},
// 물품 수정
updateWorkplaceItem: async (itemId, data, userId) => {
const db = await getDb();
const fields = [];
const params = [];
['item_type', 'item_name', 'quantity', 'x_percent', 'y_percent', 'width_percent', 'height_percent', 'is_active', 'project_id'].forEach(key => {
if (data[key] !== undefined) {
fields.push(`${key} = ?`);
params.push(data[key]);
}
});
if (fields.length === 0) return false;
fields.push('updated_by = ?', 'updated_at = NOW()');
params.push(userId, itemId);
await db.query(`UPDATE workplace_items SET ${fields.join(', ')} WHERE item_id = ?`, params);
return true;
},
// 물품 삭제 (비활성화)
deleteWorkplaceItem: async (itemId, userId) => {
const db = await getDb();
await db.query('UPDATE workplace_items SET is_active = 0, updated_by = ?, updated_at = NOW() WHERE item_id = ?', [userId, itemId]);
return true;
},
// 물품 영구 삭제
hardDeleteWorkplaceItem: async (itemId) => {
const db = await getDb();
await db.query('DELETE FROM workplace_items WHERE item_id = ?', [itemId]);
return true;
},
// ==================== 물품 유형 ====================
// 물품 유형 목록 조회
getItemTypes: async () => {
const db = await getDb();
const [rows] = await db.query('SELECT * FROM item_types WHERE is_active = 1 ORDER BY display_order');
return rows;
},
// ==================== 대시보드/통계 ====================
// 오늘 순회점검 현황
getTodayPatrolStatus: async (categoryId = null) => {
const db = await getDb();
const today = new Date().toISOString().slice(0, 10);
let query = `
SELECT s.session_id, s.patrol_time, s.status, s.inspector_id, u.name AS inspector_name,
s.started_at, s.completed_at,
(SELECT COUNT(*) FROM patrol_check_records WHERE session_id = s.session_id AND is_checked = 1) AS checked_count,
(SELECT COUNT(*) FROM patrol_check_records WHERE session_id = s.session_id) AS total_count
FROM daily_patrol_sessions s
LEFT JOIN users u ON s.inspector_id = u.user_id
WHERE s.patrol_date = ?
`;
const params = [today];
if (categoryId) {
query += ' AND s.category_id = ?';
params.push(categoryId);
}
query += ' ORDER BY s.patrol_time';
const [rows] = await db.query(query, params);
return rows;
},
// 작업장별 점검 현황 (세션 기준)
getWorkplaceCheckStatus: async (sessionId) => {
const db = await getDb();
const [rows] = await db.query(`
SELECT w.workplace_id, w.workplace_name,
COUNT(DISTINCT r.check_item_id) AS checked_count,
(SELECT COUNT(*) FROM patrol_checklist_items WHERE is_active = 1) AS total_items,
MAX(r.checked_at) AS last_check_time
FROM workplaces w
LEFT JOIN patrol_check_records r ON w.workplace_id = r.workplace_id AND r.session_id = ?
WHERE w.is_active = 1
GROUP BY w.workplace_id
ORDER BY w.workplace_name
`, [sessionId]);
return rows;
}
};
module.exports = PatrolModel;

View File

@@ -1,7 +1,6 @@
const { getDb } = require('../dbPool');
const create = async (project, callback) => {
try {
const create = async (project) => {
const db = await getDb();
const {
job_no, project_name,
@@ -19,54 +18,38 @@ const create = async (project, callback) => {
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
return result.insertId;
};
const getAll = async (callback) => {
try {
const getAll = async () => {
const db = await getDb();
const [rows] = await db.query(
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects ORDER BY project_id DESC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
return rows;
};
// 활성 프로젝트만 조회 (작업보고서용)
const getActiveProjects = async (callback) => {
try {
const getActiveProjects = async () => {
const db = await getDb();
const [rows] = await db.query(
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects
WHERE is_active = TRUE
ORDER BY project_name ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
return rows;
};
const getById = async (project_id, callback) => {
try {
const getById = async (project_id) => {
const db = await getDb();
const [rows] = await db.query(
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects WHERE project_id = ?`,
[project_id]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
return rows[0];
};
const update = async (project, callback) => {
try {
const update = async (project) => {
const db = await getDb();
const {
project_id, job_no, project_name,
@@ -91,23 +74,16 @@ const update = async (project, callback) => {
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, project_id]
);
callback(null, result.affectedRows);
} catch (err) {
callback(new Error(err.message || String(err)));
}
return result.affectedRows;
};
const remove = async (project_id, callback) => {
try {
const remove = async (project_id) => {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM projects WHERE project_id = ?`,
[project_id]
);
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
return result.affectedRows;
};
module.exports = {

View File

@@ -0,0 +1,142 @@
/**
* 작업 모델
*
* @author TK-FB-Project
* @since 2026-01-26
*/
const { getDb } = require('../dbPool');
// ==================== 작업 CRUD ====================
/**
* 작업 생성
*/
const createTask = async (taskData) => {
const db = await getDb();
const { work_type_id, task_name, description } = taskData;
const [result] = await db.query(
`INSERT INTO tasks (work_type_id, task_name, description, is_active)
VALUES (?, ?, ?, 1)`,
[work_type_id || null, task_name, description || null]
);
return result.insertId;
};
/**
* 전체 작업 목록 조회 (공정 정보 포함)
*/
const getAllTasks = async () => {
const db = await getDb();
const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
t.created_at, t.updated_at,
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
ORDER BY wt.category ASC, t.task_id DESC`
);
return rows;
};
/**
* 활성 작업만 조회
*/
const getActiveTasks = async () => {
const db = await getDb();
const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description,
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.is_active = 1
ORDER BY wt.category ASC, t.task_name ASC`
);
return rows;
};
/**
* 공정별 작업 목록 조회 (활성 작업만)
*/
const getTasksByWorkType = async (workTypeId) => {
const db = await getDb();
const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
t.created_at, t.updated_at,
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.work_type_id = ? AND t.is_active = 1
ORDER BY t.task_name ASC`,
[workTypeId]
);
return rows;
};
/**
* 단일 작업 조회
*/
const getTaskById = async (taskId) => {
const db = await getDb();
const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
t.created_at, t.updated_at,
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.task_id = ?`,
[taskId]
);
return rows[0] || null;
};
/**
* 작업 수정
*/
const updateTask = async (taskId, taskData) => {
const db = await getDb();
const { work_type_id, task_name, description, is_active } = taskData;
const [result] = await db.query(
`UPDATE tasks
SET work_type_id = ?,
task_name = ?,
description = ?,
is_active = ?,
updated_at = NOW()
WHERE task_id = ?`,
[
work_type_id || null,
task_name,
description || null,
is_active !== undefined ? is_active : 1,
taskId
]
);
return result;
};
/**
* 작업 삭제
*/
const deleteTask = async (taskId) => {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM tasks WHERE task_id = ?`,
[taskId]
);
return result;
};
module.exports = {
createTask,
getAllTasks,
getActiveTasks,
getTasksByWorkType,
getTaskById,
updateTask,
deleteTask
};

View File

@@ -0,0 +1,994 @@
// models/tbmModel.js - TBM 시스템 모델
const { getDb } = require('../dbPool');
const TbmModel = {
// ==================== TBM 세션 관련 ====================
/**
* TBM 세션 생성
*/
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 || null,
sessionData.work_type_id || null,
sessionData.task_id || null,
sessionData.work_location || null,
sessionData.created_by
];
const [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 특정 날짜의 TBM 세션 조회
*/
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
`;
const [rows] = await db.query(sql, [date]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션 상세 조회
*/
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 = ?
`;
const [rows] = await db.query(sql, [sessionId]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션 수정
*/
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.status,
sessionId
];
const [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션 완료 처리
*/
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 = ?
`;
const [result] = await db.query(sql, [endTime, sessionId]);
callback(null, result);
} catch (err) {
callback(err);
}
},
// ==================== 팀 구성 관련 ====================
/**
* 팀원 추가 (작업자별 상세 정보 포함)
*/
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,
assignmentData.project_id || null,
assignmentData.work_type_id || null,
assignmentData.task_id || null,
assignmentData.workplace_category_id || null,
assignmentData.workplace_id || null
];
const [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 팀 구성 일괄 추가 (작업자별 상세 정보 포함)
*/
addTeamMembers: async (sessionId, members, callback) => {
try {
if (!members || members.length === 0) {
return callback(null, { affectedRows: 0 });
}
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: async (sessionId, workerId, callback) => {
try {
const db = await getDb();
const sql = `
DELETE FROM tbm_team_assignments
WHERE session_id = ? AND worker_id = ?
`;
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);
}
},
// ==================== 안전 체크리스트 관련 ====================
/**
* 모든 안전 체크 항목 조회
*/
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
`;
const [rows] = await db.query(sql);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 카테고리별 안전 체크 항목 조회
*/
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
`;
const [rows] = await db.query(sql, [category]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션의 안전 체크 기록 조회
*/
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
`;
const [rows] = await db.query(sql, [sessionId]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 기록 저장/업데이트
*/
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 [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 일괄 저장
*/
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);
}
},
// ==================== 작업 인계 관련 ====================
/**
* 작업 인계 생성
*/
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 [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 작업 인계 확인
*/
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 = ?
`;
const [result] = await db.query(sql, [confirmedBy, handoverId]);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 특정 날짜의 작업 인계 목록 조회
*/
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
`;
const [rows] = await db.query(sql, [date]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 인수자가 받은 미확인 인계 건 조회
*/
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
`;
const [rows] = await db.query(sql, [toLeaderId]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
// ==================== 통계 및 리포트 ====================
/**
* 특정 기간의 TBM 통계
*/
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
`;
const [rows] = await db.query(sql, [startDate, endDate]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 리더별 TBM 진행 현황
*/
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
`;
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);
}
},
// ========== 안전 체크리스트 확장 메서드 ==========
/**
* 유형별 안전 체크 항목 조회
* @param {string} checkType - 체크 유형 (basic, weather, task)
* @param {Object} options - 추가 옵션 (weatherCondition, taskId)
*/
getSafetyChecksByType: async (checkType, options = {}, callback) => {
try {
const db = await getDb();
let sql = `
SELECT sc.*,
wc.condition_name as weather_condition_name,
wc.icon as weather_icon,
t.task_name
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
LEFT JOIN tasks t ON sc.task_id = t.task_id
WHERE sc.is_active = 1 AND sc.check_type = ?
`;
const params = [checkType];
if (checkType === 'weather' && options.weatherCondition) {
sql += ' AND sc.weather_condition = ?';
params.push(options.weatherCondition);
}
if (checkType === 'task' && options.taskId) {
sql += ' AND sc.task_id = ?';
params.push(options.taskId);
}
sql += ' ORDER BY sc.check_category, sc.display_order';
const [rows] = await db.query(sql, params);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 날씨 조건별 안전 체크 항목 조회 (복수 조건)
* @param {string[]} conditions - 날씨 조건 배열 ['rain', 'wind']
*/
getSafetyChecksByWeather: async (conditions, callback) => {
try {
const db = await getDb();
if (!conditions || conditions.length === 0) {
return callback(null, []);
}
const placeholders = conditions.map(() => '?').join(',');
const sql = `
SELECT sc.*,
wc.condition_name as weather_condition_name,
wc.icon as weather_icon
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
WHERE sc.is_active = 1
AND sc.check_type = 'weather'
AND sc.weather_condition IN (${placeholders})
ORDER BY sc.weather_condition, sc.display_order
`;
const [rows] = await db.query(sql, conditions);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 작업별 안전 체크 항목 조회 (복수 작업)
* @param {number[]} taskIds - 작업 ID 배열
*/
getSafetyChecksByTasks: async (taskIds, callback) => {
try {
const db = await getDb();
if (!taskIds || taskIds.length === 0) {
return callback(null, []);
}
const placeholders = taskIds.map(() => '?').join(',');
const sql = `
SELECT sc.*,
t.task_name,
wt.name as work_type_name
FROM tbm_safety_checks sc
LEFT JOIN tasks t ON sc.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE sc.is_active = 1
AND sc.check_type = 'task'
AND sc.task_id IN (${placeholders})
ORDER BY sc.task_id, sc.display_order
`;
const [rows] = await db.query(sql, taskIds);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션에 맞는 필터링된 안전 체크 항목 조회
* 기본 + 날씨 + 작업별 체크항목 통합 조회
* @param {number} sessionId - TBM 세션 ID
* @param {string[]} weatherConditions - 날씨 조건 배열 (optional)
*/
getFilteredSafetyChecks: async (sessionId, weatherConditions = [], callback) => {
try {
const db = await getDb();
// 1. 세션 정보에서 작업 ID 목록 조회
const [assignments] = await db.query(`
SELECT DISTINCT task_id
FROM tbm_team_assignments
WHERE session_id = ? AND task_id IS NOT NULL
`, [sessionId]);
const taskIds = assignments.map(a => a.task_id);
// 2. 기본 체크항목 조회
const [basicChecks] = await db.query(`
SELECT sc.*, 'basic' as section_type
FROM tbm_safety_checks sc
WHERE sc.is_active = 1 AND sc.check_type = 'basic'
ORDER BY sc.check_category, sc.display_order
`);
// 3. 날씨별 체크항목 조회
let weatherChecks = [];
if (weatherConditions && weatherConditions.length > 0) {
const wcPlaceholders = weatherConditions.map(() => '?').join(',');
const [rows] = await db.query(`
SELECT sc.*, wc.condition_name as weather_condition_name, wc.icon as weather_icon,
'weather' as section_type
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
WHERE sc.is_active = 1
AND sc.check_type = 'weather'
AND sc.weather_condition IN (${wcPlaceholders})
ORDER BY sc.weather_condition, sc.display_order
`, weatherConditions);
weatherChecks = rows;
}
// 4. 작업별 체크항목 조회
let taskChecks = [];
if (taskIds.length > 0) {
const taskPlaceholders = taskIds.map(() => '?').join(',');
const [rows] = await db.query(`
SELECT sc.*, t.task_name, wt.name as work_type_name,
'task' as section_type
FROM tbm_safety_checks sc
LEFT JOIN tasks t ON sc.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE sc.is_active = 1
AND sc.check_type = 'task'
AND sc.task_id IN (${taskPlaceholders})
ORDER BY sc.task_id, sc.display_order
`, taskIds);
taskChecks = rows;
}
// 5. 기존 체크 기록 조회
const [existingRecords] = await db.query(`
SELECT check_id, is_checked, notes
FROM tbm_safety_records
WHERE session_id = ?
`, [sessionId]);
const recordMap = {};
existingRecords.forEach(r => {
recordMap[r.check_id] = { is_checked: r.is_checked, notes: r.notes };
});
// 6. 기록과 병합
const mergeWithRecords = (checks) => {
return checks.map(check => ({
...check,
is_checked: recordMap[check.check_id]?.is_checked || false,
notes: recordMap[check.check_id]?.notes || null
}));
};
const result = {
basic: mergeWithRecords(basicChecks),
weather: mergeWithRecords(weatherChecks),
task: mergeWithRecords(taskChecks),
totalCount: basicChecks.length + weatherChecks.length + taskChecks.length,
weatherConditions: weatherConditions
};
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 생성 (관리자용)
*/
createSafetyCheck: async (checkData, callback) => {
try {
const db = await getDb();
const sql = `
INSERT INTO tbm_safety_checks
(check_category, check_type, weather_condition, task_id, check_item, description, is_required, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [
checkData.check_category,
checkData.check_type || 'basic',
checkData.weather_condition || null,
checkData.task_id || null,
checkData.check_item,
checkData.description || null,
checkData.is_required !== false,
checkData.display_order || 0
];
const [result] = await db.query(sql, values);
callback(null, { insertId: result.insertId });
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 수정 (관리자용)
*/
updateSafetyCheck: async (checkId, checkData, callback) => {
try {
const db = await getDb();
const sql = `
UPDATE tbm_safety_checks
SET check_category = ?,
check_type = ?,
weather_condition = ?,
task_id = ?,
check_item = ?,
description = ?,
is_required = ?,
display_order = ?,
is_active = ?,
updated_at = NOW()
WHERE check_id = ?
`;
const values = [
checkData.check_category,
checkData.check_type || 'basic',
checkData.weather_condition || null,
checkData.task_id || null,
checkData.check_item,
checkData.description || null,
checkData.is_required !== false,
checkData.display_order || 0,
checkData.is_active !== false,
checkId
];
const [result] = await db.query(sql, values);
callback(null, { affectedRows: result.affectedRows });
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 삭제 (비활성화)
*/
deleteSafetyCheck: async (checkId, callback) => {
try {
const db = await getDb();
// 실제 삭제 대신 비활성화
const sql = `UPDATE tbm_safety_checks SET is_active = 0 WHERE check_id = ?`;
const [result] = await db.query(sql, [checkId]);
callback(null, { affectedRows: result.affectedRows });
} catch (err) {
callback(err);
}
}
};
module.exports = TbmModel;

View File

@@ -1,30 +1,21 @@
const { getDb } = require('../dbPool');
// 1. 전체 도구 조회
const getAll = async (callback) => {
try {
const getAll = async () => {
const db = await getDb();
const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools');
callback(null, rows);
} catch (err) {
callback(err);
}
return rows;
};
// 2. 단일 도구 조회
const getById = async (id, callback) => {
try {
const getById = async (id) => {
const db = await getDb();
const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools WHERE id = ?', [id]);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
return rows[0];
};
// 3. 도구 생성
const create = async (tool, callback) => {
try {
const create = async (tool) => {
const db = await getDb();
const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
@@ -35,15 +26,11 @@ const create = async (tool, callback) => {
[name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
return result.insertId;
};
// 4. 도구 수정
const update = async (id, tool, callback) => {
try {
const update = async (id, tool) => {
const db = await getDb();
const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
@@ -62,24 +49,16 @@ const update = async (id, tool, callback) => {
[name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note, id]
);
callback(null, result.affectedRows);
} catch (err) {
callback(new Error(err.message || String(err)));
}
return result.affectedRows;
};
// 5. 도구 삭제
const remove = async (id, callback) => {
try {
const remove = async (id) => {
const db = await getDb();
const [result] = await db.query('DELETE FROM Tools WHERE id = ?', [id]);
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
return result.affectedRows;
};
// ✅ export 정리
module.exports = {
getAll,
getById,

View File

@@ -1,8 +1,7 @@
const { getDb } = require('../dbPool');
// 1. 문서 업로드
const create = async (doc, callback) => {
try {
const create = async (doc) => {
const db = await getDb();
const sql = `
INSERT INTO uploaded_documents
@@ -21,24 +20,16 @@ const create = async (doc, callback) => {
doc.submitted_by
];
const [result] = await db.query(sql, values);
callback(null, result.insertId);
} catch (err) {
callback(new Error(err.message || String(err)));
}
return result.insertId;
};
// 2. 전체 문서 목록 조회
const getAll = async (callback) => {
try {
const getAll = async () => {
const db = await getDb();
const [rows] = await db.query(`SELECT * FROM uploaded_documents ORDER BY created_at DESC`);
callback(null, rows);
} catch (err) {
callback(err);
}
return rows;
};
// ✅ 내보내기
module.exports = {
create,
getAll

View File

@@ -0,0 +1,400 @@
/**
* 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);
},
/**
* 휴가 사용 시 우선순위에 따라 잔액에서 차감 (Promise 버전)
* - 일일 근태 기록 저장 시 호출
* @param {number} workerId - 작업자 ID
* @param {number} year - 연도
* @param {number} daysToDeduct - 차감할 일수 (1, 0.5, 0.25)
* @returns {Promise<Object>} - 차감 결과
*/
async deductByPriority(workerId, year, daysToDeduct) {
const db = await getDb();
// 우선순위순으로 잔여 일수가 있는 잔액 조회
const [balances] = await db.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.total_days, vbd.used_days,
(vbd.total_days - vbd.used_days) as remaining_days,
vt.type_code, vt.type_name, vt.priority
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.total_days - vbd.used_days) > 0
ORDER BY vt.priority ASC
`, [workerId, year]);
if (balances.length === 0) {
// 잔액이 없어도 일단 기록은 저장 (경고만)
console.warn(`[VacationBalance] 작업자 ${workerId}${year}년 휴가 잔액이 없습니다`);
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
}
let remaining = daysToDeduct;
const deductions = [];
for (const balance of balances) {
if (remaining <= 0) break;
const available = parseFloat(balance.remaining_days);
const toDeduct = Math.min(remaining, available);
if (toDeduct > 0) {
await db.query(`
UPDATE vacation_balance_details
SET used_days = used_days + ?, updated_at = NOW()
WHERE id = ?
`, [toDeduct, balance.id]);
deductions.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
deducted: toDeduct
});
remaining -= toDeduct;
}
}
console.log(`[VacationBalance] 작업자 ${workerId}: ${daysToDeduct}일 차감 완료`, deductions);
return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
},
/**
* 휴가 취소 시 우선순위 역순으로 복구 (Promise 버전)
* @param {number} workerId - 작업자 ID
* @param {number} year - 연도
* @param {number} daysToRestore - 복구할 일수
* @returns {Promise<Object>} - 복구 결과
*/
async restoreByPriority(workerId, year, daysToRestore) {
const db = await getDb();
// 우선순위 역순으로 사용 일수가 있는 잔액 조회 (나중에 차감된 것부터 복구)
const [balances] = await db.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.used_days,
vt.type_code, vt.type_name, vt.priority
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.used_days > 0
ORDER BY vt.priority DESC
`, [workerId, year]);
let remaining = daysToRestore;
const restorations = [];
for (const balance of balances) {
if (remaining <= 0) break;
const usedDays = parseFloat(balance.used_days);
const toRestore = Math.min(remaining, usedDays);
if (toRestore > 0) {
await db.query(`
UPDATE vacation_balance_details
SET used_days = used_days - ?, updated_at = NOW()
WHERE id = ?
`, [toRestore, balance.id]);
restorations.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
restored: toRestore
});
remaining -= toRestore;
}
}
console.log(`[VacationBalance] 작업자 ${workerId}: ${daysToRestore}일 복구 완료`, restorations);
return { success: true, restorations, totalRestored: daysToRestore - remaining };
},
/**
* 특정 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
};

View File

@@ -0,0 +1,887 @@
/**
* 작업 중 문제 신고 모델
* 부적합/안전 신고 관련 DB 쿼리
*/
const { getDb } = require('../dbPool');
// ==================== 신고 카테고리 관리 ====================
/**
* 모든 신고 카테고리 조회
*/
const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order, is_active, created_at
FROM issue_report_categories
ORDER BY category_type, display_order, category_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 타입별 활성 카테고리 조회 (nonconformity/safety)
*/
const getCategoriesByType = async (categoryType, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order
FROM issue_report_categories
WHERE category_type = ? AND is_active = TRUE
ORDER BY display_order, category_id`,
[categoryType]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 생성
*/
const createCategory = async (categoryData, callback) => {
try {
const db = await getDb();
const { category_type, category_name, description = null, display_order = 0 } = categoryData;
const [result] = await db.query(
`INSERT INTO issue_report_categories (category_type, category_name, description, display_order)
VALUES (?, ?, ?, ?)`,
[category_type, category_name, description, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 수정
*/
const updateCategory = async (categoryId, categoryData, callback) => {
try {
const db = await getDb();
const { category_name, description, display_order, is_active } = categoryData;
const [result] = await db.query(
`UPDATE issue_report_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?
WHERE category_id = ?`,
[category_name, description, display_order, is_active, categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 삭제
*/
const deleteCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_categories WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 사전 정의 신고 항목 관리 ====================
/**
* 카테고리별 활성 항목 조회
*/
const getItemsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT item_id, category_id, item_name, description, severity, display_order
FROM issue_report_items
WHERE category_id = ? AND is_active = TRUE
ORDER BY display_order, item_id`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 모든 항목 조회 (관리용)
*/
const getAllItems = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT iri.item_id, iri.category_id, iri.item_name, iri.description,
iri.severity, iri.display_order, iri.is_active, iri.created_at,
irc.category_name, irc.category_type
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
ORDER BY irc.category_type, irc.display_order, iri.display_order, iri.item_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 항목 생성
*/
const createItem = async (itemData, callback) => {
try {
const db = await getDb();
const { category_id, item_name, description = null, severity = 'medium', display_order = 0 } = itemData;
const [result] = await db.query(
`INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order)
VALUES (?, ?, ?, ?, ?)`,
[category_id, item_name, description, severity, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 항목 수정
*/
const updateItem = async (itemId, itemData, callback) => {
try {
const db = await getDb();
const { item_name, description, severity, display_order, is_active } = itemData;
const [result] = await db.query(
`UPDATE issue_report_items
SET item_name = ?, description = ?, severity = ?, display_order = ?, is_active = ?
WHERE item_id = ?`,
[item_name, description, severity, display_order, is_active, itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 항목 삭제
*/
const deleteItem = async (itemId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_items WHERE item_id = ?`,
[itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 문제 신고 관리 ====================
// 한국 시간 유틸리티 import
const { getKoreaDatetime } = require('../utils/dateUtils');
/**
* 신고 생성
*/
const createReport = async (reportData, callback) => {
try {
const db = await getDb();
const {
reporter_id,
factory_category_id = null,
workplace_id = null,
custom_location = null,
tbm_session_id = null,
visit_request_id = null,
issue_category_id,
issue_item_id = null,
additional_description = null,
photo_path1 = null,
photo_path2 = null,
photo_path3 = null,
photo_path4 = null,
photo_path5 = null
} = reportData;
// 한국 시간 기준으로 신고 일시 설정
const reportDate = getKoreaDatetime();
const [result] = await db.query(
`INSERT INTO work_issue_reports
(reporter_id, report_date, factory_category_id, workplace_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[reporter_id, reportDate, factory_category_id, workplace_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
);
// 상태 변경 로그 기록
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, NULL, 'reported', ?)`,
[result.insertId, reporter_id]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 신고 목록 조회 (필터 옵션 포함)
*/
const getAllReports = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at,
wir.resolution_notes, wir.resolved_at,
wir.created_at, wir.updated_at,
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
WHERE 1=1
`;
const params = [];
// 필터 적용
if (filters.status) {
query += ` AND wir.status = ?`;
params.push(filters.status);
}
if (filters.category_type) {
query += ` AND irc.category_type = ?`;
params.push(filters.category_type);
}
if (filters.issue_category_id) {
query += ` AND wir.issue_category_id = ?`;
params.push(filters.issue_category_id);
}
if (filters.factory_category_id) {
query += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
if (filters.workplace_id) {
query += ` AND wir.workplace_id = ?`;
params.push(filters.workplace_id);
}
if (filters.reporter_id) {
query += ` AND wir.reporter_id = ?`;
params.push(filters.reporter_id);
}
if (filters.assigned_user_id) {
query += ` AND wir.assigned_user_id = ?`;
params.push(filters.assigned_user_id);
}
if (filters.start_date && filters.end_date) {
query += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.search) {
query += ` AND (wir.additional_description LIKE ? OR iri.item_name LIKE ? OR wir.custom_location LIKE ?)`;
const searchTerm = `%${filters.search}%`;
params.push(searchTerm, searchTerm, searchTerm);
}
query += ` ORDER BY wir.report_date DESC, wir.report_id DESC`;
// 페이지네이션
if (filters.limit) {
query += ` LIMIT ?`;
params.push(parseInt(filters.limit));
if (filters.offset) {
query += ` OFFSET ?`;
params.push(parseInt(filters.offset));
}
}
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 신고 상세 조회
*/
const getReportById = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at, wir.assigned_by,
wir.resolution_notes, wir.resolution_photo_path1, wir.resolution_photo_path2,
wir.resolved_at, wir.resolved_by,
wir.modification_history,
wir.created_at, wir.updated_at,
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name,
assigner.username as assigned_by_name,
resolver.username as resolved_by_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
LEFT JOIN users assigner ON wir.assigned_by = assigner.user_id
LEFT JOIN users resolver ON wir.resolved_by = resolver.user_id
WHERE wir.report_id = ?`,
[reportId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 신고 수정
*/
const updateReport = async (reportId, reportData, userId, callback) => {
try {
const db = await getDb();
// 기존 데이터 조회
const [existing] = await db.query(
`SELECT * FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (existing.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
const current = existing[0];
// 수정 이력 생성
const modifications = [];
const now = new Date().toISOString();
for (const key of Object.keys(reportData)) {
if (current[key] !== reportData[key] && reportData[key] !== undefined) {
modifications.push({
field: key,
old_value: current[key],
new_value: reportData[key],
modified_at: now,
modified_by: userId
});
}
}
// 기존 이력과 병합
const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : [];
const newHistory = [...existingHistory, ...modifications];
const {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
photo_path1,
photo_path2,
photo_path3,
photo_path4,
photo_path5
} = reportData;
const [result] = await db.query(
`UPDATE work_issue_reports
SET factory_category_id = COALESCE(?, factory_category_id),
workplace_id = COALESCE(?, workplace_id),
custom_location = COALESCE(?, custom_location),
issue_category_id = COALESCE(?, issue_category_id),
issue_item_id = COALESCE(?, issue_item_id),
additional_description = COALESCE(?, additional_description),
photo_path1 = COALESCE(?, photo_path1),
photo_path2 = COALESCE(?, photo_path2),
photo_path3 = COALESCE(?, photo_path3),
photo_path4 = COALESCE(?, photo_path4),
photo_path5 = COALESCE(?, photo_path5),
modification_history = ?,
updated_at = NOW()
WHERE report_id = ?`,
[factory_category_id, workplace_id, custom_location,
issue_category_id, issue_item_id, additional_description,
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
JSON.stringify(newHistory), reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 삭제
*/
const deleteReport = async (reportId, callback) => {
try {
const db = await getDb();
// 먼저 사진 경로 조회 (삭제용)
const [photos] = await db.query(
`SELECT photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
resolution_photo_path1, resolution_photo_path2
FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
const [result] = await db.query(
`DELETE FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
// 삭제할 사진 경로 반환
callback(null, { result, photos: photos[0] });
} catch (err) {
callback(err);
}
};
// ==================== 상태 관리 ====================
/**
* 신고 접수 (reported → received)
*/
const receiveReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'reported') {
return callback(new Error('접수 대기 상태가 아닙니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'received', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'reported', 'received', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 담당자 배정
*/
const assignReport = async (reportId, assignData, callback) => {
try {
const db = await getDb();
const { assigned_department, assigned_user_id, assigned_by } = assignData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
// 접수 상태 이상이어야 배정 가능
const validStatuses = ['received', 'in_progress'];
if (!validStatuses.includes(current[0].status)) {
return callback(new Error('접수된 상태에서만 담당자 배정이 가능합니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET assigned_department = ?, assigned_user_id = ?,
assigned_at = NOW(), assigned_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[assigned_department, assigned_user_id, assigned_by, reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 시작 (received → in_progress)
*/
const startProcessing = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'received') {
return callback(new Error('접수된 상태에서만 처리를 시작할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'in_progress', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'received', 'in_progress', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 완료 (in_progress → completed)
*/
const completeReport = async (reportId, completionData, callback) => {
try {
const db = await getDb();
const { resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by } = completionData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'in_progress') {
return callback(new Error('처리 중 상태에서만 완료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'completed', resolution_notes = ?,
resolution_photo_path1 = ?, resolution_photo_path2 = ?,
resolved_at = NOW(), resolved_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by, reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by, change_reason)
VALUES (?, 'in_progress', 'completed', ?, ?)`,
[reportId, resolved_by, resolution_notes]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 종료 (completed → closed)
*/
const closeReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'completed') {
return callback(new Error('완료된 상태에서만 종료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'closed', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'completed', 'closed', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 상태 변경 이력 조회
*/
const getStatusLogs = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT wisl.log_id, wisl.report_id, wisl.previous_status, wisl.new_status,
wisl.changed_by, wisl.change_reason, wisl.changed_at,
u.username as changed_by_name, u.name as changed_by_full_name
FROM work_issue_status_logs wisl
INNER JOIN users u ON wisl.changed_by = u.user_id
WHERE wisl.report_id = ?
ORDER BY wisl.changed_at ASC`,
[reportId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
// ==================== 통계 ====================
/**
* 신고 통계 요약
*/
const getStatsSummary = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'reported' THEN 1 ELSE 0 END) as reported,
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
FROM work_issue_reports
WHERE ${whereClause}`,
params
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 카테고리별 통계
*/
const getStatsByCategory = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
const [rows] = await db.query(
`SELECT
irc.category_type, irc.category_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
WHERE ${whereClause}
GROUP BY irc.category_id
ORDER BY irc.category_type, count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 작업장별 통계
*/
const getStatsByWorkplace = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = 'wir.workplace_id IS NOT NULL';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
wir.factory_category_id, wc.category_name as factory_name,
wir.workplace_id, w.workplace_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
INNER JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE ${whereClause}
GROUP BY wir.factory_category_id, wir.workplace_id
ORDER BY count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
getAllCategories,
getCategoriesByType,
createCategory,
updateCategory,
deleteCategory,
// 항목
getItemsByCategory,
getAllItems,
createItem,
updateItem,
deleteItem,
// 신고
createReport,
getAllReports,
getReportById,
updateReport,
deleteReport,
// 상태 관리
receiveReport,
assignReport,
startProcessing,
completeReport,
closeReport,
getStatusLogs,
// 통계
getStatsSummary,
getStatsByCategory,
getStatsByWorkplace
};

View File

@@ -10,8 +10,7 @@ const formatDate = (dateStr) => {
};
// 1. 작업자 생성
const create = async (worker, callback) => {
try {
const create = async (worker) => {
const db = await getDb();
const {
worker_name,
@@ -20,64 +19,56 @@ const create = async (worker, callback) => {
salary = null,
annual_leave = null,
status = 'active',
employment_status = 'employed'
employment_status = 'employed',
department_id = null
} = worker;
const [result] = await db.query(
`INSERT INTO workers
(worker_name, job_type, join_date, salary, annual_leave, status, employment_status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[worker_name, job_type, formatDate(join_date), salary, annual_leave, status, employment_status]
(worker_name, job_type, join_date, salary, annual_leave, status, employment_status, department_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[worker_name, job_type, formatDate(join_date), salary, annual_leave, status, employment_status, department_id]
);
callback(null, result.insertId);
} catch (err) {
console.error('❌ create 함수 에러:', err);
callback(err);
}
return result.insertId;
};
// 2. 전체 조회
const getAll = async (callback) => {
try {
const getAll = async () => {
const db = await getDb();
const [rows] = await db.query(`
SELECT
w.*,
CASE WHEN w.status = 'active' THEN 1 ELSE 0 END AS is_active,
u.user_id
u.user_id,
d.department_name
FROM workers w
LEFT JOIN users u ON w.worker_id = u.worker_id
LEFT JOIN departments d ON w.department_id = d.department_id
ORDER BY w.worker_id DESC
`);
callback(null, rows);
} catch (err) {
callback(err);
}
return rows;
};
// 3. 단일 조회
const getById = async (worker_id, callback) => {
try {
const getById = async (worker_id) => {
const db = await getDb();
const [rows] = await db.query(`
SELECT
w.*,
CASE WHEN w.status = 'active' THEN 1 ELSE 0 END AS is_active,
u.user_id
u.user_id,
d.department_name
FROM workers w
LEFT JOIN users u ON w.worker_id = u.worker_id
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.worker_id = ?
`, [worker_id]);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
return rows[0];
};
// 4. 작업자 수정
const update = async (worker, callback) => {
try {
const update = async (worker) => {
const db = await getDb();
const {
worker_id,
@@ -87,7 +78,8 @@ const update = async (worker, callback) => {
join_date,
salary,
annual_leave,
employment_status
employment_status,
department_id
} = worker;
// 업데이트할 필드만 동적으로 구성
@@ -122,10 +114,13 @@ const update = async (worker, callback) => {
updates.push('employment_status = ?');
values.push(employment_status);
}
if (department_id !== undefined) {
updates.push('department_id = ?');
values.push(department_id);
}
if (updates.length === 0) {
callback(new Error('업데이트할 필드가 없습니다'));
return;
throw new Error('업데이트할 필드가 없습니다');
}
values.push(worker_id); // WHERE 조건용
@@ -137,15 +132,11 @@ const update = async (worker, callback) => {
const [result] = await db.query(query, values);
callback(null, result.affectedRows);
} catch (err) {
console.error('❌ update 함수 에러:', err);
callback(new Error(err.message || String(err)));
}
return result.affectedRows;
};
// 5. 삭제 (외래키 제약조건 처리)
const remove = async (worker_id, callback) => {
const remove = async (worker_id) => {
const db = await getDb();
const conn = await db.getConnection();
@@ -186,18 +177,17 @@ const remove = async (worker_id, callback) => {
console.log(`✅ 작업자 삭제 완료: ${result.affectedRows}`);
await conn.commit();
callback(null, result.affectedRows);
return result.affectedRows;
} catch (err) {
await conn.rollback();
console.error(`❌ 작업자 삭제 오류 (worker_id: ${worker_id}):`, err);
callback(new Error(`작업자 삭제 중 오류가 발생했습니다: ${err.message}`));
throw new Error(`작업자 삭제 중 오류가 발생했습니다: ${err.message}`);
} finally {
conn.release();
}
};
// ✅ 모듈 내보내기 (정상 구조)
module.exports = {
create,
getAll,

View File

@@ -0,0 +1,440 @@
const { getDb } = require('../dbPool');
// ==================== 카테고리(공장) 관련 ====================
/**
* 카테고리 생성
*/
const createCategory = async (category, callback) => {
try {
const db = await getDb();
const {
category_name,
description = null,
display_order = 0,
is_active = true
} = category;
const [result] = await db.query(
`INSERT INTO workplace_categories
(category_name, description, display_order, is_active)
VALUES (?, ?, ?, ?)`,
[category_name, description, display_order, is_active]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 모든 카테고리 조회
*/
const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`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`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 활성 카테고리만 조회
*/
const getActiveCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`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`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* ID로 카테고리 조회
*/
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, layout_image, created_at, updated_at
FROM workplace_categories
WHERE category_id = ?`,
[categoryId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 수정
*/
const updateCategory = async (categoryId, category, callback) => {
try {
const db = await getDb();
const {
category_name,
description,
display_order,
is_active,
layout_image
} = category;
const [result] = await db.query(
`UPDATE workplace_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?, layout_image = ?, updated_at = NOW()
WHERE category_id = ?`,
[category_name, description, display_order, is_active, layout_image, categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 삭제
*/
const deleteCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_categories WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 작업장 관련 ====================
/**
* 작업장 생성
*/
const createWorkplace = async (workplace, callback) => {
try {
const db = await getDb();
const {
category_id = null,
workplace_name,
description = null,
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, workplace_purpose, display_priority)
VALUES (?, ?, ?, ?, ?, ?)`,
[category_id, workplace_name, description, is_active, workplace_purpose, display_priority]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 모든 작업장 조회 (카테고리 정보 포함)
*/
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.workplace_purpose, w.display_priority,
w.layout_image, 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.display_priority ASC, w.workplace_id DESC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 활성 작업장만 조회
*/
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, w.workplace_purpose, w.display_priority,
w.layout_image, w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.is_active = TRUE
ORDER BY wc.display_order ASC, w.workplace_id DESC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 카테고리별 작업장 조회
*/
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, w.workplace_purpose, w.display_priority,
w.layout_image, w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.category_id = ?
ORDER BY w.workplace_id DESC`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* ID로 작업장 조회
*/
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, w.workplace_purpose, w.display_priority,
w.layout_image, w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.workplace_id = ?`,
[workplaceId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 작업장 수정
*/
const updateWorkplace = async (workplaceId, workplace, callback) => {
try {
const db = await getDb();
const {
category_id,
workplace_name,
description,
is_active,
workplace_purpose,
display_priority,
layout_image
} = workplace;
const [result] = await db.query(
`UPDATE workplaces
SET category_id = ?, workplace_name = ?, description = ?, is_active = ?,
workplace_purpose = ?, display_priority = ?, layout_image = ?, updated_at = NOW()
WHERE workplace_id = ?`,
[category_id, workplace_name, description, is_active, workplace_purpose, display_priority, layout_image, workplaceId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 작업장 삭제
*/
const deleteWorkplace = async (workplaceId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplaces WHERE workplace_id = ?`,
[workplaceId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 작업장 지도 영역 관련 ====================
/**
* 작업장 지도 영역 생성
*/
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,
getAllCategories,
getActiveCategories,
getCategoryById,
updateCategory,
deleteCategory,
// 작업장
createWorkplace,
getAllWorkplaces,
getActiveWorkplaces,
getWorkplacesByCategory,
getWorkplaceById,
updateWorkplace,
deleteWorkplace,
// 지도 영역
createMapRegion,
getMapRegionsByCategory,
getMapRegionByWorkplace,
updateMapRegion,
deleteMapRegion,
deleteMapRegionsByCategory
};

Some files were not shown because too many files have changed in this diff Show More