Compare commits

...

72 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
Hyungi Ahn
4ac0605887 refactor: 네비게이션 헤더 최신 디자인으로 전면 개편 및 로그인 버그 수정
- fix: 로그인 API에서 user.role_name 필드 올바르게 사용 (auth.service.js)
- refactor: navbar 컴포넌트를 최신 dashboard-header 스타일로 전환
- refactor: 구버전 work-report-header 제거 (6개 페이지)
- refactor: load-navbar.js를 최신 헤더 구조에 맞게 업데이트
- style: 파란색 그라데이션 헤더, 실시간 시계, 향상된 프로필 메뉴
- docs: 2026-01-20 개발 로그 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 08:40:19 +09:00
Hyungi Ahn
6933f67a2e docs: 작업자-계정 연동 기능 가이드 추가
작업자 계정 연동 기능에 대한 상세 가이드 문서를 작성했습니다.

## 문서 내용

### 1. 기능 개요
- 개념 변경 (작업 보고서 표시 → 계정 연동)
- 주요 변경사항 설명

### 2. 기술 상세
- 데이터베이스 스키마
- 백엔드 API 구현
- 프론트엔드 구현
- 한글→영문 변환 로직

### 3. 사용 가이드
- 신규 작업자 등록 + 계정 생성
- 기존 작업자에 계정 추가
- 계정 연동 해제
- 퇴사 처리

### 4. 배포 및 운영
- 배포 절차
- 테스트 시나리오
- 문제 해결 가이드
- 보안 고려사항

### 5. 향후 개선 계획
- 비밀번호 정책
- 계정 관리 UI
- 대량 작업 기능
- 알림 기능

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 10:29:18 +09:00
Hyungi Ahn
1e4dbf10db feat: 배포 자동화 스크립트 추가
배포 과정을 자동화하는 스크립트와 가이드 문서를 추가했습니다.

## 추가된 파일

### deploy.sh
자동 배포 스크립트:
1. Git Pull
2. NPM Install (package.json 변경 시)
3. 데이터베이스 마이그레이션 (확인 후 실행)
4. PM2 서버 재시작
5. 상태 확인

### DEPLOY.md
배포 가이드 문서:
- 자동 배포 방법
- 수동 배포 단계
- 배포 후 확인사항
- 문제 해결 가이드
- 데이터베이스 백업/복구 방법

## 사용 방법

서버에서 다음 명령어로 배포:
\`\`\`bash
cd api.hyungi.net
chmod +x deploy.sh  # 처음 한 번만
./deploy.sh
\`\`\`

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 10:24:45 +09:00
Hyungi Ahn
bea62dfdee refactor: 작업자 관리 개선 - 계정 연동 기능으로 변경
작업 보고서 표시 여부 대신 계정 연동 기능으로 개선했습니다.

## 주요 변경사항

### 개념 변경
- **이전**: 작업 보고서 표시 여부 (show_in_work_reports)
- **이후**: 계정 생성/연동 기능

### 데이터베이스
- **마이그레이션**: 20260119095549_add_worker_display_fields.js
  - show_in_work_reports 컬럼 제거
  - employment_status만 유지 (employed/resigned)

- **workerModel**:
  - getAll, getById에서 users 테이블 JOIN하여 user_id 조회
  - create, update에서 show_in_work_reports 필드 제거

### 백엔드 API
- **workerController.js**:
  - createWorker: create_account 체크 시 자동으로 users 테이블에 계정 생성
    - username: hangulToRoman으로 한글 이름 변환
    - password: 초기 비밀번호 '1234' (bcrypt 해시)
    - role: User 역할 자동 할당
  - updateWorker:
    - create_account=true & 계정 없음 → 계정 생성
    - create_account=false & 계정 있음 → 계정 연동 해제 (users.worker_id=NULL)

### 프론트엔드
- **worker-management.html**:
  - "작업 보고서 표시" → "🔐 계정 생성/연동"으로 변경
  - 체크 시 로그인 계정 자동 생성 안내

- **worker-management.js**:
  - 카드 렌더링: user_id 존재 여부로 계정 연동 상태 표시 (🔐 아이콘)
  - saveWorker: create_account 필드 전송
  - show_in_work_reports 관련 로직 모두 제거

- **daily-work-report.js**:
  - 필터링 조건 단순화: 퇴사자만 제외 (employment_status≠resigned)
  - 계정 여부와 무관하게 모든 재직자 표시

## 사용 방법
1. 작업자 등록/수정 시 "계정 생성/연동" 체크
2. 자동으로 로그인 계정 생성 (초기 비밀번호: 1234)
3. 계정이 있는 작업자는 나의 대시보드, 연차/출퇴근 관리 가능

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 10:17:31 +09:00
Hyungi Ahn
25cca1482e feat: 작업자 관리 개선 - 작업보고서 표시/현장직 구분/퇴사 처리
작업자 관리 페이지에 3가지 상태 관리 기능을 추가했습니다:
1. 작업 보고서 표시 여부 (관리자 등은 작업보고서에 표시 안함)
2. 현장직/사무직 구분 (사무직은 출퇴근 관리 불필요)
3. 퇴사 처리 (퇴사자 별도 표시)

## 주요 변경사항

### 데이터베이스
- **마이그레이션**: 20260119095549_add_worker_display_fields.js
  - workers 테이블에 show_in_work_reports (BOOLEAN) 추가
  - workers 테이블에 employment_status (ENUM: employed, resigned) 추가

### 백엔드
- **workerModel.js**: create, update 함수에 새로운 필드 처리 로직 추가

### 프론트엔드
- **worker-management.html**: 작업자 모달에 3가지 체크박스 추가
  - 작업 보고서에 표시
  - 현장직 (활성화) - 사무직과 구분
  - 퇴사 처리
- **worker-management.js**:
  - 퇴사자 카드 렌더링 시 별도 스타일 적용
  - 새 필드 값 로드 및 저장 처리
- **daily-work-report.js**:
  - 작업 보고서 작성 시 show_in_work_reports=true이고 퇴사하지 않은 작업자만 표시

## 배포 절차
```bash
cd api.hyungi.net
npm run db:migrate
```

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 10:02:32 +09:00
Hyungi Ahn
70630b380a feat: 작업자-계정 통합 및 연차/출근 관리 시스템 구축
모든 작업자가 개인 계정으로 로그인하여 본인의 연차와 출근 기록을 확인할 수 있는 시스템을 구축했습니다.

주요 기능:
- 작업자-계정 1:1 통합 (기존 작업자 자동 계정 생성)
- 연차 관리 시스템 (연도별 잔액 관리)
- 출근 기록 시스템 (일일 근태 기록)
- 나의 대시보드 페이지 (개인 정보 조회)

데이터베이스:
- workers 테이블에 salary, base_annual_leave 컬럼 추가
- work_attendance_types, vacation_types 테이블 생성
- daily_attendance_records 테이블 생성
- worker_vacation_balance 테이블 생성
- 기존 작업자 자동 계정 생성 (username: 이름 기반)
- Guest 역할 추가

백엔드 API:
- 한글→영문 변환 유틸리티 (hangulToRoman.js)
- UserRoutes에 개인 정보 조회 API 추가
  - GET /api/users/me (내 정보)
  - GET /api/users/me/attendance-records (출근 기록)
  - GET /api/users/me/vacation-balance (연차 잔액)
  - GET /api/users/me/work-reports (작업 보고서)
  - GET /api/users/me/monthly-stats (월별 통계)

프론트엔드:
- 나의 대시보드 페이지 (my-dashboard.html)
- 연차 정보 위젯 (총/사용/잔여)
- 월별 출근 캘린더
- 근무 시간 통계
- 최근 작업 보고서 목록
- 네비게이션 바에 "나의 대시보드" 메뉴 추가

배포 시 주의사항:
- 마이그레이션 실행 필요
- 자동 생성된 계정 초기 비밀번호: 1234
- 작업자들에게 첫 로그인 후 비밀번호 변경 안내 필요

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 09:49:48 +09:00
Hyungi Ahn
337cd14a15 docs: 작업 분석 페이지 수정 개발로그 추가
작업 분석 페이지 모듈 로딩 오류 수정 작업에 대한 상세 개발로그를 추가했습니다.

포함 내용:
- 발견된 문제점 및 원인 분석
- 해결 방안 및 코드 변경 사항
- 전체 페이지 점검 결과
- Before/After 테스트 결과
- 기술적 배경 (ES6 모듈 시스템)
- 향후 개선 계획

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 09:29:01 +09:00
Hyungi Ahn
b0d17cd53b fix(web-ui): 작업 분석 페이지 모듈 로딩 오류 수정
작업 분석 페이지에서 발생하던 JavaScript 모듈 로딩 오류를 해결했습니다.

문제점:
- SyntaxError: import call expects one or two arguments
- ReferenceError: Can't find variable: apiCall
- 네비게이션 바 미표시

해결 방법:
- api-config.js, load-navbar.js, work-analysis.js에 type="module" 추가
- work-analysis.js에서 api-config.js import하여 로딩 순서 보장
- 스크립트 버전 업데이트 (캐시 클리어)

수정된 파일:
- web-ui/pages/analysis/work-analysis.html: 스크립트 태그에 type="module" 추가
- web-ui/js/work-analysis.js: api-config.js import 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 08:57:57 +09:00
Hyungi Ahn
d810a8b339 refactor(web-ui): 전체 UI 반응형 디자인 개선
모든 화면 크기에서 일관되고 안정적인 사용자 경험을 제공하도록
UI 컴포넌트를 전면 개선했습니다.

주요 변경사항:
- 네비게이션 바: flex-wrap, rem 단위, sticky positioning 적용
- 사용자 정보 영역: max-width로 크기 제한, 텍스트 overflow 처리
- 공통 헤더: clamp()로 반응형 폰트, 반응형 패딩 적용
- 모든 관리 페이지: ES6 모듈 로딩 통일 (type="module")
- 반응형 breakpoint: 1200px, 768px, 640px, 480px

개선 효과:
 모든 페이지에서 일관된 헤더 표시
 사용자 정보 영역 늘어나는 문제 해결
 모든 화면 크기에서 최적화된 레이아웃
 rem 단위 사용으로 접근성 개선

수정된 파일:
- web-ui/components/navbar.html: 전면 리팩토링
- web-ui/css/common.css: 반응형 스타일 추가
- web-ui/pages/**/*.html: 모듈 로딩 및 버전 업데이트 (13개 파일)
- web-ui/js/*.js: 모듈 시스템 개선

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 08:54:44 +09:00
Hyungi Ahn
344ad35651 fix: 작업자/프로젝트 관리 페이지 모듈 로딩 및 DB 스키마 동기화 수정
## 수정 내용

### 1. JavaScript 모듈 로딩 문제 수정
- ES6 import 사용 파일에 type="module" 속성 추가
- api-config.js, load-navbar.js, worker-management.js, project-management.js

### 2. DB 스키마 불일치 해결
- workers 테이블 실제 구조에 맞게 코드 수정
- 존재하지 않는 컬럼 제거: phone_number, email, hire_date, department, notes
- 실제 컬럼 사용: join_date, salary, annual_leave

### 3. 백엔드 수정
- workerModel.js: create, update 함수를 실제 테이블 구조에 맞게 수정
- workerController.js: 상세 로깅 추가

### 4. 프론트엔드 수정
- worker-management.js: 데이터 전송 구조 수정
- api-config.js: 에러 로깅 개선
- HTML 파일: 스크립트 type="module" 추가 및 버전 업데이트

### 5. 개발 문서
- 개발로그 추가: 2026-01-19_작업자관리_스키마_동기화.md

## 영향 범위
- 작업자 관리 페이지: 상태 변경 기능 정상화
- 프로젝트 관리 페이지: 모듈 로딩 오류 수정

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 08:35:36 +09:00
Hyungi Ahn
79bd9324ca refactor(web-ui): Apply modular structure and refactor dashboard
This commit applies the modular refactoring to the web-ui,
including the new daily attendance tracking feature.

- **Modular Structure:** Re-introduced the modular files 'config.js',
  'component-loader.js', and 'navigation.js' to centralize configuration,
  component loading, and navigation logic.
- **Refactored Dashboard:** Refactored 'group-leader-dashboard.js' to use
  the new 'apiCall' function from 'api-config.js' for API requests,
  removing duplicated code and improving error handling.
- **ES6 Modules:** Updated 'group-leader.html' to load scripts as
  ES6 modules ('type="module"'), ensuring compatibility with the
  modular JavaScript files.
- **Clean-up:** Removed unnecessary global variables and duplicated
  functions, improving code quality and maintainability.
2026-01-06 17:29:39 +09:00
Hyungi Ahn
7d89ec448c feat: Implement daily attendance tracking system
- Backend: Auto-sync work reports with attendance records
- Backend: Lazy initialization of daily active worker records
- Frontend: Real-time attendance status on Group Leader Dashboard
2026-01-06 17:15:56 +09:00
Hyungi Ahn
b4037c9395 feat(web-ui): Refactor web-ui for improved maintainability and modularity
This commit introduces a series of refactoring changes to the web-ui
to remove hardcoded values and improve page integration.

- **Centralized Configuration:** Created  to
  centralize API ports, paths, and navigation URLs, replacing
  hardcoded values across multiple files.
- **Modular Component Loading:** Introduced
  to handle dynamic loading of common HTML components (e.g., sidebar, navbar),
  using paths from .
- **Modular Navigation:** Created  to centralize
  page redirection logic, improving maintainability and reducing direct
   manipulations.
- **Refactored Existing Modules:** Updated ,
  , , and
   to utilize the new , ,
  and  modules.
- **ES6 Module Compatibility:** Ensured  loads scripts
  as ES6 modules () to support  statements.
2026-01-06 15:54:49 +09:00
Hyungi Ahn
48fff7df64 Fix: Worker/Project status update and filtering issues
- Added cache invalidation for Workers and Projects
- Implemented server-side status filtering for Workers
- Fixed worker update query bug (removed non-existent join_date column)
- Updated daily-work-report UI to fetch only active workers
2026-01-06 15:50:40 +09:00
Hyungi Ahn
3549710325 fix: Login 500, DB schema sync, Dashboard missing data, Major Refactoring 2025-12-19 15:47:15 +09:00
Hyungi Ahn
770fa91366 feat: Add interactive setup script
Added an interactive Node.js script (setup.js) to guide users through the initial environment setup. This script prompts for necessary database credentials and generates JWT secrets, then creates/updates the .env file.

A 'setup' script has been added to api.hyungi.net/package.json for easy execution.
This improves the first-time setup experience by streamlining the .env file creation process.
2025-12-19 13:06:26 +09:00
Hyungi Ahn
05843da1c4 refactor(db,frontend): Improve queries and modularize frontend
- Replaced SELECT* queries in 8 models with explicit columns.
- Began modularizing work-report-calendar.js by creating CalendarAPI.js, CalendarState.js, and CalendarView.js.
- Refactored manage-project.js to use global API helpers.
- Fixed API container crash by adding missing volume mounts to docker-compose.yml.
- Added new migration for missing columns in the projects table.
- Documented current DB schema and deployment notes.
2025-12-19 12:42:24 +09:00
Hyungi Ahn
8a8307edfc refactor(frontend): Begin modularizing work-report-calendar
Initiated the process of refactoring the monolithic `work-report-calendar.js` file as outlined in the Phase 2 frontend modernization plan.

- Created `CalendarAPI.js` to encapsulate all API calls related to the calendar, centralizing data fetching logic.
- Created `CalendarState.js` to manage the component's state, removing global variables from the main script.
- Refactored `work-report-calendar.js` to use the new state and API modules.
- Refactored `manage-project.js` to use the existing global API helpers, providing a consistent example for API usage.
2025-12-19 10:46:29 +09:00
Hyungi Ahn
bc5df77595 refactor(db): Replace SELECT * with explicit columns in models
Replaced `SELECT *` statements across multiple data models with explicit column lists to improve query performance, reduce data transfer, and increase code clarity. This is part of the Phase 2 refactoring plan.

- Refactored queries in the following models:
  - projectModel
  - toolsModel
  - attendanceModel
  - dailyIssueReportModel
  - issueTypeModel
  - workReportModel
  - userModel
  - dailyWorkReportModel

fix(api): Add missing volume mounts to docker-compose

Modified docker-compose.yml to mount the `config`, `middlewares`, `utils`, and `services` directories into the API container. This fixes a `MODULE_NOT_FOUND` error that caused the container to crash on startup.

feat(db): Add migration for missing project columns

Created a new database migration to add `is_active`, `project_status`, and `completed_date` columns to the `projects` table, resolving an inconsistency between the model code and the schema.

docs: Add deployment notes

Added a new markdown file to document the testing (macOS, Docker Desktop) and production (Synology NAS, Container Manager) environments.
2025-12-19 10:33:29 +09:00
919 changed files with 444866 additions and 17673 deletions

43
CHECKLIST.md Normal file
View File

@@ -0,0 +1,43 @@
# 프로젝트 체크리스트 및 컨텍스트
이 문서는 개발 시 확인해야 할 스키마 정보, 남은 작업, 삭제된 리소스 목록을 포함합니다.
---
## ✅ 현재 작업 (To-Do)
- [x] **WorkAnalysis 리팩토링** (Controller -> Service 분리)
- [ ] **테스트 코드 작성** (`tests/unit/services/workAnalysis.test.js` 추가 필요)
- [ ] **기타 컨트롤러 리팩토링** (Phase 3 진행 중)
- `monthlyStatusController.js` 확인 필요
---
## 🗄 데이터베이스 스키마 요약
*(상세 내용은 원본 스키마 파일 참조, 여기는 자주 찾는 정보 요약)*
### 주요 테이블
- `daily_work_reports`: 작업 보고서 메인 (Date, Worker, Project, WorkType, Status, Hours)
- `workers`: 작업자 마스터
- `projects`: 프로젝트 마스터
- `work_types`: 작업 유형 (1:Base, 2:Vessel, 3:Piping, 4:Wait)
- `work_status_types`: 상태 (1:정규, 2:에러)
- `error_types`: 에러 유형 (WorkStatus=2일 때 필수)
### 주의사항
- **스키마 버전**: v1 (JSON 호환)
- **Timezone**: 모든 시간은 KST 기준 처리 확인 필요
- **Soft Delete**: `deleted_at` 컬럼 사용 여부 확인
---
## 🗑 삭제/정리된 리소스 (참조용)
### 삭제된 페이지 (2025-11-03)
- **대시보드 통일**: `group-leader.html` 하나로 모든 권한 통합됨.
- **삭제 대상**:
- `dashboard/admin.html`, `dashboard/system.html` (미사용)
- `admin/factory-upload.html` 등 미사용 관리페이지
- `issue-reports/` 폴더 전체
### 삭제된 테이블 (히스토리)
*(필요 시 `DELETED_TABLES.md` 참고)*

439
CODING_GUIDE.md Normal file
View File

@@ -0,0 +1,439 @@
# TK-FB-Project 통합 개발 가이드
이 문서는 프로젝트의 실행, 규칙, 테스트, 호환성 등 모든 개발 관련 사항을 통합한 가이드입니다.
---
## 🏗 프로젝트 개요 및 아키텍처
**생산팀 내부 포털 개발 및 유지보수**
### 기술 스택
- **Backend**: Node.js, Express.js (Port: 20005)
- **Frontend**: Vanilla HTML/CSS/JS (Port: 20000)
- **Database**: MariaDB (Port: 20306), phpMyAdmin (Port: 20080)
- **Infra**: Docker Compose (Synology NAS, Mac Mini)
- **Bridge**: FastAPI (Port: 20010, Python 3.11+) - *2025.07 도입*
### 아키텍처 모식도
```
브라우저 → FastAPI (8000) → Express (3005) → MariaDB
정적 파일 서빙
```
*Note: 현재 개발 환경 포트는 위 기술 스택 섹션 참조*
---
## 🚀 실행 가이드
### 필수: 환경 변수 설정
`.env.example``.env`로 복사하고 설정하세요. **절대 커밋 금지!**
### Docker 실행
```bash
./start.sh # 간편 실행 (권장)
./stop.sh # 중지
docker-compose up -d # 수동 실행
```
---
## 📏 코딩 컨벤션
### 네이밍
| 대상 | 스타일 | 예시 |
|---|---|---|
| JS 변수/함수 | camelCase | `calculateTotal` |
| JS 클래스 | PascalCase | `UserReport` |
| 파일명 | kebab-case | `work-report.js` |
| DB 테이블/컬럼 | snake_case | `user_accounts` |
| API URL | plural, kebab | `/api/work-reports` |
### 코드 품질
- **파일 분리**: 750줄 초과 시 리팩토링 고려 (Controller/Service/Model 분리 필수).
- **Early Return**: 중첩 조건문 지양.
- **주석**: JSDoc 활용 권장.
---
## 🎨 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).
- **응답 포맷**:
```json
{ "success": true, "data": {...}, "message": "..." }
```
- **계층 구조**:
- `Controller`: 요청/응답 처리, 유효성 검사.
- `Service`: 비즈니스 로직, 트랜잭션 관리.
- `Model`: DB 쿼리 실행.
### MySQL 8.0 호환성 주의사항 (중요)
Synology NAS(MySQL 8.0)의 `Strict Mode`로 인해 `db.execute()` 사용 시 `Incorrect arguments` 에러가 발생할 수 있습니다.
- **해결책**: `db.query()` 사용 권장 (특히 복잡한 JOIN/Subquery).
- **가이드**: `models/WorkAnalysis.js` 등의 `getRecentWork` 참조.
---
## 🕐 시간대(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)
### 중요도
1. **Service Layer** ⭐ (최우선: 비즈니스 로직 검증)
2. Controller Layer (요청 처리 검증)
3. Integration (E2E)
### 실행
```bash
npm test # 전체 실행
npm run test:watch # 변경 감지
npm run test:coverage # 커버리지 측정
```
### 작성 패턴 (Service 예시)
```javascript
// Service는 DB Model을 Mocking하여 테스트
const service = require('../service');
jest.mock('../model');
it('should create report', async () => {
Model.create.mockResolvedValue(123); // Mock DB response
const result = await service.createReport(...);
expect(result).toEqual(...);
});
```
---
## 📝 Git 관리
- **커밋 메시지**: `type: subject` (예: `feat: 작업보고서 API 추가`)
- **Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- **브랜치**: `main`(배포), `develop`(통합), `feature/*`(기능)

210
DEV_LOG.md Normal file
View File

@@ -0,0 +1,210 @@
# 개발 진행 로그
## 📅 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`를 리팩토링함.
- **[NEW] Service Layer**: `services/workAnalysisService.js` 생성. 비즈니스 로직 이관.
- **[UPDATE] Model Layer**: Raw SQL 쿼리를 Controller에서 Model(`models/WorkAnalysis.js`)로 이동. `getProjectWorkTypeRawData` 메서드 추가.
- **[CLEANUP] Controller**: `getProjectWorkTypeAnalysis` 메서드가 Service를 호출하도록 단순화.
### 🐛 심각한 버그 수정 및 시스템 정상화 (2025-12-19)
**개요**: 로그인 500 에러 및 대시보드 데이터 미표시 문제 해결.
1. **DB 정상화 (Login 500 Fix)**
- **원인**: 초기화된 DB(Empty) 및 테이블명 대소문자 불일치(`Users` vs `users`).
- **조치**: `hyungi.sql` 복원 후, 컨벤션에 맞춰 테이블명 일괄 변경(`Users``users`, `Projects``projects`, `Workers``workers`, `Tasks``tasks`).
- **코드 수정**: `userModel.js`, `authController.js` 등 관련 코드의 테이블 참조 수정.
2. **프로젝트 조회 오류 수정 (Project API 500 Fix)**
- **원인**: 구버전 스키마 복원으로 인한 `projects` 테이블 컬럼 부족(`is_active`, `project_status`, `completed_date`).
- **조치**: 마이그레이션 실행하여 누락된 컬럼 추가.
3. **대시보드 작업자 미표시 수정**
- **원인 1 (Data)**: `workers` 테이블 내 `status` 값이 유효하지 않음(`..`). → `active`로 일괄 수정.
- **원인 2 (Logic)**: `INNER JOIN` 사용으로 통계가 없는 작업자 누락. → `LEFT JOIN`으로 쿼리 개선(`MonthlyStatusModel.js`).
4. **테스트 계정 생성**
- `tester` / `000000` 관리자(Leader) 계정 생성.
### 🛠️ 작업자 및 프로젝트 관리 기능 개선 (2026-01-06)
**개요**: 작업자/프로젝트의 비활성화(퇴사/종료) 처리가 즉시 반영되지 않는 문제 및 로직 오류 수정.
1. **캐시 무효화 및 필터링 적용 (Cache & Filtering)**
- **문제**: 작업자/프로젝트 상태 변경 후에도 캐시가 남아있어 드롭다운 목록에서 사라지지 않음.
- **해결**:
- `WorkerController`, `ProjectController`: 생성/수정/삭제 시 `request` 단위의 캐시 즉시 무효화 로직 추가.
- `WorkerController`: 목록 조회 시 `status` 파라미터 지원 추가.
- `daily-work-report.js`: 작업보고서 작성 시 `active` 상태인 작업자만 필터링하여 조회하도록 수정.
2. **작업자 비활성화 오류 수정 (Bug Fix)**
- **원인**: `workerModel.update` 쿼리에 DB에 존재하지 않는 `join_date` 컬럼을 업데이트하려는 시도가 있어 SQL 에러 발생.
- **해결**: `workerModel.js`에서 잘못된 컬럼(`join_date`) 참조 제거. (올바른 컬럼 `hire_date`는 유지)
3. **일일 근태 추적 시스템 구현 (Daily Attendance Tracking)**
- **Backend**:
- `AttendanceModel.initializeDailyRecords` 추가: 모든 활성 작업자에 대해 'incomplete' 상태의 근태 기록 자동 생성 (Lazy Initialization).
- `AttendanceModel.syncWithWorkReports` 추가: 작업 보고서 작성/수정/삭제 시 근태 상태(미제출/부분/완료/초과) 자동 동기화.
- `dailyWorkReportModel.js`에 동기화 로직 통합 (트랜잭션 후 처리).
- `attendanceService`에서 상태 조회 시 초기화 로직 수행.
- **Frontend**:
- `group-leader-dashboard.js` 리팩토링: 모의 데이터 대신 실제 API(`/attendance/daily-status`) 연동.
- `modern-dashboard.css`: 근태 현황 카드(`worker-card`) 및 그리드 스타일 추가.
- `group-leader.html`: 스크립트 로드 추가 및 DOM 구조 확인.
---
## 🛡보안 및 검토 리포트 (History)
### 2025-07-29 보안 취약점 분석
`api.hyungi.net` 백엔드 서버 취약점 점검 결과.
| 라이브러리 | 심각도 | 상태 | 조치사항 |
|---|---|---|---|
| `tar-fs` | **High** | 해결가능 | `npm audit fix` 권장 (완료됨) |
| `brace-expansion` | Low | 해결가능 | `npm audit fix` 권장 (완료됨) |
| `pm2` | Low | 미해결 | 업데이트 대기 필요 |
---
## 📋 향후 계획
1. **테스트 커버리지 확보**: 리팩토링된 Service Layer에 대한 Unit Test 보강.
2. **Knex 마이그레이션**: 남은 Raw SQL(Model Layer)을 Knex 쿼리빌더로 점진적 전환.
3. **API 문서화**: Swagger/OpenAPI 도입 검토.

199
api.hyungi.net/DEPLOY.md Normal file
View File

@@ -0,0 +1,199 @@
# API 서버 배포 가이드
## 자동 배포 (권장)
### 1. 배포 스크립트 실행
```bash
cd api.hyungi.net
# 처음 한 번만: 실행 권한 부여
chmod +x deploy.sh
# 배포 실행
./deploy.sh
```
배포 스크립트는 다음을 자동으로 처리합니다:
1. ✅ Git Pull
2. ✅ NPM Install (package.json 변경 시)
3. ✅ 데이터베이스 마이그레이션 (확인 후 실행)
4. ✅ PM2 서버 재시작
5. ✅ 상태 확인
---
## 수동 배포
### 1. Git Pull
```bash
cd api.hyungi.net
git pull
```
### 2. 의존성 설치 (package.json 변경 시)
```bash
npm install
```
### 3. 데이터베이스 마이그레이션
⚠️ **중요**: 마이그레이션 전 데이터베이스 백업을 권장합니다!
```bash
# 마이그레이션 실행
npm run db:migrate
# 마이그레이션 롤백 (문제 발생 시)
npm run db:rollback
```
### 4. PM2 서버 재시작
```bash
# 무중단 재시작 (권장)
pm2 reload ecosystem.config.js --env production
# 또는 일반 재시작
pm2 restart hyungi-api
# 서버 중지 후 시작
pm2 stop hyungi-api
pm2 start ecosystem.config.js --env production
```
---
## 배포 후 확인사항
### 1. 서버 상태 확인
```bash
# PM2 프로세스 목록
pm2 list
# 실시간 로그 확인
pm2 logs hyungi-api
# 에러 로그만 확인
pm2 logs hyungi-api --err
```
### 2. API 응답 확인
```bash
# Health Check
curl http://localhost:20005/health
# 또는
curl http://api.hyungi.net/health
```
### 3. 마이그레이션 상태 확인
```bash
# 현재 마이그레이션 버전 확인
npx knex migrate:currentVersion --knexfile knexfile.js
# 적용된 마이그레이션 목록
npx knex migrate:list --knexfile knexfile.js
```
---
## 문제 해결
### 마이그레이션 실패 시
1. **에러 로그 확인**
```bash
pm2 logs hyungi-api --err
```
2. **마이그레이션 롤백**
```bash
npm run db:rollback
```
3. **특정 마이그레이션만 실행**
```bash
npx knex migrate:up 20260119095549_add_worker_display_fields.js --knexfile knexfile.js
```
### 서버 시작 실패 시
1. **포트 충돌 확인**
```bash
lsof -i :20005
```
2. **PM2 프로세스 완전 삭제 후 재시작**
```bash
pm2 delete hyungi-api
pm2 start ecosystem.config.js --env production
```
3. **환경변수 확인**
```bash
cat .env
```
---
## 환경별 배포
### Development (개발)
```bash
NODE_ENV=development npm run db:migrate
pm2 reload ecosystem.config.js --env development
```
### Production (운영)
```bash
NODE_ENV=production npm run db:migrate
pm2 reload ecosystem.config.js --env production
```
---
## 데이터베이스 백업
### 백업 생성
```bash
# MySQL 백업
mysqldump -h DB_HOST -u DB_USER -p DB_NAME > backup_$(date +%Y%m%d_%H%M%S).sql
```
### 백업 복구
```bash
mysql -h DB_HOST -u DB_USER -p DB_NAME < backup_20260119_120000.sql
```
---
## CI/CD 자동화 (향후 개선안)
GitHub Actions 또는 GitLab CI/CD를 사용한 자동 배포:
```yaml
# .github/workflows/deploy.yml 예시
name: Deploy to Production
on:
push:
branches: [ master ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: SSH and Deploy
run: |
ssh user@server 'cd /path/to/api.hyungi.net && ./deploy.sh'
```
---
## 참고사항
- **마이그레이션은 한 방향으로만 진행** (forward-only)
- **rollback은 개발 환경에서만 사용 권장**
- **운영 환경에서는 반드시 백업 후 마이그레이션**
- **PM2 reload는 무중단 재시작** (downtime 없음)

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

@@ -39,6 +39,20 @@ function setupRoutes(app) {
const workReportAnalysisRoutes = require('../routes/workReportAnalysisRoutes');
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');
@@ -53,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 요청에 활동 로거 적용
@@ -69,10 +91,7 @@ function setupRoutes(app) {
app.use('/api/setup', setupRoutes);
// Health check
app.use('/api', healthRoutes);
// 일반 API에 속도 제한 적용
app.use('/api/', apiLimiter);
app.use('/api/health', healthRoutes);
// 인증이 필요 없는 공개 경로 목록
const publicPaths = [
@@ -88,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 ||
@@ -108,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);
@@ -125,6 +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,25 +13,25 @@ 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, '로그인 성공');
});
// ✅ 사용자 등록 기능 추가
exports.register = async (req, res) => {
const register = async (req, res) => {
try {
const { username, password, name, access_level, worker_id } = req.body;
const db = await getDb();
@@ -45,7 +46,7 @@ exports.register = async (req, res) => {
// 중복 아이디 확인
const [existing] = await db.query(
'SELECT user_id FROM Users WHERE username = ?',
'SELECT user_id FROM users WHERE username = ?',
[username]
);
@@ -71,7 +72,7 @@ exports.register = async (req, res) => {
// 사용자 등록
const [result] = await db.query(
`INSERT INTO Users (username, password, name, role, access_level, worker_id)
`INSERT INTO users (username, password, name, role, access_level, worker_id)
VALUES (?, ?, ?, ?, ?, ?)`,
[username, hashedPassword, name, role, access_level, worker_id]
);
@@ -95,14 +96,14 @@ exports.register = async (req, res) => {
};
// ✅ 사용자 삭제 기능 추가
exports.deleteUser = async (req, res) => {
const deleteUser = async (req, res) => {
try {
const { id } = req.params;
const db = await getDb();
// 사용자 존재 확인
const [user] = await db.query(
'SELECT user_id FROM Users WHERE user_id = ?',
'SELECT user_id FROM users WHERE user_id = ?',
[id]
);
@@ -114,7 +115,7 @@ exports.deleteUser = async (req, res) => {
}
// 사용자 삭제
await db.query('DELETE FROM Users WHERE user_id = ?', [id]);
await db.query('DELETE FROM users WHERE user_id = ?', [id]);
console.log('[사용자 삭제 성공] ID:', id);
@@ -134,14 +135,14 @@ exports.deleteUser = async (req, res) => {
};
// 모든 사용자 목록 조회
exports.getAllUsers = async (req, res) => {
const getAllUsers = async (req, res) => {
try {
const db = await getDb();
// 비밀번호 제외하고 조회
const [rows] = await db.query(
`SELECT user_id, username, name, role, access_level, worker_id, created_at
FROM Users
FROM users
ORDER BY created_at DESC`
);
@@ -153,5 +154,8 @@ exports.getAllUsers = async (req, res) => {
};
module.exports = {
login
login,
register,
deleteUser,
getAllUsers
};

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

@@ -11,6 +11,7 @@ const projectModel = require('../models/projectModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
const cache = require('../utils/cache');
/**
* 프로젝트 생성
@@ -20,12 +21,10 @@ 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();
logger.info('프로젝트 생성 성공', { project_id: id });
@@ -40,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,
@@ -58,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,
@@ -82,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('프로젝트를 찾을 수 없습니다');
@@ -112,17 +96,15 @@ 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('프로젝트를 찾을 수 없습니다');
}
// 프로젝트 캐시 무효화
await cache.invalidateCache.project();
logger.info('프로젝트 수정 성공', { project_id: id });
res.json({
@@ -142,17 +124,15 @@ 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('프로젝트를 찾을 수 없습니다');
}
// 프로젝트 캐시 무효화
await cache.invalidateCache.project();
logger.info('프로젝트 삭제 성공', { project_id: id });
res.json({

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

@@ -35,19 +35,26 @@ const getAllUsers = asyncHandler(async (req, res) => {
try {
const query = `
SELECT
user_id,
username,
name,
email,
phone,
role,
access_level,
is_active,
created_at,
updated_at,
last_login
FROM users
ORDER BY created_at DESC
u.user_id,
u.username,
u.name,
u.email,
u.role_id,
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
`;
const [users] = await db.execute(query);
@@ -217,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('수정할 필드가 없습니다');
}
@@ -277,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) {
@@ -296,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('사용자 수정 성공', {
@@ -323,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('사용자 정보를 수정하는데 실패했습니다');
}
});
@@ -457,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

@@ -433,6 +433,8 @@ const getDashboardData = asyncHandler(async (req, res) => {
}
});
const workAnalysisService = require('../services/workAnalysisService');
/**
* 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간)
*/
@@ -443,156 +445,19 @@ const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => {
logger.info('프로젝트별-작업별 시간 분석 요청', { start, end });
try {
const db = await getDb();
// 먼저 데이터 존재 여부 확인
const testQuery = `
SELECT
COUNT(*) as total_count,
MIN(report_date) as min_date,
MAX(report_date) as max_date,
SUM(work_hours) as total_hours
FROM daily_work_reports
WHERE report_date BETWEEN ? AND ?
`;
const testResults = await db.query(testQuery, [start, end]);
logger.debug('데이터 확인', {
start,
end,
count: testResults[0][0]?.total_count
});
// 먼저 간단한 테스트 쿼리로 데이터 확인
const simpleQuery = `
SELECT COUNT(*) as count, MIN(report_date) as min_date, MAX(report_date) as max_date
FROM daily_work_reports
WHERE report_date BETWEEN ? AND ?
`;
const simpleResult = await db.query(simpleQuery, [start, end]);
logger.debug('기간 내 데이터 확인', {
start,
end,
result: simpleResult[0][0]
});
// 프로젝트별-작업별 시간 분석 쿼리 (work_types 테이블과 조인)
const query = `
SELECT
COALESCE(p.project_id, dwr.project_id) as project_id,
COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name,
COALESCE(p.job_no, 'N/A') as job_no,
dwr.work_type_id,
COALESCE(wt.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name,
-- 총 시간
SUM(dwr.work_hours) as total_hours,
-- 정규 시간 (work_status_id = 1)
SUM(CASE WHEN dwr.work_status_id = 1 THEN dwr.work_hours ELSE 0 END) as regular_hours,
-- 에러 시간 (work_status_id = 2)
SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) as error_hours,
-- 작업 건수
COUNT(*) as total_reports,
COUNT(CASE WHEN dwr.work_status_id = 1 THEN 1 END) as regular_reports,
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_reports,
-- 에러율 계산
ROUND(
(SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) /
SUM(dwr.work_hours)) * 100, 2
) as error_rate_percent
FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.project_id, p.project_name, p.job_no, dwr.work_type_id, wt.name
ORDER BY p.project_name, wt.name
`;
const results = await db.query(query, [start, end]);
logger.debug('쿼리 결과', {
start,
end,
resultCount: results[0].length
});
// 데이터를 프로젝트별로 그룹화
const groupedData = {};
results[0].forEach(row => {
const projectKey = `${row.project_id}_${row.project_name}`;
if (!groupedData[projectKey]) {
groupedData[projectKey] = {
project_id: row.project_id,
project_name: row.project_name,
job_no: row.job_no,
total_project_hours: 0,
total_regular_hours: 0,
total_error_hours: 0,
work_types: []
};
}
// 프로젝트 총계 누적
groupedData[projectKey].total_project_hours += parseFloat(row.total_hours);
groupedData[projectKey].total_regular_hours += parseFloat(row.regular_hours);
groupedData[projectKey].total_error_hours += parseFloat(row.error_hours);
// 작업 유형별 데이터 추가
groupedData[projectKey].work_types.push({
work_type_id: row.work_type_id,
work_type_name: row.work_type_name,
total_hours: parseFloat(row.total_hours),
regular_hours: parseFloat(row.regular_hours),
error_hours: parseFloat(row.error_hours),
total_reports: row.total_reports,
regular_reports: row.regular_reports,
error_reports: row.error_reports,
error_rate_percent: parseFloat(row.error_rate_percent) || 0
});
});
// 프로젝트별 에러율 계산
Object.values(groupedData).forEach(project => {
project.project_error_rate = project.total_project_hours > 0
? Math.round((project.total_error_hours / project.total_project_hours) * 100 * 100) / 100
: 0;
});
// 전체 요약 통계
const totalStats = {
total_projects: Object.keys(groupedData).length,
total_work_types: new Set(results[0].map(r => r.work_type_id)).size,
grand_total_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_project_hours, 0),
grand_regular_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_regular_hours, 0),
grand_error_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_error_hours, 0)
};
totalStats.grand_error_rate = totalStats.grand_total_hours > 0
? Math.round((totalStats.grand_error_hours / totalStats.grand_total_hours) * 100 * 100) / 100
: 0;
const result = await workAnalysisService.getProjectWorkTypeAnalysis(start, end);
logger.info('프로젝트별-작업별 시간 분석 성공', {
start,
end,
projectCount: totalStats.total_projects,
workTypeCount: totalStats.total_work_types,
totalHours: totalStats.grand_total_hours
projectCount: result.summary.total_projects,
workTypeCount: result.summary.total_work_types,
totalHours: result.summary.grand_total_hours
});
res.json({
success: true,
data: {
summary: totalStats,
projects: Object.values(groupedData),
period: { start, end }
},
data: result,
message: '프로젝트별-작업별 시간 분석 완료'
});
} catch (error) {
@@ -601,6 +466,10 @@ const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => {
end,
error: error.message
});
// Service throws DatabaseError wrapper or Error
if (error.name === 'DatabaseError') {
throw error;
}
throw new DatabaseError('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다');
}
});

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

@@ -13,21 +13,44 @@ const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
const cache = require('../utils/cache');
const { optimizedQueries } = require('../utils/queryOptimizer');
const { hangulToRoman, generateUniqueUsername } = require('../utils/hangulToRoman');
const bcrypt = require('bcrypt');
const { getDb } = require('../dbPool');
/**
* 작업자 생성
*/
exports.createWorker = asyncHandler(async (req, res) => {
const workerData = req.body;
const createAccount = req.body.create_account;
logger.info('작업자 생성 요청', { name: workerData.name });
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) {
try {
const db = await getDb();
const username = await generateUniqueUsername(workerData.worker_name, db);
const hashedPassword = await bcrypt.hash('1234', 10);
// User 역할 조회
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
if (userRole && userRole.length > 0) {
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, lastID, userRole[0].id]
);
logger.info('작업자 계정 자동 생성 성공', { worker_id: lastID, username });
}
} catch (accountError) {
logger.error('계정 생성 실패 (작업자는 생성됨)', { worker_id: lastID, error: accountError.message });
}
}
// 작업자 관련 캐시 무효화
await cache.invalidateCache.worker();
@@ -45,9 +68,9 @@ exports.createWorker = asyncHandler(async (req, res) => {
* 전체 작업자 조회 (캐싱 및 페이지네이션 적용)
*/
exports.getAllWorkers = asyncHandler(async (req, res) => {
const { page = 1, limit = 10, search = '' } = req.query;
const { page = 1, limit = 100, search = '', status = '', department_id = null } = req.query;
const cacheKey = cache.createKey('workers', 'list', page, limit, search);
const cacheKey = cache.createKey('workers', 'list', page, limit, search, status, department_id);
// 캐시에서 조회
const cachedData = await cache.get(cacheKey);
@@ -62,7 +85,7 @@ exports.getAllWorkers = asyncHandler(async (req, res) => {
}
// 최적화된 쿼리 사용
const result = await optimizedQueries.getWorkersPaged(page, limit, search);
const result = await optimizedQueries.getWorkersPaged(page, limit, search, status, department_id);
// 캐시에 저장 (5분)
await cache.set(cacheKey, result, cache.TTL.MEDIUM);
@@ -86,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('작업자를 찾을 수 없습니다');
@@ -115,24 +133,118 @@ exports.updateWorker = asyncHandler(async (req, res) => {
}
const workerData = { ...req.body, worker_id: id };
const createAccount = req.body.create_account;
const changes = await new Promise((resolve, reject) => {
workerModel.update(workerData, (err, affected) => {
if (err) reject(new DatabaseError('작업자 수정 중 오류가 발생했습니다'));
else resolve(affected);
});
console.log('🔧 작업자 수정 요청:', {
worker_id: id,
받은데이터: req.body,
처리할데이터: workerData,
create_account: createAccount
});
if (changes === 0) {
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용)
const currentWorker = await workerModel.getById(id);
if (!currentWorker) {
throw new NotFoundError('작업자를 찾을 수 없습니다');
}
// 작업자 정보 업데이트
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 {
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';
}
// 작업자 관련 캐시 무효화
logger.info('작업자 수정 후 캐시 무효화', { worker_id: id });
await cache.invalidateCache.worker();
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
});
});
@@ -146,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,23 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema.table('projects', function (table) {
table.boolean('is_active').defaultTo(true).after('pm');
table.string('project_status').defaultTo('active').after('is_active');
table.date('completed_date').nullable().after('project_status');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.table('projects', function (table) {
table.dropColumn('is_active');
table.dropColumn('project_status');
table.dropColumn('completed_date');
});
};

View File

@@ -0,0 +1,57 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
// 1. roles 테이블 생성
.createTable('roles', function(table) {
table.increments('id').primary();
table.string('name', 50).notNullable().unique();
table.string('description', 255);
table.timestamps(true, true);
})
// 2. permissions 테이블 생성
.createTable('permissions', function(table) {
table.increments('id').primary();
table.string('name', 100).notNullable().unique(); // 예: 'user:create'
table.string('description', 255);
table.timestamps(true, true);
})
// 3. role_permissions (역할-권한) 조인 테이블 생성
.createTable('role_permissions', function(table) {
table.integer('role_id').unsigned().notNullable().references('id').inTable('roles').onDelete('CASCADE');
table.integer('permission_id').unsigned().notNullable().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['role_id', 'permission_id']);
})
// 4. users 테이블에 role_id 추가 및 기존 컬럼 삭제
.table('users', function(table) {
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL').after('email');
// 기존 컬럼들은 삭제 또는 비활성화 (데이터 보존을 위해 일단 이름 변경)
table.renameColumn('role', '_role_old');
table.renameColumn('access_level', '_access_level_old');
})
// 5. user_permissions (사용자-개별 권한) 조인 테이블 생성
.createTable('user_permissions', function(table) {
table.integer('user_id').notNullable().references('user_id').inTable('users').onDelete('CASCADE');
table.integer('permission_id').unsigned().notNullable().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['user_id', 'permission_id']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTableIfExists('user_permissions')
.dropTableIfExists('role_permissions')
.dropTableIfExists('permissions')
.dropTableIfExists('roles')
.table('users', function(table) {
table.dropColumn('role_id');
table.renameColumn('_role_old', 'role');
table.renameColumn('_access_level_old', 'access_level');
});
};

View File

@@ -0,0 +1,103 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// 1. Roles 생성
await knex('roles').insert([
{ id: 1, name: 'System Admin', description: '시스템 전체 관리자. 모든 권한을 가짐.' },
{ id: 2, name: 'Admin', description: '관리자. 사용자 및 프로젝트 관리 등 대부분의 권한을 가짐.' },
{ id: 3, name: 'Leader', description: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' },
{ id: 4, name: 'Worker', description: '일반 작업자. 자신의 작업 보고서 작성 및 조회 권한.' },
]);
// 2. Permissions 생성 (예시)
const permissions = [
// User
{ name: 'user:create', description: '사용자 생성' },
{ name: 'user:read', description: '사용자 정보 조회' },
{ name: 'user:update', description: '사용자 정보 수정' },
{ name: 'user:delete', description: '사용자 삭제' },
// Project
{ name: 'project:create', description: '프로젝트 생성' },
{ name: 'project:read', description: '프로젝트 조회' },
{ name: 'project:update', description: '프로젝트 수정' },
{ name: 'project:delete', description: '프로젝트 삭제' },
// Work Report
{ name: 'work-report:create', description: '작업 보고서 생성' },
{ name: 'work-report:read-own', description: '자신의 작업 보고서 조회' },
{ name: 'work-report:read-team', description: '팀의 작업 보고서 조회' },
{ name: 'work-report:read-all', description: '모든 작업 보고서 조회' },
{ name: 'work-report:update', description: '작업 보고서 수정' },
{ name: 'work-report:delete', description: '작업 보고서 삭제' },
// System
{ name: 'system:read-logs', description: '시스템 로그 조회' },
{ name: 'system:manage-settings', description: '시스템 설정 관리' },
];
await knex('permissions').insert(permissions);
// 3. Role-Permissions 매핑
const allPermissions = await knex('permissions').select('id', 'name');
const permissionMap = allPermissions.reduce((acc, p) => {
acc[p.name] = p.id;
return acc;
}, {});
const rolePermissions = {
// System Admin (모든 권한)
'System Admin': allPermissions.map(p => p.id),
// Admin
'Admin': [
permissionMap['user:create'], permissionMap['user:read'], permissionMap['user:update'], permissionMap['user:delete'],
permissionMap['project:create'], permissionMap['project:read'], permissionMap['project:update'], permissionMap['project:delete'],
permissionMap['work-report:read-all'], permissionMap['work-report:update'], permissionMap['work-report:delete'],
],
// Leader
'Leader': [
permissionMap['user:read'],
permissionMap['project:read'],
permissionMap['work-report:read-team'],
permissionMap['work-report:read-own'],
permissionMap['work-report:create'],
],
// Worker
'Worker': [
permissionMap['work-report:create'],
permissionMap['work-report:read-own'],
],
};
const rolePermissionInserts = [];
for (const roleName in rolePermissions) {
const roleId = (await knex('roles').where('name', roleName).first()).id;
rolePermissions[roleName].forEach(permissionId => {
rolePermissionInserts.push({ role_id: roleId, permission_id: permissionId });
});
}
await knex('role_permissions').insert(rolePermissionInserts);
// 4. 기존 사용자에게 역할 부여 (예: 기존 admin -> Admin, leader -> Leader, user -> Worker)
await knex.raw(`
UPDATE users SET role_id =
CASE
WHEN _role_old = 'system' THEN (SELECT id FROM roles WHERE name = 'System Admin')
WHEN _role_old = 'admin' THEN (SELECT id FROM roles WHERE name = 'Admin')
WHEN _role_old = 'leader' THEN (SELECT id FROM roles WHERE name = 'Leader')
ELSE (SELECT id FROM roles WHERE name = 'Worker')
END
`);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
await knex('role_permissions').del();
await knex('user_permissions').del();
await knex('roles').del();
await knex('permissions').del();
// 역할 롤백 (단순화된 버전)
await knex.raw("UPDATE users SET _role_old = 'user' WHERE role_id IS NOT NULL");
};

View File

@@ -0,0 +1,62 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
const hasHireDate = await knex.schema.hasColumn('workers', 'hire_date');
if (!hasHireDate) {
await knex.schema.alterTable('workers', function (table) {
// Modify status to ENUM
// Note: Knex might not support modifying to ENUM easily across DBs, but valid for MySQL
// We use raw SQL for status modification to be safe with existing data
// Add new columns
table.string('phone_number', 20).nullable().comment('전화번호');
table.string('email', 100).nullable().comment('이메일');
table.date('hire_date').nullable().comment('입사일');
table.string('department', 100).nullable().comment('부서');
table.text('notes').nullable().comment('비고');
});
// Update status column using raw query
await knex.raw(`
ALTER TABLE workers
MODIFY COLUMN status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '작업자 상태 (active: 활성, inactive: 비활성)'
`);
// Add indexes
await knex.raw(`CREATE INDEX IF NOT EXISTS idx_workers_status ON workers(status)`);
await knex.raw(`CREATE INDEX IF NOT EXISTS idx_workers_hire_date ON workers(hire_date)`);
// Set NULL status to active
await knex('workers').whereNull('status').update({ status: 'active' });
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function (knex) {
// We generally don't want to lose data on rollback of this critical schema fix,
// but technically we should revert changes.
// For safety, we might skip dropping columns or implement it carefully.
const hasHireDate = await knex.schema.hasColumn('workers', 'hire_date');
if (hasHireDate) {
await knex.schema.alterTable('workers', function (table) {
table.dropColumn('phone_number');
table.dropColumn('email');
table.dropColumn('hire_date');
table.dropColumn('department');
table.dropColumn('notes');
});
await knex.raw(`
ALTER TABLE workers
MODIFY COLUMN status VARCHAR(20) DEFAULT 'active' COMMENT '상태 (active, inactive)'
`);
}
};

View File

@@ -0,0 +1,151 @@
/**
* 권한 시스템 단순화 및 페이지 접근 권한 추가
* - Leader와 Worker를 User로 통합
* - 페이지 접근 권한 테이블 생성
* - Admin이 사용자별 페이지 접근 권한을 설정할 수 있도록 함
*
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// 1. 페이지 목록 테이블 생성
await knex.schema.createTable('pages', function(table) {
table.increments('id').primary();
table.string('page_key', 100).notNullable().unique(); // 예: 'worker-management', 'project-management'
table.string('page_name', 100).notNullable(); // 예: '작업자 관리', '프로젝트 관리'
table.string('page_path', 255).notNullable(); // 예: '/pages/management/worker-management.html'
table.string('category', 50); // 예: 'management', 'dashboard', 'admin'
table.string('description', 255);
table.boolean('is_admin_only').defaultTo(false); // Admin 전용 페이지 여부
table.integer('display_order').defaultTo(0); // 표시 순서
table.timestamps(true, true);
});
// 2. 사용자별 페이지 접근 권한 테이블 생성
await knex.schema.createTable('user_page_access', function(table) {
table.integer('user_id').notNullable()
.references('user_id').inTable('users').onDelete('CASCADE');
table.integer('page_id').unsigned().notNullable()
.references('id').inTable('pages').onDelete('CASCADE');
table.boolean('can_access').defaultTo(true); // 접근 가능 여부
table.timestamp('granted_at').defaultTo(knex.fn.now());
table.integer('granted_by') // 권한을 부여한 Admin의 user_id
.references('user_id').inTable('users').onDelete('SET NULL');
table.primary(['user_id', 'page_id']);
});
// 3. 기본 페이지 목록 삽입
await knex('pages').insert([
// Dashboard
{ page_key: 'dashboard-user', page_name: '사용자 대시보드', page_path: '/pages/dashboard/user.html', category: 'dashboard', is_admin_only: false, display_order: 1 },
{ page_key: 'dashboard-leader', page_name: '그룹장 대시보드', page_path: '/pages/dashboard/group-leader.html', category: 'dashboard', is_admin_only: false, display_order: 2 },
// Management
{ page_key: 'worker-management', page_name: '작업자 관리', page_path: '/pages/management/worker-management.html', category: 'management', is_admin_only: false, display_order: 10 },
{ page_key: 'project-management', page_name: '프로젝트 관리', page_path: '/pages/management/project-management.html', category: 'management', is_admin_only: false, display_order: 11 },
{ page_key: 'work-management', page_name: '작업 관리', page_path: '/pages/management/work-management.html', category: 'management', is_admin_only: false, display_order: 12 },
{ page_key: 'code-management', page_name: '코드 관리', page_path: '/pages/management/code-management.html', category: 'management', is_admin_only: false, display_order: 13 },
// Common
{ page_key: 'daily-work-report', page_name: '작업 현황 확인', page_path: '/pages/common/daily-work-report-viewer.html', category: 'common', is_admin_only: false, display_order: 20 },
// Admin
{ page_key: 'user-management', page_name: '사용자 관리', page_path: '/pages/admin/manage-user.html', category: 'admin', is_admin_only: true, display_order: 100 },
]);
// 4. roles 테이블 업데이트: Leader와 Worker를 User로 통합
// Leader와 Worker 역할을 가진 사용자를 모두 User로 변경
const userRoleId = await knex('roles').where('name', 'Worker').first().then(r => r.id);
const leaderRoleId = await knex('roles').where('name', 'Leader').first().then(r => r ? r.id : null);
if (leaderRoleId) {
// Leader를 User로 변경
await knex('users').where('role_id', leaderRoleId).update({ role_id: userRoleId });
}
// 5. role_permissions 업데이트: Worker 권한을 확장하여 모든 일반 기능 사용 가능하게
const allPermissions = await knex('permissions').select('id', 'name');
const permissionMap = allPermissions.reduce((acc, p) => {
acc[p.name] = p.id;
return acc;
}, {});
// Worker 역할의 기존 권한 삭제
await knex('role_permissions').where('role_id', userRoleId).del();
// Worker(이제 User) 역할에 모든 일반 권한 부여 (Admin/System 권한 제외)
const userPermissions = [
permissionMap['user:read'],
permissionMap['project:read'],
permissionMap['project:create'],
permissionMap['project:update'],
permissionMap['work-report:create'],
permissionMap['work-report:read-own'],
permissionMap['work-report:read-team'],
permissionMap['work-report:read-all'],
permissionMap['work-report:update'],
permissionMap['work-report:delete'],
].filter(Boolean); // undefined 제거
const rolePermissionInserts = userPermissions.map(permissionId => ({
role_id: userRoleId,
permission_id: permissionId
}));
await knex('role_permissions').insert(rolePermissionInserts);
// 6. Leader 역할 삭제 (더 이상 사용하지 않음)
if (leaderRoleId) {
await knex('role_permissions').where('role_id', leaderRoleId).del();
await knex('roles').where('id', leaderRoleId).del();
}
// 7. Worker 역할 이름을 'User'로 변경
await knex('roles').where('id', userRoleId).update({
name: 'User',
description: '일반 사용자. 작업 보고서 및 프로젝트 관리 등 모든 일반 기능을 사용할 수 있음.'
});
// 8. 모든 일반 사용자에게 모든 페이지 접근 권한 부여 (Admin 페이지 제외)
const normalPages = await knex('pages').where('is_admin_only', false).select('id');
const normalUsers = await knex('users').where('role_id', userRoleId).select('user_id');
const userPageAccessInserts = [];
normalUsers.forEach(user => {
normalPages.forEach(page => {
userPageAccessInserts.push({
user_id: user.user_id,
page_id: page.id,
can_access: true
});
});
});
if (userPageAccessInserts.length > 0) {
await knex('user_page_access').insert(userPageAccessInserts);
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
// 테이블 삭제 (역순)
await knex.schema.dropTableIfExists('user_page_access');
await knex.schema.dropTableIfExists('pages');
// User 역할을 다시 Worker로 변경
const userRoleId = await knex('roles').where('name', 'User').first().then(r => r ? r.id : null);
if (userRoleId) {
await knex('roles').where('id', userRoleId).update({
name: 'Worker',
description: '일반 작업자. 자신의 작업 보고서 작성 및 조회 권한.'
});
}
// Leader 역할 재생성
await knex('roles').insert([
{ id: 3, name: 'Leader', description: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' }
]);
};

View File

@@ -0,0 +1,27 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
await knex.schema.alterTable('workers', (table) => {
// 재직 상태 (employed: 재직, resigned: 퇴사)
table.enum('employment_status', ['employed', 'resigned'])
.defaultTo('employed')
.notNullable()
.comment('재직 상태 (employed: 재직, resigned: 퇴사)');
});
console.log('✅ workers 테이블에 employment_status 컬럼 추가 완료');
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
await knex.schema.alterTable('workers', (table) => {
table.dropColumn('employment_status');
});
console.log('✅ workers 테이블에서 employment_status 컬럼 삭제 완료');
};

View File

@@ -0,0 +1,33 @@
/**
* 마이그레이션: Workers 테이블에 급여 및 기본 연차 컬럼 추가
* 작성일: 2026-01-19
*
* 변경사항:
* - salary 컬럼 추가 (NULL 허용, 선택 사항)
* - base_annual_leave 컬럼 추가 (기본값: 15일)
*/
exports.up = async function(knex) {
console.log('⏳ Workers 테이블에 salary, base_annual_leave 컬럼 추가 중...');
await knex.schema.alterTable('workers', (table) => {
// 급여 정보 (선택 사항, NULL 허용)
table.decimal('salary', 12, 2).nullable().comment('급여 (선택)');
// 기본 연차 일수 (기본값: 15일)
table.integer('base_annual_leave').defaultTo(15).notNullable().comment('기본 연차 일수');
});
console.log('✅ Workers 테이블 컬럼 추가 완료');
};
exports.down = async function(knex) {
console.log('⏳ Workers 테이블에서 salary, base_annual_leave 컬럼 제거 중...');
await knex.schema.alterTable('workers', (table) => {
table.dropColumn('salary');
table.dropColumn('base_annual_leave');
});
console.log('✅ Workers 테이블 컬럼 제거 완료');
};

View File

@@ -0,0 +1,112 @@
/**
* 마이그레이션: 출근/근태 관련 테이블 생성
* 작성일: 2026-01-19
*
* 생성 테이블:
* - work_attendance_types: 출근 유형 (정상, 지각, 조퇴, 결근, 휴가)
* - vacation_types: 휴가 유형 (연차, 반차, 병가, 경조사)
* - daily_attendance_records: 일일 출근 기록
* - worker_vacation_balance: 작업자 연차 잔액 (연도별)
*/
exports.up = async function(knex) {
console.log('⏳ 출근/근태 관련 테이블 생성 중...');
// 1. 출근 유형 테이블
await knex.schema.createTable('work_attendance_types', (table) => {
table.increments('id').primary();
table.string('type_code', 20).unique().notNullable().comment('유형 코드');
table.string('type_name', 50).notNullable().comment('유형 이름');
table.text('description').nullable().comment('설명');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
console.log('✅ work_attendance_types 테이블 생성 완료');
// 초기 데이터 입력
await knex('work_attendance_types').insert([
{ type_code: 'NORMAL', type_name: '정상 출근', description: '정상 출근' },
{ type_code: 'LATE', type_name: '지각', description: '지각' },
{ type_code: 'EARLY_LEAVE', type_name: '조퇴', description: '조퇴' },
{ type_code: 'ABSENT', type_name: '결근', description: '무단 결근' },
{ type_code: 'VACATION', type_name: '휴가', description: '승인된 휴가' }
]);
console.log('✅ work_attendance_types 초기 데이터 입력 완료');
// 2. 휴가 유형 테이블
await knex.schema.createTable('vacation_types', (table) => {
table.increments('id').primary();
table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
table.string('type_name', 50).notNullable().comment('휴가 이름');
table.decimal('deduct_days', 3, 1).defaultTo(1.0).comment('차감 일수');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
console.log('✅ vacation_types 테이블 생성 완료');
// 초기 데이터 입력
await knex('vacation_types').insert([
{ type_code: 'ANNUAL', type_name: '연차', deduct_days: 1.0 },
{ type_code: 'HALF_ANNUAL', type_name: '반차', deduct_days: 0.5 },
{ type_code: 'SICK', type_name: '병가', deduct_days: 1.0 },
{ type_code: 'SPECIAL', type_name: '경조사', deduct_days: 0 }
]);
console.log('✅ vacation_types 초기 데이터 입력 완료');
// 3. 일일 출근 기록 테이블
await knex.schema.createTable('daily_attendance_records', (table) => {
table.increments('id').primary();
table.integer('worker_id').unsigned().notNullable().comment('작업자 ID');
table.date('record_date').notNullable().comment('기록 날짜');
table.integer('attendance_type_id').unsigned().notNullable().comment('출근 유형 ID');
table.integer('vacation_type_id').unsigned().nullable().comment('휴가 유형 ID');
table.time('check_in_time').nullable().comment('출근 시간');
table.time('check_out_time').nullable().comment('퇴근 시간');
table.decimal('total_work_hours', 4, 2).defaultTo(0).comment('총 근무 시간');
table.boolean('is_overtime_approved').defaultTo(false).comment('초과근무 승인 여부');
table.text('notes').nullable().comment('비고');
table.integer('created_by').unsigned().notNullable().comment('등록자 user_id');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.unique(['worker_id', 'record_date']);
table.foreign('worker_id').references('workers.worker_id').onDelete('CASCADE');
table.foreign('attendance_type_id').references('work_attendance_types.id');
table.foreign('vacation_type_id').references('vacation_types.id');
table.foreign('created_by').references('users.user_id');
});
console.log('✅ daily_attendance_records 테이블 생성 완료');
// 4. 작업자 연차 잔액 테이블
await knex.schema.createTable('worker_vacation_balance', (table) => {
table.increments('id').primary();
table.integer('worker_id').unsigned().notNullable().comment('작업자 ID');
table.integer('year').notNullable().comment('연도');
table.decimal('total_annual_leave', 4, 1).defaultTo(15.0).comment('총 연차');
table.decimal('used_annual_leave', 4, 1).defaultTo(0).comment('사용 연차');
// remaining_annual_leave는 애플리케이션 레벨에서 계산
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.unique(['worker_id', 'year']);
table.foreign('worker_id').references('workers.worker_id').onDelete('CASCADE');
});
console.log('✅ worker_vacation_balance 테이블 생성 완료');
console.log('✅ 모든 출근/근태 관련 테이블 생성 완료');
};
exports.down = async function(knex) {
console.log('⏳ 출근/근태 관련 테이블 제거 중...');
await knex.schema.dropTableIfExists('worker_vacation_balance');
await knex.schema.dropTableIfExists('daily_attendance_records');
await knex.schema.dropTableIfExists('vacation_types');
await knex.schema.dropTableIfExists('work_attendance_types');
console.log('✅ 모든 출근/근태 관련 테이블 제거 완료');
};

View File

@@ -0,0 +1,104 @@
/**
* 마이그레이션: 기존 작업자에게 계정 자동 생성
* 작성일: 2026-01-19
*
* 작업 내용:
* 1. 계정이 없는 기존 작업자 조회
* 2. 각 작업자에 대해 users 테이블에 계정 생성
* 3. username은 이름 기반으로 자동 생성 (예: 홍길동 → hong.gildong)
* 4. 초기 비밀번호는 '1234'로 통일 (첫 로그인 시 변경 권장)
* 5. 현재 연도 연차 잔액 초기화 (workers.annual_leave 사용)
*/
const bcrypt = require('bcrypt');
const { generateUniqueUsername } = require('../../utils/hangulToRoman');
exports.up = async function(knex) {
console.log('⏳ 기존 작업자들에게 계정 자동 생성 중...');
// 1. 계정이 없는 작업자 조회
const workersWithoutAccount = await knex('workers')
.leftJoin('users', 'workers.worker_id', 'users.worker_id')
.whereNull('users.user_id')
.select(
'workers.worker_id',
'workers.worker_name',
'workers.email',
'workers.status',
'workers.annual_leave'
);
console.log(`📊 계정이 없는 작업자: ${workersWithoutAccount.length}`);
if (workersWithoutAccount.length === 0) {
console.log(' 계정이 필요한 작업자가 없습니다.');
return;
}
// 2. 각 작업자에 대해 계정 생성
const initialPassword = '1234'; // 초기 비밀번호
const hashedPassword = await bcrypt.hash(initialPassword, 10);
// User 역할 ID 조회
const userRole = await knex('roles')
.where('name', 'User')
.first();
if (!userRole) {
throw new Error('User 역할이 존재하지 않습니다. 권한 마이그레이션을 먼저 실행하세요.');
}
let successCount = 0;
let errorCount = 0;
for (const worker of workersWithoutAccount) {
try {
// username 생성 (중복 체크 포함)
const username = await generateUniqueUsername(worker.worker_name, knex);
// 계정 생성
await knex('users').insert({
username: username,
password: hashedPassword,
name: worker.worker_name,
email: worker.email,
worker_id: worker.worker_id,
role_id: userRole.id,
is_active: worker.status === 'active' ? 1 : 0,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
console.log(`${worker.worker_name} (ID: ${worker.worker_id}) → username: ${username}`);
successCount++;
// 현재 연도 연차 잔액 초기화
const currentYear = new Date().getFullYear();
await knex('worker_vacation_balance').insert({
worker_id: worker.worker_id,
year: currentYear,
total_annual_leave: worker.annual_leave || 15,
used_annual_leave: 0,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
} catch (error) {
console.error(`${worker.worker_name} 계정 생성 실패:`, error.message);
errorCount++;
}
}
console.log(`\n📊 작업 완료: 성공 ${successCount}명, 실패 ${errorCount}`);
console.log(`🔐 초기 비밀번호: ${initialPassword} (모든 계정 공통)`);
console.log('⚠️ 사용자들에게 첫 로그인 후 비밀번호를 변경하도록 안내해주세요!');
};
exports.down = async function(knex) {
console.log('⏳ 자동 생성된 계정 제거 중...');
// 이 마이그레이션으로 생성된 계정은 구분하기 어려우므로
// rollback 시 주의가 필요합니다.
console.log('⚠️ 경고: 이 마이그레이션의 rollback은 권장하지 않습니다.');
console.log(' 필요시 수동으로 users 테이블을 관리하세요.');
};

View File

@@ -0,0 +1,51 @@
/**
* 마이그레이션: 게스트 역할 추가
* 작성일: 2026-01-19
*
* 변경사항:
* - Guest 역할 추가 (계정 없이 특정 기능 접근 가능)
* - 게스트 전용 페이지 추가 (신고 채널 등)
*/
exports.up = async function(knex) {
console.log('⏳ 게스트 역할 추가 중...');
// 1. Guest 역할 추가
const [guestRoleId] = await knex('roles').insert({
name: 'Guest',
description: '게스트 (계정 없이 특정 기능 접근 가능)',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
console.log(`✅ Guest 역할 추가 완료 (ID: ${guestRoleId})`);
// 2. 게스트 전용 페이지 추가
await knex('pages').insert({
page_key: 'guest_report',
page_name: '신고 채널',
page_path: '/pages/guest/report.html',
category: 'guest',
is_admin_only: false,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
console.log('✅ 게스트 전용 페이지 추가 완료 (신고 채널)');
console.log('✅ 게스트 역할 추가 완료');
};
exports.down = async function(knex) {
console.log('⏳ 게스트 역할 제거 중...');
// 페이지 제거
await knex('pages')
.where('page_key', 'guest_report')
.delete();
// 역할 제거
await knex('roles')
.where('name', 'Guest')
.delete();
console.log('✅ 게스트 역할 제거 완료');
};

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

97
api.hyungi.net/deploy.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/bin/bash
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} API 서버 배포 스크립트${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# 현재 디렉토리 확인
if [ ! -f "package.json" ]; then
echo -e "${RED}❌ 오류: package.json을 찾을 수 없습니다.${NC}"
echo -e "${RED} api.hyungi.net 디렉토리에서 실행해주세요.${NC}"
exit 1
fi
echo -e "${BLUE}📂 현재 디렉토리:${NC} $(pwd)"
echo ""
# 1. Git Pull
echo -e "${YELLOW}1⃣ Git Pull 시작...${NC}"
git pull
if [ $? -ne 0 ]; then
echo -e "${RED}❌ Git Pull 실패${NC}"
exit 1
fi
echo -e "${GREEN}✅ Git Pull 완료${NC}"
echo ""
# 2. NPM Install (package.json 변경 확인)
echo -e "${YELLOW}2⃣ 의존성 확인 중...${NC}"
if git diff HEAD@{1} HEAD --name-only | grep -q "package.json"; then
echo -e "${YELLOW}📦 package.json 변경 감지 - npm install 실행${NC}"
npm install
if [ $? -ne 0 ]; then
echo -e "${RED}❌ npm install 실패${NC}"
exit 1
fi
echo -e "${GREEN}✅ npm install 완료${NC}"
else
echo -e "${GREEN}✅ package.json 변경 없음 - 건너뜀${NC}"
fi
echo ""
# 3. 데이터베이스 마이그레이션
echo -e "${YELLOW}3⃣ 데이터베이스 마이그레이션 시작...${NC}"
echo -e "${YELLOW}⚠️ 주의: 마이그레이션 전 데이터베이스 백업을 권장합니다.${NC}"
echo ""
read -p "마이그레이션을 실행하시겠습니까? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
npm run db:migrate
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 마이그레이션 실패${NC}"
echo -e "${YELLOW}💡 롤백이 필요하면 다음 명령어를 실행하세요:${NC}"
echo -e "${YELLOW} npm run db:rollback${NC}"
exit 1
fi
echo -e "${GREEN}✅ 마이그레이션 완료${NC}"
else
echo -e "${YELLOW}⏭️ 마이그레이션 건너뜀${NC}"
fi
echo ""
# 4. PM2 재시작
echo -e "${YELLOW}4⃣ PM2 서버 재시작...${NC}"
if command -v pm2 &> /dev/null; then
pm2 reload ecosystem.config.js --env production
if [ $? -ne 0 ]; then
echo -e "${RED}❌ PM2 reload 실패${NC}"
exit 1
fi
echo -e "${GREEN}✅ PM2 reload 완료${NC}"
echo ""
# PM2 상태 확인
echo -e "${BLUE}📊 PM2 프로세스 상태:${NC}"
pm2 list
else
echo -e "${YELLOW}⚠️ PM2가 설치되지 않았습니다. 수동으로 서버를 재시작해주세요.${NC}"
fi
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} ✅ 배포 완료!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}📝 배포 후 확인사항:${NC}"
echo -e " 1. API 서버 응답 확인: curl http://localhost:20005/health"
echo -e " 2. 로그 확인: pm2 logs hyungi-api"
echo -e " 3. 에러 발생 시: pm2 logs hyungi-api --err"
echo ""

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

@@ -129,8 +129,10 @@ const requireRole = (...roles) => {
}
const userRole = req.user.role;
const userRoleLower = userRole ? userRole.toLowerCase() : '';
const rolesLower = roles.map(r => r.toLowerCase());
if (!roles.includes(userRole)) {
if (!rolesLower.includes(userRoleLower)) {
logger.warn('권한 체크 실패: 역할 불일치', {
user_id: req.user.user_id || req.user.id,
username: req.user.username,

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