Compare commits
148 Commits
02e39f1102
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbffa47a9d | ||
|
|
bf0d7fd87a | ||
|
|
56f626911a | ||
|
|
178155df6b | ||
|
|
d49aa01bd5 | ||
|
|
f28922a3ae | ||
|
|
7db072ed14 | ||
|
|
0de9d5bb48 | ||
|
|
de6d918d42 | ||
|
|
1cfd4da8ba | ||
|
|
46a1f8310d | ||
|
|
28a5924e76 | ||
|
|
d3487fd8fd | ||
|
|
48e3b58865 | ||
|
|
697af50963 | ||
|
|
b70904a4de | ||
|
|
cc69b452ab | ||
|
|
05d9e90c39 | ||
|
|
72e4a8b277 | ||
|
|
3f870b247d | ||
|
|
4063eba5bb | ||
|
|
118dc29c95 | ||
|
|
52e6ec16f8 | ||
|
|
cbddf5a7a4 | ||
|
|
6e3c5d6748 | ||
|
|
35b140aa38 | ||
|
|
7cf0614e3b | ||
|
|
ca86dc316c | ||
|
|
02e9f8c0ab | ||
|
|
30222df0ef | ||
|
|
708beb7fe5 | ||
|
|
bf11ccebf5 | ||
|
|
59242177d6 | ||
|
|
7e78f66838 | ||
|
|
9f181644f9 | ||
|
|
6cd613c071 | ||
|
|
ba2e3481e9 | ||
|
|
58b756d973 | ||
|
|
e9ece8c6f1 | ||
|
|
39c333bb39 | ||
|
|
bc92b0d5b0 | ||
|
|
9efb8c881a | ||
|
|
661523e963 | ||
|
|
7c1369a1be | ||
|
|
2c032bd9ea | ||
|
|
2308499668 | ||
|
|
f09c86ee01 | ||
|
|
766cb90e8f | ||
|
|
5832755475 | ||
|
|
41bb755181 | ||
|
|
5e22ff75e7 | ||
|
|
dcd40e692f | ||
|
|
798cc38945 | ||
|
|
cf75462380 | ||
|
|
0cc37d7773 | ||
|
|
a5f96dfe17 | ||
|
|
fdd28d63b2 | ||
|
|
c9249da944 | ||
|
|
80eb018caa | ||
|
|
4309d308bc | ||
|
|
4bb4fbd225 | ||
|
|
10fd65ba9e | ||
|
|
65e5530a6a | ||
|
|
1340918f8e | ||
|
|
798ccc62ad | ||
|
|
0ebe6e5a31 | ||
|
|
f7adbabb0f | ||
|
|
617b6f5c6f | ||
|
|
ca09f89cda | ||
|
|
c37ca24788 | ||
|
|
242dca83b5 | ||
|
|
b855ac973a | ||
|
|
c71286b52b | ||
|
|
7ccec81615 | ||
|
|
f68c66e696 | ||
|
|
77b66f49ae | ||
|
|
3cc38791c8 | ||
|
|
b8d3a516e1 | ||
|
|
972fc07f8d | ||
|
|
ab9e5a46cc | ||
|
|
1c47505b0d | ||
|
|
71132a1e8d | ||
|
|
f728f84117 | ||
|
|
5054398f4f | ||
|
|
755e4142e1 | ||
|
|
492843342a | ||
|
|
c9524d9958 | ||
|
|
b9e3b868bd | ||
|
|
df688879a4 | ||
|
|
76e4224b32 | ||
|
|
01f27948e4 | ||
|
|
d45466ad77 | ||
|
|
f3b7f1a34f | ||
|
|
1980c83377 | ||
|
|
f58dd115c9 | ||
|
|
408bf1af62 | ||
|
|
ec7699b270 | ||
|
|
0c8801849c | ||
|
|
d96a75adc2 | ||
|
|
9cbf4c98a5 | ||
|
|
8016237038 | ||
|
|
d16b2f68ba | ||
|
|
d7408ce603 | ||
|
|
9bd3888738 | ||
|
|
9528a544c6 | ||
|
|
c615d0f121 | ||
|
|
6a721258b8 | ||
|
|
53596ba540 | ||
|
|
b67e8f2c9f | ||
|
|
b2ce691ef9 | ||
|
|
a30482ec34 | ||
|
|
71ef40c26c | ||
|
|
5dee4fd600 | ||
|
|
3c611daa29 | ||
|
|
666f0f2df4 | ||
|
|
2357744b02 | ||
|
|
4dd39ceab7 | ||
|
|
d3cef659ce | ||
|
|
5ac7af7b04 | ||
|
|
f434b4d66f | ||
|
|
517fef46a9 | ||
|
|
31adc39d89 | ||
|
|
e3b7626e07 | ||
|
|
65b2bbe552 | ||
|
|
46cd98c6ea | ||
|
|
eb9266d83a | ||
|
|
6b584f9881 | ||
|
|
0afe864ba3 | ||
|
|
913ab2fcfd | ||
|
|
60b2fd1b8d | ||
|
|
1fd6253fbc | ||
|
|
295928c725 | ||
|
|
7aaac1e334 | ||
|
|
672a7039df | ||
|
|
8683787a01 | ||
|
|
658474af71 | ||
|
|
1040adee10 | ||
|
|
822c654ce5 | ||
|
|
eea99359b5 | ||
|
|
549e78ba61 | ||
|
|
c769fa040d | ||
|
|
afb63e4e94 | ||
|
|
4d783e47c9 | ||
|
|
07a6253692 | ||
|
|
b7771f8232 | ||
|
|
943ed63d77 | ||
|
|
6411eab210 | ||
|
|
66676ac923 |
154
.cowork/sprints/sprint-004/PLAN.md
Normal file
154
.cowork/sprints/sprint-004/PLAN.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Sprint 004: 월간 근무 비교·확인·정산
|
||||||
|
|
||||||
|
> 작성: Cowork | 초안: 2026-03-30 | 갱신: 2026-03-31 (출근부 양식 반영)
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
1. **작업보고서 vs 근태관리 비교 페이지**: 그룹장이 입력한 작업보고서와 근태관리 데이터를 일별로 비교하여 불일치를 시각화
|
||||||
|
2. **월말 확인 프로세스**: 작업자가 자신의 월간 근무 내역을 확인(승인/반려)하는 워크플로우
|
||||||
|
3. **출근부 엑셀 다운로드**: 전원 확인 완료 시, 업로드된 양식에 맞는 출근부 엑셀 내보내기
|
||||||
|
4. **연차 잔액 연동**: sp_vacation_balances 기반 신규/사용/잔여 데이터 엑셀 포함
|
||||||
|
|
||||||
|
## 현재 구현 상태 (2026-03-31)
|
||||||
|
|
||||||
|
| 항목 | 파일 | 상태 | 비고 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| DB 마이그레이션 | `api/db/migrations/20260330_create_monthly_work_confirmations.sql` | ✅ 완료 | |
|
||||||
|
| 모델 | `api/models/monthlyComparisonModel.js` (232줄) | ✅ 완료 | getExportData 추가됨 |
|
||||||
|
| 컨트롤러 | `api/controllers/monthlyComparisonController.js` (560줄) | ✅ 완료 | exportExcel 출근부 양식 재작성 |
|
||||||
|
| 라우트 | `api/routes/monthlyComparisonRoutes.js` (33줄) | ✅ 완료 | |
|
||||||
|
| 라우트 등록 | `api/routes.js` 157줄 | ✅ 완료 | |
|
||||||
|
| 프론트엔드 HTML | `web/pages/attendance/monthly-comparison.html` | ✅ 완료 | |
|
||||||
|
| 프론트엔드 JS | `web/js/monthly-comparison.js` (558줄) | ✅ 완료 | |
|
||||||
|
| 프론트엔드 CSS | `web/css/monthly-comparison.css` | ✅ 완료 | |
|
||||||
|
| 대시보드 연동 | `web/js/production-dashboard.js` | ✅ 완료 | PAGE_ICONS 추가 |
|
||||||
|
|
||||||
|
> 모든 파일이 존재하고 라우트가 등록되어 있으나, **실 서버 배포 및 통합 테스트 미완료**.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- monthly.html에서 그룹장이 등록한 근태정보를 각 작업자가 개별 확인·승인
|
||||||
|
- 전원 confirmed 완료 시 지원팀이 출근부 엑셀 다운로드 가능
|
||||||
|
- 출근부 양식: 업로드된 `출근부_2026.02_TK 생산팀.xlsx` 기준
|
||||||
|
- attendanceService.js에서 연차 자동 차감/복원이 이미 구현되어 있으므로 sp_vacation_balances가 소스 오브 트루스
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ monthly.html (그룹장 → 근태 입력) │
|
||||||
|
│ ↓ │
|
||||||
|
│ daily_attendance_records 저장 │
|
||||||
|
│ attendanceService.js → sp_vacation_balances 자동 반영 │
|
||||||
|
└─────────────────┬────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ monthly-comparison.html │
|
||||||
|
│ │
|
||||||
|
│ [작업자 뷰] [관리자 뷰] │
|
||||||
|
│ ├ 작업보고서 vs 근태 일별 비교 ├ 전체 확인 현황 │
|
||||||
|
│ ├ 요약 카드 (근무일/시간/연장/휴가) ├ 개별 작업자 상세 │
|
||||||
|
│ └ 확인(승인) / 반려(사유 입력) └ 엑셀 다운로드 │
|
||||||
|
│ ↓ (반려 시) │
|
||||||
|
│ notifications → 지원팀에 알림 │
|
||||||
|
│ ↓ (전원 확인 완료 시) │
|
||||||
|
│ 출근부 엑셀 다운로드 │
|
||||||
|
│ (출근부_YYYY.MM_부서명.xlsx) │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 출근부 엑셀 양식 (신규)
|
||||||
|
|
||||||
|
기존 2-시트 구조(월간 근무 현황 + 일별 상세)를 **출근부 단일 시트**로 변경.
|
||||||
|
|
||||||
|
```
|
||||||
|
시트명: {YY}.{M}월 출근부
|
||||||
|
|
||||||
|
Row 2: 부서명 | [부서명 (병합)]
|
||||||
|
Row 3: 근로기간 | [YYYY년 M월 (병합)]
|
||||||
|
Row 4: [이름] [담당] 1 2 3 ... 31 [총시간] [M월(병합)] [비고(병합)]
|
||||||
|
Row 5: 일 월 화 ... [ ] 신규 사용 잔여
|
||||||
|
Row 6+: 김두수 1 0 0 0 ... 휴무 =SUM() 15 2 =AF-AG
|
||||||
|
|
||||||
|
셀 값 규칙:
|
||||||
|
정상출근 → 근무시간(숫자, 0.00)
|
||||||
|
주말(근무없음) → '휴무'
|
||||||
|
연차(ANNUAL) → '연차'
|
||||||
|
반차(HALF_ANNUAL) → '반차'
|
||||||
|
반반차(ANNUAL_QUARTER) → '반반차'
|
||||||
|
조퇴(EARLY_LEAVE) → '조퇴'
|
||||||
|
병가(SICK) → '병가'
|
||||||
|
경조사(SPECIAL) → '경조사'
|
||||||
|
|
||||||
|
스타일:
|
||||||
|
폰트: 맑은 고딕 12pt, 가운데 정렬
|
||||||
|
주말 헤더: 빨간 폰트 (FFFF0000)
|
||||||
|
테두리: thin 전체
|
||||||
|
헤더 행 높이: 40, 데이터 행 높이: 60
|
||||||
|
수식: 총시간=SUM(날짜열), 잔여=신규-사용
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 엔드포인트
|
||||||
|
|
||||||
|
| Method | Path | 접근권한 | 설명 |
|
||||||
|
|--------|------|---------|------|
|
||||||
|
| GET | /api/monthly-comparison/my-records | 인증된 사용자 | 내 일별 비교 데이터 |
|
||||||
|
| GET | /api/monthly-comparison/records | support_team+ 또는 본인 | 특정 작업자 비교 조회 |
|
||||||
|
| POST | /api/monthly-comparison/confirm | 인증된 사용자 (본인) | 승인/반려 |
|
||||||
|
| GET | /api/monthly-comparison/all-status | support_team+ | 전체 확인 현황 |
|
||||||
|
| GET | /api/monthly-comparison/export | support_team+ | 출근부 엑셀 다운로드 |
|
||||||
|
|
||||||
|
## 데이터 소스
|
||||||
|
|
||||||
|
| 데이터 | 테이블 | 비고 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 작업보고서 | daily_work_reports | report_date, work_hours, project_id |
|
||||||
|
| 근태 기록 | daily_attendance_records | record_date, total_work_hours, vacation_type_id |
|
||||||
|
| 확인 상태 | monthly_work_confirmations | 신규 테이블 (Sprint 004) |
|
||||||
|
| 연차 잔액 | sp_vacation_balances | year별 SUM(total_days, used_days) |
|
||||||
|
| 휴가 유형 | vacation_types | type_code → 출근부 텍스트 매핑 |
|
||||||
|
| 작업자 정보 | workers + departments | worker_id 순 정렬 |
|
||||||
|
|
||||||
|
## 작업 분할
|
||||||
|
|
||||||
|
| 섹션 | 핵심 작업 | 규모 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **A (Backend)** | 모델·컨트롤러 검증 + 출근부 엑셀 엣지케이스 + 배포 | 파일 4개, 검증 위주 |
|
||||||
|
| **B (Frontend)** | UI 최종 검증 + 모바일 반응형 + 캐시 버스팅 + 배포 | 파일 4개, 검증 위주 |
|
||||||
|
|
||||||
|
> 기존 코드가 모두 구현되어 있으므로 **검증·배포 중심**으로 진행.
|
||||||
|
|
||||||
|
## 섹션 간 의존성
|
||||||
|
|
||||||
|
```
|
||||||
|
A ──(API 제공)──→ B
|
||||||
|
|
||||||
|
GET /api/monthly-comparison/my-records
|
||||||
|
POST /api/monthly-comparison/confirm
|
||||||
|
GET /api/monthly-comparison/all-status
|
||||||
|
GET /api/monthly-comparison/records
|
||||||
|
GET /api/monthly-comparison/export
|
||||||
|
```
|
||||||
|
|
||||||
|
Section A 배포 완료 후 Section B 프론트엔드가 정상 동작 가능.
|
||||||
|
|
||||||
|
## 완료 조건
|
||||||
|
- [x] 마이그레이션 파일 생성 (20260330_create_monthly_work_confirmations.sql)
|
||||||
|
- [x] 모든 Backend 파일 생성 (model, controller, routes)
|
||||||
|
- [x] routes.js에 라우트 등록
|
||||||
|
- [x] 프론트엔드 페이지 생성 (HTML, JS, CSS)
|
||||||
|
- [x] 대시보드 아이콘 매핑 추가
|
||||||
|
- [x] 출근부 양식 엑셀 재작성 (getExportData + exportExcel)
|
||||||
|
- [ ] 실 서버 마이그레이션 실행
|
||||||
|
- [ ] Docker 빌드·배포
|
||||||
|
- [ ] 통합 테스트 (작업자 확인 → 지원팀 엑셀 다운로드 전체 플로우)
|
||||||
|
- [ ] 모바일 반응형 확인
|
||||||
|
- [ ] 엣지케이스 확인 (데이터 없는 월, 중도 입사자 등)
|
||||||
|
|
||||||
|
## 주요 변경 이력
|
||||||
|
|
||||||
|
| 날짜 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| 2026-03-30 | 초안 작성 + Section A/B 코드 구현 |
|
||||||
|
| 2026-03-31 | 출근부 양식 반영: getExcelData→getExportData, exportExcel 전면 재작성 |
|
||||||
|
| 2026-03-31 | 버그 수정: export 조건에 rejected 추가, admin detail 모드 버튼 숨김 |
|
||||||
|
| 2026-04-01 | 워크플로우 확장: pending→review_sent→confirmed/change_request/rejected 상태 추가 |
|
||||||
|
| 2026-04-01 | 정책 변경: 확인요청 발송 전 전원 admin_checked 필수 (선택적→필수) |
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
[Cloudflare Tunnel] → tk-cloudflared
|
[Cloudflare Tunnel] → tk-cloudflared
|
||||||
├── tkds.technicalkorea.net → tk-gateway:80 (로그인 + 대시보드 + 공유JS)
|
├── tkfb.technicalkorea.net → tk-gateway:80 (로그인 + 대시보드 + 공유JS)
|
||||||
├── tkfb.technicalkorea.net → tk-system1-web:80 (공장관리)
|
├── tkfb.technicalkorea.net → tk-system1-web:80 (공장관리)
|
||||||
├── tkreport.technicalkorea.net → tk-system2-web:80 (신고)
|
├── tkreport.technicalkorea.net → tk-system2-web:80 (신고)
|
||||||
├── tkqc.technicalkorea.net → tk-system3-web:80 (부적합관리)
|
├── tkqc.technicalkorea.net → tk-system3-web:80 (부적합관리)
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
## SSO 인증 흐름
|
## SSO 인증 흐름
|
||||||
|
|
||||||
1. 사용자가 아무 서비스 접근 → 프론트엔드 JS가 `sso_token` 쿠키 확인
|
1. 사용자가 아무 서비스 접근 → 프론트엔드 JS가 `sso_token` 쿠키 확인
|
||||||
2. 토큰 없음/만료 → `tkds.technicalkorea.net/dashboard`로 리다이렉트 (redirect 파라미터 포함)
|
2. 토큰 없음/만료 → `tkfb.technicalkorea.net/dashboard`로 리다이렉트 (redirect 파라미터 포함)
|
||||||
3. 로그인 폼 제출 → `POST /auth/login` → sso-auth가 JWT 발급
|
3. 로그인 폼 제출 → `POST /auth/login` → sso-auth가 JWT 발급
|
||||||
4. 쿠키 설정: `sso_token`, `sso_user`, `sso_refresh_token` (domain=`.technicalkorea.net`)
|
4. 쿠키 설정: `sso_token`, `sso_user`, `sso_refresh_token` (domain=`.technicalkorea.net`)
|
||||||
5. redirect 파라미터가 있으면 원래 페이지로, 없으면 대시보드 표시
|
5. redirect 파라미터가 있으면 원래 페이지로, 없으면 대시보드 표시
|
||||||
@@ -76,7 +76,7 @@ Gateway(`/shared/`)에서 서빙:
|
|||||||
|
|
||||||
각 서비스의 core.js에서 동적 로딩:
|
각 서비스의 core.js에서 동적 로딩:
|
||||||
```
|
```
|
||||||
프로덕션: https://tkds.technicalkorea.net/shared/notification-bell.js
|
프로덕션: https://tkfb.technicalkorea.net/shared/notification-bell.js
|
||||||
로컬: http://localhost:30000/shared/notification-bell.js
|
로컬: http://localhost:30000/shared/notification-bell.js
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Gateway(`/shared/`)에서 서빙:
|
|||||||
5. `sso-auth-service/config/`에 새 origin 추가
|
5. `sso-auth-service/config/`에 새 origin 추가
|
||||||
6. `system1-factory/api/config/cors.js`에 새 origin 추가 (API 호출 시)
|
6. `system1-factory/api/config/cors.js`에 새 origin 추가 (API 호출 시)
|
||||||
7. 알림 벨 사용 시: core.js에 `_loadNotificationBell()` 함수 추가
|
7. 알림 벨 사용 시: core.js에 `_loadNotificationBell()` 함수 추가
|
||||||
8. 로그인 리다이렉트: `tkds.technicalkorea.net/dashboard?redirect=` 패턴 사용
|
8. 로그인 리다이렉트: `tkfb.technicalkorea.net/dashboard?redirect=` 패턴 사용
|
||||||
|
|
||||||
## 배포 절차
|
## 배포 절차
|
||||||
|
|
||||||
|
|||||||
32
CLAUDE.md
32
CLAUDE.md
@@ -40,6 +40,38 @@ git push && ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services &&
|
|||||||
```
|
```
|
||||||
상세: DEPLOY-GUIDE.md 참조. 아키텍처: ARCHITECTURE.md 참조.
|
상세: DEPLOY-GUIDE.md 참조. 아키텍처: ARCHITECTURE.md 참조.
|
||||||
|
|
||||||
|
## 개발 주의사항 — 실수 기록
|
||||||
|
|
||||||
|
### admin 계정으로만 테스트하지 말 것
|
||||||
|
admin(role=admin/system)은 대부분의 권한 체크를 건너뜀. 반드시 **일반 사용자(role=user) 계정으로 테스트**할 것.
|
||||||
|
- `pagePermission.js`: admin은 L18에서 `return next()`로 미들웨어 전체 스킵
|
||||||
|
- `tkfb-core.js initAuth()`: admin은 `accessibleKeys` 조회 자체를 안 함
|
||||||
|
- **실제 사례 (2026-04-01)**: `getPool()` async 함수에 `await` 누락 → admin만 통과, 일반 사용자 전원 500 에러. admin 계정으로만 테스트하여 배포 전 미발견
|
||||||
|
|
||||||
|
### workers 테이블과 sso_users 테이블 불일치
|
||||||
|
모든 사용자가 `workers` 테이블에 있지 않음 (생산지원팀 등 사무직). department_id 조회 시 반드시 **sso_users fallback** 사용:
|
||||||
|
```sql
|
||||||
|
COALESCE(w.department_id, su.department_id) AS department_id
|
||||||
|
```
|
||||||
|
- `dashboardModel.js getUserInfo()` — 수정 완료
|
||||||
|
- `pageAccessRoutes.js` 부서 조회 — 수정 완료
|
||||||
|
- **신규 코드 작성 시**: workers JOIN 후 department_id 사용하면 동일 버그 재발
|
||||||
|
|
||||||
|
### 전역 스코프 const vs function 충돌
|
||||||
|
`const`로 선언한 변수명과 다른 스크립트 파일의 `function` 선언이 같은 이름이면 `SyntaxError: Identifier already declared` 발생. 해당 스크립트 **전체**가 실행 안 됨.
|
||||||
|
- **실제 사례 (2026-04-01)**: `tkfb-core.js`에 `const escHtml = escapeHtml;` 추가 → 4개 JS 파일에서 `function escHtml()` 재선언 → 모바일 전체 미작동
|
||||||
|
- **규칙**: 전역 alias 추가 시 `grep -r "function 함수명" --include="*.js"` 로 충돌 확인 필수
|
||||||
|
|
||||||
|
### 캐시 버스팅 누락
|
||||||
|
JS/CSS 수정 후 HTML의 `?v=YYYYMMDDNN` 갱신을 빠뜨리면 브라우저 캐시로 구버전 실행됨.
|
||||||
|
- 특히 `tkfb-core.js`는 **35개 HTML**에서 참조 — 일괄 갱신 필수
|
||||||
|
- `shared-bottom-nav.js`는 5개 HTML에서 참조
|
||||||
|
- **규칙**: JS/CSS 수정 시 해당 파일을 참조하는 모든 HTML의 버전 갱신. `grep -r "파일명" --include="*.html"` 로 대상 확인
|
||||||
|
|
||||||
|
### nginx 프록시 경로 우선순위
|
||||||
|
nginx location 블록은 **더 구체적인 경로가 먼저** 매칭됨. `/api/auth/`를 `/api/` 뒤에 넣으면 `/api/`가 먼저 잡힘.
|
||||||
|
- **실제 사례 (2026-04-01)**: `/api/auth/change-password` 요청이 system1-api로 라우팅되어 404. `/api/auth/` location을 `/api/` 앞에 추가하여 해결
|
||||||
|
|
||||||
## 멀티 에이전트 워크플로우
|
## 멀티 에이전트 워크플로우
|
||||||
이 프로젝트는 Cowork(설계/검토) + Claude Code(코딩) 멀티 에이전트 방식을 지원한다.
|
이 프로젝트는 Cowork(설계/검토) + Claude Code(코딩) 멀티 에이전트 방식을 지원한다.
|
||||||
- **워크플로우 가이드**: `.cowork/WORKFLOW-GUIDE.md`
|
- **워크플로우 가이드**: `.cowork/WORKFLOW-GUIDE.md`
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
RUN mkdir -p /app/data
|
COPY --chown=appuser:appuser . .
|
||||||
|
RUN mkdir -p /app/data && chown appuser:appuser /app/data
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import json
|
|||||||
from services.ollama_client import ollama_client
|
from services.ollama_client import ollama_client
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_user_input(text: str, max_length: int = 500) -> str:
|
||||||
|
"""사용자 입력 길이 제한 및 정리"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return str(text)[:max_length].strip()
|
||||||
|
|
||||||
|
|
||||||
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
|
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
|
||||||
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
||||||
|
|
||||||
@@ -35,10 +42,12 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
|||||||
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
|
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
|
||||||
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
||||||
|
|
||||||
|
safe_text = sanitize_user_input(user_text)
|
||||||
prompt = f"""카테고리 목록:
|
prompt = f"""카테고리 목록:
|
||||||
{category_context}
|
{category_context}
|
||||||
|
|
||||||
사용자 입력: "{user_text}"
|
사용자 입력:
|
||||||
|
<user_input>{safe_text}</user_input>
|
||||||
|
|
||||||
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
||||||
|
|
||||||
@@ -71,12 +80,14 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
|||||||
async def summarize_report(data: dict) -> dict:
|
async def summarize_report(data: dict) -> dict:
|
||||||
"""최종 신고 내용을 요약"""
|
"""최종 신고 내용을 요약"""
|
||||||
prompt = f"""신고 정보:
|
prompt = f"""신고 정보:
|
||||||
- 설명: {data.get('description', '')}
|
<user_input>
|
||||||
- 유형: {data.get('type', '')}
|
- 설명: {sanitize_user_input(data.get('description', ''))}
|
||||||
- 카테고리: {data.get('category', '')}
|
- 유형: {sanitize_user_input(data.get('type', ''))}
|
||||||
- 항목: {data.get('item', '')}
|
- 카테고리: {sanitize_user_input(data.get('category', ''))}
|
||||||
- 위치: {data.get('location', '')}
|
- 항목: {sanitize_user_input(data.get('item', ''))}
|
||||||
- 프로젝트: {data.get('project', '')}
|
- 위치: {sanitize_user_input(data.get('location', ''))}
|
||||||
|
- 프로젝트: {sanitize_user_input(data.get('project', ''))}
|
||||||
|
</user_input>
|
||||||
|
|
||||||
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
- mariadb_data:/var/lib/mysql
|
- mariadb_data:/var/lib/mysql
|
||||||
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
|
||||||
ports:
|
ports:
|
||||||
- "30306:3306"
|
- "127.0.0.1:30306:3306"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||||
timeout: 20s
|
timeout: 20s
|
||||||
@@ -608,7 +608,7 @@ services:
|
|||||||
container_name: tk-phpmyadmin
|
container_name: tk-phpmyadmin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "30880:80"
|
- "127.0.0.1:30880:80"
|
||||||
environment:
|
environment:
|
||||||
- PMA_HOST=mariadb
|
- PMA_HOST=mariadb
|
||||||
- PMA_USER=${PMA_USER:-root}
|
- PMA_USER=${PMA_USER:-root}
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
|
|
||||||
// ===== Card Definitions =====
|
// ===== Card Definitions =====
|
||||||
var SYSTEM_CARDS = [
|
var SYSTEM_CARDS = [
|
||||||
{ id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard.html', pageKey: 'dashboard', color: '#1a56db' },
|
{ id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard-new.html', pageKey: 'dashboard', color: '#1a56db' },
|
||||||
{ id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
|
{ id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
|
||||||
{ id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' },
|
{ id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' },
|
||||||
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
|
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
|
||||||
@@ -781,13 +781,14 @@
|
|||||||
|
|
||||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
var redirect = new URLSearchParams(location.search).get('redirect');
|
||||||
if (redirect && isSafeRedirect(redirect)) {
|
if (redirect && isSafeRedirect(redirect)) {
|
||||||
window.location.href = redirect;
|
var sep = redirect.indexOf('#') === -1 ? '#' : '&';
|
||||||
|
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(data.access_token);
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/dashboard';
|
window.location.href = '/dashboard';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errEl.textContent = err.message;
|
errEl.textContent = err.message;
|
||||||
errEl.style.display = '';
|
errEl.style.display = 'block';
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '\uB85C\uADF8\uC778';
|
btn.textContent = '\uB85C\uADF8\uC778';
|
||||||
@@ -840,7 +841,8 @@
|
|||||||
// Already logged in + redirect param
|
// Already logged in + redirect param
|
||||||
var redirect = params.get('redirect');
|
var redirect = params.get('redirect');
|
||||||
if (redirect && isSafeRedirect(redirect)) {
|
if (redirect && isSafeRedirect(redirect)) {
|
||||||
window.location.href = redirect;
|
var sep = redirect.indexOf('#') === -1 ? '#' : '&';
|
||||||
|
window.location.href = redirect + sep + '_sso=' + encodeURIComponent(token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
var loginUrl;
|
var loginUrl;
|
||||||
|
|
||||||
if (hostname.includes('technicalkorea.net')) {
|
if (hostname.includes('technicalkorea.net')) {
|
||||||
loginUrl = window.location.protocol + '//tkds.technicalkorea.net/dashboard';
|
loginUrl = window.location.protocol + '//tkfb.technicalkorea.net/dashboard';
|
||||||
} else {
|
} else {
|
||||||
// 개발 환경: tkds 포트 (30780)
|
// 개발 환경: tkds 포트 (30780)
|
||||||
loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard';
|
loginUrl = window.location.protocol + '//' + hostname + ':30780/dashboard';
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ===================================================================
|
|
||||||
# TK Factory Services - 원격 배포 스크립트 (맥북에서 실행)
|
|
||||||
# ===================================================================
|
|
||||||
# 사용법: ./scripts/deploy-remote.sh
|
|
||||||
# 설정: ~/.tk-deploy-config
|
|
||||||
# ===================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
CONFIG_FILE="$HOME/.tk-deploy-config"
|
|
||||||
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# === 설정 로드 ===
|
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
|
||||||
echo -e "${RED}ERROR: 설정 파일이 없습니다: $CONFIG_FILE${NC}"
|
|
||||||
cat <<'EXAMPLE'
|
|
||||||
|
|
||||||
다음 내용으로 생성하세요:
|
|
||||||
|
|
||||||
NAS_HOST=100.71.132.52
|
|
||||||
NAS_USER=hyungi
|
|
||||||
NAS_DEPLOY_PATH=/volume1/docker_1/tk-factory-services
|
|
||||||
NAS_SUDO_PASS=<sudo 비밀번호>
|
|
||||||
EXAMPLE
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
source "$CONFIG_FILE"
|
|
||||||
|
|
||||||
for var in NAS_HOST NAS_USER NAS_DEPLOY_PATH NAS_SUDO_PASS; do
|
|
||||||
if [ -z "${!var}" ]; then
|
|
||||||
echo -e "${RED}ERROR: $CONFIG_FILE에 $var 가 설정되지 않았습니다${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
DOCKER="/usr/local/bin/docker"
|
|
||||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
|
|
||||||
|
|
||||||
# === 헬퍼 함수 ===
|
|
||||||
ssh_cmd() {
|
|
||||||
ssh -o ConnectTimeout=10 "${NAS_USER}@${NAS_HOST}" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
nas_docker() {
|
|
||||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && echo '${NAS_SUDO_PASS}' | sudo -S ${DOCKER} $*" 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# === Phase 1: Pre-flight 체크 ===
|
|
||||||
echo "=== TK Factory Services - 원격 배포 ==="
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[1/5] Pre-flight 체크${NC}"
|
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
|
|
||||||
# Working tree clean 확인
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo -e "${RED}ERROR: 로컬에 커밋되지 않은 변경사항이 있습니다${NC}"
|
|
||||||
echo ""
|
|
||||||
git status --short
|
|
||||||
echo ""
|
|
||||||
echo "먼저 커밋하거나 stash하세요."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 로컬 커밋 정보
|
|
||||||
LOCAL_HASH=$(git rev-parse HEAD)
|
|
||||||
LOCAL_SHORT=$(git rev-parse --short HEAD)
|
|
||||||
LOCAL_MSG=$(git log -1 --format='%s')
|
|
||||||
LOCAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
||||||
|
|
||||||
# origin 동기화 확인
|
|
||||||
git fetch origin --quiet
|
|
||||||
|
|
||||||
ORIGIN_HASH=$(git rev-parse "origin/${LOCAL_BRANCH}" 2>/dev/null || echo "")
|
|
||||||
if [ "$LOCAL_HASH" != "$ORIGIN_HASH" ]; then
|
|
||||||
echo -e "${RED}ERROR: 로컬 커밋이 origin에 push되지 않았습니다${NC}"
|
|
||||||
echo " 로컬: ${LOCAL_SHORT} (${LOCAL_MSG})"
|
|
||||||
echo " 원격: $(git rev-parse --short "origin/${LOCAL_BRANCH}" 2>/dev/null || echo 'N/A')"
|
|
||||||
echo ""
|
|
||||||
echo "먼저 push하세요: git push origin ${LOCAL_BRANCH}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e " 로컬 HEAD: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
|
|
||||||
echo -e " 브랜치: ${LOCAL_BRANCH}"
|
|
||||||
|
|
||||||
# === Phase 2: NAS 상태 비교 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[2/5] NAS 배포 상태 확인${NC}"
|
|
||||||
|
|
||||||
NAS_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H'" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -z "$NAS_HASH" ]; then
|
|
||||||
echo -e "${RED}ERROR: NAS에서 git 정보를 가져올 수 없습니다${NC}"
|
|
||||||
echo " 경로: ${NAS_DEPLOY_PATH}"
|
|
||||||
echo " NAS에 git clone이 완료되었는지 확인하세요."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
NAS_SHORT="${NAS_HASH:0:7}"
|
|
||||||
NAS_MSG=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%s'" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$LOCAL_HASH" = "$NAS_HASH" ]; then
|
|
||||||
echo -e " ${GREEN}이미 최신 버전입니다!${NC} (${NAS_SHORT} - ${NAS_MSG})"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e " NAS 현재: ${YELLOW}${NAS_SHORT}${NC} - ${NAS_MSG}"
|
|
||||||
echo -e " 배포 대상: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
|
|
||||||
|
|
||||||
# 배포될 커밋 목록
|
|
||||||
COMMIT_COUNT=$(git log "${NAS_HASH}..${LOCAL_HASH}" --oneline | wc -l | tr -d ' ')
|
|
||||||
echo ""
|
|
||||||
echo "=== 배포될 커밋 (${COMMIT_COUNT}개) ==="
|
|
||||||
git log "${NAS_HASH}..${LOCAL_HASH}" --oneline --no-decorate
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
read -p "배포를 진행하시겠습니까? [y/N] " confirm
|
|
||||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
|
||||||
echo "배포가 취소되었습니다."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# === Phase 3: 배포 실행 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[3/5] NAS 코드 업데이트${NC}"
|
|
||||||
|
|
||||||
ssh_cmd "cd ${NAS_DEPLOY_PATH} && git fetch origin && git reset --hard origin/${DEPLOY_BRANCH}"
|
|
||||||
|
|
||||||
UPDATED_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H %s'" 2>/dev/null)
|
|
||||||
echo -e " ${GREEN}완료${NC}: ${UPDATED_HASH}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[4/5] Docker 컨테이너 빌드 및 재시작${NC}"
|
|
||||||
echo " (빌드에 시간이 걸릴 수 있습니다...)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
nas_docker "compose up -d --build"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " nginx 프록시 컨테이너 재시작 (IP 캐시 갱신)..."
|
|
||||||
nas_docker "restart tk-gateway tk-system2-web tk-system3-web"
|
|
||||||
|
|
||||||
# === Phase 4: 배포 후 검증 ===
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[5/5] 배포 검증${NC} (15초 대기 후 health check)"
|
|
||||||
sleep 15
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Container Status ==="
|
|
||||||
nas_docker "compose ps --format 'table {{.Name}}\t{{.Status}}'" || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== HTTP Health Check ==="
|
|
||||||
HEALTH_PASS=0
|
|
||||||
HEALTH_FAIL=0
|
|
||||||
|
|
||||||
check_remote() {
|
|
||||||
local name="$1"
|
|
||||||
local path="$2"
|
|
||||||
local status
|
|
||||||
status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 http://localhost:${path}" 2>/dev/null || echo "000")
|
|
||||||
if [ "$status" -ge 200 ] 2>/dev/null && [ "$status" -lt 400 ] 2>/dev/null; then
|
|
||||||
printf " %-25s ${GREEN}OK${NC} (%s)\n" "$name" "$status"
|
|
||||||
((HEALTH_PASS++))
|
|
||||||
else
|
|
||||||
printf " %-25s ${RED}FAIL${NC} (%s)\n" "$name" "$status"
|
|
||||||
((HEALTH_FAIL++))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_remote "Gateway" "30000/"
|
|
||||||
check_remote "SSO Auth" "30050/health"
|
|
||||||
check_remote "System 1 API" "30005/api/health"
|
|
||||||
check_remote "System 1 Web" "30080/"
|
|
||||||
check_remote "System 1 FastAPI" "30008/health"
|
|
||||||
check_remote "System 2 API" "30105/api/health"
|
|
||||||
check_remote "System 2 Web" "30180/"
|
|
||||||
check_remote "System 3 API" "30200/api/health"
|
|
||||||
check_remote "System 3 Web" "30280/"
|
|
||||||
check_remote "tkuser API" "30300/api/health"
|
|
||||||
check_remote "tkuser Web" "30380/"
|
|
||||||
check_remote "phpMyAdmin" "30880/"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " Health: PASS=${HEALTH_PASS} FAIL=${HEALTH_FAIL}"
|
|
||||||
|
|
||||||
# === Phase 5: 배포 로그 기록 ===
|
|
||||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
|
||||||
ssh_cmd "echo '${TIMESTAMP} | ${LOCAL_SHORT} | ${LOCAL_MSG}' >> ${NAS_DEPLOY_PATH}/DEPLOY_LOG"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if [ "$HEALTH_FAIL" -gt 0 ]; then
|
|
||||||
echo -e "${YELLOW}배포 완료 (일부 서비스 health check 실패)${NC}"
|
|
||||||
echo " 로그 확인: ssh ${NAS_USER}@${NAS_HOST} \"cd ${NAS_DEPLOY_PATH} && echo '...' | sudo -S ${DOCKER} compose logs --tail=50\""
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}배포 완료!${NC}"
|
|
||||||
fi
|
|
||||||
echo " 버전: ${LOCAL_SHORT} - ${LOCAL_MSG}"
|
|
||||||
39
shared/frontend/sso-relay.js
Normal file
39
shared/frontend/sso-relay.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
|
||||||
|
*
|
||||||
|
* Canonical source: shared/frontend/sso-relay.js
|
||||||
|
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
|
||||||
|
* system1-factory/web/js/sso-relay.js
|
||||||
|
* system2-report/web/js/sso-relay.js
|
||||||
|
* system3-nonconformance/web/static/js/sso-relay.js
|
||||||
|
* user-management/web/static/js/sso-relay.js
|
||||||
|
* tkpurchase/web/static/js/sso-relay.js
|
||||||
|
* tksafety/web/static/js/sso-relay.js
|
||||||
|
* tksupport/web/static/js/sso-relay.js
|
||||||
|
*
|
||||||
|
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
|
||||||
|
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
var hash = location.hash;
|
||||||
|
if (!hash || hash.indexOf('_sso=') === -1) return;
|
||||||
|
|
||||||
|
var match = hash.match(/[#&]_sso=([^&]*)/);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
var token = decodeURIComponent(match[1]);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
// 로컬(1st-party) 쿠키 설정
|
||||||
|
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
|
||||||
|
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
|
||||||
|
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||||
|
}
|
||||||
|
document.cookie = cookie;
|
||||||
|
|
||||||
|
// localStorage 폴백
|
||||||
|
try { localStorage.setItem('sso_token', token); } catch (e) {}
|
||||||
|
|
||||||
|
// URL에서 hash 제거
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
})();
|
||||||
61
shared/middleware/pagePermission.js
Normal file
61
shared/middleware/pagePermission.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 페이지 권한 미들웨어 (shared)
|
||||||
|
* admin/system 역할은 자동 통과, 일반 사용자는 개인/부서 권한 체크
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* const { createRequirePage } = require('../../shared/middleware/pagePermission');
|
||||||
|
* const requirePage = createRequirePage(() => getPool());
|
||||||
|
* router.get('/some', requirePage('page_name'), controller.handler);
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createRequirePage(getPool) {
|
||||||
|
return function requirePage(pageName) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const role = (req.user.role || '').toLowerCase();
|
||||||
|
|
||||||
|
// admin/system 자동 통과
|
||||||
|
if (role === 'admin' || role === 'system') return next();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = typeof getPool === 'function' ? await getPool() : getPool;
|
||||||
|
|
||||||
|
// 1. 개인 권한 체크
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
|
||||||
|
[userId, pageName]
|
||||||
|
);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return rows[0].can_access
|
||||||
|
? next()
|
||||||
|
: res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 부서 권한 체크
|
||||||
|
const [userRows] = await db.query(
|
||||||
|
'SELECT department_id FROM sso_users WHERE user_id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (userRows.length > 0 && userRows[0].department_id) {
|
||||||
|
const [deptRows] = await db.query(
|
||||||
|
'SELECT can_access FROM department_page_permissions WHERE department_id = ? AND page_name = ?',
|
||||||
|
[userRows[0].department_id, pageName]
|
||||||
|
);
|
||||||
|
if (deptRows.length > 0) {
|
||||||
|
return deptRows[0].can_access
|
||||||
|
? next()
|
||||||
|
: res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 권한 레코드 없음 → 거부
|
||||||
|
return res.status(403).json({ success: false, error: '접근 권한이 없습니다' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Permission check error:', err);
|
||||||
|
return res.status(500).json({ success: false, error: '권한 확인 실패' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createRequirePage };
|
||||||
@@ -9,13 +9,14 @@ const notifyHelper = {
|
|||||||
/**
|
/**
|
||||||
* 알림 전송
|
* 알림 전송
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system)
|
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system, purchase)
|
||||||
* @param {string} opts.title - 알림 제목
|
* @param {string} opts.title - 알림 제목
|
||||||
* @param {string} [opts.message] - 알림 내용
|
* @param {string} [opts.message] - 알림 내용
|
||||||
* @param {string} [opts.link_url] - 클릭 시 이동 URL
|
* @param {string} [opts.link_url] - 클릭 시 이동 URL
|
||||||
* @param {string} [opts.reference_type] - 연관 테이블명
|
* @param {string} [opts.reference_type] - 연관 테이블명
|
||||||
* @param {number} [opts.reference_id] - 연관 레코드 ID
|
* @param {number} [opts.reference_id] - 연관 레코드 ID
|
||||||
* @param {number} [opts.created_by] - 생성자 user_id
|
* @param {number} [opts.created_by] - 생성자 user_id
|
||||||
|
* @param {number[]} [opts.target_user_ids] - 특정 사용자 직접 알림 (생략 시 type 기반 브로드캐스트)
|
||||||
*/
|
*/
|
||||||
async send(opts) {
|
async send(opts) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ async function login(req, res, next) {
|
|||||||
await userModel.updateLastLogin(user.user_id);
|
await userModel.updateLastLogin(user.user_id);
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
const refresh_token = jwt.sign(
|
const refresh_token = jwt.sign(
|
||||||
{ user_id: user.user_id, type: 'refresh' },
|
{ user_id: user.user_id, type: 'refresh' },
|
||||||
JWT_REFRESH_SECRET,
|
JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
||||||
@@ -159,7 +159,7 @@ async function loginForm(req, res, next) {
|
|||||||
await userModel.updateLastLogin(user.user_id);
|
await userModel.updateLastLogin(user.user_id);
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
access_token,
|
access_token,
|
||||||
@@ -187,7 +187,8 @@ async function validate(req, res, next) {
|
|||||||
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
|
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
// TODO: issuer/audience 클레임 검증 추가 검토
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
const user = await userModel.findById(decoded.user_id || decoded.id);
|
const user = await userModel.findById(decoded.user_id || decoded.id);
|
||||||
if (!user || !user.is_active) {
|
if (!user || !user.is_active) {
|
||||||
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
|
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
|
||||||
@@ -229,7 +230,7 @@ async function me(req, res, next) {
|
|||||||
return res.status(401).json({ detail: 'Not authenticated' });
|
return res.status(401).json({ detail: 'Not authenticated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
const user = await userModel.findById(decoded.user_id || decoded.id);
|
const user = await userModel.findById(decoded.user_id || decoded.id);
|
||||||
if (!user || !user.is_active) {
|
if (!user || !user.is_active) {
|
||||||
return res.status(401).json({ detail: 'User not found or inactive' });
|
return res.status(401).json({ detail: 'User not found or inactive' });
|
||||||
@@ -261,7 +262,7 @@ async function refresh(req, res, next) {
|
|||||||
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
|
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET);
|
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
|
||||||
if (decoded.type !== 'refresh') {
|
if (decoded.type !== 'refresh') {
|
||||||
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
|
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
|
||||||
}
|
}
|
||||||
@@ -272,11 +273,11 @@ async function refresh(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = createTokenPayload(user);
|
const payload = createTokenPayload(user);
|
||||||
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
||||||
const new_refresh_token = jwt.sign(
|
const new_refresh_token = jwt.sign(
|
||||||
{ user_id: user.user_id, type: 'refresh' },
|
{ user_id: user.user_id, type: 'refresh' },
|
||||||
JWT_REFRESH_SECRET,
|
JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -365,6 +366,65 @@ async function deleteUser(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/change-password — 본인 비밀번호 변경
|
||||||
|
*/
|
||||||
|
async function changePassword(req, res, next) {
|
||||||
|
try {
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token) return res.status(401).json({ success: false, message: '인증이 필요합니다' });
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
|
const userId = decoded.user_id || decoded.id;
|
||||||
|
const user = await userModel.findById(userId);
|
||||||
|
if (!user || !user.is_active) {
|
||||||
|
return res.status(401).json({ success: false, message: '유효하지 않은 사용자입니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ success: false, message: '현재 비밀번호와 새 비밀번호를 모두 입력해주세요' });
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
return res.status(400).json({ success: false, message: '새 비밀번호는 6자 이상이어야 합니다' });
|
||||||
|
}
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
return res.status(400).json({ success: false, message: '새 비밀번호는 현재 비밀번호와 달라야 합니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await userModel.verifyPassword(currentPassword, user.password_hash);
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(400).json({ success: false, message: '현재 비밀번호가 올바르지 않습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await userModel.update(userId, { password: newPassword });
|
||||||
|
res.json({ success: true, message: '비밀번호가 변경되었습니다. 다시 로그인해주세요.' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({ success: false, message: '인증이 만료되었습니다' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/check-password-strength — 비밀번호 강도 체크
|
||||||
|
*/
|
||||||
|
async function checkPasswordStrength(req, res) {
|
||||||
|
const { password } = req.body;
|
||||||
|
if (!password) return res.json({ success: true, data: { score: 0, level: 'weak' } });
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (password.length >= 6) score++;
|
||||||
|
if (password.length >= 8) score++;
|
||||||
|
if (/[A-Z]/.test(password)) score++;
|
||||||
|
if (/[0-9]/.test(password)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||||
|
|
||||||
|
const level = score <= 1 ? 'weak' : score <= 3 ? 'medium' : 'strong';
|
||||||
|
res.json({ success: true, data: { score, level } });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bearer 토큰 또는 쿠키에서 토큰 추출
|
* Bearer 토큰 또는 쿠키에서 토큰 추출
|
||||||
*/
|
*/
|
||||||
@@ -387,5 +447,7 @@ module.exports = {
|
|||||||
getUsers,
|
getUsers,
|
||||||
createUser,
|
createUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
deleteUser
|
deleteUser,
|
||||||
|
changePassword,
|
||||||
|
checkPasswordStrength
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ const allowedOrigins = [
|
|||||||
'https://tkpurchase.technicalkorea.net',
|
'https://tkpurchase.technicalkorea.net',
|
||||||
'https://tksafety.technicalkorea.net',
|
'https://tksafety.technicalkorea.net',
|
||||||
'https://tksupport.technicalkorea.net',
|
'https://tksupport.technicalkorea.net',
|
||||||
'https://tkds.technicalkorea.net',
|
'https://tkfb.technicalkorea.net',
|
||||||
];
|
];
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380');
|
allowedOrigins.push('http://localhost:30000', 'http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280', 'http://localhost:30380');
|
||||||
}
|
}
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: function(origin, cb) {
|
origin: function(origin, cb) {
|
||||||
if (!origin || allowedOrigins.includes(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
|
if (!origin || allowedOrigins.includes(origin) || /^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin) || /^http:\/\/(192\.168\.\d+\.\d+|localhost)(:\d+)?$/.test(origin)) return cb(null, true);
|
||||||
cb(new Error('CORS blocked: ' + origin));
|
cb(null, false);
|
||||||
},
|
},
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ router.get('/me', authController.me);
|
|||||||
router.post('/refresh', authController.refresh);
|
router.post('/refresh', authController.refresh);
|
||||||
router.post('/logout', authController.logout);
|
router.post('/logout', authController.logout);
|
||||||
|
|
||||||
|
// 인증 사용자 엔드포인트
|
||||||
|
router.post('/change-password', authController.changePassword);
|
||||||
|
router.post('/check-password-strength', authController.checkPasswordStrength);
|
||||||
|
|
||||||
// 관리자 엔드포인트
|
// 관리자 엔드포인트
|
||||||
router.get('/users', requireAdmin, authController.getUsers);
|
router.get('/users', requireAdmin, authController.getUsers);
|
||||||
router.post('/users', requireAdmin, authController.createUser);
|
router.post('/users', requireAdmin, authController.createUser);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
|
|||||||
# 앱 소스 복사
|
# 앱 소스 복사
|
||||||
COPY system1-factory/api/ ./
|
COPY system1-factory/api/ ./
|
||||||
|
|
||||||
|
# shared 모듈 심링크 (routes에서 ../../../shared/ 경로 호환)
|
||||||
|
RUN ln -s /usr/src/app/shared /usr/src/shared && ln -s /usr/src/app/shared /usr/shared
|
||||||
|
|
||||||
# 로그/업로드 디렉토리 생성
|
# 로그/업로드 디렉토리 생성
|
||||||
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
|
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
|
||||||
RUN chown -R node:node /usr/src/app
|
RUN chown -R node:node /usr/src/app
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const logger = require('../utils/logger');
|
|||||||
*/
|
*/
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
'https://tkfb.technicalkorea.net', // System 1 (공장관리)
|
'https://tkfb.technicalkorea.net', // System 1 (공장관리)
|
||||||
'https://tkds.technicalkorea.net', // Gateway/Dashboard
|
'https://tkfb.technicalkorea.net', // Gateway/Dashboard
|
||||||
'https://tkreport.technicalkorea.net', // System 2
|
'https://tkreport.technicalkorea.net', // System 2
|
||||||
'https://tkqc.technicalkorea.net', // System 3
|
'https://tkqc.technicalkorea.net', // System 3
|
||||||
'https://tkuser.technicalkorea.net', // User Management
|
'https://tkuser.technicalkorea.net', // User Management
|
||||||
@@ -50,6 +50,12 @@ const corsOptions = {
|
|||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *.technicalkorea.net 서브도메인 허용 (인앱 브라우저 대응)
|
||||||
|
if (/^https?:\/\/[a-z0-9-]+\.technicalkorea\.net$/.test(origin)) {
|
||||||
|
logger.debug('CORS: technicalkorea.net 서브도메인 허용', { origin });
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
// 개발 환경에서는 모든 localhost 허용
|
// 개발 환경에서는 모든 localhost 허용
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
||||||
@@ -64,9 +70,9 @@ const corsOptions = {
|
|||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 차단
|
// 차단 (500 에러 대신 CORS 헤더 미포함으로 거부)
|
||||||
logger.warn('CORS: 차단된 Origin', { origin });
|
logger.warn('CORS: 차단된 Origin', { origin });
|
||||||
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`));
|
callback(null, false);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,8 +53,14 @@ function setupRoutes(app) {
|
|||||||
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
|
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
|
||||||
const purchaseRoutes = require('../routes/purchaseRoutes');
|
const purchaseRoutes = require('../routes/purchaseRoutes');
|
||||||
const settlementRoutes = require('../routes/settlementRoutes');
|
const settlementRoutes = require('../routes/settlementRoutes');
|
||||||
|
const itemAliasRoutes = require('../routes/itemAliasRoutes');
|
||||||
|
const purchaseBatchRoutes = require('../routes/purchaseBatchRoutes');
|
||||||
|
const consumableCategoryRoutes = require('../routes/consumableCategoryRoutes');
|
||||||
const scheduleRoutes = require('../routes/scheduleRoutes');
|
const scheduleRoutes = require('../routes/scheduleRoutes');
|
||||||
const meetingRoutes = require('../routes/meetingRoutes');
|
const meetingRoutes = require('../routes/meetingRoutes');
|
||||||
|
const proxyInputRoutes = require('../routes/proxyInputRoutes');
|
||||||
|
const monthlyComparisonRoutes = require('../routes/monthlyComparisonRoutes');
|
||||||
|
const dashboardRoutes = require('../routes/dashboardRoutes');
|
||||||
|
|
||||||
// Rate Limiters 설정
|
// Rate Limiters 설정
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
@@ -163,8 +169,14 @@ function setupRoutes(app) {
|
|||||||
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
|
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
|
||||||
app.use('/api/purchases', purchaseRoutes); // 구매 내역
|
app.use('/api/purchases', purchaseRoutes); // 구매 내역
|
||||||
app.use('/api/settlements', settlementRoutes); // 월간 정산
|
app.use('/api/settlements', settlementRoutes); // 월간 정산
|
||||||
|
app.use('/api/item-aliases', itemAliasRoutes); // 품목 별칭
|
||||||
|
app.use('/api/purchase-batches', purchaseBatchRoutes); // 구매 그룹
|
||||||
|
app.use('/api/consumable-categories', consumableCategoryRoutes); // 소모품 카테고리
|
||||||
app.use('/api/schedule', scheduleRoutes); // 공정표
|
app.use('/api/schedule', scheduleRoutes); // 공정표
|
||||||
app.use('/api/meetings', meetingRoutes); // 생산회의록
|
app.use('/api/meetings', meetingRoutes); // 생산회의록
|
||||||
|
app.use('/api/proxy-input', proxyInputRoutes); // 대리입력 + 일별현황
|
||||||
|
app.use('/api/monthly-comparison', monthlyComparisonRoutes); // 월간 비교·확인·정산
|
||||||
|
app.use('/api/dashboard', dashboardRoutes); // 대시보드 개인 요약
|
||||||
app.use('/api', uploadBgRoutes);
|
app.use('/api', uploadBgRoutes);
|
||||||
|
|
||||||
// Swagger API 문서
|
// Swagger API 문서
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const ConsumableCategoryModel = require('../models/consumableCategoryModel');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const ConsumableCategoryController = {
|
||||||
|
getAll: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.all !== '1';
|
||||||
|
const rows = await ConsumableCategoryModel.getAll(activeOnly);
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ConsumableCategory getAll error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category_code, category_name, icon, color_bg, color_fg, sort_order } = req.body;
|
||||||
|
if (!category_code || !category_name) {
|
||||||
|
return res.status(400).json({ success: false, message: '코드와 이름을 입력해주세요.' });
|
||||||
|
}
|
||||||
|
const cat = await ConsumableCategoryModel.create({
|
||||||
|
categoryCode: category_code, categoryName: category_name,
|
||||||
|
icon, colorBg: color_bg, colorFg: color_fg, sortOrder: sort_order
|
||||||
|
});
|
||||||
|
res.status(201).json({ success: true, data: cat, message: '카테고리가 추가되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') {
|
||||||
|
return res.status(400).json({ success: false, message: '이미 존재하는 코드입니다.' });
|
||||||
|
}
|
||||||
|
logger.error('ConsumableCategory create error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category_name, icon, color_bg, color_fg, sort_order } = req.body;
|
||||||
|
const cat = await ConsumableCategoryModel.update(req.params.id, {
|
||||||
|
categoryName: category_name, icon, colorBg: color_bg, colorFg: color_fg, sortOrder: sort_order
|
||||||
|
});
|
||||||
|
if (!cat) return res.status(404).json({ success: false, message: '카테고리를 찾을 수 없습니다.' });
|
||||||
|
res.json({ success: true, data: cat, message: '카테고리가 수정되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ConsumableCategory update error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivate: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cat = await ConsumableCategoryModel.deactivate(req.params.id);
|
||||||
|
if (!cat) return res.status(404).json({ success: false, message: '카테고리를 찾을 수 없습니다.' });
|
||||||
|
res.json({ success: true, data: cat, message: '카테고리가 비활성화되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ConsumableCategory deactivate error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ConsumableCategoryController;
|
||||||
92
system1-factory/api/controllers/dashboardController.js
Normal file
92
system1-factory/api/controllers/dashboardController.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 대시보드 컨트롤러
|
||||||
|
* Sprint 003 — 개인 요약 API
|
||||||
|
*/
|
||||||
|
const DashboardModel = require('../models/dashboardModel');
|
||||||
|
const logger = require('../../shared/utils/logger');
|
||||||
|
|
||||||
|
const DashboardController = {
|
||||||
|
/**
|
||||||
|
* GET /api/dashboard/my-summary
|
||||||
|
* 연차 잔여 + 월간 연장근로 + 접근 가능 페이지
|
||||||
|
*/
|
||||||
|
getMySummary: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
|
||||||
|
// 1단계: 사용자 정보 먼저 조회 (worker_id 필요)
|
||||||
|
const userInfo = await DashboardModel.getUserInfo(userId);
|
||||||
|
if (!userInfo) {
|
||||||
|
return res.status(404).json({ success: false, message: '사용자 정보를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const departmentId = userInfo.department_id;
|
||||||
|
const role = userInfo.role;
|
||||||
|
|
||||||
|
// 2단계: 나머지 3개 병렬 조회 (연차: sp_vacation_balances from user_id)
|
||||||
|
const [vacationRows, overtime, quickAccess] = await Promise.all([
|
||||||
|
DashboardModel.getVacationBalance(userId, year),
|
||||||
|
DashboardModel.getMonthlyOvertime(userId, year, month),
|
||||||
|
DashboardModel.getQuickAccess(userId, departmentId, role)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 연차 응답 가공
|
||||||
|
const details = vacationRows.map(v => ({
|
||||||
|
type_name: v.type_name,
|
||||||
|
type_code: v.type_code,
|
||||||
|
balance_type: v.balance_type || 'AUTO',
|
||||||
|
expires_at: v.expires_at || null,
|
||||||
|
total: parseFloat(v.total_days) || 0,
|
||||||
|
used: parseFloat(v.used_days) || 0,
|
||||||
|
remaining: parseFloat(v.remaining_days) || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 만료되지 않은 balance만 합산 (만료된 이월연차 제외)
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
const activeRows = vacationRows.filter(v => !v.expires_at || v.expires_at >= today);
|
||||||
|
const totalDays = activeRows.reduce((s, v) => s + (parseFloat(v.total_days) || 0), 0);
|
||||||
|
const usedDays = activeRows.reduce((s, v) => s + (parseFloat(v.used_days) || 0), 0);
|
||||||
|
const remainingDays = totalDays - usedDays;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
user_id: userInfo.user_id,
|
||||||
|
name: userInfo.name,
|
||||||
|
worker_name: userInfo.worker_name || userInfo.name,
|
||||||
|
job_type: userInfo.job_type || '',
|
||||||
|
department_name: userInfo.department_name,
|
||||||
|
department_id: userInfo.department_id,
|
||||||
|
role: userInfo.role
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
year,
|
||||||
|
total_days: totalDays,
|
||||||
|
used_days: usedDays,
|
||||||
|
remaining_days: remainingDays,
|
||||||
|
details
|
||||||
|
},
|
||||||
|
overtime: {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
total_overtime_hours: parseFloat(overtime.total_overtime_hours) || 0,
|
||||||
|
overtime_days: parseInt(overtime.overtime_days) || 0,
|
||||||
|
total_work_days: parseInt(overtime.total_work_days) || 0,
|
||||||
|
total_work_hours: parseFloat(overtime.total_work_hours) || 0,
|
||||||
|
avg_daily_hours: parseFloat(parseFloat(overtime.avg_daily_hours || 0).toFixed(1))
|
||||||
|
},
|
||||||
|
quick_access: quickAccess
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('대시보드 요약 조회 오류:', err);
|
||||||
|
res.status(500).json({ success: false, message: '대시보드 데이터 조회 중 오류가 발생했습니다.', error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = DashboardController;
|
||||||
47
system1-factory/api/controllers/itemAliasController.js
Normal file
47
system1-factory/api/controllers/itemAliasController.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const ItemAliasModel = require('../models/itemAliasModel');
|
||||||
|
const koreanSearch = require('../utils/koreanSearch');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const ItemAliasController = {
|
||||||
|
getAll: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await ItemAliasModel.getAll();
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ItemAlias getAll error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { item_id, alias_name } = req.body;
|
||||||
|
if (!item_id || !alias_name || !alias_name.trim()) {
|
||||||
|
return res.status(400).json({ success: false, message: '품목 ID와 별칭을 입력해주세요.' });
|
||||||
|
}
|
||||||
|
const id = await ItemAliasModel.create(item_id, alias_name);
|
||||||
|
koreanSearch.clearCache();
|
||||||
|
res.status(201).json({ success: true, data: { alias_id: id }, message: '별칭이 등록되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') {
|
||||||
|
return res.status(400).json({ success: false, message: '이미 등록된 별칭입니다.' });
|
||||||
|
}
|
||||||
|
logger.error('ItemAlias create error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const deleted = await ItemAliasModel.delete(req.params.id);
|
||||||
|
if (!deleted) return res.status(404).json({ success: false, message: '별칭을 찾을 수 없습니다.' });
|
||||||
|
koreanSearch.clearCache();
|
||||||
|
res.json({ success: true, message: '별칭이 삭제되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('ItemAlias delete error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ItemAliasController;
|
||||||
624
system1-factory/api/controllers/monthlyComparisonController.js
Normal file
624
system1-factory/api/controllers/monthlyComparisonController.js
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
// controllers/monthlyComparisonController.js — 월간 비교·확인·정산
|
||||||
|
const Model = require('../models/monthlyComparisonModel');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
|
||||||
|
|
||||||
|
// 일별 비교 상태 판정
|
||||||
|
function determineStatus(report, attendance, isHoliday) {
|
||||||
|
const hasReport = report && report.total_hours > 0;
|
||||||
|
const hasAttendance = attendance && attendance.total_work_hours > 0;
|
||||||
|
const isVacation = attendance && attendance.vacation_type_id;
|
||||||
|
|
||||||
|
if (isHoliday && !hasReport && !hasAttendance) return 'holiday';
|
||||||
|
if (isVacation) return 'vacation';
|
||||||
|
if (!hasReport && !hasAttendance) return 'none';
|
||||||
|
if (hasReport && !hasAttendance) return 'report_only';
|
||||||
|
if (!hasReport && hasAttendance) return 'attend_only';
|
||||||
|
|
||||||
|
const diff = Math.abs(report.total_hours - attendance.total_work_hours);
|
||||||
|
return diff <= 0.5 ? 'match' : 'mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜별 비교 데이터 생성
|
||||||
|
async function buildComparisonData(userId, year, month) {
|
||||||
|
const [reports, attendances, confirmation, holidays] = await Promise.all([
|
||||||
|
Model.getWorkReports(userId, year, month),
|
||||||
|
Model.getAttendanceRecords(userId, year, month),
|
||||||
|
Model.getConfirmation(userId, year, month),
|
||||||
|
Model.getCompanyHolidays(year, month)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 날짜 맵 생성
|
||||||
|
const reportMap = {};
|
||||||
|
reports.forEach(r => {
|
||||||
|
const key = r.report_date instanceof Date
|
||||||
|
? r.report_date.toISOString().split('T')[0]
|
||||||
|
: String(r.report_date).split('T')[0];
|
||||||
|
reportMap[key] = r;
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendMap = {};
|
||||||
|
attendances.forEach(a => {
|
||||||
|
const key = a.record_date instanceof Date
|
||||||
|
? a.record_date.toISOString().split('T')[0]
|
||||||
|
: String(a.record_date).split('T')[0];
|
||||||
|
attendMap[key] = a;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 해당 월의 모든 날짜 생성
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const dailyRecords = [];
|
||||||
|
let totalWorkDays = 0, totalWorkHours = 0, totalOvertimeHours = 0;
|
||||||
|
let vacationDays = 0, mismatchCount = 0;
|
||||||
|
const mismatchDetails = { hours_diff: 0, missing_report: 0, missing_attendance: 0 };
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const isHoliday = dayOfWeek === 0 || dayOfWeek === 6 || holidays.dateSet.has(dateStr);
|
||||||
|
|
||||||
|
const report = reportMap[dateStr] || null;
|
||||||
|
const attend = attendMap[dateStr] || null;
|
||||||
|
const status = determineStatus(report, attend, isHoliday);
|
||||||
|
|
||||||
|
let hoursDiff = 0;
|
||||||
|
if (report && attend && report.total_hours && attend.total_work_hours) {
|
||||||
|
hoursDiff = parseFloat((report.total_hours - attend.total_work_hours).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계
|
||||||
|
if (status === 'match' || status === 'mismatch') {
|
||||||
|
totalWorkDays++;
|
||||||
|
totalWorkHours += parseFloat(attend?.total_work_hours || report?.total_hours || 0);
|
||||||
|
}
|
||||||
|
if (status === 'report_only') { totalWorkDays++; totalWorkHours += parseFloat(report.total_hours || 0); }
|
||||||
|
if (status === 'attend_only') { totalWorkDays++; totalWorkHours += parseFloat(attend.total_work_hours || 0); }
|
||||||
|
if (status === 'vacation' && attend) { vacationDays += parseFloat(attend.vacation_days) || 1; }
|
||||||
|
if (status === 'mismatch') { mismatchCount++; mismatchDetails.hours_diff++; }
|
||||||
|
if (status === 'report_only') { mismatchCount++; mismatchDetails.missing_attendance++; }
|
||||||
|
if (status === 'attend_only') { mismatchCount++; mismatchDetails.missing_report++; }
|
||||||
|
|
||||||
|
// 연장근로: 8h 초과분
|
||||||
|
if (attend && attend.total_work_hours > 8) {
|
||||||
|
totalOvertimeHours += parseFloat(attend.total_work_hours) - 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyRecords.push({
|
||||||
|
date: dateStr,
|
||||||
|
day_of_week: DAYS_KR[dayOfWeek],
|
||||||
|
is_holiday: isHoliday,
|
||||||
|
holiday_name: holidays.nameMap[dateStr] || (dayOfWeek === 0 || dayOfWeek === 6 ? '주말' : null),
|
||||||
|
work_report: report ? {
|
||||||
|
total_hours: parseFloat(report.total_hours),
|
||||||
|
entries: [{ project_name: report.project_names || '', work_type: report.work_type_names || '', hours: parseFloat(report.total_hours) }]
|
||||||
|
} : null,
|
||||||
|
attendance: attend ? {
|
||||||
|
total_work_hours: parseFloat(attend.total_work_hours),
|
||||||
|
attendance_type: attend.attendance_type_name || '',
|
||||||
|
attendance_type_id: attend.attendance_type_id || null,
|
||||||
|
vacation_type: attend.vacation_type_name || null,
|
||||||
|
vacation_type_id: attend.vacation_type_id || null,
|
||||||
|
vacation_days: attend.vacation_days ? parseFloat(attend.vacation_days) : null
|
||||||
|
} : null,
|
||||||
|
status,
|
||||||
|
hours_diff: hoursDiff
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
total_work_days: totalWorkDays,
|
||||||
|
total_work_hours: parseFloat(totalWorkHours.toFixed(2)),
|
||||||
|
total_overtime_hours: parseFloat(totalOvertimeHours.toFixed(2)),
|
||||||
|
vacation_days: vacationDays,
|
||||||
|
mismatch_count: mismatchCount,
|
||||||
|
mismatch_details: mismatchDetails
|
||||||
|
},
|
||||||
|
confirmation: confirmation ? {
|
||||||
|
status: confirmation.status,
|
||||||
|
confirmed_at: confirmation.confirmed_at,
|
||||||
|
rejected_at: confirmation.rejected_at,
|
||||||
|
reject_reason: confirmation.reject_reason,
|
||||||
|
change_details: confirmation.change_details || null,
|
||||||
|
admin_checked: confirmation.admin_checked || 0
|
||||||
|
} : { status: 'pending', confirmed_at: null, reject_reason: null, change_details: null, admin_checked: 0 },
|
||||||
|
daily_records: dailyRecords
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MonthlyComparisonController = {
|
||||||
|
// GET /my-records
|
||||||
|
getMyRecords: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const { year, month } = req.query;
|
||||||
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
||||||
|
|
||||||
|
const worker = await Model.getWorkerInfo(userId);
|
||||||
|
const data = await buildComparisonData(userId, parseInt(year), parseInt(month));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: worker || { user_id: userId },
|
||||||
|
period: { year: parseInt(year), month: parseInt(month) },
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison getMyRecords error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET /records (관리자용)
|
||||||
|
getRecords: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month, user_id } = req.query;
|
||||||
|
if (!year || !month || !user_id) return res.status(400).json({ success: false, message: 'year, month, user_id 필수' });
|
||||||
|
|
||||||
|
const reqUserId = req.user.user_id || req.user.id;
|
||||||
|
const targetUserId = parseInt(user_id);
|
||||||
|
|
||||||
|
// 본인 아니면 support_team 이상 필요
|
||||||
|
if (targetUserId !== reqUserId && !ADMIN_ROLES.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ success: false, message: '접근 권한이 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = await Model.getWorkerInfo(targetUserId);
|
||||||
|
const data = await buildComparisonData(targetUserId, parseInt(year), parseInt(month));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: worker || { user_id: targetUserId },
|
||||||
|
period: { year: parseInt(year), month: parseInt(month) },
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison getRecords error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /confirm
|
||||||
|
confirm: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const { year, month, status, reject_reason } = req.body;
|
||||||
|
|
||||||
|
if (!year || !month || !status) return res.status(400).json({ success: false, message: 'year, month, status 필수' });
|
||||||
|
if (!['confirmed', 'change_request'].includes(status)) return res.status(400).json({ success: false, message: "status는 'confirmed' 또는 'change_request'만 허용" });
|
||||||
|
const change_details = req.body.change_details || null;
|
||||||
|
if (status === 'change_request' && !change_details) {
|
||||||
|
return res.status(400).json({ success: false, message: '수정 내용을 입력해주세요.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요약 통계 계산
|
||||||
|
const compData = await buildComparisonData(userId, parseInt(year), parseInt(month));
|
||||||
|
|
||||||
|
let notificationData = null;
|
||||||
|
if (status === 'change_request') {
|
||||||
|
const worker = await Model.getWorkerInfo(userId);
|
||||||
|
const recipients = await Model.getSupportTeamUsers();
|
||||||
|
notificationData = {
|
||||||
|
recipients,
|
||||||
|
title: '월간 근무 수정요청',
|
||||||
|
message: `${worker?.worker_name || '작업자'}(${worker?.department_name || ''})님이 ${year}년 ${month}월 근무 내역 수정을 요청했습니다.`,
|
||||||
|
linkUrl: `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${year}&month=${month}`,
|
||||||
|
createdBy: userId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Model.upsertConfirmation({
|
||||||
|
user_id: userId, year: parseInt(year), month: parseInt(month), status,
|
||||||
|
reject_reason: null,
|
||||||
|
change_details: change_details ? JSON.stringify(change_details) : null,
|
||||||
|
...compData.summary
|
||||||
|
}, notificationData);
|
||||||
|
|
||||||
|
if (result.error) return res.status(409).json({ success: false, message: result.error });
|
||||||
|
|
||||||
|
const msg = status === 'confirmed' ? '확인이 완료되었습니다.' : '수정요청이 접수되었습니다. 관리자에게 알림이 전달됩니다.';
|
||||||
|
res.json({ success: true, data: result, message: msg });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison confirm error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /review-send (관리자 → 확인요청 발송)
|
||||||
|
reviewSend: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month, user_id } = req.body;
|
||||||
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
||||||
|
const reviewedBy = req.user.user_id || req.user.id;
|
||||||
|
const userIds = user_id ? [parseInt(user_id)] : null;
|
||||||
|
const result = await Model.bulkReviewSend(parseInt(year), parseInt(month), userIds, reviewedBy);
|
||||||
|
if (result.error) return res.status(400).json({ success: false, message: result.error });
|
||||||
|
res.json({ success: true, data: result, message: `${result.count}명에게 확인요청을 발송했습니다.` });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison reviewSend error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /review-respond (관리자 → 수정요청 응답)
|
||||||
|
reviewRespond: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id, year, month, action, reject_reason } = req.body;
|
||||||
|
if (!user_id || !year || !month || !action) return res.status(400).json({ success: false, message: 'user_id, year, month, action 필수' });
|
||||||
|
if (!['approve', 'reject'].includes(action)) return res.status(400).json({ success: false, message: "action은 'approve' 또는 'reject'" });
|
||||||
|
if (action === 'reject' && (!reject_reason || !reject_reason.trim())) {
|
||||||
|
return res.status(400).json({ success: false, message: '거부 사유를 입력해주세요.' });
|
||||||
|
}
|
||||||
|
const respondedBy = req.user.user_id || req.user.id;
|
||||||
|
const result = await Model.reviewRespond(parseInt(user_id), parseInt(year), parseInt(month), action, reject_reason, respondedBy);
|
||||||
|
if (result.error) return res.status(409).json({ success: false, message: result.error });
|
||||||
|
const msg = action === 'approve' ? '수정 승인 완료. 작업자에게 재확인 요청됩니다.' : '수정요청이 거부되었습니다.';
|
||||||
|
res.json({ success: true, data: result, message: msg });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison reviewRespond error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST /admin-check (관리자 개별 검토 태깅)
|
||||||
|
adminCheck: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id, year, month, checked } = req.body;
|
||||||
|
if (!user_id || !year || !month) return res.status(400).json({ success: false, message: 'user_id, year, month 필수' });
|
||||||
|
const checkedBy = req.user.user_id || req.user.id;
|
||||||
|
const result = await Model.adminCheck(parseInt(user_id), parseInt(year), parseInt(month), !!checked, checkedBy);
|
||||||
|
res.json({ success: true, data: result, message: checked ? '검토완료 표시' : '검토 해제' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison adminCheck error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET /all-status (support_team+)
|
||||||
|
getAllStatus: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month, department_id } = req.query;
|
||||||
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
||||||
|
|
||||||
|
const workers = await Model.getAllStatus(parseInt(year), parseInt(month), department_id ? parseInt(department_id) : null);
|
||||||
|
|
||||||
|
let confirmed = 0, pending = 0, rejected = 0, review_sent = 0, change_request = 0;
|
||||||
|
workers.forEach(w => {
|
||||||
|
if (w.status === 'confirmed') confirmed++;
|
||||||
|
else if (w.status === 'rejected') rejected++;
|
||||||
|
else if (w.status === 'review_sent') review_sent++;
|
||||||
|
else if (w.status === 'change_request') change_request++;
|
||||||
|
else pending++;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
period: { year: parseInt(year), month: parseInt(month) },
|
||||||
|
summary: { total_workers: workers.length, confirmed, pending, rejected, review_sent, change_request },
|
||||||
|
workers: workers.map(w => ({
|
||||||
|
user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type,
|
||||||
|
department_name: w.department_name, status: w.status || 'pending',
|
||||||
|
confirmed_at: w.confirmed_at, reject_reason: w.reject_reason,
|
||||||
|
change_details: w.change_details || null,
|
||||||
|
admin_checked: w.admin_checked || 0,
|
||||||
|
total_work_days: w.total_work_days || 0,
|
||||||
|
total_work_hours: parseFloat(w.total_work_hours || 0),
|
||||||
|
total_overtime_hours: parseFloat(w.total_overtime_hours || 0),
|
||||||
|
vacation_days: parseFloat(w.vacation_days || 0),
|
||||||
|
mismatch_count: w.mismatch_count || 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison getAllStatus error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GET /export (support_team+, 전원 confirmed일 때만)
|
||||||
|
// 출근부 양식 — 업로드된 템플릿 매칭
|
||||||
|
exportExcel: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month } = req.query;
|
||||||
|
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
|
||||||
|
|
||||||
|
const y = parseInt(year), m = parseInt(month);
|
||||||
|
|
||||||
|
// 전원 confirmed 체크
|
||||||
|
const statusList = await Model.getAllStatus(y, m);
|
||||||
|
const notConfirmed = statusList.filter(w => !w.status || w.status !== 'confirmed');
|
||||||
|
if (notConfirmed.length > 0) {
|
||||||
|
const pendingCount = notConfirmed.filter(w => !w.status || w.status === 'pending').length;
|
||||||
|
const rejectedCount = notConfirmed.filter(w => w.status === 'rejected').length;
|
||||||
|
const parts = [];
|
||||||
|
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
|
||||||
|
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
|
||||||
|
return res.status(403).json({ success: false, message: `${parts.join(', ')} 상태입니다. 전원 확인 완료 후 다운로드 가능합니다.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const { workers, attendance, vacations } = await Model.getExportData(y, m);
|
||||||
|
if (workers.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '해당 월 데이터가 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 근태 맵 구성: { user_id -> { day -> record } }
|
||||||
|
const attendMap = {};
|
||||||
|
attendance.forEach(a => {
|
||||||
|
const uid = a.user_id;
|
||||||
|
if (!attendMap[uid]) attendMap[uid] = {};
|
||||||
|
const d = a.record_date instanceof Date ? a.record_date : new Date(a.record_date);
|
||||||
|
const day = d.getDate();
|
||||||
|
attendMap[uid][day] = a;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연차 맵: { user_id -> { total, used, remaining } }
|
||||||
|
const vacMap = {};
|
||||||
|
vacations.forEach(v => { vacMap[v.user_id] = v; });
|
||||||
|
|
||||||
|
// 월 정보
|
||||||
|
const daysInMonth = new Date(y, m, 0).getDate();
|
||||||
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const yy = String(y).slice(-2);
|
||||||
|
const deptName = workers[0]?.department_name || '생산팀';
|
||||||
|
|
||||||
|
// 휴가 유형 → 출근부 텍스트 매핑
|
||||||
|
const VAC_TEXT = {
|
||||||
|
'ANNUAL': '연차', 'HALF_ANNUAL': '반차', 'ANNUAL_QUARTER': '반반차',
|
||||||
|
'EARLY_LEAVE': '조퇴', 'SICK': '병가', 'SPECIAL': '경조사'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
const ws = wb.addWorksheet(`${yy}.${m}월 출근부`);
|
||||||
|
|
||||||
|
// ── 기본 스타일 ──
|
||||||
|
const FONT = { name: '맑은 고딕', size: 12 };
|
||||||
|
const CENTER = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
||||||
|
const THIN_BORDER = {
|
||||||
|
top: { style: 'thin' }, bottom: { style: 'thin' },
|
||||||
|
left: { style: 'thin' }, right: { style: 'thin' }
|
||||||
|
};
|
||||||
|
const RED_FONT = { ...FONT, color: { argb: 'FFFF0000' } };
|
||||||
|
|
||||||
|
// ── 열 폭 설정 ──
|
||||||
|
// A=이름(10), B=담당(6), C~(daysInMonth)=날짜열(5), 총시간(8), 신규(8), 사용(8), 잔여(8), 비고(14)
|
||||||
|
const dayColStart = 3; // C열 = col 3
|
||||||
|
const dayColEnd = dayColStart + daysInMonth - 1;
|
||||||
|
const colTotal = dayColEnd + 1; // 총시간
|
||||||
|
const colNewVac = colTotal + 1; // N월 신규
|
||||||
|
const colUsedVac = colNewVac + 1; // N월 사용
|
||||||
|
const colRemVac = colUsedVac + 1; // N월 잔여
|
||||||
|
const colNote1 = colRemVac + 1; // 비고 (2열 병합)
|
||||||
|
const colNote2 = colNote1 + 1;
|
||||||
|
const lastCol = colNote2;
|
||||||
|
|
||||||
|
ws.getColumn(1).width = 10; // A 이름
|
||||||
|
ws.getColumn(2).width = 6; // B 담당
|
||||||
|
for (let c = dayColStart; c <= dayColEnd; c++) ws.getColumn(c).width = 5;
|
||||||
|
ws.getColumn(colTotal).width = 8;
|
||||||
|
ws.getColumn(colNewVac).width = 8;
|
||||||
|
ws.getColumn(colUsedVac).width = 8;
|
||||||
|
ws.getColumn(colRemVac).width = 8;
|
||||||
|
ws.getColumn(colNote1).width = 7;
|
||||||
|
ws.getColumn(colNote2).width = 7;
|
||||||
|
|
||||||
|
// ── Row 1: 빈 행 (여백) ──
|
||||||
|
ws.getRow(1).height = 10;
|
||||||
|
|
||||||
|
// ── Row 2: 부서명 ──
|
||||||
|
ws.getRow(2).height = 30;
|
||||||
|
ws.getCell(2, 1).value = '부서명';
|
||||||
|
ws.getCell(2, 1).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(2, 1).alignment = CENTER;
|
||||||
|
ws.mergeCells(2, 2, 2, 5);
|
||||||
|
ws.getCell(2, 2).value = deptName;
|
||||||
|
ws.getCell(2, 2).font = FONT;
|
||||||
|
ws.getCell(2, 2).alignment = { ...CENTER, horizontal: 'left' };
|
||||||
|
|
||||||
|
// ── Row 3: 근로기간 ──
|
||||||
|
ws.getRow(3).height = 30;
|
||||||
|
ws.getCell(3, 1).value = '근로기간';
|
||||||
|
ws.getCell(3, 1).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(3, 1).alignment = CENTER;
|
||||||
|
ws.mergeCells(3, 2, 3, 5);
|
||||||
|
ws.getCell(3, 2).value = `${y}년 ${m}월`;
|
||||||
|
ws.getCell(3, 2).font = FONT;
|
||||||
|
ws.getCell(3, 2).alignment = { ...CENTER, horizontal: 'left' };
|
||||||
|
|
||||||
|
// ── Row 4-5: 헤더 (병합) ──
|
||||||
|
ws.getRow(4).height = 40;
|
||||||
|
ws.getRow(5).height = 40;
|
||||||
|
|
||||||
|
// A4:A5 이름
|
||||||
|
ws.mergeCells(4, 1, 5, 1);
|
||||||
|
ws.getCell(4, 1).value = '이름';
|
||||||
|
ws.getCell(4, 1).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, 1).alignment = CENTER;
|
||||||
|
ws.getCell(4, 1).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// B4:B5 담당
|
||||||
|
ws.mergeCells(4, 2, 5, 2);
|
||||||
|
ws.getCell(4, 2).value = '담당';
|
||||||
|
ws.getCell(4, 2).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, 2).alignment = CENTER;
|
||||||
|
ws.getCell(4, 2).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 날짜 헤더: Row4=일자, Row5=요일
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const col = dayColStart + day - 1;
|
||||||
|
const date = new Date(y, m - 1, day);
|
||||||
|
const dow = date.getDay();
|
||||||
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
|
|
||||||
|
// Row 4: 날짜 숫자
|
||||||
|
const cell4 = ws.getCell(4, col);
|
||||||
|
cell4.value = day;
|
||||||
|
cell4.font = isWeekend ? { ...FONT, bold: true, color: { argb: 'FFFF0000' } } : { ...FONT, bold: true };
|
||||||
|
cell4.alignment = CENTER;
|
||||||
|
cell4.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// Row 5: 요일
|
||||||
|
const cell5 = ws.getCell(5, col);
|
||||||
|
cell5.value = DAYS_KR[dow];
|
||||||
|
cell5.font = isWeekend ? RED_FONT : FONT;
|
||||||
|
cell5.alignment = CENTER;
|
||||||
|
cell5.border = THIN_BORDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총시간 헤더 (4:5 병합)
|
||||||
|
ws.mergeCells(4, colTotal, 5, colTotal);
|
||||||
|
ws.getCell(4, colTotal).value = '총시간';
|
||||||
|
ws.getCell(4, colTotal).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, colTotal).alignment = CENTER;
|
||||||
|
ws.getCell(4, colTotal).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 연차 헤더: Row4 = "N월" 병합, Row5 = 신규/사용/잔여
|
||||||
|
ws.mergeCells(4, colNewVac, 4, colRemVac);
|
||||||
|
ws.getCell(4, colNewVac).value = `${m}월`;
|
||||||
|
ws.getCell(4, colNewVac).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, colNewVac).alignment = CENTER;
|
||||||
|
ws.getCell(4, colNewVac).border = THIN_BORDER;
|
||||||
|
|
||||||
|
ws.getCell(5, colNewVac).value = '신규';
|
||||||
|
ws.getCell(5, colNewVac).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(5, colNewVac).alignment = CENTER;
|
||||||
|
ws.getCell(5, colNewVac).border = THIN_BORDER;
|
||||||
|
|
||||||
|
ws.getCell(5, colUsedVac).value = '사용';
|
||||||
|
ws.getCell(5, colUsedVac).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(5, colUsedVac).alignment = CENTER;
|
||||||
|
ws.getCell(5, colUsedVac).border = THIN_BORDER;
|
||||||
|
|
||||||
|
ws.getCell(5, colRemVac).value = '잔여';
|
||||||
|
ws.getCell(5, colRemVac).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(5, colRemVac).alignment = CENTER;
|
||||||
|
ws.getCell(5, colRemVac).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 비고 헤더 (4:5, 2열 병합)
|
||||||
|
ws.mergeCells(4, colNote1, 5, colNote2);
|
||||||
|
ws.getCell(4, colNote1).value = '비고';
|
||||||
|
ws.getCell(4, colNote1).font = { ...FONT, bold: true };
|
||||||
|
ws.getCell(4, colNote1).alignment = CENTER;
|
||||||
|
ws.getCell(4, colNote1).border = THIN_BORDER;
|
||||||
|
|
||||||
|
// ── 데이터 행 ──
|
||||||
|
const dataStartRow = 6;
|
||||||
|
workers.forEach((worker, idx) => {
|
||||||
|
const row = dataStartRow + idx;
|
||||||
|
ws.getRow(row).height = 60;
|
||||||
|
const userAttend = attendMap[worker.user_id] || {};
|
||||||
|
const userVac = vacMap[worker.user_id] || { total_days: 0, used_days: 0, remaining_days: 0 };
|
||||||
|
|
||||||
|
// A: 이름
|
||||||
|
const cellName = ws.getCell(row, 1);
|
||||||
|
cellName.value = worker.worker_name;
|
||||||
|
cellName.font = FONT;
|
||||||
|
cellName.alignment = CENTER;
|
||||||
|
cellName.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// B: 직종
|
||||||
|
const cellJob = ws.getCell(row, 2);
|
||||||
|
cellJob.value = worker.job_type || '';
|
||||||
|
cellJob.font = FONT;
|
||||||
|
cellJob.alignment = CENTER;
|
||||||
|
cellJob.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// C~: 일별 데이터
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const col = dayColStart + day - 1;
|
||||||
|
const cell = ws.getCell(row, col);
|
||||||
|
const date = new Date(y, m - 1, day);
|
||||||
|
const dow = date.getDay();
|
||||||
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
|
const rec = userAttend[day];
|
||||||
|
|
||||||
|
if (rec && rec.vacation_type_code) {
|
||||||
|
// 휴가
|
||||||
|
cell.value = VAC_TEXT[rec.vacation_type_code] || rec.vacation_type_name || '휴가';
|
||||||
|
cell.font = FONT;
|
||||||
|
} else if (isWeekend && (!rec || !rec.total_work_hours || parseFloat(rec.total_work_hours) === 0)) {
|
||||||
|
// 주말 + 근무 없음 → 휴무
|
||||||
|
cell.value = '휴무';
|
||||||
|
cell.font = FONT;
|
||||||
|
} else if (rec && parseFloat(rec.total_work_hours) > 0) {
|
||||||
|
// 정상 출근 → 근무시간(숫자)
|
||||||
|
cell.value = parseFloat(rec.total_work_hours);
|
||||||
|
cell.numFmt = '0.00';
|
||||||
|
cell.font = FONT;
|
||||||
|
} else {
|
||||||
|
// 데이터 없음 (평일 미출근 등)
|
||||||
|
cell.value = '';
|
||||||
|
cell.font = FONT;
|
||||||
|
}
|
||||||
|
cell.alignment = CENTER;
|
||||||
|
cell.border = THIN_BORDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총시간 = SUM 수식 (C열~마지막 날짜열)
|
||||||
|
const firstDayCol = ws.getColumn(dayColStart).letter;
|
||||||
|
const lastDayCol = ws.getColumn(dayColEnd).letter;
|
||||||
|
const cellTotal = ws.getCell(row, colTotal);
|
||||||
|
cellTotal.value = { formula: `SUM(${firstDayCol}${row}:${lastDayCol}${row})` };
|
||||||
|
cellTotal.numFmt = '0.00';
|
||||||
|
cellTotal.font = { ...FONT, bold: true };
|
||||||
|
cellTotal.alignment = CENTER;
|
||||||
|
cellTotal.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 연차 신규
|
||||||
|
const cellNew = ws.getCell(row, colNewVac);
|
||||||
|
cellNew.value = parseFloat(userVac.total_days) || 0;
|
||||||
|
cellNew.numFmt = '0.0';
|
||||||
|
cellNew.font = FONT;
|
||||||
|
cellNew.alignment = CENTER;
|
||||||
|
cellNew.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 연차 사용
|
||||||
|
const cellUsed = ws.getCell(row, colUsedVac);
|
||||||
|
cellUsed.value = parseFloat(userVac.used_days) || 0;
|
||||||
|
cellUsed.numFmt = '0.0';
|
||||||
|
cellUsed.font = FONT;
|
||||||
|
cellUsed.alignment = CENTER;
|
||||||
|
cellUsed.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 연차 잔여 = 수식(신규 - 사용)
|
||||||
|
const newCol = ws.getColumn(colNewVac).letter;
|
||||||
|
const usedCol = ws.getColumn(colUsedVac).letter;
|
||||||
|
const cellRem = ws.getCell(row, colRemVac);
|
||||||
|
cellRem.value = { formula: `${newCol}${row}-${usedCol}${row}` };
|
||||||
|
cellRem.numFmt = '0.0';
|
||||||
|
cellRem.font = { ...FONT, bold: true };
|
||||||
|
cellRem.alignment = CENTER;
|
||||||
|
cellRem.border = THIN_BORDER;
|
||||||
|
|
||||||
|
// 비고 (병합)
|
||||||
|
ws.mergeCells(row, colNote1, row, colNote2);
|
||||||
|
const cellNote = ws.getCell(row, colNote1);
|
||||||
|
cellNote.value = '';
|
||||||
|
cellNote.font = FONT;
|
||||||
|
cellNote.alignment = CENTER;
|
||||||
|
cellNote.border = THIN_BORDER;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 응답 ──
|
||||||
|
const filename = encodeURIComponent(`출근부_${y}.${String(m).padStart(2, '0')}_${deptName}.xlsx`);
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
await wb.xlsx.write(res);
|
||||||
|
res.end();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('monthlyComparison exportExcel error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '엑셀 생성 실패' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = MonthlyComparisonController;
|
||||||
210
system1-factory/api/controllers/proxyInputController.js
Normal file
210
system1-factory/api/controllers/proxyInputController.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* 대리입력 + 일별 현황 컨트롤러
|
||||||
|
*/
|
||||||
|
const ProxyInputModel = require('../models/proxyInputModel');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const logger = require('../../shared/utils/logger');
|
||||||
|
|
||||||
|
const ProxyInputController = {
|
||||||
|
/**
|
||||||
|
* POST /api/proxy-input — 대리입력 (단일 트랜잭션)
|
||||||
|
*/
|
||||||
|
proxyInput: async (req, res) => {
|
||||||
|
const { session_date, leader_id, entries, safety_notes, work_location } = req.body;
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!session_date) {
|
||||||
|
return res.status(400).json({ success: false, message: '날짜는 필수입니다.' });
|
||||||
|
}
|
||||||
|
if (!entries || !Array.isArray(entries) || entries.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '작업자 정보는 최소 1명 필요합니다.' });
|
||||||
|
}
|
||||||
|
if (entries.length > 30) {
|
||||||
|
return res.status(400).json({ success: false, message: '한 번에 30명까지 입력 가능합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 유효성 (과거 30일 ~ 오늘)
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const inputDate = new Date(session_date);
|
||||||
|
const diffDays = Math.floor((today - inputDate) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays < 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '미래 날짜는 입력할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
if (diffDays > 30) {
|
||||||
|
return res.status(400).json({ success: false, message: '30일 이내 날짜만 입력 가능합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// entries 필수 필드 검사
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.user_id || !entry.project_id || !entry.work_type_id || !entry.work_hours) {
|
||||||
|
return res.status(400).json({ success: false, message: '각 작업자의 user_id, project_id, work_type_id, work_hours는 필수입니다.' });
|
||||||
|
}
|
||||||
|
if (entry.work_hours <= 0 || entry.work_hours > 24) {
|
||||||
|
return res.status(400).json({ success: false, message: '근무 시간은 0 초과 24 이하여야 합니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const userIds = entries.map(e => e.user_id);
|
||||||
|
|
||||||
|
// 1. 기존 보고서 확인 (UPSERT 분기)
|
||||||
|
const [existingReports] = await conn.query(
|
||||||
|
`SELECT id, user_id FROM daily_work_reports WHERE report_date = ? AND user_id IN (${userIds.map(() => '?').join(',')})`,
|
||||||
|
[session_date, ...userIds]
|
||||||
|
);
|
||||||
|
const existingMap = {};
|
||||||
|
existingReports.forEach(r => { existingMap[r.user_id] = r.id; });
|
||||||
|
|
||||||
|
// 2. 신규 작업자용 TBM 세션 생성 (기존 있으면 재사용)
|
||||||
|
let sessionId = null;
|
||||||
|
const newUserIds = userIds.filter(id => !existingMap[id]);
|
||||||
|
if (newUserIds.length > 0) {
|
||||||
|
const sessionResult = await ProxyInputModel.createProxySession(conn, {
|
||||||
|
session_date,
|
||||||
|
leader_id: leader_id || userId,
|
||||||
|
proxy_input_by: userId,
|
||||||
|
created_by: userId,
|
||||||
|
safety_notes: safety_notes || '',
|
||||||
|
work_location: work_location || ''
|
||||||
|
});
|
||||||
|
sessionId = sessionResult.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 각 entry 처리 (UPSERT)
|
||||||
|
const createdWorkers = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const existingReportId = existingMap[entry.user_id];
|
||||||
|
|
||||||
|
if (existingReportId) {
|
||||||
|
// UPDATE 기존 보고서
|
||||||
|
await conn.query(`
|
||||||
|
UPDATE daily_work_reports SET
|
||||||
|
project_id = ?, work_type_id = ?, work_hours = ?,
|
||||||
|
work_status_id = ?, start_time = ?, end_time = ?, note = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
`, [entry.project_id, entry.work_type_id, entry.work_hours,
|
||||||
|
entry.work_status_id || 1, entry.start_time || null, entry.end_time || null,
|
||||||
|
entry.note || '', existingReportId]);
|
||||||
|
|
||||||
|
createdWorkers.push({ user_id: entry.user_id, report_id: existingReportId, action: 'updated' });
|
||||||
|
} else {
|
||||||
|
// INSERT 신규 — TBM 배정 + 작업보고서
|
||||||
|
const assignResult = await ProxyInputModel.createTeamAssignment(conn, {
|
||||||
|
session_id: sessionId,
|
||||||
|
user_id: entry.user_id,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
work_type_id: entry.work_type_id,
|
||||||
|
task_id: entry.task_id || null,
|
||||||
|
workplace_id: entry.workplace_id || null,
|
||||||
|
work_hours: entry.work_hours
|
||||||
|
});
|
||||||
|
const assignmentId = assignResult.insertId;
|
||||||
|
|
||||||
|
const reportResult = await ProxyInputModel.createWorkReport(conn, {
|
||||||
|
report_date: session_date,
|
||||||
|
user_id: entry.user_id,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
work_type_id: entry.work_type_id,
|
||||||
|
task_id: entry.task_id || null,
|
||||||
|
work_status_id: entry.work_status_id || 1,
|
||||||
|
work_hours: entry.work_hours,
|
||||||
|
start_time: entry.start_time || null,
|
||||||
|
end_time: entry.end_time || null,
|
||||||
|
note: entry.note || '',
|
||||||
|
tbm_session_id: sessionId,
|
||||||
|
tbm_assignment_id: assignmentId,
|
||||||
|
created_by: userId,
|
||||||
|
created_by_name: req.user.name || req.user.username || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
createdWorkers.push({ user_id: entry.user_id, report_id: reportResult.insertId, action: 'created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 처리 (defect_hours > 0 && 기존 defect 없을 때만)
|
||||||
|
const defectHours = parseFloat(entry.defect_hours) || 0;
|
||||||
|
const reportId = existingReportId || createdWorkers[createdWorkers.length - 1].report_id;
|
||||||
|
if (defectHours > 0) {
|
||||||
|
const [existingDefects] = await conn.query(
|
||||||
|
'SELECT defect_id FROM work_report_defects WHERE report_id = ?', [reportId]
|
||||||
|
);
|
||||||
|
if (existingDefects.length === 0) {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO work_report_defects (report_id, defect_hours, category_id, item_id, note) VALUES (?, ?, ?, ?, '대리입력')`,
|
||||||
|
[reportId, defectHours, entry.defect_category_id || null, entry.defect_item_id || null]
|
||||||
|
);
|
||||||
|
await conn.query(
|
||||||
|
'UPDATE daily_work_reports SET error_hours = ?, work_status_id = 2 WHERE id = ?',
|
||||||
|
[defectHours, reportId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: `${entries.length}명의 대리입력이 완료되었습니다.`,
|
||||||
|
data: {
|
||||||
|
session_id: sessionId,
|
||||||
|
is_proxy_input: true,
|
||||||
|
created_reports: entries.length,
|
||||||
|
workers: createdWorkers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
try { await conn.rollback(); } catch (e) {}
|
||||||
|
logger.error('대리입력 오류:', err);
|
||||||
|
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.' });
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/proxy-input/daily-status — 일별 현황
|
||||||
|
*/
|
||||||
|
getDailyStatus: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date } = req.query;
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({ success: false, message: '날짜(date) 파라미터는 필수입니다.' });
|
||||||
|
}
|
||||||
|
const data = await ProxyInputModel.getDailyStatus(date);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('일별 현황 조회 오류:', err);
|
||||||
|
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/proxy-input/daily-status/detail — 작업자별 상세
|
||||||
|
*/
|
||||||
|
getDailyStatusDetail: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, user_id } = req.query;
|
||||||
|
if (!date || !user_id) {
|
||||||
|
return res.status(400).json({ success: false, message: 'date와 user_id 파라미터는 필수입니다.' });
|
||||||
|
}
|
||||||
|
const data = await ProxyInputModel.getDailyStatusDetail(date, parseInt(user_id));
|
||||||
|
if (!data.worker) {
|
||||||
|
return res.status(404).json({ success: false, message: '작업자를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('일별 상세 조회 오류:', err);
|
||||||
|
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ProxyInputController;
|
||||||
175
system1-factory/api/controllers/purchaseBatchController.js
Normal file
175
system1-factory/api/controllers/purchaseBatchController.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
const PurchaseBatchModel = require('../models/purchaseBatchModel');
|
||||||
|
const PurchaseRequestModel = require('../models/purchaseRequestModel');
|
||||||
|
const { saveBase64Image } = require('../services/imageUploadService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const notifyHelper = require('../../../shared/utils/notifyHelper');
|
||||||
|
|
||||||
|
const PurchaseBatchController = {
|
||||||
|
getAll: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.query;
|
||||||
|
const rows = await PurchaseBatchModel.getAll({ status });
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch getAll error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const batch = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
|
||||||
|
// 포함된 요청 목록도 함께 반환
|
||||||
|
const requests = await PurchaseRequestModel.getAll({ batch_id: req.params.id });
|
||||||
|
res.json({ success: true, data: { ...batch, requests } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch getById error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그룹 생성 + 요청 포함
|
||||||
|
create: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { batch_name, category, vendor_id, notes, request_ids } = req.body;
|
||||||
|
if (!request_ids || !request_ids.length) {
|
||||||
|
return res.status(400).json({ success: false, message: '그룹에 포함할 신청 건을 선택해주세요.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchId = await PurchaseBatchModel.create({
|
||||||
|
batchName: batch_name,
|
||||||
|
category,
|
||||||
|
vendorId: vendor_id,
|
||||||
|
notes,
|
||||||
|
createdBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await PurchaseBatchModel.addRequests(batchId, request_ids);
|
||||||
|
|
||||||
|
// 신청자들에게 알림
|
||||||
|
const requesterIds = await PurchaseRequestModel.getRequesterIdsByBatch(batchId);
|
||||||
|
if (requesterIds.length > 0) {
|
||||||
|
notifyHelper.send({
|
||||||
|
type: 'purchase',
|
||||||
|
title: '구매 진행 안내',
|
||||||
|
message: '신청하신 소모품 구매가 진행됩니다.',
|
||||||
|
link_url: '/pages/purchase/request-mobile.html',
|
||||||
|
target_user_ids: requesterIds,
|
||||||
|
created_by: req.user.id
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = await PurchaseBatchModel.getById(batchId);
|
||||||
|
res.status(201).json({ success: true, data: batch, message: '그룹이 생성되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch create error:', err);
|
||||||
|
res.status(400).json({ success: false, message: err.message || '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { batch_name, category, vendor_id, notes, add_request_ids, remove_request_ids } = req.body;
|
||||||
|
const batch = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
|
||||||
|
|
||||||
|
if (batch_name !== undefined || category !== undefined || vendor_id !== undefined || notes !== undefined) {
|
||||||
|
await PurchaseBatchModel.update(req.params.id, {
|
||||||
|
batchName: batch_name !== undefined ? batch_name : batch.batch_name,
|
||||||
|
category: category !== undefined ? category : batch.category,
|
||||||
|
vendorId: vendor_id !== undefined ? vendor_id : batch.vendor_id,
|
||||||
|
notes: notes !== undefined ? notes : batch.notes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (add_request_ids && add_request_ids.length) {
|
||||||
|
await PurchaseBatchModel.addRequests(req.params.id, add_request_ids);
|
||||||
|
}
|
||||||
|
if (remove_request_ids && remove_request_ids.length) {
|
||||||
|
await PurchaseBatchModel.removeRequests(req.params.id, remove_request_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
res.json({ success: true, data: updated, message: '그룹이 수정되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch update error:', err);
|
||||||
|
res.status(400).json({ success: false, message: err.message || '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const deleted = await PurchaseBatchModel.delete(req.params.id);
|
||||||
|
if (!deleted) return res.status(400).json({ success: false, message: '대기 상태의 그룹만 삭제할 수 있습니다.' });
|
||||||
|
res.json({ success: true, message: '그룹이 삭제되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch delete error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그룹 일괄 구매 처리
|
||||||
|
purchase: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const batch = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
|
||||||
|
if (batch.status !== 'pending') {
|
||||||
|
return res.status(400).json({ success: false, message: '대기 상태의 그룹만 구매 처리할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// batch 내 모든 요청 purchased 전환
|
||||||
|
await PurchaseRequestModel.markBatchPurchased(req.params.id);
|
||||||
|
await PurchaseBatchModel.markPurchased(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
res.json({ success: true, message: '일괄 구매 처리가 완료되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch purchase error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그룹 일괄 입고 처리
|
||||||
|
receive: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const batch = await PurchaseBatchModel.getById(req.params.id);
|
||||||
|
if (!batch) return res.status(404).json({ success: false, message: '그룹을 찾을 수 없습니다.' });
|
||||||
|
if (batch.status !== 'purchased') {
|
||||||
|
return res.status(400).json({ success: false, message: '구매완료 상태의 그룹만 입고 처리할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { received_location, photo } = req.body;
|
||||||
|
let receivedPhotoPath = null;
|
||||||
|
if (photo) {
|
||||||
|
receivedPhotoPath = await saveBase64Image(photo, 'received', 'purchase_received');
|
||||||
|
}
|
||||||
|
|
||||||
|
await PurchaseRequestModel.receiveBatch(req.params.id, {
|
||||||
|
receivedPhotoPath,
|
||||||
|
receivedLocation: received_location,
|
||||||
|
receivedBy: req.user.id
|
||||||
|
});
|
||||||
|
await PurchaseBatchModel.markReceived(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
// 신청자들에게 입고 알림
|
||||||
|
const requesterIds = await PurchaseRequestModel.getRequesterIdsByBatch(req.params.id);
|
||||||
|
if (requesterIds.length > 0) {
|
||||||
|
notifyHelper.send({
|
||||||
|
type: 'purchase',
|
||||||
|
title: '소모품 입고 완료',
|
||||||
|
message: `소모품이 입고되었습니다.${received_location ? ' 보관위치: ' + received_location : ''}`,
|
||||||
|
link_url: '/pages/purchase/request-mobile.html',
|
||||||
|
target_user_ids: requesterIds,
|
||||||
|
created_by: req.user.id
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: '일괄 입고 처리가 완료되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseBatch receive error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PurchaseBatchController;
|
||||||
@@ -2,14 +2,16 @@ const PurchaseRequestModel = require('../models/purchaseRequestModel');
|
|||||||
const PurchaseModel = require('../models/purchaseModel');
|
const PurchaseModel = require('../models/purchaseModel');
|
||||||
const { saveBase64Image } = require('../services/imageUploadService');
|
const { saveBase64Image } = require('../services/imageUploadService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const notifyHelper = require('../../../shared/utils/notifyHelper');
|
||||||
|
const koreanSearch = require('../utils/koreanSearch');
|
||||||
|
|
||||||
const PurchaseRequestController = {
|
const PurchaseRequestController = {
|
||||||
// 구매신청 목록
|
// 구매신청 목록
|
||||||
getAll: async (req, res) => {
|
getAll: async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, category, from_date, to_date } = req.query;
|
const { status, category, from_date, to_date, batch_id } = req.query;
|
||||||
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
|
const isAdmin = req.user && ['admin', 'system'].includes(req.user.access_level);
|
||||||
const filters = { status, category, from_date, to_date };
|
const filters = { status, category, from_date, to_date, batch_id };
|
||||||
if (!isAdmin) filters.requester_id = req.user.id;
|
if (!isAdmin) filters.requester_id = req.user.id;
|
||||||
const rows = await PurchaseRequestModel.getAll(filters);
|
const rows = await PurchaseRequestModel.getAll(filters);
|
||||||
res.json({ success: true, data: rows });
|
res.json({ success: true, data: rows });
|
||||||
@@ -113,6 +115,188 @@ const PurchaseRequestController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 품목 등록 + 신청 동시 처리 (단일 트랜잭션)
|
||||||
|
registerAndRequest: async (req, res) => {
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
let conn;
|
||||||
|
try {
|
||||||
|
const { item_name, spec, maker, category, quantity, notes, photo } = req.body;
|
||||||
|
if (!item_name || !item_name.trim()) {
|
||||||
|
return res.status(400).json({ success: false, message: '품목명을 입력해주세요.' });
|
||||||
|
}
|
||||||
|
if (!quantity || quantity < 1) {
|
||||||
|
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
conn = await db.getConnection();
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 1. 소모품 마스터 등록 (중복 확인)
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
`SELECT item_id FROM consumable_items
|
||||||
|
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
|
||||||
|
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
|
||||||
|
[item_name.trim(), spec || null, spec || null, maker || null, maker || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
let itemId;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
itemId = existing[0].item_id;
|
||||||
|
} else {
|
||||||
|
const [insertResult] = await conn.query(
|
||||||
|
`INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`,
|
||||||
|
[item_name.trim(), spec || null, maker || null, category || 'consumable']
|
||||||
|
);
|
||||||
|
itemId = insertResult.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 사진 업로드 (트랜잭션 외부 — 파일 저장은 DB 롤백 불가이므로 마지막에)
|
||||||
|
let photo_path = null;
|
||||||
|
if (photo) {
|
||||||
|
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 구매 신청 생성
|
||||||
|
const [reqResult] = await conn.query(
|
||||||
|
`INSERT INTO purchase_requests (item_id, quantity, requester_id, request_date, notes, photo_path)
|
||||||
|
VALUES (?, ?, ?, CURDATE(), ?, ?)`,
|
||||||
|
[itemId, quantity, req.user.id, notes || null, photo_path]
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
|
// 검색 캐시 무효화
|
||||||
|
koreanSearch.clearCache();
|
||||||
|
|
||||||
|
const request = await PurchaseRequestModel.getById(reqResult.insertId);
|
||||||
|
res.status(201).json({ success: true, data: request, message: '품목 등록 및 신청이 완료되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
if (conn) await conn.rollback().catch(() => {});
|
||||||
|
logger.error('registerAndRequest error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
} finally {
|
||||||
|
if (conn) conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 일괄 신청 (장바구니, 트랜잭션)
|
||||||
|
bulkCreate: async (req, res) => {
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
let conn;
|
||||||
|
try {
|
||||||
|
const { items, photo } = req.body;
|
||||||
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '신청할 품목을 선택해주세요.' });
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.item_id && !item.item_name) {
|
||||||
|
return res.status(400).json({ success: false, message: '품목 정보가 올바르지 않습니다.' });
|
||||||
|
}
|
||||||
|
if (!item.quantity || item.quantity < 1) {
|
||||||
|
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진 업로드 (트랜잭션 밖 — 파일은 롤백 불가)
|
||||||
|
let photo_path = null;
|
||||||
|
if (photo) {
|
||||||
|
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
conn = await db.getConnection();
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const createdIds = [];
|
||||||
|
let newItemRegistered = false;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
let itemId = item.item_id || null;
|
||||||
|
|
||||||
|
// 신규 품목 등록 (is_new)
|
||||||
|
if (item.is_new && item.item_name) {
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
`SELECT item_id FROM consumable_items
|
||||||
|
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
|
||||||
|
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
|
||||||
|
[item.item_name.trim(), item.spec || null, item.spec || null, item.maker || null, item.maker || null]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
itemId = existing[0].item_id;
|
||||||
|
} else {
|
||||||
|
// 신규 품목 사진 저장 (마스터에)
|
||||||
|
let itemPhotoPath = null;
|
||||||
|
if (item.item_photo) {
|
||||||
|
itemPhotoPath = await saveBase64Image(item.item_photo, 'item', 'consumables');
|
||||||
|
}
|
||||||
|
const [ins] = await conn.query(
|
||||||
|
`INSERT INTO consumable_items (item_name, spec, maker, category, photo_path, is_active) VALUES (?, ?, ?, ?, ?, 1)`,
|
||||||
|
[item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable', itemPhotoPath]
|
||||||
|
);
|
||||||
|
itemId = ins.insertId;
|
||||||
|
newItemRegistered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// purchase_request 생성
|
||||||
|
const [result] = await conn.query(
|
||||||
|
`INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURDATE(), ?, ?)`,
|
||||||
|
[itemId, item.is_new && !itemId ? item.item_name : null, item.is_new && !itemId ? item.category : null,
|
||||||
|
item.quantity, req.user.id, item.notes || null, photo_path]
|
||||||
|
);
|
||||||
|
createdIds.push(result.insertId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
if (newItemRegistered) koreanSearch.clearCache();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: { request_ids: createdIds, count: createdIds.length },
|
||||||
|
message: `${createdIds.length}건의 소모품 신청이 등록되었습니다.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (conn) await conn.rollback().catch(() => {});
|
||||||
|
logger.error('bulkCreate error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
} finally {
|
||||||
|
if (conn) conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 품목 마스터 사진 등록/업데이트
|
||||||
|
updateItemPhoto: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photo } = req.body;
|
||||||
|
if (!photo) return res.status(400).json({ success: false, message: '사진을 첨부해주세요.' });
|
||||||
|
const itemPhotoPath = await saveBase64Image(photo, 'item', 'consumables');
|
||||||
|
if (!itemPhotoPath) return res.status(500).json({ success: false, message: '사진 저장에 실패했습니다.' });
|
||||||
|
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query('UPDATE consumable_items SET photo_path = ? WHERE item_id = ?', [itemPhotoPath, req.params.id]);
|
||||||
|
koreanSearch.clearCache();
|
||||||
|
res.json({ success: true, data: { photo_path: itemPhotoPath }, message: '품목 사진이 등록되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('updateItemPhoto error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 스마트 검색 (초성 + 별칭 + substring)
|
||||||
|
search: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
const results = await koreanSearch.search(q || '');
|
||||||
|
res.json({ success: true, data: results });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest search error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 소모품 목록 (select용)
|
// 소모품 목록 (select용)
|
||||||
getConsumableItems: async (req, res) => {
|
getConsumableItems: async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -133,6 +317,138 @@ const PurchaseRequestController = {
|
|||||||
logger.error('Vendors get error:', err);
|
logger.error('Vendors get error:', err);
|
||||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 내 신청 목록 (모바일용, 페이지네이션)
|
||||||
|
getMyRequests: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
|
const { status } = req.query;
|
||||||
|
const result = await PurchaseRequestModel.getMyRequests(req.user.id, { page, limit, status });
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest getMyRequests error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 개별 입고 처리 (admin)
|
||||||
|
receive: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||||
|
if (existing.status !== 'purchased') {
|
||||||
|
return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 입고 처리할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { received_location, photo } = req.body;
|
||||||
|
let receivedPhotoPath = null;
|
||||||
|
if (photo) {
|
||||||
|
receivedPhotoPath = await saveBase64Image(photo, 'received', 'purchase_received');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await PurchaseRequestModel.receive(req.params.id, {
|
||||||
|
receivedPhotoPath,
|
||||||
|
receivedLocation: received_location || null,
|
||||||
|
receivedBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// batch 내 전체 입고 완료 시 batch.status 자동 전환
|
||||||
|
if (existing.batch_id) {
|
||||||
|
const allReceived = await PurchaseRequestModel.checkBatchAllReceived(existing.batch_id);
|
||||||
|
if (allReceived) {
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_batches SET status = 'received', received_at = NOW(), received_by = ? WHERE batch_id = ?`,
|
||||||
|
[req.user.id, existing.batch_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 신청자에게 입고 알림
|
||||||
|
notifyHelper.send({
|
||||||
|
type: 'purchase',
|
||||||
|
title: '소모품 입고 완료',
|
||||||
|
message: `${existing.item_name || existing.custom_item_name} 입고 완료${received_location ? '. 보관위치: ' + received_location : ''}`,
|
||||||
|
link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id,
|
||||||
|
target_user_ids: [existing.requester_id],
|
||||||
|
created_by: req.user.id
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated, message: '입고 처리가 완료되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest receive error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구매 취소 (purchased → cancelled)
|
||||||
|
cancel: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||||
|
if (existing.status !== 'purchased') {
|
||||||
|
return res.status(400).json({ success: false, message: '구매완료 상태의 신청만 취소할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
const { cancel_reason } = req.body;
|
||||||
|
const updated = await PurchaseRequestModel.cancelPurchase(req.params.id, {
|
||||||
|
cancelledBy: req.user.id,
|
||||||
|
cancelReason: cancel_reason
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: updated, message: '구매가 취소되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest cancel error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 반품 (received → returned)
|
||||||
|
returnItem: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||||
|
if (existing.status !== 'received') {
|
||||||
|
return res.status(400).json({ success: false, message: '입고완료 상태의 신청만 반품할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
const { cancel_reason } = req.body;
|
||||||
|
const updated = await PurchaseRequestModel.returnItem(req.params.id, {
|
||||||
|
cancelledBy: req.user.id,
|
||||||
|
cancelReason: cancel_reason
|
||||||
|
});
|
||||||
|
|
||||||
|
// 신청자에게 반품 알림
|
||||||
|
notifyHelper.send({
|
||||||
|
type: 'purchase',
|
||||||
|
title: '소모품 반품 처리',
|
||||||
|
message: `${existing.item_name || existing.custom_item_name} 반품 처리되었습니다.${cancel_reason ? ' 사유: ' + cancel_reason : ''}`,
|
||||||
|
link_url: '/pages/purchase/request-mobile.html?view=' + req.params.id,
|
||||||
|
target_user_ids: [existing.requester_id],
|
||||||
|
created_by: req.user.id
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated, message: '반품 처리되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest return error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 취소 → 대기로 되돌리기
|
||||||
|
revertCancel: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await PurchaseRequestModel.getById(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ success: false, message: '신청 건을 찾을 수 없습니다.' });
|
||||||
|
if (existing.status !== 'cancelled') {
|
||||||
|
return res.status(400).json({ success: false, message: '취소 상태의 신청만 되돌릴 수 있습니다.' });
|
||||||
|
}
|
||||||
|
const updated = await PurchaseRequestModel.revertCancel(req.params.id);
|
||||||
|
res.json({ success: true, data: updated, message: '대기 상태로 되돌렸습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PurchaseRequest revertCancel error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ const SettlementController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 입고일 기준 월간 요약
|
||||||
|
getMonthlyReceivedSummary: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year_month } = req.query;
|
||||||
|
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||||
|
const categorySummary = await SettlementModel.getCategorySummaryByReceived(year_month);
|
||||||
|
res.json({ success: true, data: { categorySummary } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Settlement received summary error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 입고일 기준 월간 상세 목록
|
||||||
|
getMonthlyReceivedList: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year_month } = req.query;
|
||||||
|
if (!year_month) return res.status(400).json({ success: false, message: '년월을 선택해주세요.' });
|
||||||
|
const rows = await SettlementModel.getMonthlyReceived(year_month);
|
||||||
|
res.json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Settlement received list error:', err);
|
||||||
|
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 정산 완료
|
// 정산 완료
|
||||||
complete: async (req, res) => {
|
complete: async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ const vacationBalanceController = {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
for (const balance of balances) {
|
for (const balance of balances) {
|
||||||
const { user_id, vacation_type_id, year, total_days, notes } = balance;
|
const { user_id, vacation_type_id, year, total_days, notes, balance_type } = balance;
|
||||||
|
|
||||||
if (!user_id || !vacation_type_id || !year || total_days === undefined) {
|
if (!user_id || !vacation_type_id || !year || total_days === undefined) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
@@ -221,17 +221,17 @@ const vacationBalanceController = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const btype = balance_type || 'AUTO';
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO vacation_balance_details
|
INSERT INTO sp_vacation_balances
|
||||||
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at)
|
||||||
VALUES (?, ?, ?, ?, 0, ?, ?)
|
VALUES (?, ?, ?, ?, 0, ?, ?, ?, NULL)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
total_days = VALUES(total_days),
|
total_days = VALUES(total_days),
|
||||||
notes = VALUES(notes),
|
notes = VALUES(notes)
|
||||||
updated_at = NOW()
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await db.query(query, [user_id, vacation_type_id, year, total_days, notes || null, created_by]);
|
await db.query(query, [user_id, vacation_type_id, year, total_days, notes || null, created_by, btype]);
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('휴가 잔액 저장 오류:', err);
|
logger.error('휴가 잔액 저장 오류:', err);
|
||||||
|
|||||||
@@ -201,6 +201,15 @@ const vacationRequestController = {
|
|||||||
return res.status(400).json({ success: false, message: '이미 처리된 신청입니다' });
|
return res.status(400).json({ success: false, message: '이미 처리된 신청입니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sp_vacation_balances 차감 (특별휴가 우선 → 이월 → 기본 순서)
|
||||||
|
const request = results[0];
|
||||||
|
const year = new Date(request.start_date).getFullYear();
|
||||||
|
const daysUsed = parseFloat(request.days_used) || 0;
|
||||||
|
if (daysUsed > 0) {
|
||||||
|
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
||||||
|
await vacationBalanceModel.deductByPriority(request.user_id, year, daysUsed);
|
||||||
|
}
|
||||||
|
|
||||||
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note });
|
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note });
|
||||||
res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
|
res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ exports.up = async function(knex) {
|
|||||||
table.increments('id').primary();
|
table.increments('id').primary();
|
||||||
table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
|
table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
|
||||||
table.string('type_name', 50).notNullable().comment('휴가 이름');
|
table.string('type_name', 50).notNullable().comment('휴가 이름');
|
||||||
table.decimal('deduct_days', 3, 1).defaultTo(1.0).comment('차감 일수');
|
table.decimal('deduct_days', 4, 2).defaultTo(1.00).comment('차감 일수');
|
||||||
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 대리입력 식별 컬럼 추가
|
||||||
|
ALTER TABLE tbm_sessions ADD COLUMN IF NOT EXISTS is_proxy_input TINYINT(1) DEFAULT 0 COMMENT '대리입력 여부';
|
||||||
|
ALTER TABLE tbm_sessions ADD COLUMN IF NOT EXISTS proxy_input_by INT NULL COMMENT '대리입력자 sso_users.user_id (앱 레벨 참조)';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS monthly_work_confirmations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL COMMENT '작업자 user_id (workers.user_id)',
|
||||||
|
year INT NOT NULL,
|
||||||
|
month INT NOT NULL,
|
||||||
|
status ENUM('pending', 'confirmed', 'rejected') NOT NULL DEFAULT 'pending',
|
||||||
|
total_work_days INT DEFAULT 0 COMMENT '총 근무일수',
|
||||||
|
total_work_hours DECIMAL(6,2) DEFAULT 0 COMMENT '총 근무시간',
|
||||||
|
total_overtime_hours DECIMAL(6,2) DEFAULT 0 COMMENT '총 연장근로시간',
|
||||||
|
vacation_days DECIMAL(4,2) DEFAULT 0 COMMENT '휴가 일수',
|
||||||
|
mismatch_count INT DEFAULT 0 COMMENT '불일치 건수',
|
||||||
|
reject_reason TEXT NULL COMMENT '반려 사유',
|
||||||
|
confirmed_at TIMESTAMP NULL COMMENT '확인 일시',
|
||||||
|
rejected_at TIMESTAMP NULL COMMENT '반려 일시',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_user_year_month (user_id, year, month),
|
||||||
|
KEY idx_year_month (year, month),
|
||||||
|
KEY idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='월간 근무 확인 (승인/반려)'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- vacation_types.deduct_days 정밀도 수정: DECIMAL(3,1) → DECIMAL(4,2)
|
||||||
|
-- 0.25(반반차)가 0.3으로 반올림되는 문제 해결
|
||||||
|
ALTER TABLE vacation_types MODIFY deduct_days DECIMAL(4,2) DEFAULT 1.00;
|
||||||
|
UPDATE vacation_types SET deduct_days = 0.25 WHERE type_code = 'ANNUAL_QUARTER';
|
||||||
|
-- type_code가 표준 유형인데 balance_type이 COMPANY_GRANT인 잘못된 레코드 삭제
|
||||||
|
DELETE svb FROM sp_vacation_balances svb
|
||||||
|
JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
|
WHERE svb.balance_type = 'COMPANY_GRANT'
|
||||||
|
AND vt.type_code IN ('CARRYOVER', 'LONG_SERVICE', 'ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER');
|
||||||
|
-- 조퇴 휴가 유형 추가 (0.75일 = 반차+반반차)
|
||||||
|
INSERT IGNORE INTO vacation_types (type_code, type_name, deduct_days, is_active, priority)
|
||||||
|
VALUES ('EARLY_LEAVE', '조퇴', 0.75, 1, 10);
|
||||||
|
-- 작업자 월간 확인 페이지 등록
|
||||||
|
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, display_order)
|
||||||
|
VALUES ('attendance.my_monthly_confirm', '월간 근무 확인', '/pages/attendance/my-monthly-confirm.html', '근태 관리', 25);
|
||||||
|
-- 2026년 법정 공휴일 + 대체공휴일 일괄 등록
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-01-01', '신정', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-16', '설날 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-17', '설날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-18', '설날 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-03-01', '삼일절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-03-02', '대체공휴일(삼일절)', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-05-05', '어린이날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-05-24', '석가탄신일', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-06-06', '현충일', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-08-15', '광복절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-24', '추석 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-25', '추석', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-26', '추석 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-10-03', '개천절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-10-09', '한글날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-12-25', '크리스마스', 'PAID', 1);
|
||||||
|
-- NULL leader_user_id 복구 (created_by로 채움)
|
||||||
|
UPDATE tbm_sessions SET leader_user_id = created_by WHERE leader_user_id IS NULL;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 소모품 카테고리 테이블 분리 (ENUM → 마스터 테이블)
|
||||||
|
|
||||||
|
-- 1단계: 카테고리 마스터 테이블 생성
|
||||||
|
CREATE TABLE IF NOT EXISTS consumable_categories (
|
||||||
|
category_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
category_code VARCHAR(30) NOT NULL UNIQUE COMMENT '코드 (consumable, safety 등)',
|
||||||
|
category_name VARCHAR(50) NOT NULL COMMENT '표시명',
|
||||||
|
icon VARCHAR(30) DEFAULT 'fa-box' COMMENT 'Font Awesome 아이콘',
|
||||||
|
color_bg VARCHAR(30) DEFAULT '#dbeafe' COMMENT '배경색',
|
||||||
|
color_fg VARCHAR(30) DEFAULT '#1e40af' COMMENT '글자색',
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2단계: 기존 4개 시드
|
||||||
|
INSERT IGNORE INTO consumable_categories (category_code, category_name, icon, color_bg, color_fg, sort_order) VALUES
|
||||||
|
('consumable', '소모품', 'fa-box', '#dbeafe', '#1e40af', 1),
|
||||||
|
('safety', '안전용품', 'fa-hard-hat', '#dcfce7', '#166534', 2),
|
||||||
|
('repair', '수선비', 'fa-wrench', '#fef3c7', '#92400e', 3),
|
||||||
|
('equipment', '설비', 'fa-cogs', '#f3e8ff', '#7e22ce', 4);
|
||||||
|
|
||||||
|
-- 3단계: ENUM → VARCHAR 변환
|
||||||
|
ALTER TABLE consumable_items MODIFY COLUMN category VARCHAR(30) DEFAULT 'consumable';
|
||||||
|
ALTER TABLE purchase_requests MODIFY COLUMN custom_category VARCHAR(30) NULL;
|
||||||
|
ALTER TABLE purchase_batches MODIFY COLUMN category VARCHAR(30) NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 월간 확인 워크플로우 확장: pending → review_sent → confirmed/change_request/rejected
|
||||||
|
ALTER TABLE monthly_work_confirmations
|
||||||
|
MODIFY status ENUM('pending','review_sent','confirmed','change_request','rejected') DEFAULT 'pending';
|
||||||
|
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS reviewed_by INT NULL;
|
||||||
|
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMP NULL;
|
||||||
|
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS change_details TEXT NULL;
|
||||||
|
ALTER TABLE monthly_work_confirmations ADD COLUMN IF NOT EXISTS admin_checked TINYINT(1) DEFAULT 0;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- 소모품 구매 취소/반품 지원 + 입고일 관리
|
||||||
|
|
||||||
|
-- 1. purchase_requests.status ENUM에 cancelled, returned 추가
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
MODIFY COLUMN status ENUM('pending','grouped','purchased','received','cancelled','returned','hold') DEFAULT 'pending'
|
||||||
|
COMMENT '대기, 구매진행중, 구매완료, 입고완료, 취소, 반품, 보류';
|
||||||
|
|
||||||
|
-- 2. 취소/반품 관련 컬럼 추가
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
ADD COLUMN cancelled_at TIMESTAMP NULL COMMENT '취소 시각' AFTER received_by,
|
||||||
|
ADD COLUMN cancelled_by INT NULL COMMENT '취소 처리자' AFTER cancelled_at,
|
||||||
|
ADD COLUMN cancel_reason TEXT NULL COMMENT '취소/반품 사유' AFTER cancelled_by;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- 소모품 구매 관리 시스템 v2: 상태 확장 + 그룹화 + 별칭 + 입고
|
||||||
|
|
||||||
|
-- 1. purchase_requests.status ENUM 확장
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
MODIFY COLUMN status ENUM('pending','grouped','purchased','received','hold') DEFAULT 'pending'
|
||||||
|
COMMENT '대기, 구매진행중, 구매완료, 입고완료, 보류';
|
||||||
|
|
||||||
|
-- 2. 입고/그룹 관련 컬럼 추가
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
ADD COLUMN batch_id INT NULL COMMENT '구매 묶음 ID' AFTER photo_path,
|
||||||
|
ADD COLUMN received_photo_path VARCHAR(255) NULL COMMENT '입고 사진' AFTER batch_id,
|
||||||
|
ADD COLUMN received_location VARCHAR(200) NULL COMMENT '입고 보관 위치' AFTER received_photo_path,
|
||||||
|
ADD COLUMN received_at TIMESTAMP NULL COMMENT '입고 확인 시각' AFTER received_location,
|
||||||
|
ADD COLUMN received_by INT NULL COMMENT '입고 확인자' AFTER received_at,
|
||||||
|
ADD CONSTRAINT fk_pr_received_by FOREIGN KEY (received_by) REFERENCES sso_users(user_id);
|
||||||
|
|
||||||
|
-- 3. 구매 묶음(그룹) 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_batches (
|
||||||
|
batch_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
batch_name VARCHAR(100) COMMENT '묶음 이름',
|
||||||
|
category ENUM('consumable','safety','repair','equipment') NULL COMMENT '분류',
|
||||||
|
vendor_id INT NULL COMMENT '예정 업체',
|
||||||
|
status ENUM('pending','purchased','received') DEFAULT 'pending'
|
||||||
|
COMMENT '진행중, 구매완료, 입고완료',
|
||||||
|
notes TEXT,
|
||||||
|
created_by INT NOT NULL COMMENT '생성자',
|
||||||
|
purchased_at TIMESTAMP NULL COMMENT '구매 처리 시점',
|
||||||
|
purchased_by INT NULL COMMENT '구매 처리자',
|
||||||
|
received_at TIMESTAMP NULL COMMENT '입고 확인 시점',
|
||||||
|
received_by INT NULL COMMENT '입고 확인자',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
|
||||||
|
FOREIGN KEY (created_by) REFERENCES sso_users(user_id),
|
||||||
|
FOREIGN KEY (purchased_by) REFERENCES sso_users(user_id),
|
||||||
|
FOREIGN KEY (received_by) REFERENCES sso_users(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. batch FK
|
||||||
|
ALTER TABLE purchase_requests
|
||||||
|
ADD CONSTRAINT fk_pr_batch FOREIGN KEY (batch_id)
|
||||||
|
REFERENCES purchase_batches(batch_id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- 5. 품목 별칭 테이블 (한국어 동의어/약어 매핑)
|
||||||
|
CREATE TABLE IF NOT EXISTS item_aliases (
|
||||||
|
alias_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT NOT NULL COMMENT 'FK → consumable_items',
|
||||||
|
alias_name VARCHAR(100) NOT NULL COMMENT '별칭/축약어',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES consumable_items(item_id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_item_alias (item_id, alias_name),
|
||||||
|
INDEX idx_alias_name (alias_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. notification_recipients ENUM에 'purchase' 추가
|
||||||
|
ALTER TABLE notification_recipients
|
||||||
|
MODIFY COLUMN notification_type
|
||||||
|
ENUM('repair','safety','nonconformity','equipment','maintenance','system','purchase')
|
||||||
|
NOT NULL COMMENT '알림 유형';
|
||||||
|
|
||||||
|
-- 7. 페이지 키 등록
|
||||||
|
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, is_admin_only, display_order) VALUES
|
||||||
|
('purchase.request_mobile', '소모품 신청 (모바일)', '/pages/purchase/request-mobile.html', 'purchase', 0, 42);
|
||||||
27
system1-factory/api/db/migrations/20260401_seed_aliases.sql
Normal file
27
system1-factory/api/db/migrations/20260401_seed_aliases.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- 소모품 별칭 시드 데이터 (item_name LIKE 매칭, 데이터 없으면 무시)
|
||||||
|
|
||||||
|
-- 장갑류
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '장갑' FROM consumable_items WHERE item_name LIKE '%면장갑%';
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '목장갑' FROM consumable_items WHERE item_name LIKE '%면장갑%';
|
||||||
|
|
||||||
|
-- 테이프류
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '테이프' FROM consumable_items WHERE item_name LIKE '%절연테이프%';
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '전기테이프' FROM consumable_items WHERE item_name LIKE '%절연테이프%';
|
||||||
|
|
||||||
|
-- 연마류
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '사포' FROM consumable_items WHERE item_name LIKE '%연마지%' OR item_name LIKE '%연마석%';
|
||||||
|
|
||||||
|
-- 마스크
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '마스크' FROM consumable_items WHERE item_name LIKE '%방진마스크%' OR item_name LIKE '%방독마스크%';
|
||||||
|
|
||||||
|
-- 안전화
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '작업화' FROM consumable_items WHERE item_name LIKE '%안전화%';
|
||||||
|
INSERT IGNORE INTO item_aliases (item_id, alias_name)
|
||||||
|
SELECT item_id, '신발' FROM consumable_items WHERE item_name LIKE '%안전화%';
|
||||||
@@ -48,7 +48,7 @@ async function runStartupMigrations() {
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const migrationFiles = ['20260326_schedule_extensions.sql'];
|
const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_add_proxy_input_fields.sql', '20260330_create_monthly_work_confirmations.sql', '20260331_fix_deduct_days_precision.sql', '20260401_monthly_confirm_workflow.sql'];
|
||||||
for (const file of migrationFiles) {
|
for (const file of migrationFiles) {
|
||||||
const sqlPath = path.join(__dirname, 'db', 'migrations', file);
|
const sqlPath = path.join(__dirname, 'db', 'migrations', file);
|
||||||
if (!fs.existsSync(sqlPath)) continue;
|
if (!fs.existsSync(sqlPath)) continue;
|
||||||
|
|||||||
@@ -150,9 +150,10 @@ class AttendanceModel {
|
|||||||
static async initializeDailyRecords(date, createdBy) {
|
static async initializeDailyRecords(date, createdBy) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
// 1. 활성 작업자 조회
|
// 1. 활성 작업자 조회 (입사일 이전 제외)
|
||||||
const [workers] = await db.execute(
|
const [workers] = await db.execute(
|
||||||
'SELECT user_id FROM workers WHERE status = "active" AND user_id IS NOT NULL'
|
'SELECT user_id FROM workers WHERE status = "active" AND user_id IS NOT NULL AND (hire_date IS NULL OR hire_date <= ?)',
|
||||||
|
[date]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (workers.length === 0) return { inserted: 0 };
|
if (workers.length === 0) return { inserted: 0 };
|
||||||
|
|||||||
47
system1-factory/api/models/consumableCategoryModel.js
Normal file
47
system1-factory/api/models/consumableCategoryModel.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// models/consumableCategoryModel.js
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const ConsumableCategoryModel = {
|
||||||
|
async getAll(activeOnly = true) {
|
||||||
|
const db = await getDb();
|
||||||
|
let sql = 'SELECT * FROM consumable_categories';
|
||||||
|
if (activeOnly) sql += ' WHERE is_active = 1';
|
||||||
|
sql += ' ORDER BY sort_order, category_name';
|
||||||
|
const [rows] = await db.query(sql);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query('SELECT * FROM consumable_categories WHERE category_id = ?', [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create({ categoryCode, categoryName, icon, colorBg, colorFg, sortOrder }) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO consumable_categories (category_code, category_name, icon, color_bg, color_fg, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[categoryCode, categoryName, icon || 'fa-box', colorBg || '#dbeafe', colorFg || '#1e40af', sortOrder || 0]
|
||||||
|
);
|
||||||
|
return this.getById(result.insertId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, { categoryName, icon, colorBg, colorFg, sortOrder }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE consumable_categories SET category_name = ?, icon = ?, color_bg = ?, color_fg = ?, sort_order = ?
|
||||||
|
WHERE category_id = ?`,
|
||||||
|
[categoryName, icon, colorBg, colorFg, sortOrder, id]
|
||||||
|
);
|
||||||
|
return this.getById(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deactivate(id) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query('UPDATE consumable_categories SET is_active = 0 WHERE category_id = ?', [id]);
|
||||||
|
return this.getById(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ConsumableCategoryModel;
|
||||||
148
system1-factory/api/models/dashboardModel.js
Normal file
148
system1-factory/api/models/dashboardModel.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 대시보드 개인 요약 모델
|
||||||
|
* Sprint 003 — 연차/연장근로/접근 페이지 통합 조회
|
||||||
|
*/
|
||||||
|
const { getDb } = require('../config/database');
|
||||||
|
|
||||||
|
const OVERTIME_THRESHOLD = 8; // 연장근로 기준 시간
|
||||||
|
|
||||||
|
const DashboardModel = {
|
||||||
|
/**
|
||||||
|
* 사용자 정보 조회 (쿼리 1 — 먼저 실행)
|
||||||
|
*/
|
||||||
|
getUserInfo: async (userId) => {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.execute(`
|
||||||
|
SELECT u.user_id, u.name, u.role,
|
||||||
|
w.worker_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(w.department_id, u.department_id) AS department_id,
|
||||||
|
COALESCE(d.department_name, d2.department_name, '미배정') AS department_name
|
||||||
|
FROM sso_users u
|
||||||
|
LEFT JOIN workers w ON u.user_id = w.user_id
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
LEFT JOIN departments d2 ON u.department_id = d2.department_id
|
||||||
|
WHERE u.user_id = ?
|
||||||
|
`, [userId]);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연차 현황 조회 (쿼리 2)
|
||||||
|
*/
|
||||||
|
getVacationBalance: async (userId, year) => {
|
||||||
|
if (!userId) return [];
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.execute(`
|
||||||
|
SELECT svb.vacation_type_id, svb.total_days, svb.used_days,
|
||||||
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
|
svb.balance_type, svb.expires_at,
|
||||||
|
vt.type_name, vt.type_code
|
||||||
|
FROM sp_vacation_balances svb
|
||||||
|
JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
|
WHERE svb.user_id = ? AND svb.year = ?
|
||||||
|
ORDER BY vt.priority
|
||||||
|
`, [userId, year]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월간 연장근로 조회 (쿼리 3)
|
||||||
|
*/
|
||||||
|
getMonthlyOvertime: async (userId, year, month) => {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.execute(`
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN dar.total_work_hours > ${OVERTIME_THRESHOLD} THEN 1 END) AS overtime_days,
|
||||||
|
COALESCE(SUM(CASE WHEN dar.total_work_hours > ${OVERTIME_THRESHOLD} THEN dar.total_work_hours - ${OVERTIME_THRESHOLD} ELSE 0 END), 0) AS total_overtime_hours,
|
||||||
|
COUNT(*) AS total_work_days,
|
||||||
|
COALESCE(SUM(dar.total_work_hours), 0) AS total_work_hours,
|
||||||
|
COALESCE(AVG(dar.total_work_hours), 0) AS avg_daily_hours
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
WHERE dar.user_id = ? AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
|
||||||
|
AND dar.total_work_hours > 0
|
||||||
|
`, [userId, year, month]);
|
||||||
|
return rows[0] || { overtime_days: 0, total_overtime_hours: 0, total_work_days: 0, total_work_hours: 0, avg_daily_hours: 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근 가능 페이지 조회 (쿼리 4)
|
||||||
|
*/
|
||||||
|
getQuickAccess: async (userId, departmentId, role) => {
|
||||||
|
const db = await getDb();
|
||||||
|
const isAdmin = ['admin', 'system'].includes((role || '').toLowerCase());
|
||||||
|
|
||||||
|
// 모든 페이지 조회
|
||||||
|
const [allPages] = await db.execute(`
|
||||||
|
SELECT id, page_key, page_name, page_path, category, is_admin_only
|
||||||
|
FROM pages
|
||||||
|
ORDER BY display_order, page_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
return {
|
||||||
|
department_pages: allPages.map(formatPage),
|
||||||
|
personal_pages: [],
|
||||||
|
admin_pages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부서 권한 페이지
|
||||||
|
// department_page_permissions.page_name은 's1.work.tbm' 형식 (시스템 접두사 포함)
|
||||||
|
// pages.page_key는 'work.tbm' 형식 (접두사 없음)
|
||||||
|
// → 's1.' 접두사를 제거하여 매칭
|
||||||
|
let deptPageKeys = new Set();
|
||||||
|
if (departmentId) {
|
||||||
|
const [deptRows] = await db.execute(`
|
||||||
|
SELECT dpp.page_name
|
||||||
|
FROM department_page_permissions dpp
|
||||||
|
WHERE dpp.department_id = ? AND dpp.can_access = 1
|
||||||
|
`, [departmentId]);
|
||||||
|
deptRows.forEach(r => {
|
||||||
|
const key = r.page_name.startsWith('s1.') ? r.page_name.slice(3) : r.page_name;
|
||||||
|
deptPageKeys.add(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개인 권한 페이지 (user_page_permissions.page_name 기반)
|
||||||
|
const [personalRows] = await db.execute(`
|
||||||
|
SELECT upp.page_name
|
||||||
|
FROM user_page_permissions upp
|
||||||
|
WHERE upp.user_id = ? AND upp.can_access = 1
|
||||||
|
`, [userId]);
|
||||||
|
const personalPageKeys = new Set();
|
||||||
|
personalRows.forEach(r => {
|
||||||
|
const key = r.page_name.startsWith('s1.') ? r.page_name.slice(3) : r.page_name;
|
||||||
|
personalPageKeys.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 분류 (부서 우선, 중복 없음 — 권한 있는 페이지만)
|
||||||
|
const departmentPages = [];
|
||||||
|
const personalPages = [];
|
||||||
|
|
||||||
|
for (const page of allPages) {
|
||||||
|
if (deptPageKeys.has(page.page_key)) {
|
||||||
|
departmentPages.push(formatPage(page));
|
||||||
|
} else if (personalPageKeys.has(page.page_key)) {
|
||||||
|
personalPages.push(formatPage(page));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
department_pages: departmentPages,
|
||||||
|
personal_pages: personalPages,
|
||||||
|
admin_pages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPage(page) {
|
||||||
|
return {
|
||||||
|
page_key: page.page_key,
|
||||||
|
page_name: page.page_name,
|
||||||
|
page_path: page.page_path,
|
||||||
|
icon: '',
|
||||||
|
category: page.category || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DashboardModel;
|
||||||
35
system1-factory/api/models/itemAliasModel.js
Normal file
35
system1-factory/api/models/itemAliasModel.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// models/itemAliasModel.js
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const ItemAliasModel = {
|
||||||
|
async getAll() {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
`SELECT ia.*, ci.item_name, ci.spec, ci.maker, ci.category
|
||||||
|
FROM item_aliases ia
|
||||||
|
JOIN consumable_items ci ON ia.item_id = ci.item_id
|
||||||
|
ORDER BY ci.item_name, ia.alias_name`
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(itemId, aliasName) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO item_aliases (item_id, alias_name) VALUES (?, ?)`,
|
||||||
|
[itemId, aliasName.trim()]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(aliasId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [result] = await db.query(
|
||||||
|
`DELETE FROM item_aliases WHERE alias_id = ?`,
|
||||||
|
[aliasId]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ItemAliasModel;
|
||||||
387
system1-factory/api/models/monthlyComparisonModel.js
Normal file
387
system1-factory/api/models/monthlyComparisonModel.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
// models/monthlyComparisonModel.js — 월간 비교·확인·정산
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const MonthlyComparisonModel = {
|
||||||
|
// 0. 해당 월의 회사 휴무일 조회
|
||||||
|
async getCompanyHolidays(year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
`SELECT holiday_date, holiday_name FROM company_holidays
|
||||||
|
WHERE YEAR(holiday_date) = ? AND MONTH(holiday_date) = ?`,
|
||||||
|
[year, month]
|
||||||
|
);
|
||||||
|
const dateSet = new Set();
|
||||||
|
const nameMap = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
const d = r.holiday_date instanceof Date
|
||||||
|
? r.holiday_date.toISOString().split('T')[0]
|
||||||
|
: String(r.holiday_date).split('T')[0];
|
||||||
|
dateSet.add(d);
|
||||||
|
nameMap[d] = r.holiday_name;
|
||||||
|
});
|
||||||
|
return { dateSet, nameMap };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 1. 작업보고서 일별 합산
|
||||||
|
async getWorkReports(userId, year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT
|
||||||
|
dwr.report_date,
|
||||||
|
SUM(dwr.work_hours) AS total_hours,
|
||||||
|
GROUP_CONCAT(DISTINCT p.project_name SEPARATOR ', ') AS project_names,
|
||||||
|
GROUP_CONCAT(DISTINCT wt.name SEPARATOR ', ') AS work_type_names
|
||||||
|
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.user_id = ?
|
||||||
|
AND YEAR(dwr.report_date) = ?
|
||||||
|
AND MONTH(dwr.report_date) = ?
|
||||||
|
GROUP BY dwr.report_date
|
||||||
|
ORDER BY dwr.report_date
|
||||||
|
`, [userId, year, month]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. 근태관리 일별 기록
|
||||||
|
async getAttendanceRecords(userId, year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT
|
||||||
|
dar.record_date,
|
||||||
|
dar.total_work_hours,
|
||||||
|
dar.attendance_type_id,
|
||||||
|
dar.vacation_type_id,
|
||||||
|
dar.status,
|
||||||
|
dar.is_present,
|
||||||
|
dar.notes,
|
||||||
|
wat.type_name AS attendance_type_name,
|
||||||
|
vt.type_name AS vacation_type_name,
|
||||||
|
vt.deduct_days AS vacation_days
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
|
||||||
|
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||||
|
WHERE dar.user_id = ?
|
||||||
|
AND YEAR(dar.record_date) = ?
|
||||||
|
AND MONTH(dar.record_date) = ?
|
||||||
|
ORDER BY dar.record_date
|
||||||
|
`, [userId, year, month]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. 확인 상태 조회
|
||||||
|
async getConfirmation(userId, year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT * FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
|
||||||
|
[userId, year, month]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. 확인 UPSERT + 반려 시 알림 (단일 트랜잭션)
|
||||||
|
async upsertConfirmation(data, notificationData) {
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 기존 상태 체크 + 전환 검증
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ? FOR UPDATE',
|
||||||
|
[data.user_id, data.year, data.month]
|
||||||
|
);
|
||||||
|
const currentStatus = existing.length > 0 ? existing[0].status : null;
|
||||||
|
|
||||||
|
if (currentStatus === 'confirmed') {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '이미 확인된 내역은 변경할 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업자 확인: review_sent 또는 rejected 상태에서만 가능
|
||||||
|
if (data.status === 'confirmed' && currentStatus && currentStatus !== 'review_sent' && currentStatus !== 'rejected') {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '관리자 확인요청 후에 확인할 수 있습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업자 수정요청: review_sent 상태에서만 가능
|
||||||
|
if (data.status === 'change_request' && currentStatus !== 'review_sent') {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '확인요청 상태에서만 수정요청이 가능합니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPSERT
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
INSERT INTO monthly_work_confirmations
|
||||||
|
(user_id, year, month, status, total_work_days, total_work_hours,
|
||||||
|
total_overtime_hours, vacation_days, mismatch_count, reject_reason,
|
||||||
|
confirmed_at, rejected_at, change_details)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
status = VALUES(status),
|
||||||
|
total_work_days = VALUES(total_work_days),
|
||||||
|
total_work_hours = VALUES(total_work_hours),
|
||||||
|
total_overtime_hours = VALUES(total_overtime_hours),
|
||||||
|
vacation_days = VALUES(vacation_days),
|
||||||
|
mismatch_count = VALUES(mismatch_count),
|
||||||
|
reject_reason = VALUES(reject_reason),
|
||||||
|
confirmed_at = VALUES(confirmed_at),
|
||||||
|
rejected_at = VALUES(rejected_at),
|
||||||
|
change_details = VALUES(change_details)
|
||||||
|
`, [
|
||||||
|
data.user_id, data.year, data.month, data.status,
|
||||||
|
data.total_work_days || 0, data.total_work_hours || 0,
|
||||||
|
data.total_overtime_hours || 0, data.vacation_days || 0,
|
||||||
|
data.mismatch_count || 0, data.reject_reason || null,
|
||||||
|
data.status === 'confirmed' ? new Date() : null,
|
||||||
|
data.status === 'rejected' ? new Date() : null,
|
||||||
|
data.change_details || null
|
||||||
|
]);
|
||||||
|
|
||||||
|
const confirmationId = result.insertId || (existing.length > 0 ? existing[0].id : null);
|
||||||
|
|
||||||
|
// 알림 생성 (반려 또는 수정요청)
|
||||||
|
if (notificationData && confirmationId) {
|
||||||
|
const { recipients, title, message, linkUrl, createdBy } = notificationData;
|
||||||
|
for (const recipientId of recipients) {
|
||||||
|
await conn.query(`
|
||||||
|
INSERT INTO notifications
|
||||||
|
(user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by)
|
||||||
|
VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?)
|
||||||
|
`, [recipientId, title, message, linkUrl, confirmationId, createdBy]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { id: confirmationId, status: data.status };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 관리자: 확인요청 발송 (pending → review_sent)
|
||||||
|
async bulkReviewSend(year, month, userIds, reviewedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 대상 작업자 결정 (userIds 있으면 단건, 없으면 pending 전체)
|
||||||
|
let targetIds = userIds || [];
|
||||||
|
if (!targetIds.length) {
|
||||||
|
const [pendingRows] = await conn.query(
|
||||||
|
`SELECT DISTINCT w.user_id FROM workers w
|
||||||
|
LEFT JOIN monthly_work_confirmations mwc ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
|
||||||
|
WHERE w.status = 'active' AND w.user_id IS NOT NULL
|
||||||
|
AND (mwc.status IS NULL OR mwc.status = 'pending')`,
|
||||||
|
[year, month]
|
||||||
|
);
|
||||||
|
targetIds = pendingRows.map(r => r.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetIds.length) {
|
||||||
|
await conn.rollback();
|
||||||
|
return { count: 0, message: '대상 작업자가 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 전환 + 알림 생성
|
||||||
|
for (const uid of targetIds) {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO monthly_work_confirmations (user_id, year, month, status, reviewed_by, reviewed_at)
|
||||||
|
VALUES (?, ?, ?, 'review_sent', ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE status = 'review_sent', reviewed_by = ?, reviewed_at = NOW()`,
|
||||||
|
[uid, year, month, reviewedBy, reviewedBy]
|
||||||
|
);
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, is_read, created_by)
|
||||||
|
VALUES (?, 'system', '월간 근무 확인 요청', ?, '/pages/attendance/my-monthly-confirm.html?year=${year}&month=${month}', 'monthly_work_confirmation', 0, ?)`,
|
||||||
|
[uid, `${year}년 ${month}월 근무 내역을 확인해주세요.`, reviewedBy]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { count: targetIds.length };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
|
||||||
|
async reviewRespond(userId, year, month, action, rejectReason, respondedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const [existing] = await conn.query(
|
||||||
|
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
|
||||||
|
[userId, year, month]
|
||||||
|
);
|
||||||
|
if (!existing.length || existing[0].status !== 'change_request') {
|
||||||
|
await conn.rollback();
|
||||||
|
return { error: '수정요청 상태가 아닙니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
var newStatus = action === 'approve' ? 'review_sent' : 'rejected';
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE monthly_work_confirmations SET status = ?, reviewed_by = ?, reviewed_at = NOW(),
|
||||||
|
reject_reason = ?, change_details = NULL WHERE id = ?`,
|
||||||
|
[newStatus, respondedBy, action === 'reject' ? rejectReason : null, existing[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 작업자에게 알림
|
||||||
|
var title = action === 'approve' ? '수정요청 승인' : '수정요청 거부';
|
||||||
|
var message = action === 'approve'
|
||||||
|
? `${year}년 ${month}월 근무 수정이 반영되었습니다. 다시 확인해주세요.`
|
||||||
|
: `${year}년 ${month}월 근무 수정요청이 거부되었습니다. 사유: ${rejectReason || '-'}`;
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by)
|
||||||
|
VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?)`,
|
||||||
|
[userId, title, message, '/pages/attendance/my-monthly-confirm.html?year=' + year + '&month=' + month, existing[0].id, respondedBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { status: newStatus };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. 전체 작업자 확인 현황 (실제 근태 데이터 집계 포함)
|
||||||
|
async getAllStatus(year, month, departmentId) {
|
||||||
|
const db = await getDb();
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
w.user_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name,
|
||||||
|
COALESCE(mwc.status, 'pending') AS status,
|
||||||
|
mwc.confirmed_at, mwc.rejected_at, mwc.reject_reason,
|
||||||
|
mwc.change_details, COALESCE(mwc.admin_checked, 0) AS admin_checked,
|
||||||
|
COALESCE(att.work_days, 0) AS total_work_days,
|
||||||
|
COALESCE(att.work_hours, 0) AS total_work_hours,
|
||||||
|
COALESCE(att.overtime_hours, 0) AS total_overtime_hours,
|
||||||
|
COALESCE(att.vac_days, 0) AS vacation_days
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
LEFT JOIN monthly_work_confirmations mwc
|
||||||
|
ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ?
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT dar.user_id,
|
||||||
|
COUNT(CASE WHEN dar.total_work_hours > 0 THEN 1 END) AS work_days,
|
||||||
|
COALESCE(SUM(dar.total_work_hours), 0) AS work_hours,
|
||||||
|
COALESCE(SUM(CASE WHEN dar.total_work_hours > 8 THEN dar.total_work_hours - 8 ELSE 0 END), 0) AS overtime_hours,
|
||||||
|
COALESCE(SUM(CASE WHEN vt.deduct_days IS NOT NULL THEN vt.deduct_days ELSE 0 END), 0) AS vac_days
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||||
|
WHERE YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
|
||||||
|
GROUP BY dar.user_id
|
||||||
|
) att ON w.user_id = att.user_id
|
||||||
|
WHERE w.status = 'active' AND w.user_id IS NOT NULL
|
||||||
|
`;
|
||||||
|
const params = [year, month, year, month];
|
||||||
|
if (departmentId) {
|
||||||
|
sql += ' AND w.department_id = ?';
|
||||||
|
params.push(departmentId);
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY d.department_name, w.worker_name';
|
||||||
|
|
||||||
|
const [rows] = await db.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5b. 관리자 개별 검토 태깅
|
||||||
|
async adminCheck(userId, year, month, checked, checkedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO monthly_work_confirmations (user_id, year, month, status, admin_checked)
|
||||||
|
VALUES (?, ?, ?, 'pending', ?)
|
||||||
|
ON DUPLICATE KEY UPDATE admin_checked = ?
|
||||||
|
`, [userId, year, month, checked ? 1 : 0, checked ? 1 : 0]);
|
||||||
|
return { admin_checked: checked };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 6. 지원팀 사용자 목록 (알림 수신자)
|
||||||
|
async getSupportTeamUsers() {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
"SELECT user_id FROM sso_users WHERE role IN ('support_team', 'admin', 'system') AND is_active = 1"
|
||||||
|
);
|
||||||
|
return rows.map(r => r.user_id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 7. 출근부 엑셀용 — 작업자 목록 + 일별 근태 + 연차잔액
|
||||||
|
async getExportData(year, month, departmentId) {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// (a) 해당 부서 활성 작업자 (worker_id 순)
|
||||||
|
let workerSql = `
|
||||||
|
SELECT w.user_id, w.worker_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
WHERE w.status = 'active'
|
||||||
|
`;
|
||||||
|
const workerParams = [];
|
||||||
|
if (departmentId) { workerSql += ' AND w.department_id = ?'; workerParams.push(departmentId); }
|
||||||
|
workerSql += ' ORDER BY w.worker_id';
|
||||||
|
const [workers] = await db.query(workerSql, workerParams);
|
||||||
|
|
||||||
|
if (workers.length === 0) return { workers: [], attendance: [], vacations: [] };
|
||||||
|
|
||||||
|
const userIds = workers.map(w => w.user_id);
|
||||||
|
const placeholders = userIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
// (b) 일별 근태 기록
|
||||||
|
const [attendance] = await db.query(`
|
||||||
|
SELECT dar.user_id, dar.record_date,
|
||||||
|
dar.total_work_hours,
|
||||||
|
dar.attendance_type_id,
|
||||||
|
dar.vacation_type_id,
|
||||||
|
vt.type_code AS vacation_type_code,
|
||||||
|
vt.type_name AS vacation_type_name,
|
||||||
|
vt.deduct_days
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||||
|
WHERE dar.user_id IN (${placeholders})
|
||||||
|
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
|
||||||
|
ORDER BY dar.user_id, dar.record_date
|
||||||
|
`, [...userIds, year, month]);
|
||||||
|
|
||||||
|
// (c) 연차 잔액 (sp_vacation_balances)
|
||||||
|
const [vacations] = await db.query(`
|
||||||
|
SELECT svb.user_id,
|
||||||
|
SUM(svb.total_days) AS total_days,
|
||||||
|
SUM(svb.used_days) AS used_days,
|
||||||
|
SUM(svb.total_days - svb.used_days) AS remaining_days
|
||||||
|
FROM sp_vacation_balances svb
|
||||||
|
WHERE svb.user_id IN (${placeholders}) AND svb.year = ?
|
||||||
|
GROUP BY svb.user_id
|
||||||
|
`, [...userIds, year]);
|
||||||
|
|
||||||
|
return { workers, attendance, vacations };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 8. 작업자 정보
|
||||||
|
async getWorkerInfo(userId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT w.user_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
WHERE w.user_id = ?
|
||||||
|
`, [userId]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = MonthlyComparisonModel;
|
||||||
@@ -19,7 +19,6 @@ const PageAccessModel = {
|
|||||||
FROM pages p
|
FROM pages p
|
||||||
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
|
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
|
||||||
LEFT JOIN users granter ON upa.granted_by = granter.user_id
|
LEFT JOIN users granter ON upa.granted_by = granter.user_id
|
||||||
WHERE p.is_admin_only = 0
|
|
||||||
ORDER BY p.category, p.display_order
|
ORDER BY p.category, p.display_order
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ const PageAccessModel = {
|
|||||||
is_admin_only,
|
is_admin_only,
|
||||||
display_order
|
display_order
|
||||||
FROM pages
|
FROM pages
|
||||||
WHERE is_admin_only = 0
|
|
||||||
ORDER BY category, display_order
|
ORDER BY category, display_order
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
242
system1-factory/api/models/proxyInputModel.js
Normal file
242
system1-factory/api/models/proxyInputModel.js
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 대리입력 + 일별 현황 모델
|
||||||
|
*/
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const ProxyInputModel = {
|
||||||
|
/**
|
||||||
|
* 중복 배정 체크 (같은 날짜 + 같은 작업자)
|
||||||
|
*/
|
||||||
|
checkDuplicateAssignments: async (conn, sessionDate, userIds) => {
|
||||||
|
if (!userIds.length) return [];
|
||||||
|
const placeholders = userIds.map(() => '?').join(',');
|
||||||
|
const [rows] = await conn.query(`
|
||||||
|
SELECT ta.user_id, w.worker_name, ta.session_id
|
||||||
|
FROM tbm_team_assignments ta
|
||||||
|
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||||
|
JOIN workers w ON ta.user_id = w.user_id
|
||||||
|
WHERE s.session_date = ? AND ta.user_id IN (${placeholders}) AND s.status != 'cancelled'
|
||||||
|
`, [sessionDate, ...userIds]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자 존재 여부 체크
|
||||||
|
*/
|
||||||
|
validateWorkers: async (conn, userIds) => {
|
||||||
|
if (!userIds.length) return [];
|
||||||
|
const placeholders = userIds.map(() => '?').join(',');
|
||||||
|
const [rows] = await conn.query(`
|
||||||
|
SELECT user_id FROM workers WHERE user_id IN (${placeholders}) AND status = 'active'
|
||||||
|
`, [...userIds]);
|
||||||
|
return rows.map(r => r.user_id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBM 세션 생성 (대리입력)
|
||||||
|
*/
|
||||||
|
createProxySession: async (conn, data) => {
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
INSERT INTO tbm_sessions (session_date, leader_user_id, status, is_proxy_input, proxy_input_by, created_by, safety_notes, work_location)
|
||||||
|
VALUES (?, ?, 'completed', 1, ?, ?, ?, ?)
|
||||||
|
`, [data.session_date, data.leader_id, data.proxy_input_by, data.created_by, data.safety_notes || '', data.work_location || '']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팀 배정 생성
|
||||||
|
*/
|
||||||
|
createTeamAssignment: async (conn, data) => {
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
INSERT INTO tbm_team_assignments (session_id, user_id, project_id, work_type_id, task_id, workplace_id, work_hours, is_present)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
`, [data.session_id, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.workplace_id || null, data.work_hours]);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업보고서 생성 (accumulative)
|
||||||
|
*/
|
||||||
|
createWorkReport: async (conn, data) => {
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
INSERT INTO daily_work_reports (report_date, user_id, project_id, work_type_id, task_id, work_status_id, work_hours, start_time, end_time, note, tbm_session_id, tbm_assignment_id, created_by, created_by_name, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
`, [data.report_date, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.work_status_id || 1, data.work_hours, data.start_time || null, data.end_time || null, data.note || '', data.tbm_session_id, data.tbm_assignment_id, data.created_by, data.created_by_name || '']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 현황 조회
|
||||||
|
*/
|
||||||
|
getDailyStatus: async (date) => {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// 1. 활성 작업자
|
||||||
|
const [workers] = await db.query(`
|
||||||
|
SELECT w.user_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
WHERE w.status = 'active' AND w.user_id IS NOT NULL
|
||||||
|
ORDER BY w.worker_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. TBM 배정 현황
|
||||||
|
const [tbmAssignments] = await db.query(`
|
||||||
|
SELECT ta.user_id, ta.session_id, s.leader_user_id,
|
||||||
|
lu.worker_name AS leader_name, s.is_proxy_input
|
||||||
|
FROM tbm_team_assignments ta
|
||||||
|
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||||
|
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
|
||||||
|
WHERE s.session_date = ? AND s.status != 'cancelled'
|
||||||
|
`, [date]);
|
||||||
|
|
||||||
|
// 3. 작업보고서 현황
|
||||||
|
const [reports] = await db.query(`
|
||||||
|
SELECT dwr.user_id, SUM(dwr.work_hours) AS total_hours, COUNT(*) AS entry_count
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
WHERE dwr.report_date = ?
|
||||||
|
GROUP BY dwr.user_id
|
||||||
|
`, [date]);
|
||||||
|
|
||||||
|
// 4. 해당 날짜의 연차 기록
|
||||||
|
const [vacationRecords] = await db.query(`
|
||||||
|
SELECT dar.user_id, dar.vacation_type_id,
|
||||||
|
vt.type_code AS vacation_type_code,
|
||||||
|
vt.type_name AS vacation_type_name,
|
||||||
|
vt.deduct_days
|
||||||
|
FROM daily_attendance_records dar
|
||||||
|
JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||||
|
WHERE dar.record_date = ? AND dar.vacation_type_id IS NOT NULL
|
||||||
|
`, [date]);
|
||||||
|
|
||||||
|
// 5. 해당 날짜가 회사 휴무일인지 확인
|
||||||
|
const [holidayRows] = await db.query(
|
||||||
|
`SELECT holiday_date, holiday_name FROM company_holidays WHERE holiday_date = ?`,
|
||||||
|
[date]
|
||||||
|
);
|
||||||
|
const isCompanyHoliday = holidayRows.length > 0;
|
||||||
|
const holidayName = isCompanyHoliday ? holidayRows[0].holiday_name : null;
|
||||||
|
const dateObj = new Date(date);
|
||||||
|
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
|
||||||
|
|
||||||
|
// 메모리에서 조합
|
||||||
|
const tbmMap = {};
|
||||||
|
tbmAssignments.forEach(ta => {
|
||||||
|
if (!tbmMap[ta.user_id]) tbmMap[ta.user_id] = [];
|
||||||
|
tbmMap[ta.user_id].push(ta);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportMap = {};
|
||||||
|
reports.forEach(r => { reportMap[r.user_id] = r; });
|
||||||
|
|
||||||
|
const vacMap = {};
|
||||||
|
vacationRecords.forEach(v => { vacMap[v.user_id] = v; });
|
||||||
|
|
||||||
|
let tbmCompleted = 0, reportCompleted = 0, bothCompleted = 0, bothMissing = 0;
|
||||||
|
|
||||||
|
const workerList = workers.map(w => {
|
||||||
|
const hasTbm = !!tbmMap[w.user_id];
|
||||||
|
const hasReport = !!reportMap[w.user_id];
|
||||||
|
const tbmSessions = (tbmMap[w.user_id] || []).map(ta => ({
|
||||||
|
session_id: ta.session_id,
|
||||||
|
leader_name: ta.leader_name,
|
||||||
|
is_proxy_input: !!ta.is_proxy_input
|
||||||
|
}));
|
||||||
|
const totalReportHours = reportMap[w.user_id]?.total_hours || 0;
|
||||||
|
const vac = vacMap[w.user_id] || null;
|
||||||
|
|
||||||
|
let status = 'both_missing';
|
||||||
|
if (hasTbm && hasReport) { status = 'complete'; bothCompleted++; }
|
||||||
|
else if (hasTbm && !hasReport) { status = 'tbm_only'; }
|
||||||
|
else if (!hasTbm && hasReport) { status = 'report_only'; }
|
||||||
|
else { bothMissing++; }
|
||||||
|
|
||||||
|
if (hasTbm) tbmCompleted++;
|
||||||
|
if (hasReport) reportCompleted++;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type,
|
||||||
|
department_name: w.department_name, has_tbm: hasTbm, has_report: hasReport,
|
||||||
|
tbm_sessions: tbmSessions, total_report_hours: totalReportHours, status,
|
||||||
|
vacation_type_id: vac ? vac.vacation_type_id : null,
|
||||||
|
vacation_type_code: vac ? vac.vacation_type_code : null,
|
||||||
|
vacation_type_name: vac ? vac.vacation_type_name : null,
|
||||||
|
vacation_hours: vac ? (8 - parseFloat(vac.deduct_days) * 8) : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
is_holiday: isWeekend || isCompanyHoliday,
|
||||||
|
holiday_name: isCompanyHoliday ? holidayName : (isWeekend ? '주말' : null),
|
||||||
|
summary: {
|
||||||
|
total_active_workers: workers.length,
|
||||||
|
tbm_completed: tbmCompleted,
|
||||||
|
tbm_missing: workers.length - tbmCompleted,
|
||||||
|
report_completed: reportCompleted,
|
||||||
|
report_missing: workers.length - reportCompleted,
|
||||||
|
both_completed: bothCompleted,
|
||||||
|
both_missing: bothMissing
|
||||||
|
},
|
||||||
|
workers: workerList
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업자별 상세 조회
|
||||||
|
*/
|
||||||
|
getDailyStatusDetail: async (date, userId) => {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// 작업자 정보
|
||||||
|
const [workerRows] = await db.query(`
|
||||||
|
SELECT w.user_id, w.worker_name, w.job_type,
|
||||||
|
COALESCE(d.department_name, '미배정') AS department_name
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||||
|
WHERE w.user_id = ?
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
// TBM 세션
|
||||||
|
const [tbmSessions] = await db.query(`
|
||||||
|
SELECT ta.session_id, s.status, s.is_proxy_input,
|
||||||
|
lu.worker_name AS leader_name,
|
||||||
|
pu.name AS proxy_input_by_name,
|
||||||
|
p.project_name, wt.work_type_name, ta.work_hours
|
||||||
|
FROM tbm_team_assignments ta
|
||||||
|
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||||
|
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
|
||||||
|
LEFT JOIN sso_users pu ON s.proxy_input_by = pu.user_id
|
||||||
|
LEFT JOIN projects p ON ta.project_id = p.project_id
|
||||||
|
LEFT JOIN work_types wt ON ta.work_type_id = wt.work_type_id
|
||||||
|
WHERE s.session_date = ? AND ta.user_id = ? AND s.status != 'cancelled'
|
||||||
|
`, [date, userId]);
|
||||||
|
|
||||||
|
// 작업보고서
|
||||||
|
const [workReports] = await db.query(`
|
||||||
|
SELECT dwr.report_id, dwr.work_hours, dwr.created_at, dwr.created_by,
|
||||||
|
cu.name AS created_by_name,
|
||||||
|
p.project_name, wt.work_type_name, t.task_name,
|
||||||
|
ws.status_name AS work_status,
|
||||||
|
s.is_proxy_input
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
LEFT JOIN sso_users cu ON dwr.created_by = cu.user_id
|
||||||
|
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||||
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.work_type_id
|
||||||
|
LEFT JOIN tasks t ON dwr.task_id = t.task_id
|
||||||
|
LEFT JOIN work_statuses ws ON dwr.work_status_id = ws.work_status_id
|
||||||
|
LEFT JOIN tbm_sessions s ON dwr.tbm_session_id = s.session_id
|
||||||
|
WHERE dwr.report_date = ? AND dwr.user_id = ?
|
||||||
|
ORDER BY dwr.created_at
|
||||||
|
`, [date, userId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
worker: workerRows[0] || null,
|
||||||
|
tbm_sessions: tbmSessions,
|
||||||
|
work_reports: workReports
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ProxyInputModel;
|
||||||
117
system1-factory/api/models/purchaseBatchModel.js
Normal file
117
system1-factory/api/models/purchaseBatchModel.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// models/purchaseBatchModel.js
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
const PurchaseBatchModel = {
|
||||||
|
async getAll(filters = {}) {
|
||||||
|
const db = await getDb();
|
||||||
|
let sql = `
|
||||||
|
SELECT pb.*, su.name AS created_by_name,
|
||||||
|
v.vendor_name,
|
||||||
|
(SELECT COUNT(*) FROM purchase_requests WHERE batch_id = pb.batch_id) AS request_count
|
||||||
|
FROM purchase_batches pb
|
||||||
|
LEFT JOIN sso_users su ON pb.created_by = su.user_id
|
||||||
|
LEFT JOIN vendors v ON pb.vendor_id = v.vendor_id
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params = [];
|
||||||
|
if (filters.status) { sql += ' AND pb.status = ?'; params.push(filters.status); }
|
||||||
|
sql += ' ORDER BY pb.created_at DESC';
|
||||||
|
const [rows] = await db.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT pb.*, su.name AS created_by_name, v.vendor_name
|
||||||
|
FROM purchase_batches pb
|
||||||
|
LEFT JOIN sso_users su ON pb.created_by = su.user_id
|
||||||
|
LEFT JOIN vendors v ON pb.vendor_id = v.vendor_id
|
||||||
|
WHERE pb.batch_id = ?
|
||||||
|
`, [batchId]);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create({ batchName, category, vendorId, notes, createdBy }) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO purchase_batches (batch_name, category, vendor_id, notes, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[batchName || null, category || null, vendorId || null, notes || null, createdBy]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(batchId, { batchName, category, vendorId, notes }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_batches SET batch_name = ?, category = ?, vendor_id = ?, notes = ?
|
||||||
|
WHERE batch_id = ? AND status = 'pending'`,
|
||||||
|
[batchName || null, category || null, vendorId || null, notes || null, batchId]
|
||||||
|
);
|
||||||
|
return this.getById(batchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
// pending 상태만 삭제 가능
|
||||||
|
const [batch] = await db.query('SELECT status FROM purchase_batches WHERE batch_id = ?', [batchId]);
|
||||||
|
if (!batch.length || batch[0].status !== 'pending') return false;
|
||||||
|
|
||||||
|
// 포함된 요청 복원
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET batch_id = NULL, status = 'pending' WHERE batch_id = ?`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
await db.query('DELETE FROM purchase_batches WHERE batch_id = ?', [batchId]);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async markPurchased(batchId, purchasedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_batches SET status = 'purchased', purchased_at = NOW(), purchased_by = ?
|
||||||
|
WHERE batch_id = ? AND status = 'pending'`,
|
||||||
|
[purchasedBy, batchId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async markReceived(batchId, receivedBy) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_batches SET status = 'received', received_at = NOW(), received_by = ?
|
||||||
|
WHERE batch_id = ? AND status = 'purchased'`,
|
||||||
|
[receivedBy, batchId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch에 요청 추가 (검증: pending이고 다른 batch에 속하지 않음)
|
||||||
|
async addRequests(batchId, requestIds) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [existing] = await db.query(
|
||||||
|
`SELECT request_id, batch_id, status FROM purchase_requests WHERE request_id IN (?)`,
|
||||||
|
[requestIds]
|
||||||
|
);
|
||||||
|
const invalid = existing.filter(r => r.status !== 'pending' || r.batch_id !== null);
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
const ids = invalid.map(r => r.request_id);
|
||||||
|
throw new Error(`다음 요청은 추가할 수 없습니다 (이미 그룹 소속이거나 대기 상태가 아님): ${ids.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurchaseRequestModel = require('./purchaseRequestModel');
|
||||||
|
await PurchaseRequestModel.groupIntoBatch(requestIds, batchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch에서 요청 제거
|
||||||
|
async removeRequests(batchId, requestIds) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [batch] = await db.query('SELECT status FROM purchase_batches WHERE batch_id = ?', [batchId]);
|
||||||
|
if (!batch.length || batch[0].status !== 'pending') {
|
||||||
|
throw new Error('진행중인 그룹에서만 요청을 제거할 수 있습니다.');
|
||||||
|
}
|
||||||
|
const PurchaseRequestModel = require('./purchaseRequestModel');
|
||||||
|
await PurchaseRequestModel.removeFromBatch(requestIds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = PurchaseBatchModel;
|
||||||
@@ -2,17 +2,19 @@
|
|||||||
const { getDb } = require('../dbPool');
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
const PurchaseRequestModel = {
|
const PurchaseRequestModel = {
|
||||||
// 구매신청 목록 (소모품 정보 LEFT JOIN — item_id NULL 허용)
|
// 구매신청 목록 (소모품 정보 LEFT JOIN — item_id NULL 허용, batch 정보 포함)
|
||||||
async getAll(filters = {}) {
|
async getAll(filters = {}) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
||||||
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
||||||
pr.custom_item_name, pr.custom_category,
|
pr.custom_item_name, pr.custom_category,
|
||||||
su.name AS requester_name
|
su.name AS requester_name,
|
||||||
|
pb.batch_name, pb.status AS batch_status, pb.category AS batch_category
|
||||||
FROM purchase_requests pr
|
FROM purchase_requests pr
|
||||||
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
||||||
|
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -25,23 +27,28 @@ const PurchaseRequestModel = {
|
|||||||
}
|
}
|
||||||
if (filters.from_date) { sql += ' AND pr.request_date >= ?'; params.push(filters.from_date); }
|
if (filters.from_date) { sql += ' AND pr.request_date >= ?'; params.push(filters.from_date); }
|
||||||
if (filters.to_date) { sql += ' AND pr.request_date <= ?'; params.push(filters.to_date); }
|
if (filters.to_date) { sql += ' AND pr.request_date <= ?'; params.push(filters.to_date); }
|
||||||
|
if (filters.batch_id) { sql += ' AND pr.batch_id = ?'; params.push(filters.batch_id); }
|
||||||
|
|
||||||
sql += ' ORDER BY pr.created_at DESC';
|
sql += ' ORDER BY pr.created_at DESC';
|
||||||
const [rows] = await db.query(sql, params);
|
const [rows] = await db.query(sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 단건 조회
|
// 단건 조회 (batch 정보 포함)
|
||||||
async getById(requestId) {
|
async getById(requestId) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
||||||
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
||||||
pr.custom_item_name, pr.custom_category,
|
pr.custom_item_name, pr.custom_category,
|
||||||
su.name AS requester_name
|
su.name AS requester_name,
|
||||||
|
pb.batch_name, pb.status AS batch_status, pb.category AS batch_category,
|
||||||
|
rsu.name AS received_by_name
|
||||||
FROM purchase_requests pr
|
FROM purchase_requests pr
|
||||||
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
||||||
|
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
|
||||||
|
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
|
||||||
WHERE pr.request_id = ?
|
WHERE pr.request_id = ?
|
||||||
`, [requestId]);
|
`, [requestId]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
@@ -105,6 +112,164 @@ const PurchaseRequestModel = {
|
|||||||
[requestId]
|
[requestId]
|
||||||
);
|
);
|
||||||
return result.affectedRows > 0;
|
return result.affectedRows > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 내 신청 목록 (모바일용, 페이지네이션)
|
||||||
|
async getMyRequests(userId, { page = 1, limit = 20, status } = {}) {
|
||||||
|
const db = await getDb();
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
let where = 'WHERE pr.requester_id = ?';
|
||||||
|
const params = [userId];
|
||||||
|
|
||||||
|
if (status) { where += ' AND pr.status = ?'; params.push(status); }
|
||||||
|
|
||||||
|
const [[{ total }]] = await db.query(
|
||||||
|
`SELECT COUNT(*) AS total FROM purchase_requests pr ${where}`, params
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT pr.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price, ci.unit,
|
||||||
|
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
|
||||||
|
pr.custom_item_name, pr.custom_category,
|
||||||
|
pb.batch_name, pb.status AS batch_status,
|
||||||
|
rsu.name AS received_by_name
|
||||||
|
FROM purchase_requests pr
|
||||||
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
|
LEFT JOIN purchase_batches pb ON pr.batch_id = pb.batch_id
|
||||||
|
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
|
||||||
|
${where}
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows,
|
||||||
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch에 요청 그룹화 (status → grouped)
|
||||||
|
async groupIntoBatch(requestIds, batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET batch_id = ?, status = 'grouped'
|
||||||
|
WHERE request_id IN (?) AND status = 'pending' AND batch_id IS NULL`,
|
||||||
|
[batchId, requestIds]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch에서 제거 (status → pending 복원)
|
||||||
|
async removeFromBatch(requestIds) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET batch_id = NULL, status = 'pending'
|
||||||
|
WHERE request_id IN (?) AND status = 'grouped'`,
|
||||||
|
[requestIds]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch 내 전체 요청 purchased 전환
|
||||||
|
async markBatchPurchased(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET status = 'purchased' WHERE batch_id = ? AND status = 'grouped'`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 개별 입고 처리
|
||||||
|
async receive(requestId, { receivedPhotoPath, receivedLocation, receivedBy }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'received', received_photo_path = ?, received_location = ?,
|
||||||
|
received_at = NOW(), received_by = ?
|
||||||
|
WHERE request_id = ? AND status = 'purchased'`,
|
||||||
|
[receivedPhotoPath || null, receivedLocation || null, receivedBy, requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch 내 전체 입고 처리
|
||||||
|
async receiveBatch(batchId, { receivedPhotoPath, receivedLocation, receivedBy }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'received', received_photo_path = ?, received_location = ?,
|
||||||
|
received_at = NOW(), received_by = ?
|
||||||
|
WHERE batch_id = ? AND status = 'purchased'`,
|
||||||
|
[receivedPhotoPath || null, receivedLocation || null, receivedBy, batchId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch 내 모든 요청이 received인지 확인
|
||||||
|
async checkBatchAllReceived(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [[{ total, received }]] = await db.query(
|
||||||
|
`SELECT COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) AS received
|
||||||
|
FROM purchase_requests WHERE batch_id = ?`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
return total > 0 && total === received;
|
||||||
|
},
|
||||||
|
|
||||||
|
// grouped 상태에서 hold (batch에서 자동 제거)
|
||||||
|
async holdFromGrouped(requestId, holdReason) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests SET status = 'hold', hold_reason = ?, batch_id = NULL
|
||||||
|
WHERE request_id = ? AND status = 'grouped'`,
|
||||||
|
[holdReason || null, requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// batch 내 신청자 ID 목록 조회
|
||||||
|
async getRequesterIdsByBatch(batchId) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
`SELECT DISTINCT requester_id FROM purchase_requests WHERE batch_id = ?`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
return rows.map(r => r.requester_id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 구매 취소 (purchased → pending 복원, batch에서도 제거)
|
||||||
|
async cancelPurchase(requestId, { cancelledBy, cancelReason }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'cancelled', cancelled_at = NOW(), cancelled_by = ?, cancel_reason = ?,
|
||||||
|
batch_id = NULL
|
||||||
|
WHERE request_id = ? AND status = 'purchased'`,
|
||||||
|
[cancelledBy, cancelReason || null, requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 반품 (received → returned)
|
||||||
|
async returnItem(requestId, { cancelledBy, cancelReason }) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'returned', cancelled_at = NOW(), cancelled_by = ?, cancel_reason = ?
|
||||||
|
WHERE request_id = ? AND status = 'received'`,
|
||||||
|
[cancelledBy, cancelReason || null, requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 취소/반품에서 원래 상태로 되돌리기
|
||||||
|
async revertCancel(requestId) {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.query(
|
||||||
|
`UPDATE purchase_requests
|
||||||
|
SET status = 'pending', cancelled_at = NULL, cancelled_by = NULL, cancel_reason = NULL
|
||||||
|
WHERE request_id = ? AND status = 'cancelled'`,
|
||||||
|
[requestId]
|
||||||
|
);
|
||||||
|
return this.getById(requestId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,47 @@ const SettlementModel = {
|
|||||||
return { year_month: yearMonth, vendor_id: vendorId, status: 'pending' };
|
return { year_month: yearMonth, vendor_id: vendorId, status: 'pending' };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 입고일 기준 월간 분류별 요약
|
||||||
|
async getCategorySummaryByReceived(yearMonth) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT ci.category,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
SUM(pr.quantity * COALESCE(p.unit_price, 0)) AS total_amount
|
||||||
|
FROM purchase_requests pr
|
||||||
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
|
LEFT JOIN purchases p ON p.request_id = pr.request_id
|
||||||
|
WHERE pr.status = 'received'
|
||||||
|
AND DATE_FORMAT(pr.received_at, '%Y-%m') = ?
|
||||||
|
GROUP BY ci.category
|
||||||
|
`, [yearMonth]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 입고일 기준 월간 상세 목록
|
||||||
|
async getMonthlyReceived(yearMonth) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT pr.request_id, pr.quantity, pr.received_at, pr.received_location,
|
||||||
|
pr.received_photo_path, pr.status, pr.notes,
|
||||||
|
ci.item_name, ci.spec, ci.maker, ci.category, ci.unit, ci.base_price,
|
||||||
|
p.unit_price, p.purchase_date, p.vendor_id,
|
||||||
|
v.vendor_name,
|
||||||
|
su.name AS requester_name,
|
||||||
|
rsu.name AS received_by_name
|
||||||
|
FROM purchase_requests pr
|
||||||
|
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
|
||||||
|
LEFT JOIN purchases p ON p.request_id = pr.request_id
|
||||||
|
LEFT JOIN vendors v ON p.vendor_id = v.vendor_id
|
||||||
|
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
|
||||||
|
LEFT JOIN sso_users rsu ON pr.received_by = rsu.user_id
|
||||||
|
WHERE pr.status IN ('received', 'returned')
|
||||||
|
AND DATE_FORMAT(pr.received_at, '%Y-%m') = ?
|
||||||
|
ORDER BY pr.received_at DESC
|
||||||
|
`, [yearMonth]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
// 가격 변동 목록 (월간)
|
// 가격 변동 목록 (월간)
|
||||||
async getPriceChanges(yearMonth) {
|
async getPriceChanges(yearMonth) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ const vacationBalanceModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vbd.*,
|
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
|
||||||
vt.type_name,
|
svb.total_days, svb.used_days,
|
||||||
vt.type_code,
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
vt.priority,
|
svb.balance_type, svb.expires_at, svb.notes,
|
||||||
vt.is_special
|
svb.created_by, svb.created_at, svb.updated_at,
|
||||||
FROM vacation_balance_details vbd
|
vt.type_name, vt.type_code, vt.priority, vt.is_special
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
FROM sp_vacation_balances svb
|
||||||
WHERE vbd.user_id = ? AND vbd.year = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
|
WHERE svb.user_id = ? AND svb.year = ?
|
||||||
ORDER BY vt.priority ASC, vt.type_name ASC
|
ORDER BY vt.priority ASC, vt.type_name ASC
|
||||||
`, [userId, year]);
|
`, [userId, year]);
|
||||||
return rows;
|
return rows;
|
||||||
@@ -33,14 +34,16 @@ const vacationBalanceModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vbd.*,
|
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
|
||||||
vt.type_name,
|
svb.total_days, svb.used_days,
|
||||||
vt.type_code
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
FROM vacation_balance_details vbd
|
svb.balance_type, svb.expires_at,
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
vt.type_name, vt.type_code
|
||||||
WHERE vbd.user_id = ?
|
FROM sp_vacation_balances svb
|
||||||
AND vbd.vacation_type_id = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
AND vbd.year = ?
|
WHERE svb.user_id = ?
|
||||||
|
AND svb.vacation_type_id = ?
|
||||||
|
AND svb.year = ?
|
||||||
`, [userId, vacationTypeId, year]);
|
`, [userId, vacationTypeId, year]);
|
||||||
return rows;
|
return rows;
|
||||||
},
|
},
|
||||||
@@ -52,16 +55,17 @@ const vacationBalanceModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vbd.*,
|
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
|
||||||
w.worker_name,
|
svb.total_days, svb.used_days,
|
||||||
w.employment_status,
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
vt.type_name,
|
svb.balance_type, svb.expires_at, svb.notes,
|
||||||
vt.type_code,
|
svb.created_by, svb.created_at, svb.updated_at,
|
||||||
vt.priority
|
w.worker_name, w.employment_status,
|
||||||
FROM vacation_balance_details vbd
|
vt.type_name, vt.type_code, vt.priority
|
||||||
INNER JOIN workers w ON vbd.user_id = w.user_id
|
FROM sp_vacation_balances svb
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
INNER JOIN workers w ON svb.user_id = w.user_id
|
||||||
WHERE vbd.year = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
|
WHERE svb.year = ?
|
||||||
AND w.employment_status = 'employed'
|
AND w.employment_status = 'employed'
|
||||||
ORDER BY w.worker_name ASC, vt.priority ASC
|
ORDER BY w.worker_name ASC, vt.priority ASC
|
||||||
`, [year]);
|
`, [year]);
|
||||||
@@ -73,7 +77,7 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async create(balanceData) {
|
async create(balanceData) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`INSERT INTO vacation_balance_details SET ?`, balanceData);
|
const [result] = await db.query(`INSERT INTO sp_vacation_balances SET ?`, balanceData);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -82,7 +86,7 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async update(id, updateData) {
|
async update(id, updateData) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`UPDATE vacation_balance_details SET ? WHERE id = ?`, [updateData, id]);
|
const [result] = await db.query(`UPDATE sp_vacation_balances SET ? WHERE id = ?`, [updateData, id]);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -91,7 +95,7 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`DELETE FROM vacation_balance_details WHERE id = ?`, [id]);
|
const [result] = await db.query(`DELETE FROM sp_vacation_balances WHERE id = ?`, [id]);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ const vacationBalanceModel = {
|
|||||||
async deductDays(userId, vacationTypeId, year, daysToDeduct) {
|
async deductDays(userId, vacationTypeId, year, daysToDeduct) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`
|
const [result] = await db.query(`
|
||||||
UPDATE vacation_balance_details
|
UPDATE sp_vacation_balances
|
||||||
SET used_days = used_days + ?,
|
SET used_days = used_days + ?,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -117,7 +121,7 @@ const vacationBalanceModel = {
|
|||||||
async restoreDays(userId, vacationTypeId, year, daysToRestore) {
|
async restoreDays(userId, vacationTypeId, year, daysToRestore) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [result] = await db.query(`
|
const [result] = await db.query(`
|
||||||
UPDATE vacation_balance_details
|
UPDATE sp_vacation_balances
|
||||||
SET used_days = GREATEST(0, used_days - ?),
|
SET used_days = GREATEST(0, used_days - ?),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -134,20 +138,21 @@ const vacationBalanceModel = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vbd.id,
|
svb.id,
|
||||||
vbd.vacation_type_id,
|
svb.vacation_type_id,
|
||||||
vt.type_name,
|
vt.type_name,
|
||||||
vt.type_code,
|
vt.type_code,
|
||||||
vt.priority,
|
vt.priority,
|
||||||
vbd.total_days,
|
svb.total_days,
|
||||||
vbd.used_days,
|
svb.used_days,
|
||||||
vbd.remaining_days
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
FROM vacation_balance_details vbd
|
svb.balance_type
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
FROM sp_vacation_balances svb
|
||||||
WHERE vbd.user_id = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
AND vbd.year = ?
|
WHERE svb.user_id = ?
|
||||||
AND vbd.remaining_days > 0
|
AND svb.year = ?
|
||||||
ORDER BY vt.priority ASC
|
AND (svb.total_days - svb.used_days) > 0
|
||||||
|
ORDER BY vt.priority ASC, FIELD(svb.balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
|
||||||
`, [userId, year]);
|
`, [userId, year]);
|
||||||
return rows;
|
return rows;
|
||||||
},
|
},
|
||||||
@@ -161,8 +166,8 @@ const vacationBalanceModel = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const query = `INSERT INTO vacation_balance_details
|
const query = `INSERT INTO sp_vacation_balances
|
||||||
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type)
|
||||||
VALUES ?`;
|
VALUES ?`;
|
||||||
|
|
||||||
const values = balances.map(b => [
|
const values = balances.map(b => [
|
||||||
@@ -172,7 +177,8 @@ const vacationBalanceModel = {
|
|||||||
b.total_days || 0,
|
b.total_days || 0,
|
||||||
b.used_days || 0,
|
b.used_days || 0,
|
||||||
b.notes || null,
|
b.notes || null,
|
||||||
b.created_by
|
b.created_by,
|
||||||
|
b.balance_type || 'AUTO'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [result] = await db.query(query, [values]);
|
const [result] = await db.query(query, [values]);
|
||||||
@@ -204,52 +210,57 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async deductByPriority(userId, year, daysToDeduct) {
|
async deductByPriority(userId, year, daysToDeduct) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
const [balances] = await db.query(`
|
const [balances] = await conn.query(`
|
||||||
SELECT vbd.id, vbd.vacation_type_id, vbd.total_days, vbd.used_days,
|
SELECT svb.id, svb.vacation_type_id, svb.total_days, svb.used_days,
|
||||||
(vbd.total_days - vbd.used_days) as remaining_days,
|
(svb.total_days - svb.used_days) AS remaining_days,
|
||||||
vt.type_code, vt.type_name, vt.priority
|
svb.balance_type,
|
||||||
FROM vacation_balance_details vbd
|
vt.type_code, vt.type_name, vt.priority
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
FROM sp_vacation_balances svb
|
||||||
WHERE vbd.user_id = ? AND vbd.year = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
AND (vbd.total_days - vbd.used_days) > 0
|
WHERE svb.user_id = ? AND svb.year = ?
|
||||||
ORDER BY vt.priority ASC
|
AND (svb.total_days - svb.used_days) > 0
|
||||||
`, [userId, year]);
|
AND (svb.expires_at IS NULL OR svb.expires_at >= CURDATE())
|
||||||
|
ORDER BY vt.priority ASC, FIELD(svb.balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
|
||||||
|
FOR UPDATE
|
||||||
|
`, [userId, year]);
|
||||||
|
|
||||||
if (balances.length === 0) {
|
if (balances.length === 0) {
|
||||||
console.warn(`[VacationBalance] 작업자 ${userId}의 ${year}년 휴가 잔액이 없습니다`);
|
await conn.rollback();
|
||||||
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
|
console.warn(`[VacationBalance] 작업자 ${userId}의 ${year}년 휴가 잔액이 없습니다`);
|
||||||
}
|
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
|
||||||
|
|
||||||
let remaining = daysToDeduct;
|
|
||||||
const deductions = [];
|
|
||||||
|
|
||||||
for (const balance of balances) {
|
|
||||||
if (remaining <= 0) break;
|
|
||||||
|
|
||||||
const available = parseFloat(balance.remaining_days);
|
|
||||||
const toDeduct = Math.min(remaining, available);
|
|
||||||
|
|
||||||
if (toDeduct > 0) {
|
|
||||||
await db.query(`
|
|
||||||
UPDATE vacation_balance_details
|
|
||||||
SET used_days = used_days + ?, updated_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
`, [toDeduct, balance.id]);
|
|
||||||
|
|
||||||
deductions.push({
|
|
||||||
balance_id: balance.id,
|
|
||||||
type_code: balance.type_code,
|
|
||||||
type_name: balance.type_name,
|
|
||||||
deducted: toDeduct
|
|
||||||
});
|
|
||||||
|
|
||||||
remaining -= toDeduct;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToDeduct}일 차감 완료`, deductions);
|
let remaining = daysToDeduct;
|
||||||
return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
|
const deductions = [];
|
||||||
|
|
||||||
|
for (const balance of balances) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
const available = parseFloat(balance.remaining_days);
|
||||||
|
const toDeduct = Math.min(remaining, available);
|
||||||
|
if (toDeduct > 0) {
|
||||||
|
await conn.query(`
|
||||||
|
UPDATE sp_vacation_balances
|
||||||
|
SET used_days = used_days + ?, updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
`, [toDeduct, balance.id]);
|
||||||
|
deductions.push({ balance_id: balance.id, type_code: balance.type_code, type_name: balance.type_name, balance_type: balance.balance_type, deducted: toDeduct });
|
||||||
|
remaining -= toDeduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToDeduct}일 차감 완료`, deductions);
|
||||||
|
return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,46 +268,49 @@ const vacationBalanceModel = {
|
|||||||
*/
|
*/
|
||||||
async restoreByPriority(userId, year, daysToRestore) {
|
async restoreByPriority(userId, year, daysToRestore) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
const [balances] = await db.query(`
|
const [balances] = await conn.query(`
|
||||||
SELECT vbd.id, vbd.vacation_type_id, vbd.used_days,
|
SELECT svb.id, svb.vacation_type_id, svb.used_days,
|
||||||
vt.type_code, vt.type_name, vt.priority
|
svb.balance_type,
|
||||||
FROM vacation_balance_details vbd
|
vt.type_code, vt.type_name, vt.priority
|
||||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
FROM sp_vacation_balances svb
|
||||||
WHERE vbd.user_id = ? AND vbd.year = ?
|
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
AND vbd.used_days > 0
|
WHERE svb.user_id = ? AND svb.year = ?
|
||||||
ORDER BY vt.priority DESC
|
AND svb.used_days > 0
|
||||||
`, [userId, year]);
|
ORDER BY vt.priority DESC, FIELD(svb.balance_type, 'COMPANY_GRANT', 'LONG_SERVICE', 'MANUAL', 'AUTO', 'CARRY_OVER')
|
||||||
|
FOR UPDATE
|
||||||
|
`, [userId, year]);
|
||||||
|
|
||||||
let remaining = daysToRestore;
|
let remaining = daysToRestore;
|
||||||
const restorations = [];
|
const restorations = [];
|
||||||
|
|
||||||
for (const balance of balances) {
|
for (const balance of balances) {
|
||||||
if (remaining <= 0) break;
|
if (remaining <= 0) break;
|
||||||
|
const usedDays = parseFloat(balance.used_days);
|
||||||
const usedDays = parseFloat(balance.used_days);
|
const toRestore = Math.min(remaining, usedDays);
|
||||||
const toRestore = Math.min(remaining, usedDays);
|
if (toRestore > 0) {
|
||||||
|
await conn.query(`
|
||||||
if (toRestore > 0) {
|
UPDATE sp_vacation_balances
|
||||||
await db.query(`
|
SET used_days = used_days - ?, updated_at = NOW()
|
||||||
UPDATE vacation_balance_details
|
WHERE id = ?
|
||||||
SET used_days = used_days - ?, updated_at = NOW()
|
`, [toRestore, balance.id]);
|
||||||
WHERE id = ?
|
restorations.push({ balance_id: balance.id, type_code: balance.type_code, type_name: balance.type_name, balance_type: balance.balance_type, restored: toRestore });
|
||||||
`, [toRestore, balance.id]);
|
remaining -= toRestore;
|
||||||
|
}
|
||||||
restorations.push({
|
|
||||||
balance_id: balance.id,
|
|
||||||
type_code: balance.type_code,
|
|
||||||
type_name: balance.type_name,
|
|
||||||
restored: toRestore
|
|
||||||
});
|
|
||||||
|
|
||||||
remaining -= toRestore;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToRestore}일 복구 완료`, restorations);
|
await conn.commit();
|
||||||
return { success: true, restorations, totalRestored: daysToRestore - remaining };
|
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToRestore}일 복구 완료`, restorations);
|
||||||
|
return { success: true, restorations, totalRestored: daysToRestore - remaining };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
"express-validator": "^7.2.1",
|
"express-validator": "^7.2.1",
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ function setupRoutes(app) {
|
|||||||
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
|
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
|
||||||
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
||||||
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||||
|
app.use('/api/proxy-input', require('./routes/proxyInputRoutes')); // 대리입력 + 일별현황
|
||||||
|
app.use('/api/monthly-comparison', require('./routes/monthlyComparisonRoutes')); // 월간 비교·확인·정산
|
||||||
|
app.use('/api/dashboard', require('./routes/dashboardRoutes')); // 대시보드 개인 요약
|
||||||
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
||||||
app.use('/api/departments', departmentRoutes); // 부서 관리
|
app.use('/api/departments', departmentRoutes); // 부서 관리
|
||||||
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
||||||
|
|||||||
13
system1-factory/api/routes/consumableCategoryRoutes.js
Normal file
13
system1-factory/api/routes/consumableCategoryRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/consumableCategoryController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
router.get('/', ctrl.getAll);
|
||||||
|
router.post('/', requirePage('factory_purchases'), ctrl.create);
|
||||||
|
router.put('/:id', requirePage('factory_purchases'), ctrl.update);
|
||||||
|
router.put('/:id/deactivate', requirePage('factory_purchases'), ctrl.deactivate);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
13
system1-factory/api/routes/dashboardRoutes.js
Normal file
13
system1-factory/api/routes/dashboardRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 대시보드 라우터
|
||||||
|
* Sprint 003 — 개인 요약 API
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const dashboardController = require('../controllers/dashboardController');
|
||||||
|
const { verifyToken } = require('../middlewares/auth');
|
||||||
|
|
||||||
|
// 모든 인증된 사용자 접근 가능
|
||||||
|
router.get('/my-summary', verifyToken, dashboardController.getMySummary);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const departmentController = require('../controllers/departmentController');
|
const departmentController = require('../controllers/departmentController');
|
||||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 부서 목록 조회 (인증 필요)
|
// 부서 목록 조회 (인증 필요)
|
||||||
router.get('/', requireAuth, departmentController.getAll);
|
router.get('/', requireAuth, departmentController.getAll);
|
||||||
@@ -14,18 +17,18 @@ router.get('/:id', requireAuth, departmentController.getById);
|
|||||||
router.get('/:id/workers', requireAuth, departmentController.getWorkers);
|
router.get('/:id/workers', requireAuth, departmentController.getWorkers);
|
||||||
|
|
||||||
// 부서 생성 (관리자만)
|
// 부서 생성 (관리자만)
|
||||||
router.post('/', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.create);
|
router.post('/', requireAuth, requirePage('factory_departments'), departmentController.create);
|
||||||
|
|
||||||
// 부서 수정 (관리자만)
|
// 부서 수정 (관리자만)
|
||||||
router.put('/:id', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.update);
|
router.put('/:id', requireAuth, requirePage('factory_departments'), departmentController.update);
|
||||||
|
|
||||||
// 부서 삭제 (관리자만)
|
// 부서 삭제 (관리자만)
|
||||||
router.delete('/:id', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.delete);
|
router.delete('/:id', requireAuth, requirePage('factory_departments'), departmentController.delete);
|
||||||
|
|
||||||
// 작업자 부서 이동 (관리자만)
|
// 작업자 부서 이동 (관리자만)
|
||||||
router.post('/move-worker', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.moveWorker);
|
router.post('/move-worker', requireAuth, requirePage('factory_departments'), departmentController.moveWorker);
|
||||||
|
|
||||||
// 여러 작업자 부서 일괄 이동 (관리자만)
|
// 여러 작업자 부서 일괄 이동 (관리자만)
|
||||||
router.post('/move-workers', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.moveWorkers);
|
router.post('/move-workers', requireAuth, requirePage('factory_departments'), departmentController.moveWorkers);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
12
system1-factory/api/routes/itemAliasRoutes.js
Normal file
12
system1-factory/api/routes/itemAliasRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/itemAliasController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
router.get('/', requirePage('factory_purchases'), ctrl.getAll);
|
||||||
|
router.post('/', requirePage('factory_purchases'), ctrl.create);
|
||||||
|
router.delete('/:id', requirePage('factory_purchases'), ctrl.delete);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/meetingController');
|
const ctrl = require('../controllers/meetingController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 회의록
|
// 회의록
|
||||||
router.get('/', ctrl.getAll);
|
router.get('/', ctrl.getAll);
|
||||||
router.get('/action-items', ctrl.getActionItems);
|
router.get('/action-items', ctrl.getActionItems);
|
||||||
router.get('/:id', ctrl.getById);
|
router.get('/:id', ctrl.getById);
|
||||||
router.post('/', requireMinLevel('support_team'), ctrl.create);
|
router.post('/', requirePage('factory_meetings'), ctrl.create);
|
||||||
router.put('/:id', requireMinLevel('support_team'), ctrl.update);
|
router.put('/:id', requirePage('factory_meetings'), ctrl.update);
|
||||||
router.put('/:id/publish', requireMinLevel('support_team'), ctrl.publish);
|
router.put('/:id/publish', requirePage('factory_meetings'), ctrl.publish);
|
||||||
router.put('/:id/unpublish', requireMinLevel('admin'), ctrl.unpublish);
|
router.put('/:id/unpublish', requirePage('factory_meetings'), ctrl.unpublish);
|
||||||
router.delete('/:id', requireMinLevel('admin'), ctrl.delete);
|
router.delete('/:id', requirePage('factory_meetings'), ctrl.delete);
|
||||||
|
|
||||||
// 안건
|
// 안건
|
||||||
router.post('/:id/items', requireMinLevel('support_team'), ctrl.addItem);
|
router.post('/:id/items', requirePage('factory_meetings'), ctrl.addItem);
|
||||||
router.put('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.updateItem);
|
router.put('/:id/items/:itemId', requirePage('factory_meetings'), ctrl.updateItem);
|
||||||
router.delete('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.deleteItem);
|
router.delete('/:id/items/:itemId', requirePage('factory_meetings'), ctrl.deleteItem);
|
||||||
|
|
||||||
// 조치상태 업데이트
|
// 조치상태 업데이트
|
||||||
router.put('/items/:itemId/status', requireMinLevel('group_leader'), ctrl.updateItemStatus);
|
router.put('/items/:itemId/status', requirePage('factory_meetings'), ctrl.updateItemStatus);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
41
system1-factory/api/routes/monthlyComparisonRoutes.js
Normal file
41
system1-factory/api/routes/monthlyComparisonRoutes.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/monthlyComparisonController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
|
||||||
|
function requireSupportTeam(req, res, next) {
|
||||||
|
const role = (req.user?.role || '').toLowerCase();
|
||||||
|
if (!ADMIN_ROLES.includes(role)) {
|
||||||
|
return res.status(403).json({ success: false, message: '지원팀 이상 권한이 필요합니다.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본인 월간 비교
|
||||||
|
router.get('/my-records', ctrl.getMyRecords);
|
||||||
|
|
||||||
|
// 특정 작업자 비교 (내부에서 권한 체크)
|
||||||
|
router.get('/records', ctrl.getRecords);
|
||||||
|
|
||||||
|
// 확인/반려
|
||||||
|
router.post('/confirm', ctrl.confirm);
|
||||||
|
|
||||||
|
// 관리자: 확인요청 발송 (pending → review_sent)
|
||||||
|
router.post('/review-send', requireSupportTeam, ctrl.reviewSend);
|
||||||
|
|
||||||
|
// 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected)
|
||||||
|
router.post('/review-respond', requireSupportTeam, ctrl.reviewRespond);
|
||||||
|
|
||||||
|
// 관리자: 개별 검토 태깅
|
||||||
|
router.post('/admin-check', requireSupportTeam, ctrl.adminCheck);
|
||||||
|
|
||||||
|
// 전체 현황 (support_team+)
|
||||||
|
router.get('/all-status', requireSupportTeam, ctrl.getAllStatus);
|
||||||
|
|
||||||
|
// 엑셀 다운로드 (support_team+)
|
||||||
|
router.get('/export', requireSupportTeam, ctrl.exportExcel);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3,6 +3,11 @@ const router = express.Router();
|
|||||||
const { getDb } = require('../dbPool');
|
const { getDb } = require('../dbPool');
|
||||||
const { requireAuth, requireAdmin } = require('../middlewares/auth');
|
const { requireAuth, requireAdmin } = require('../middlewares/auth');
|
||||||
|
|
||||||
|
// Admin 역할 확인 헬퍼
|
||||||
|
function isAdminRole(role) {
|
||||||
|
return ['admin', 'system'].includes((role || '').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모든 페이지 목록 조회
|
* 모든 페이지 목록 조회
|
||||||
* GET /api/pages
|
* GET /api/pages
|
||||||
@@ -11,7 +16,7 @@ router.get('/pages', requireAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [pages] = await db.query(`
|
const [pages] = await db.query(`
|
||||||
SELECT id, page_key, page_name, page_path, category, description, is_admin_only, display_order
|
SELECT id, page_key, page_name, page_path, category, description, display_order
|
||||||
FROM pages
|
FROM pages
|
||||||
ORDER BY display_order, page_name
|
ORDER BY display_order, page_name
|
||||||
`);
|
`);
|
||||||
@@ -19,7 +24,7 @@ router.get('/pages', requireAuth, async (req, res) => {
|
|||||||
res.json({ success: true, data: pages });
|
res.json({ success: true, data: pages });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 목록 조회 오류:', error);
|
console.error('페이지 목록 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 목록을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 목록을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,24 +37,21 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
// 사용자의 역할 확인
|
// 사용자 조회 (sso_users)
|
||||||
const [userRows] = await db.query(`
|
const [userRows] = await db.query(`
|
||||||
SELECT u.user_id, u.username, u.role_id, r.name as role_name
|
SELECT user_id, name, role FROM sso_users WHERE user_id = ?
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
`, [userId]);
|
`, [userId]);
|
||||||
|
|
||||||
if (userRows.length === 0) {
|
if (userRows.length === 0) {
|
||||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
|
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userRows[0];
|
const user = userRows[0];
|
||||||
|
|
||||||
// Admin/System Admin인 경우 모든 페이지 접근 가능
|
// Admin인 경우 모든 페이지 접근 가능
|
||||||
if (user.role_name === 'Admin' || user.role_name === 'System Admin') {
|
if (isAdminRole(user.role)) {
|
||||||
const [allPages] = await db.query(`
|
const [allPages] = await db.query(`
|
||||||
SELECT id, page_key, page_name, page_path, category, is_admin_only
|
SELECT id, page_key, page_name, page_path, category
|
||||||
FROM pages
|
FROM pages
|
||||||
ORDER BY display_order, page_name
|
ORDER BY display_order, page_name
|
||||||
`);
|
`);
|
||||||
@@ -60,15 +62,24 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
page_name: page.page_name,
|
page_name: page.page_name,
|
||||||
page_path: page.page_path,
|
page_path: page.page_path,
|
||||||
category: page.category,
|
category: page.category,
|
||||||
is_admin_only: page.is_admin_only,
|
|
||||||
can_access: true,
|
can_access: true,
|
||||||
is_default: true // Admin은 기본적으로 모든 권한 보유
|
is_default: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json({ success: true, data: { user, pageAccess } });
|
return res.json({ success: true, data: { user, pageAccess } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자의 부서 조회 (workers 우선, 없으면 sso_users fallback)
|
||||||
|
const [workerRows] = await db.query(`
|
||||||
|
SELECT COALESCE(w.department_id, su2.department_id, 0) AS department_id
|
||||||
|
FROM sso_users su2
|
||||||
|
LEFT JOIN workers w ON su2.user_id = w.user_id
|
||||||
|
WHERE su2.user_id = ?
|
||||||
|
`, [userId]);
|
||||||
|
const departmentId = workerRows[0]?.department_id || 0;
|
||||||
|
|
||||||
// 일반 사용자의 페이지 접근 권한 조회
|
// 일반 사용자의 페이지 접근 권한 조회
|
||||||
|
// department_page_permissions.page_name은 's1.' 접두사 사용, pages.page_key는 접두사 없음
|
||||||
const [pageAccess] = await db.query(`
|
const [pageAccess] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.id as page_id,
|
p.id as page_id,
|
||||||
@@ -76,21 +87,22 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
p.page_name,
|
p.page_name,
|
||||||
p.page_path,
|
p.page_path,
|
||||||
p.category,
|
p.category,
|
||||||
p.is_admin_only,
|
COALESCE(upp.can_access, dpp.can_access, p.is_default_accessible, 0) as can_access,
|
||||||
COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access,
|
upp.granted_at
|
||||||
upa.granted_at,
|
|
||||||
u2.username as granted_by_username
|
|
||||||
FROM pages p
|
FROM pages p
|
||||||
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
|
LEFT JOIN user_page_permissions upp
|
||||||
LEFT JOIN users u2 ON upa.granted_by = u2.user_id
|
ON upp.user_id = ?
|
||||||
WHERE p.is_admin_only = 0
|
AND (upp.page_name COLLATE utf8mb4_general_ci = CONCAT('s1.', p.page_key) OR upp.page_name COLLATE utf8mb4_general_ci = p.page_key)
|
||||||
|
LEFT JOIN department_page_permissions dpp
|
||||||
|
ON dpp.department_id = ?
|
||||||
|
AND (dpp.page_name COLLATE utf8mb4_general_ci = CONCAT('s1.', p.page_key) OR dpp.page_name COLLATE utf8mb4_general_ci = p.page_key)
|
||||||
ORDER BY p.display_order, p.page_name
|
ORDER BY p.display_order, p.page_name
|
||||||
`, [userId]);
|
`, [userId, departmentId]);
|
||||||
|
|
||||||
res.json({ success: true, data: { user, pageAccess } });
|
res.json({ success: true, data: { user, pageAccess } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 조회 오류:', error);
|
console.error('페이지 접근 권한 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,56 +113,35 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId } = req.params;
|
if (!isAdminRole(req.user.role)) {
|
||||||
const { pageIds, canAccess } = req.body;
|
return res.status(403).json({ success: false, message: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
|
||||||
const adminUserId = req.user.user_id; // 권한을 부여하는 Admin의 user_id
|
|
||||||
|
|
||||||
// Admin 권한 확인
|
|
||||||
const db = await getDb();
|
|
||||||
const [adminRows] = await db.query(`
|
|
||||||
SELECT u.role_id, r.name as role_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
`, [adminUserId]);
|
|
||||||
|
|
||||||
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
|
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { pageIds, canAccess } = req.body;
|
||||||
|
const adminUserId = req.user.user_id;
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
// 사용자 존재 확인
|
// 사용자 존재 확인
|
||||||
const [userRows] = await db.query('SELECT user_id FROM users WHERE user_id = ?', [userId]);
|
const [userRows] = await db.query('SELECT user_id FROM sso_users WHERE user_id = ?', [userId]);
|
||||||
if (userRows.length === 0) {
|
if (userRows.length === 0) {
|
||||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
|
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이지 접근 권한 업데이트
|
// 페이지 접근 권한 업데이트
|
||||||
for (const pageId of pageIds) {
|
for (const pageId of pageIds) {
|
||||||
// 기존 권한 확인
|
await db.query(`
|
||||||
const [existing] = await db.query(
|
INSERT INTO user_page_access (user_id, page_id, can_access, granted_by)
|
||||||
'SELECT * FROM user_page_access WHERE user_id = ? AND page_id = ?',
|
VALUES (?, ?, ?, ?)
|
||||||
[userId, pageId]
|
ON DUPLICATE KEY UPDATE can_access = ?, granted_at = NOW(), granted_by = ?
|
||||||
);
|
`, [userId, pageId, canAccess ? 1 : 0, adminUserId, canAccess ? 1 : 0, adminUserId]);
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
// 업데이트
|
|
||||||
await db.query(
|
|
||||||
'UPDATE user_page_access SET can_access = ?, granted_at = NOW(), granted_by = ? WHERE user_id = ? AND page_id = ?',
|
|
||||||
[canAccess ? 1 : 0, adminUserId, userId, pageId]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 삽입
|
|
||||||
await db.query(
|
|
||||||
'INSERT INTO user_page_access (user_id, page_id, can_access, granted_by) VALUES (?, ?, ?, ?)',
|
|
||||||
[userId, pageId, canAccess ? 1 : 0, adminUserId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
|
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 부여 오류:', error);
|
console.error('페이지 접근 권한 부여 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,23 +151,13 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
|
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId, pageId } = req.params;
|
if (!isAdminRole(req.user.role)) {
|
||||||
const adminUserId = req.user.user_id;
|
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
|
||||||
|
|
||||||
// Admin 권한 확인
|
|
||||||
const db = await getDb();
|
|
||||||
const [adminRows] = await db.query(`
|
|
||||||
SELECT u.role_id, r.name as role_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
`, [adminUserId]);
|
|
||||||
|
|
||||||
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
|
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 접근 권한 삭제
|
const { userId, pageId } = req.params;
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
'DELETE FROM user_page_access WHERE user_id = ? AND page_id = ?',
|
'DELETE FROM user_page_access WHERE user_id = ? AND page_id = ?',
|
||||||
[userId, pageId]
|
[userId, pageId]
|
||||||
@@ -185,7 +166,7 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
|
|||||||
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
|
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 회수 오류:', error);
|
console.error('페이지 접근 권한 회수 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 회수하는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한을 회수하는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,42 +176,29 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
|
|||||||
*/
|
*/
|
||||||
router.get('/page-access/summary', requireAuth, async (req, res) => {
|
router.get('/page-access/summary', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const adminUserId = req.user.user_id;
|
if (!isAdminRole(req.user.role)) {
|
||||||
|
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
|
||||||
// Admin 권한 확인
|
|
||||||
const db = await getDb();
|
|
||||||
const [adminRows] = await db.query(`
|
|
||||||
SELECT u.role_id, r.name as role_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.user_id = ?
|
|
||||||
`, [adminUserId]);
|
|
||||||
|
|
||||||
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
|
|
||||||
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 사용자와 페이지 권한 조회
|
const db = await getDb();
|
||||||
const [summary] = await db.query(`
|
const [summary] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
u.user_id,
|
su.user_id,
|
||||||
u.username,
|
su.name,
|
||||||
u.name,
|
su.role,
|
||||||
r.name as role_name,
|
|
||||||
COUNT(DISTINCT upa.page_id) as accessible_pages_count,
|
COUNT(DISTINCT upa.page_id) as accessible_pages_count,
|
||||||
(SELECT COUNT(*) FROM pages WHERE is_admin_only = 0) as total_pages_count
|
(SELECT COUNT(*) FROM pages) as total_pages_count
|
||||||
FROM users u
|
FROM sso_users su
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
LEFT JOIN user_page_access upa ON su.user_id = upa.user_id AND upa.can_access = 1
|
||||||
LEFT JOIN user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1
|
WHERE su.role NOT IN ('admin', 'system')
|
||||||
WHERE r.name NOT IN ('Admin', 'System Admin')
|
GROUP BY su.user_id, su.name, su.role
|
||||||
GROUP BY u.user_id, u.username, u.name, r.name
|
ORDER BY su.name
|
||||||
ORDER BY u.username
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json({ success: true, data: summary });
|
res.json({ success: true, data: summary });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('페이지 접근 권한 요약 조회 오류:', error);
|
console.error('페이지 접근 권한 요약 조회 오류:', error);
|
||||||
res.status(500).json({ success: false, error: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
|
res.status(500).json({ success: false, message: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const projectController = require('../controllers/projectController');
|
const projectController = require('../controllers/projectController');
|
||||||
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// READ - 인증된 사용자
|
// READ - 인증된 사용자
|
||||||
router.get('/', requireAuth, projectController.getAllProjects);
|
router.get('/', requireAuth, projectController.getAllProjects);
|
||||||
@@ -10,10 +13,10 @@ router.get('/active/list', requireAuth, projectController.getActiveProjects);
|
|||||||
router.get('/:project_id', requireAuth, projectController.getProjectById);
|
router.get('/:project_id', requireAuth, projectController.getProjectById);
|
||||||
|
|
||||||
// CREATE/UPDATE - support_team 이상 권한 필요
|
// CREATE/UPDATE - support_team 이상 권한 필요
|
||||||
router.post('/', requireAuth, requireMinLevel('support_team'), projectController.createProject);
|
router.post('/', requireAuth, requirePage('factory_projects'), projectController.createProject);
|
||||||
router.put('/:project_id', requireAuth, requireMinLevel('support_team'), projectController.updateProject);
|
router.put('/:project_id', requireAuth, requirePage('factory_projects'), projectController.updateProject);
|
||||||
|
|
||||||
// DELETE - admin 이상 권한 필요
|
// DELETE - admin 이상 권한 필요
|
||||||
router.delete('/:project_id', requireAuth, requireMinLevel('admin'), projectController.removeProject);
|
router.delete('/:project_id', requireAuth, requirePage('factory_projects'), projectController.removeProject);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
20
system1-factory/api/routes/proxyInputRoutes.js
Normal file
20
system1-factory/api/routes/proxyInputRoutes.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 대리입력 + 일별 현황 라우터
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const proxyInputController = require('../controllers/proxyInputController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
// 대리입력
|
||||||
|
router.post('/', requirePage('factory_proxy_input'), proxyInputController.proxyInput);
|
||||||
|
|
||||||
|
// 일별 현황
|
||||||
|
router.get('/daily-status', requirePage('factory_daily_status'), proxyInputController.getDailyStatus);
|
||||||
|
|
||||||
|
// 작업자별 상세
|
||||||
|
router.get('/daily-status/detail', requirePage('factory_daily_status'), proxyInputController.getDailyStatusDetail);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
16
system1-factory/api/routes/purchaseBatchRoutes.js
Normal file
16
system1-factory/api/routes/purchaseBatchRoutes.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/purchaseBatchController');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
|
router.get('/', requirePage('factory_purchases'), ctrl.getAll);
|
||||||
|
router.get('/:id', requirePage('factory_purchases'), ctrl.getById);
|
||||||
|
router.post('/', requirePage('factory_purchases'), ctrl.create);
|
||||||
|
router.put('/:id', requirePage('factory_purchases'), ctrl.update);
|
||||||
|
router.delete('/:id', requirePage('factory_purchases'), ctrl.delete);
|
||||||
|
router.post('/:id/purchase', requirePage('factory_purchases'), ctrl.purchase);
|
||||||
|
router.put('/:id/receive', requirePage('factory_purchases'), ctrl.receive);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,18 +1,34 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/purchaseRequestController');
|
const ctrl = require('../controllers/purchaseRequestController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 보조 데이터
|
// 보조 데이터
|
||||||
router.get('/consumable-items', ctrl.getConsumableItems);
|
router.get('/consumable-items', ctrl.getConsumableItems);
|
||||||
|
router.put('/consumable-items/:id/photo', ctrl.updateItemPhoto);
|
||||||
router.get('/vendors', ctrl.getVendors);
|
router.get('/vendors', ctrl.getVendors);
|
||||||
|
router.get('/search', ctrl.search);
|
||||||
|
|
||||||
|
// 내 신청 (모바일용 페이지네이션) — /:id 보다 먼저 등록
|
||||||
|
router.get('/my-requests', ctrl.getMyRequests);
|
||||||
|
|
||||||
|
// 품목 등록 + 신청 동시 (트랜잭션)
|
||||||
|
router.post('/register-and-request', ctrl.registerAndRequest);
|
||||||
|
// 일괄 신청 (장바구니)
|
||||||
|
router.post('/bulk', ctrl.bulkCreate);
|
||||||
|
|
||||||
// 구매신청 CRUD
|
// 구매신청 CRUD
|
||||||
router.get('/', ctrl.getAll);
|
router.get('/', ctrl.getAll);
|
||||||
router.get('/:id', ctrl.getById);
|
router.get('/:id', ctrl.getById);
|
||||||
router.post('/', ctrl.create);
|
router.post('/', ctrl.create);
|
||||||
router.put('/:id/hold', requireMinLevel('admin'), ctrl.hold);
|
router.put('/:id/hold', requirePage('factory_purchases'), ctrl.hold);
|
||||||
router.put('/:id/revert', requireMinLevel('admin'), ctrl.revert);
|
router.put('/:id/revert', requirePage('factory_purchases'), ctrl.revert);
|
||||||
|
router.put('/:id/receive', requirePage('factory_purchases'), ctrl.receive);
|
||||||
|
router.put('/:id/cancel', requirePage('factory_purchases'), ctrl.cancel);
|
||||||
|
router.put('/:id/return', requirePage('factory_purchases'), ctrl.returnItem);
|
||||||
|
router.put('/:id/revert-cancel', requirePage('factory_purchases'), ctrl.revertCancel);
|
||||||
router.delete('/:id', ctrl.delete);
|
router.delete('/:id', ctrl.delete);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/purchaseController');
|
const ctrl = require('../controllers/purchaseController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
router.get('/', ctrl.getAll);
|
router.get('/', ctrl.getAll);
|
||||||
router.post('/', requireMinLevel('admin'), ctrl.create);
|
router.post('/', requirePage('factory_purchases'), ctrl.create);
|
||||||
router.get('/price-history/:itemId', ctrl.getPriceHistory);
|
router.get('/price-history/:itemId', ctrl.getPriceHistory);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/scheduleController');
|
const ctrl = require('../controllers/scheduleController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 제품유형
|
// 제품유형
|
||||||
router.get('/product-types', ctrl.getProductTypes);
|
router.get('/product-types', ctrl.getProductTypes);
|
||||||
|
|
||||||
// 표준공정 자동 생성
|
// 표준공정 자동 생성
|
||||||
router.post('/generate-from-template', requireMinLevel('support_team'), ctrl.generateFromTemplate);
|
router.post('/generate-from-template', requirePage('factory_schedules'), ctrl.generateFromTemplate);
|
||||||
|
|
||||||
// 공정 단계
|
// 공정 단계
|
||||||
router.get('/phases', ctrl.getPhases);
|
router.get('/phases', ctrl.getPhases);
|
||||||
router.post('/phases', requireMinLevel('admin'), ctrl.createPhase);
|
router.post('/phases', requirePage('factory_schedules'), ctrl.createPhase);
|
||||||
router.put('/phases/:id', requireMinLevel('admin'), ctrl.updatePhase);
|
router.put('/phases/:id', requirePage('factory_schedules'), ctrl.updatePhase);
|
||||||
|
|
||||||
// 작업 템플릿
|
// 작업 템플릿
|
||||||
router.get('/templates', ctrl.getTemplates);
|
router.get('/templates', ctrl.getTemplates);
|
||||||
@@ -20,21 +22,21 @@ router.get('/templates', ctrl.getTemplates);
|
|||||||
// 공정표 항목
|
// 공정표 항목
|
||||||
router.get('/entries', ctrl.getEntries);
|
router.get('/entries', ctrl.getEntries);
|
||||||
router.get('/entries/gantt', ctrl.getGanttData);
|
router.get('/entries/gantt', ctrl.getGanttData);
|
||||||
router.post('/entries', requireMinLevel('support_team'), ctrl.createEntry);
|
router.post('/entries', requirePage('factory_schedules'), ctrl.createEntry);
|
||||||
router.post('/entries/batch', requireMinLevel('support_team'), ctrl.createBatchEntries);
|
router.post('/entries/batch', requirePage('factory_schedules'), ctrl.createBatchEntries);
|
||||||
router.put('/entries/:id', requireMinLevel('support_team'), ctrl.updateEntry);
|
router.put('/entries/:id', requirePage('factory_schedules'), ctrl.updateEntry);
|
||||||
router.put('/entries/:id/progress', requireMinLevel('group_leader'), ctrl.updateProgress);
|
router.put('/entries/:id/progress', requirePage('factory_schedules'), ctrl.updateProgress);
|
||||||
router.delete('/entries/:id', requireMinLevel('admin'), ctrl.deleteEntry);
|
router.delete('/entries/:id', requirePage('factory_schedules'), ctrl.deleteEntry);
|
||||||
|
|
||||||
// 의존관계
|
// 의존관계
|
||||||
router.post('/entries/:id/dependencies', requireMinLevel('support_team'), ctrl.addDependency);
|
router.post('/entries/:id/dependencies', requirePage('factory_schedules'), ctrl.addDependency);
|
||||||
router.delete('/entries/:id/dependencies/:depId', requireMinLevel('support_team'), ctrl.removeDependency);
|
router.delete('/entries/:id/dependencies/:depId', requirePage('factory_schedules'), ctrl.removeDependency);
|
||||||
|
|
||||||
// 마일스톤
|
// 마일스톤
|
||||||
router.get('/milestones', ctrl.getMilestones);
|
router.get('/milestones', ctrl.getMilestones);
|
||||||
router.post('/milestones', requireMinLevel('support_team'), ctrl.createMilestone);
|
router.post('/milestones', requirePage('factory_schedules'), ctrl.createMilestone);
|
||||||
router.put('/milestones/:id', requireMinLevel('support_team'), ctrl.updateMilestone);
|
router.put('/milestones/:id', requirePage('factory_schedules'), ctrl.updateMilestone);
|
||||||
router.delete('/milestones/:id', requireMinLevel('admin'), ctrl.deleteMilestone);
|
router.delete('/milestones/:id', requirePage('factory_schedules'), ctrl.deleteMilestone);
|
||||||
|
|
||||||
// 부적합 연동
|
// 부적합 연동
|
||||||
router.get('/nonconformance', ctrl.getNonconformance);
|
router.get('/nonconformance', ctrl.getNonconformance);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../controllers/settlementController');
|
const ctrl = require('../controllers/settlementController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
router.get('/summary', ctrl.getMonthlySummary);
|
router.get('/summary', ctrl.getMonthlySummary);
|
||||||
router.get('/purchases', ctrl.getMonthlyPurchases);
|
router.get('/purchases', ctrl.getMonthlyPurchases);
|
||||||
router.get('/price-changes', ctrl.getPriceChanges);
|
router.get('/price-changes', ctrl.getPriceChanges);
|
||||||
router.post('/complete', requireMinLevel('admin'), ctrl.complete);
|
router.get('/received-summary', ctrl.getMonthlyReceivedSummary);
|
||||||
router.post('/cancel', requireMinLevel('admin'), ctrl.cancel);
|
router.get('/received-list', ctrl.getMonthlyReceivedList);
|
||||||
|
router.post('/complete', requirePage('factory_settlements'), ctrl.complete);
|
||||||
|
router.post('/cancel', requirePage('factory_settlements'), ctrl.cancel);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const TbmController = require('../controllers/tbmController');
|
const TbmController = require('../controllers/tbmController');
|
||||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// ==================== TBM 세션 관련 ====================
|
// ==================== TBM 세션 관련 ====================
|
||||||
|
|
||||||
@@ -56,13 +59,13 @@ router.delete('/sessions/:sessionId/team/:userId', requireAuth, TbmController.re
|
|||||||
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
|
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
|
||||||
|
|
||||||
// 안전 체크 항목 생성 (관리자용)
|
// 안전 체크 항목 생성 (관리자용)
|
||||||
router.post('/safety-checks', requireAuth, requireRole('admin', 'system'), TbmController.createSafetyCheck);
|
router.post('/safety-checks', requireAuth, requirePage('factory_tbm'), TbmController.createSafetyCheck);
|
||||||
|
|
||||||
// 안전 체크 항목 수정 (관리자용)
|
// 안전 체크 항목 수정 (관리자용)
|
||||||
router.put('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.updateSafetyCheck);
|
router.put('/safety-checks/:checkId', requireAuth, requirePage('factory_tbm'), TbmController.updateSafetyCheck);
|
||||||
|
|
||||||
// 안전 체크 항목 삭제 (관리자용)
|
// 안전 체크 항목 삭제 (관리자용)
|
||||||
router.delete('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.deleteSafetyCheck);
|
router.delete('/safety-checks/:checkId', requireAuth, requirePage('factory_tbm'), TbmController.deleteSafetyCheck);
|
||||||
|
|
||||||
// TBM 세션의 안전 체크 기록 조회
|
// TBM 세션의 안전 체크 기록 조회
|
||||||
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
|
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const controller = require('../controllers/toolsController');
|
const controller = require('../controllers/toolsController');
|
||||||
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 읽기 작업: 인증된 사용자
|
// 읽기 작업: 인증된 사용자
|
||||||
router.get('/', requireAuth, controller.getAll);
|
router.get('/', requireAuth, controller.getAll);
|
||||||
router.get('/:id', requireAuth, controller.getById);
|
router.get('/:id', requireAuth, controller.getById);
|
||||||
|
|
||||||
// 쓰기 작업: group_leader 이상 권한 필요
|
// 쓰기 작업: group_leader 이상 권한 필요
|
||||||
router.post('/', requireAuth, requireMinLevel('group_leader'), controller.create);
|
router.post('/', requireAuth, requirePage('factory_tools'), controller.create);
|
||||||
router.put('/:id', requireAuth, requireMinLevel('group_leader'), controller.update);
|
router.put('/:id', requireAuth, requirePage('factory_tools'), controller.update);
|
||||||
router.delete('/:id', requireAuth, requireMinLevel('admin'), controller.delete);
|
router.delete('/:id', requireAuth, requirePage('factory_tools'), controller.delete);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity');
|
const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
@@ -31,7 +34,7 @@ const upload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 관리자 권한 필요
|
// 관리자 권한 필요
|
||||||
router.post('/upload-bg', requireAuth, requireMinLevel('admin'), upload.single('image'), async (req, res) => {
|
router.post('/upload-bg', requireAuth, requirePage('factory_uploads'), upload.single('image'), async (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ success: false, message: '파일이 없습니다.' });
|
return res.status(400).json({ success: false, message: '파일이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const workIssueController = require('../controllers/workIssueController');
|
const workIssueController = require('../controllers/workIssueController');
|
||||||
const { requireMinLevel } = require('../middlewares/auth');
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// ==================== 카테고리 관리 ====================
|
// ==================== 카테고리 관리 ====================
|
||||||
|
|
||||||
@@ -16,13 +18,13 @@ router.get('/categories', workIssueController.getAllCategories);
|
|||||||
router.get('/categories/type/:type', workIssueController.getCategoriesByType);
|
router.get('/categories/type/:type', workIssueController.getCategoriesByType);
|
||||||
|
|
||||||
// 카테고리 생성 (admin 이상)
|
// 카테고리 생성 (admin 이상)
|
||||||
router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory);
|
router.post('/categories', requirePage('factory_work_issues'), workIssueController.createCategory);
|
||||||
|
|
||||||
// 카테고리 수정 (admin 이상)
|
// 카테고리 수정 (admin 이상)
|
||||||
router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory);
|
router.put('/categories/:id', requirePage('factory_work_issues'), workIssueController.updateCategory);
|
||||||
|
|
||||||
// 카테고리 삭제 (admin 이상)
|
// 카테고리 삭제 (admin 이상)
|
||||||
router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory);
|
router.delete('/categories/:id', requirePage('factory_work_issues'), workIssueController.deleteCategory);
|
||||||
|
|
||||||
// ==================== 사전 정의 항목 관리 ====================
|
// ==================== 사전 정의 항목 관리 ====================
|
||||||
|
|
||||||
@@ -33,24 +35,24 @@ router.get('/items', workIssueController.getAllItems);
|
|||||||
router.get('/items/category/:categoryId', workIssueController.getItemsByCategory);
|
router.get('/items/category/:categoryId', workIssueController.getItemsByCategory);
|
||||||
|
|
||||||
// 항목 생성 (admin 이상)
|
// 항목 생성 (admin 이상)
|
||||||
router.post('/items', requireMinLevel('admin'), workIssueController.createItem);
|
router.post('/items', requirePage('factory_work_issues'), workIssueController.createItem);
|
||||||
|
|
||||||
// 항목 수정 (admin 이상)
|
// 항목 수정 (admin 이상)
|
||||||
router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem);
|
router.put('/items/:id', requirePage('factory_work_issues'), workIssueController.updateItem);
|
||||||
|
|
||||||
// 항목 삭제 (admin 이상)
|
// 항목 삭제 (admin 이상)
|
||||||
router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem);
|
router.delete('/items/:id', requirePage('factory_work_issues'), workIssueController.deleteItem);
|
||||||
|
|
||||||
// ==================== 통계 ====================
|
// ==================== 통계 ====================
|
||||||
|
|
||||||
// 통계 요약 (support_team 이상)
|
// 통계 요약 (support_team 이상)
|
||||||
router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary);
|
router.get('/stats/summary', requirePage('factory_work_issues'), workIssueController.getStatsSummary);
|
||||||
|
|
||||||
// 카테고리별 통계 (support_team 이상)
|
// 카테고리별 통계 (support_team 이상)
|
||||||
router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory);
|
router.get('/stats/by-category', requirePage('factory_work_issues'), workIssueController.getStatsByCategory);
|
||||||
|
|
||||||
// 작업장별 통계 (support_team 이상)
|
// 작업장별 통계 (support_team 이상)
|
||||||
router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace);
|
router.get('/stats/by-workplace', requirePage('factory_work_issues'), workIssueController.getStatsByWorkplace);
|
||||||
|
|
||||||
// ==================== 문제 신고 관리 ====================
|
// ==================== 문제 신고 관리 ====================
|
||||||
|
|
||||||
@@ -72,10 +74,10 @@ router.delete('/:id', workIssueController.deleteReport);
|
|||||||
// ==================== 상태 관리 ====================
|
// ==================== 상태 관리 ====================
|
||||||
|
|
||||||
// 신고 접수 (support_team 이상)
|
// 신고 접수 (support_team 이상)
|
||||||
router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport);
|
router.put('/:id/receive', requirePage('factory_work_issues'), workIssueController.receiveReport);
|
||||||
|
|
||||||
// 담당자 배정 (support_team 이상)
|
// 담당자 배정 (support_team 이상)
|
||||||
router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport);
|
router.put('/:id/assign', requirePage('factory_work_issues'), workIssueController.assignReport);
|
||||||
|
|
||||||
// 처리 시작
|
// 처리 시작
|
||||||
router.put('/:id/start', workIssueController.startProcessing);
|
router.put('/:id/start', workIssueController.startProcessing);
|
||||||
@@ -84,7 +86,7 @@ router.put('/:id/start', workIssueController.startProcessing);
|
|||||||
router.put('/:id/complete', workIssueController.completeReport);
|
router.put('/:id/complete', workIssueController.completeReport);
|
||||||
|
|
||||||
// 신고 종료 (admin 이상)
|
// 신고 종료 (admin 이상)
|
||||||
router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport);
|
router.put('/:id/close', requirePage('factory_work_issues'), workIssueController.closeReport);
|
||||||
|
|
||||||
// 상태 변경 이력 조회
|
// 상태 변경 이력 조회
|
||||||
router.get('/:id/logs', workIssueController.getStatusLogs);
|
router.get('/:id/logs', workIssueController.getStatusLogs);
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const workReportAnalysisController = require('../controllers/workReportAnalysisController');
|
const workReportAnalysisController = require('../controllers/workReportAnalysisController');
|
||||||
const { requireAuth, requireRole } = require('../middlewares/auth');
|
const { requireAuth } = require('../middlewares/auth');
|
||||||
|
const { createRequirePage } = require('../../../shared/middleware/pagePermission');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
const requirePage = createRequirePage(getDb);
|
||||||
|
|
||||||
// 🔒 모든 분석 라우트에 인증 + Admin 권한 필요
|
// 🔒 모든 분석 라우트에 인증 + Admin 권한 필요
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.use(requireRole('admin', 'system'));
|
router.use(requirePage('factory_work_analysis'));
|
||||||
|
|
||||||
// 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록)
|
// 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록)
|
||||||
router.get('/filters', workReportAnalysisController.getAnalysisFilters);
|
router.get('/filters', workReportAnalysisController.getAnalysisFilters);
|
||||||
|
|||||||
@@ -9,16 +9,21 @@
|
|||||||
|
|
||||||
const AttendanceModel = require('../models/attendanceModel');
|
const AttendanceModel = require('../models/attendanceModel');
|
||||||
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 휴가 사용 유형 ID를 차감 일수로 변환
|
* 휴가 사용 유형 ID → 차감 일수 (DB vacation_types.deduct_days 조회)
|
||||||
* vacation_type_id: 1=연차(1일), 2=반차(0.5일), 3=반반차(0.25일)
|
|
||||||
*/
|
*/
|
||||||
const getVacationDays = (vacationTypeId) => {
|
const getVacationDays = async (vacationTypeId) => {
|
||||||
const daysMap = { 1: 1, 2: 0.5, 3: 0.25 };
|
if (!vacationTypeId) return 0;
|
||||||
return daysMap[vacationTypeId] || 0;
|
const db = await getDb();
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
'SELECT deduct_days FROM vacation_types WHERE id = ?',
|
||||||
|
[vacationTypeId]
|
||||||
|
);
|
||||||
|
return rows.length > 0 ? parseFloat(rows[0].deduct_days) || 0 : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,8 +148,8 @@ const upsertAttendanceRecordService = async (recordData) => {
|
|||||||
|
|
||||||
// 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트)
|
// 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트)
|
||||||
const year = new Date(record_date).getFullYear();
|
const year = new Date(record_date).getFullYear();
|
||||||
const previousDays = getVacationDays(previousVacationTypeId);
|
const previousDays = await getVacationDays(previousVacationTypeId);
|
||||||
const newDays = getVacationDays(vacation_type_id);
|
const newDays = await getVacationDays(vacation_type_id);
|
||||||
|
|
||||||
// 이전 휴가가 있었고 변경된 경우 → 복구 후 차감
|
// 이전 휴가가 있었고 변경된 경우 → 복구 후 차감
|
||||||
if (previousDays !== newDays) {
|
if (previousDays !== newDays) {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ try {
|
|||||||
const UPLOAD_DIRS = {
|
const UPLOAD_DIRS = {
|
||||||
issues: path.join(__dirname, '../uploads/issues'),
|
issues: path.join(__dirname, '../uploads/issues'),
|
||||||
equipments: path.join(__dirname, '../uploads/equipments'),
|
equipments: path.join(__dirname, '../uploads/equipments'),
|
||||||
purchase_requests: path.join(__dirname, '../uploads/purchase_requests')
|
purchase_requests: path.join(__dirname, '../uploads/purchase_requests'),
|
||||||
|
purchase_received: path.join(__dirname, '../uploads/purchase_received'),
|
||||||
|
consumables: path.join(__dirname, '../uploads/consumables')
|
||||||
};
|
};
|
||||||
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
|
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
|
||||||
const MAX_SIZE = { width: 1920, height: 1920 };
|
const MAX_SIZE = { width: 1920, height: 1920 };
|
||||||
|
|||||||
156
system1-factory/api/utils/koreanSearch.js
Normal file
156
system1-factory/api/utils/koreanSearch.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* 한국어 스마트 검색 유틸리티
|
||||||
|
* - 초성 추출 및 매칭
|
||||||
|
* - 별칭(alias) 매칭
|
||||||
|
* - 인메모리 캐시 (5분 TTL)
|
||||||
|
*/
|
||||||
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
|
// 초성 목록 (19개)
|
||||||
|
const CHOSUNG = [
|
||||||
|
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ',
|
||||||
|
'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 자음 문자 집합 (초성 판별용)
|
||||||
|
const JAMO_SET = new Set([
|
||||||
|
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ',
|
||||||
|
'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 캐시
|
||||||
|
let cache = null;
|
||||||
|
let cacheTime = 0;
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한글 완성형 문자에서 초성 추출
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {string} 초성 문자열
|
||||||
|
*/
|
||||||
|
function extractChosung(str) {
|
||||||
|
let result = '';
|
||||||
|
for (const ch of str) {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
if (code >= 0xAC00 && code <= 0xD7A3) {
|
||||||
|
const idx = Math.floor((code - 0xAC00) / (21 * 28));
|
||||||
|
result += CHOSUNG[idx];
|
||||||
|
} else {
|
||||||
|
result += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색어가 모두 자음(초성)인지 판별
|
||||||
|
* @param {string} query
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isChosungOnly(query) {
|
||||||
|
if (query.length < 2) return false;
|
||||||
|
for (const ch of query) {
|
||||||
|
if (!JAMO_SET.has(ch)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 로드 (consumable_items + item_aliases)
|
||||||
|
*/
|
||||||
|
async function loadCache() {
|
||||||
|
if (cache && (Date.now() - cacheTime < CACHE_TTL)) return cache;
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const [items] = await db.query(
|
||||||
|
`SELECT item_id, item_name, spec, maker, category, base_price, unit, photo_path
|
||||||
|
FROM consumable_items WHERE is_active = 1`
|
||||||
|
);
|
||||||
|
const [aliases] = await db.query(
|
||||||
|
`SELECT alias_id, item_id, alias_name FROM item_aliases`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 아이템별 별칭 맵 생성
|
||||||
|
const aliasMap = {};
|
||||||
|
for (const a of aliases) {
|
||||||
|
if (!aliasMap[a.item_id]) aliasMap[a.item_id] = [];
|
||||||
|
aliasMap[a.item_id].push(a.alias_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초성 미리 계산
|
||||||
|
const enriched = items.map(item => ({
|
||||||
|
...item,
|
||||||
|
aliases: aliasMap[item.item_id] || [],
|
||||||
|
chosung_name: extractChosung(item.item_name),
|
||||||
|
chosung_aliases: (aliasMap[item.item_id] || []).map(a => extractChosung(a))
|
||||||
|
}));
|
||||||
|
|
||||||
|
cache = enriched;
|
||||||
|
cacheTime = Date.now();
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 무효화
|
||||||
|
*/
|
||||||
|
function clearCache() {
|
||||||
|
cache = null;
|
||||||
|
cacheTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스마트 검색
|
||||||
|
* @param {string} query - 검색어
|
||||||
|
* @returns {Promise<Array>} 스코어 기준 상위 20건
|
||||||
|
*/
|
||||||
|
async function search(query) {
|
||||||
|
if (!query || query.trim().length === 0) return [];
|
||||||
|
const items = await loadCache();
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const qChosung = isChosungOnly(q) ? q : null;
|
||||||
|
|
||||||
|
const scored = [];
|
||||||
|
for (const item of items) {
|
||||||
|
let score = 0;
|
||||||
|
let matchType = '';
|
||||||
|
|
||||||
|
const nameLower = item.item_name.toLowerCase();
|
||||||
|
const specLower = (item.spec || '').toLowerCase();
|
||||||
|
const makerLower = (item.maker || '').toLowerCase();
|
||||||
|
|
||||||
|
// exact match (이름 완전 일치)
|
||||||
|
if (nameLower === q) {
|
||||||
|
score = 100; matchType = 'exact';
|
||||||
|
}
|
||||||
|
// substring match (이름)
|
||||||
|
else if (nameLower.includes(q)) {
|
||||||
|
score = 80; matchType = 'name';
|
||||||
|
}
|
||||||
|
// alias match
|
||||||
|
else if (item.aliases.some(a => a.toLowerCase().includes(q))) {
|
||||||
|
score = 75; matchType = 'alias';
|
||||||
|
}
|
||||||
|
// spec/maker match
|
||||||
|
else if (specLower.includes(q) || makerLower.includes(q)) {
|
||||||
|
score = 70; matchType = 'spec';
|
||||||
|
}
|
||||||
|
// 초성 매칭 (이름)
|
||||||
|
else if (qChosung && item.chosung_name.includes(qChosung)) {
|
||||||
|
score = 50; matchType = 'chosung';
|
||||||
|
}
|
||||||
|
// 초성 매칭 (별칭)
|
||||||
|
else if (qChosung && item.chosung_aliases.some(ca => ca.includes(qChosung))) {
|
||||||
|
score = 40; matchType = 'chosung_alias';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0) {
|
||||||
|
scored.push({ ...item, _score: score, _matchType: matchType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 점수 높은 순, 같은 점수면 이름 짧은 순 (더 구체적)
|
||||||
|
scored.sort((a, b) => b._score - a._score || a.item_name.length - b.item_name.length);
|
||||||
|
return scored.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { search, clearCache, extractChosung, isChosungOnly };
|
||||||
@@ -11,11 +11,15 @@ RUN apt-get update && apt-get install -y \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# non-root user 생성
|
||||||
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
|
|
||||||
# 애플리케이션 코드 복사
|
# 애플리케이션 코드 복사
|
||||||
COPY . .
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
# 포트 노출
|
# 포트 노출
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 애플리케이션 실행
|
# 애플리케이션 실행
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -8,6 +8,7 @@ class Settings:
|
|||||||
# 기본 설정
|
# 기본 설정
|
||||||
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
|
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
|
||||||
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
|
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
|
||||||
|
JWT_SECRET: str = os.getenv("JWT_SECRET", "")
|
||||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
||||||
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import jwt as pyjwt
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -206,22 +207,41 @@ async def analytics_dashboard():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _verify_proxy_token(request: Request) -> dict:
|
||||||
|
"""프록시 요청의 JWT 토큰을 검증하여 사용자 정보 반환"""
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing or invalid authorization")
|
||||||
|
token = auth_header.split(" ", 1)[1]
|
||||||
|
if not settings.JWT_SECRET:
|
||||||
|
logger.warning("JWT_SECRET이 설정되지 않아 토큰 검증을 건너뜁니다")
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = pyjwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||||
|
return payload
|
||||||
|
except pyjwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
||||||
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
|
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
|
||||||
|
|
||||||
|
# JWT 검증 (defense in depth — Express 백엔드도 자체 검증함)
|
||||||
|
user_payload = _verify_proxy_token(request)
|
||||||
|
user_id = user_payload.get("user_id", user_payload.get("id", "anon"))
|
||||||
|
|
||||||
# Express.js API URL 구성
|
# Express.js API URL 구성
|
||||||
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
||||||
|
|
||||||
# 요청 데이터 준비
|
# 요청 데이터 준비
|
||||||
headers = dict(request.headers)
|
headers = dict(request.headers)
|
||||||
headers.pop("host", None) # host 헤더 제거
|
headers.pop("host", None) # host 헤더 제거
|
||||||
|
|
||||||
params = dict(request.query_params)
|
params = dict(request.query_params)
|
||||||
|
|
||||||
# GET 요청에 대해서만 캐싱 적용
|
# GET 요청에 대해서만 캐싱 적용 (user_id 포함하여 사용자 간 캐시 격리)
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
cache_key = cache_manager._generate_key("api", path, **params)
|
cache_key = cache_manager._generate_key("api", path, _uid=str(user_id), **params)
|
||||||
cached_result = await cache_manager.get(cache_key)
|
cached_result = await cache_manager.get(cache_key)
|
||||||
|
|
||||||
if cached_result is not None:
|
if cached_result is not None:
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ uvicorn[standard]==0.24.0
|
|||||||
aiohttp==3.9.1
|
aiohttp==3.9.1
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
PyJWT==2.8.0
|
||||||
292
system1-factory/web/css/daily-status.css
Normal file
292
system1-factory/web/css/daily-status.css
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/* daily-status.css — 일별 입력 현황 대시보드 */
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.ds-header {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
margin: -16px -16px 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 56px;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.ds-header h1 { font-size: 1.125rem; font-weight: 700; }
|
||||||
|
|
||||||
|
/* Date Navigation */
|
||||||
|
.ds-date-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.ds-date-btn {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: none; cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.ds-date-btn:hover { background: #e5e7eb; color: #374151; }
|
||||||
|
.ds-date-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.ds-date-display {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.ds-date-display #dateText { font-size: 1rem; font-weight: 700; color: #1f2937; }
|
||||||
|
.ds-day-label { font-size: 0.75rem; color: #6b7280; }
|
||||||
|
|
||||||
|
/* Summary Cards */
|
||||||
|
.ds-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.ds-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
border-top: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.ds-card:active { transform: scale(0.97); }
|
||||||
|
.ds-card-total { border-top-color: #3b82f6; }
|
||||||
|
.ds-card-done { border-top-color: #16a34a; }
|
||||||
|
.ds-card-missing { border-top-color: #dc2626; }
|
||||||
|
.ds-card-num { font-size: 1.5rem; font-weight: 800; color: #1f2937; line-height: 1; }
|
||||||
|
.ds-card-label { font-size: 0.7rem; color: #6b7280; margin-top: 4px; }
|
||||||
|
.ds-card-pct { font-size: 0.7rem; font-weight: 600; color: #9ca3af; margin-top: 2px; }
|
||||||
|
.ds-card-done .ds-card-pct { color: #16a34a; }
|
||||||
|
.ds-card-missing .ds-card-pct { color: #dc2626; }
|
||||||
|
|
||||||
|
/* Filter Tabs */
|
||||||
|
.ds-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.ds-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ds-tab.active { background: white; color: #2563eb; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||||
|
.ds-tab-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
min-width: 18px; height: 18px;
|
||||||
|
font-size: 0.65rem; font-weight: 700;
|
||||||
|
background: #e5e7eb; color: #6b7280;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.ds-tab.active .ds-tab-badge { background: #dbeafe; color: #2563eb; }
|
||||||
|
|
||||||
|
/* Worker List */
|
||||||
|
.ds-list { padding-bottom: 140px; }
|
||||||
|
.ds-worker-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ds-worker-row:active { background: #f9fafb; }
|
||||||
|
|
||||||
|
.ds-status-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ds-status-dot.complete { background: #16a34a; }
|
||||||
|
.ds-status-dot.tbm_only, .ds-status-dot.report_only { background: #f59e0b; }
|
||||||
|
.ds-status-dot.both_missing { background: #dc2626; }
|
||||||
|
|
||||||
|
.ds-worker-info { flex: 1; min-width: 0; }
|
||||||
|
.ds-worker-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
|
||||||
|
.ds-worker-dept { font-size: 0.7rem; color: #9ca3af; }
|
||||||
|
.ds-worker-status { text-align: right; flex-shrink: 0; }
|
||||||
|
.ds-worker-status span {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.ds-badge-ok { background: #dcfce7; color: #16a34a; }
|
||||||
|
.ds-badge-no { background: #fef2f2; color: #dc2626; }
|
||||||
|
.ds-badge-proxy { background: #ede9fe; color: #7c3aed; font-size: 0.6rem; }
|
||||||
|
|
||||||
|
/* Skeleton */
|
||||||
|
.ds-skeleton {
|
||||||
|
height: 56px;
|
||||||
|
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: ds-shimmer 1.5s infinite;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
|
|
||||||
|
/* Empty / No Permission */
|
||||||
|
.ds-empty, .ds-no-perm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Bottom Action */
|
||||||
|
.ds-bottom-action {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||||||
|
left: 0; right: 0;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
z-index: 30;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.ds-proxy-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ds-proxy-btn:hover { background: #1d4ed8; }
|
||||||
|
.ds-proxy-btn:disabled { background: #d1d5db; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Bottom Sheet */
|
||||||
|
.ds-sheet-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.ds-sheet {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 41;
|
||||||
|
padding: 0 16px 24px;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.ds-sheet.open { transform: translateY(0); }
|
||||||
|
.ds-sheet-handle {
|
||||||
|
width: 40px; height: 4px;
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 10px auto 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ds-sheet-header {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.ds-sheet-header span:first-child { font-size: 1rem; font-weight: 700; color: #1f2937; }
|
||||||
|
.ds-sheet-sub { font-size: 0.75rem; color: #9ca3af; }
|
||||||
|
.ds-sheet-body { min-height: 80px; }
|
||||||
|
.ds-sheet-loading { text-align: center; padding: 24px; color: #9ca3af; font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.ds-sheet-section { margin-bottom: 12px; }
|
||||||
|
.ds-sheet-section-title {
|
||||||
|
font-size: 0.75rem; font-weight: 700; color: #6b7280;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.ds-sheet-card {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.ds-sheet-card.empty { color: #9ca3af; text-align: center; }
|
||||||
|
|
||||||
|
.ds-sheet-actions {
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.ds-sheet-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom Nav (reuse tbm-mobile pattern) */
|
||||||
|
.m-bottom-nav {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
display: flex; justify-content: space-around;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
|
z-index: 35;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.m-nav-item {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 2px; color: #9ca3af;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.m-nav-item svg { width: 22px; height: 22px; }
|
||||||
|
.m-nav-item.active { color: #2563eb; }
|
||||||
|
.m-nav-label { font-weight: 500; }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body { max-width: 480px; margin: 0 auto; }
|
||||||
|
}
|
||||||
381
system1-factory/web/css/monthly-comparison.css
Normal file
381
system1-factory/web/css/monthly-comparison.css
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
/* monthly-comparison.css — 월간 비교·확인·정산 */
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.mc-header {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
margin: -16px -16px 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 56px;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.mc-header-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.mc-back-btn {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none; color: white; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mc-header h1 { font-size: 1.05rem; font-weight: 700; flex: 1; }
|
||||||
|
.mc-view-toggle {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none; color: white; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month Navigation */
|
||||||
|
.mc-month-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mc-month-nav button {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #6b7280; background: #f3f4f6;
|
||||||
|
border: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mc-month-nav button:hover { background: #e5e7eb; }
|
||||||
|
.mc-month-nav span { font-size: 1rem; font-weight: 700; color: #1f2937; }
|
||||||
|
.mc-status-badge {
|
||||||
|
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
|
||||||
|
font-size: 0.7rem; font-weight: 600;
|
||||||
|
padding: 3px 8px; border-radius: 12px;
|
||||||
|
}
|
||||||
|
.mc-status-badge.pending { background: #fef3c7; color: #92400e; }
|
||||||
|
.mc-status-badge.confirmed { background: #dcfce7; color: #166534; }
|
||||||
|
.mc-status-badge.rejected { background: #fef2f2; color: #991b1b; }
|
||||||
|
.mc-status-badge.change_request { background: #fff7ed; color: #c2410c; }
|
||||||
|
.mc-status-badge.review_sent { background: #dbeafe; color: #1e40af; }
|
||||||
|
.mc-status-badge.admin_checked { background: #dcfce7; color: #166534; }
|
||||||
|
|
||||||
|
/* Summary Cards */
|
||||||
|
.mc-summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.mc-summary-cards { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
.mc-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.mc-card-value { font-size: 1.25rem; font-weight: 800; color: #1f2937; }
|
||||||
|
.mc-card-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Mismatch Alert */
|
||||||
|
.mc-mismatch-alert {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fffbeb; border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.8rem; color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Daily List */
|
||||||
|
.mc-daily-list { padding-bottom: 100px; }
|
||||||
|
.mc-daily-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.mc-daily-card.match { border-left-color: #10b981; }
|
||||||
|
.mc-daily-card.mismatch { background: #fffbeb; border-left-color: #f59e0b; }
|
||||||
|
.mc-daily-card.report_only { background: #eff6ff; border-left-color: #3b82f6; }
|
||||||
|
.mc-daily-card.attend_only { background: #f5f3ff; border-left-color: #8b5cf6; }
|
||||||
|
.mc-daily-card.vacation { background: #f0fdf4; border-left-color: #34d399; }
|
||||||
|
.mc-daily-card.holiday { background: #f9fafb; border-left-color: #9ca3af; }
|
||||||
|
.mc-daily-card.none { background: #fef2f2; border-left-color: #ef4444; }
|
||||||
|
|
||||||
|
.mc-daily-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.mc-daily-date { font-size: 0.85rem; font-weight: 600; color: #1f2937; }
|
||||||
|
.mc-daily-status { font-size: 0.7rem; font-weight: 600; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.mc-daily-row { font-size: 0.8rem; color: #374151; margin: 2px 0; }
|
||||||
|
.mc-daily-row span { color: #6b7280; }
|
||||||
|
.mc-daily-diff {
|
||||||
|
font-size: 0.75rem; font-weight: 600; color: #f59e0b;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom Actions */
|
||||||
|
.mc-bottom-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
z-index: 30;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.mc-confirm-btn {
|
||||||
|
flex: 1; padding: 12px;
|
||||||
|
background: #10b981; color: white;
|
||||||
|
font-size: 0.85rem; font-weight: 700;
|
||||||
|
border: none; border-radius: 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mc-confirm-btn:hover { background: #059669; }
|
||||||
|
.mc-reject-btn {
|
||||||
|
flex: 1; padding: 12px;
|
||||||
|
background: white; color: #ef4444;
|
||||||
|
font-size: 0.85rem; font-weight: 700;
|
||||||
|
border: 2px solid #fecaca; border-radius: 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mc-reject-btn:hover { background: #fef2f2; }
|
||||||
|
|
||||||
|
.mc-confirmed-status {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem; color: #059669; font-weight: 600;
|
||||||
|
margin-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin View */
|
||||||
|
.mc-admin-summary {
|
||||||
|
background: white; border-radius: 10px;
|
||||||
|
padding: 16px; margin-bottom: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.mc-progress-bar {
|
||||||
|
height: 8px; background: #e5e7eb; border-radius: 4px;
|
||||||
|
overflow: hidden; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.mc-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #f59e0b, #10b981);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.mc-progress-text { font-size: 0.8rem; font-weight: 600; color: #1f2937; margin-bottom: 4px; }
|
||||||
|
.mc-status-counts { font-size: 0.75rem; color: #6b7280; display: flex; gap: 12px; }
|
||||||
|
|
||||||
|
/* Filter Tabs */
|
||||||
|
.mc-filter-tabs {
|
||||||
|
display: flex; gap: 4px;
|
||||||
|
padding: 4px; background: #f3f4f6;
|
||||||
|
border-radius: 10px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.mc-tab {
|
||||||
|
flex: 1; padding: 8px 4px;
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: #6b7280; background: transparent;
|
||||||
|
border: none; border-radius: 8px; cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mc-tab.active { background: white; color: #2563eb; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||||
|
|
||||||
|
/* Worker List (admin) */
|
||||||
|
.mc-worker-list { padding-bottom: 100px; }
|
||||||
|
.mc-worker-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.mc-worker-card:active { background: #f9fafb; }
|
||||||
|
.mc-worker-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
|
||||||
|
.mc-worker-dept { font-size: 0.7rem; color: #9ca3af; }
|
||||||
|
.mc-worker-stats { font-size: 0.75rem; color: #6b7280; margin: 4px 0; }
|
||||||
|
.mc-worker-status {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.mc-worker-status-badge {
|
||||||
|
font-size: 0.65rem; font-weight: 600;
|
||||||
|
padding: 2px 8px; border-radius: 10px;
|
||||||
|
}
|
||||||
|
.mc-worker-status-badge.confirmed { background: #166534; color: white; }
|
||||||
|
.mc-worker-status-badge.admin_checked { background: #dcfce7; color: #166534; }
|
||||||
|
.mc-worker-status-badge.pending { background: #fef3c7; color: #92400e; }
|
||||||
|
.mc-worker-status-badge.review_sent { background: #dbeafe; color: #1e40af; }
|
||||||
|
.mc-worker-status-badge.change_request { background: #fff7ed; color: #c2410c; }
|
||||||
|
.mc-worker-status-badge.rejected { background: #fef2f2; color: #991b1b; }
|
||||||
|
.mc-worker-reject-reason {
|
||||||
|
font-size: 0.7rem; color: #991b1b;
|
||||||
|
margin-top: 4px; padding-left: 8px;
|
||||||
|
border-left: 2px solid #fecaca;
|
||||||
|
}
|
||||||
|
.mc-worker-mismatch {
|
||||||
|
font-size: 0.65rem; font-weight: 600;
|
||||||
|
color: #f59e0b; background: #fffbeb;
|
||||||
|
padding: 1px 6px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom Export */
|
||||||
|
.mc-bottom-export {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
z-index: 30;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mc-export-btn {
|
||||||
|
width: 100%; padding: 12px;
|
||||||
|
background: #059669; color: white;
|
||||||
|
font-size: 0.85rem; font-weight: 700;
|
||||||
|
border: none; border-radius: 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mc-export-btn:disabled { background: #d1d5db; cursor: not-allowed; }
|
||||||
|
.mc-export-note { font-size: 0.7rem; color: #9ca3af; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.mc-modal-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.mc-modal {
|
||||||
|
background: white; border-radius: 12px;
|
||||||
|
width: 100%; max-width: 400px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mc-modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
font-weight: 700; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.mc-modal-header button { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1.1rem; }
|
||||||
|
.mc-modal-body { padding: 16px; }
|
||||||
|
.mc-modal-desc { font-size: 0.85rem; color: #374151; margin-bottom: 8px; }
|
||||||
|
.mc-textarea {
|
||||||
|
width: 100%; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||||
|
padding: 10px; font-size: 0.85rem; resize: none;
|
||||||
|
}
|
||||||
|
.mc-modal-note { font-size: 0.75rem; color: #6b7280; margin-top: 8px; }
|
||||||
|
.mc-modal-footer {
|
||||||
|
display: flex; gap: 8px; padding: 12px 16px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.mc-modal-cancel {
|
||||||
|
flex: 1; padding: 10px; border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px; background: white; cursor: pointer; font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.mc-modal-submit {
|
||||||
|
flex: 1; padding: 10px; background: #ef4444; color: white;
|
||||||
|
border: none; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty / No Permission */
|
||||||
|
.mc-empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton (reuse) */
|
||||||
|
.ds-skeleton {
|
||||||
|
height: 56px;
|
||||||
|
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: ds-shimmer 1.5s infinite;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
|
.ds-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; }
|
||||||
|
.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Change Request Panel (detail mode) */
|
||||||
|
.mc-change-panel {
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.mc-change-header {
|
||||||
|
font-size: 0.85rem; font-weight: 700; color: #c2410c;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.mc-change-list { margin-bottom: 10px; }
|
||||||
|
.mc-change-item {
|
||||||
|
font-size: 0.8rem; color: #374151;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid #fde6d0;
|
||||||
|
}
|
||||||
|
.mc-change-item:last-child { border-bottom: none; }
|
||||||
|
.mc-change-from { color: #9ca3af; text-decoration: line-through; }
|
||||||
|
.mc-change-to { color: #c2410c; font-weight: 600; }
|
||||||
|
.mc-change-desc {
|
||||||
|
font-size: 0.8rem; color: #374151;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.mc-change-actions {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
}
|
||||||
|
.mc-change-approve {
|
||||||
|
flex: 1; padding: 10px;
|
||||||
|
background: #2563eb; color: white;
|
||||||
|
font-size: 0.8rem; font-weight: 600;
|
||||||
|
border: none; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mc-change-approve:hover { background: #1d4ed8; }
|
||||||
|
.mc-change-reject {
|
||||||
|
flex: 1; padding: 10px;
|
||||||
|
background: white; color: #ef4444;
|
||||||
|
font-size: 0.8rem; font-weight: 600;
|
||||||
|
border: 2px solid #fecaca; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mc-change-reject:hover { background: #fef2f2; }
|
||||||
|
|
||||||
|
/* Worker card change summary */
|
||||||
|
.mc-worker-change-summary {
|
||||||
|
font-size: 0.7rem; color: #c2410c;
|
||||||
|
margin-top: 4px; padding-left: 8px;
|
||||||
|
border-left: 2px solid #fed7aa;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) { body { max-width: 480px; margin: 0 auto; } }
|
||||||
|
|
||||||
|
/* Inline Edit */
|
||||||
|
.mc-edit-btn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 12px; padding: 2px 6px; margin-left: auto; }
|
||||||
|
.mc-edit-btn:hover { color: #2563eb; }
|
||||||
|
.mc-attend-row { display: flex; align-items: center; }
|
||||||
|
.mc-edit-form { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
|
||||||
|
.mc-edit-row { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||||
|
.mc-edit-row label { width: 36px; font-weight: 600; color: #6b7280; font-size: 12px; }
|
||||||
|
.mc-edit-input { width: 60px; padding: 4px 6px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; text-align: center; }
|
||||||
|
.mc-edit-select { padding: 4px 6px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; flex: 1; }
|
||||||
|
.mc-edit-actions { display: flex; gap: 6px; margin-top: 2px; }
|
||||||
|
.mc-edit-save { padding: 4px 12px; background: #10b981; color: white; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; }
|
||||||
|
.mc-edit-save:hover { background: #059669; }
|
||||||
|
.mc-edit-cancel { padding: 4px 12px; background: #e5e7eb; color: #374151; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; }
|
||||||
|
.mc-edit-cancel:hover { background: #d1d5db; }
|
||||||
170
system1-factory/web/css/my-monthly-confirm.css
Normal file
170
system1-factory/web/css/my-monthly-confirm.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/* my-monthly-confirm.css — 작업자 월간 확인 (모바일 캘린더) */
|
||||||
|
body { max-width: 480px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* 월 네비게이션 */
|
||||||
|
.mmc-month-nav {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 12px; padding: 12px 0; position: relative;
|
||||||
|
}
|
||||||
|
.mmc-month-nav button {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #6b7280; background: #f3f4f6; border: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mmc-month-nav button:hover { background: #e5e7eb; }
|
||||||
|
.mmc-month-nav > span { font-size: 1rem; font-weight: 700; color: #1f2937; }
|
||||||
|
.mmc-status-badge {
|
||||||
|
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
|
||||||
|
font-size: 0.7rem; font-weight: 600; padding: 3px 8px; border-radius: 12px;
|
||||||
|
}
|
||||||
|
.mmc-status-badge.pending { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.mmc-status-badge.review_sent { background: #dbeafe; color: #1e40af; }
|
||||||
|
.mmc-status-badge.confirmed { background: #dcfce7; color: #166534; }
|
||||||
|
.mmc-status-badge.change_request { background: #fef3c7; color: #92400e; }
|
||||||
|
.mmc-status-badge.rejected { background: #fef2f2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* 사용자 정보 */
|
||||||
|
.mmc-user-info {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
padding: 0 4px 8px; font-size: 0.95rem; font-weight: 700; color: #1f2937;
|
||||||
|
}
|
||||||
|
.mmc-user-dept { font-size: 0.8rem; font-weight: 400; color: #6b7280; }
|
||||||
|
|
||||||
|
/* ===== 캘린더 그리드 ===== */
|
||||||
|
.cal-grid {
|
||||||
|
background: white; border-radius: 12px; overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.cal-header {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
|
background: #f9fafb; border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.cal-dow {
|
||||||
|
text-align: center; padding: 8px 0; font-size: 0.7rem; font-weight: 600; color: #6b7280;
|
||||||
|
}
|
||||||
|
.cal-dow.sun { color: #ef4444; }
|
||||||
|
.cal-dow.sat { color: #3b82f6; }
|
||||||
|
|
||||||
|
.cal-body { display: grid; grid-template-columns: repeat(7, 1fr); }
|
||||||
|
.cal-cell {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
padding: 6px 2px; min-height: 54px; border-bottom: 1px solid #f3f4f6;
|
||||||
|
border-right: 1px solid #f3f4f6; cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.cal-cell:nth-child(7n) { border-right: none; }
|
||||||
|
.cal-cell:active { background: #eff6ff; }
|
||||||
|
.cal-cell.selected { background: #dbeafe; }
|
||||||
|
.cal-cell.empty { background: #fafafa; cursor: default; }
|
||||||
|
|
||||||
|
.cal-day { font-size: 0.7rem; font-weight: 600; color: #374151; margin-bottom: 2px; }
|
||||||
|
.cal-cell.sun .cal-day { color: #ef4444; }
|
||||||
|
.cal-cell.sat .cal-day { color: #3b82f6; }
|
||||||
|
|
||||||
|
.cal-val { font-size: 0.65rem; font-weight: 700; line-height: 1.2; text-align: center; }
|
||||||
|
|
||||||
|
/* 셀 상태별 색상 — 정시=흰색, 연차=노랑, 연장=연보라, 휴무=회색 */
|
||||||
|
.cal-cell.normal { background: white; }
|
||||||
|
.cal-cell.normal .cal-val { color: #1f2937; }
|
||||||
|
.cal-cell.vac { background: #fefce8; }
|
||||||
|
.cal-cell.vac .cal-val { color: #92400e; font-weight: 700; }
|
||||||
|
.cal-cell.off { background: #f3f4f6; }
|
||||||
|
.cal-cell.off .cal-val { color: #9ca3af; font-weight: 500; }
|
||||||
|
.cal-cell.overtime { background: #f5f3ff; }
|
||||||
|
.cal-cell.overtime .cal-val { color: #7c3aed; }
|
||||||
|
.cal-cell.special { background: #fff7ed; }
|
||||||
|
.cal-cell.special .cal-val { color: #b45309; }
|
||||||
|
.cal-cell.partial .cal-val { color: #6b7280; }
|
||||||
|
.cal-cell.none .cal-val { color: #d1d5db; }
|
||||||
|
.cal-cell.changed { outline: 2px solid #f59e0b; outline-offset: -2px; }
|
||||||
|
.cal-cell.changed::after { content: '수정'; position: absolute; top: 1px; right: 2px; font-size: 0.5rem; color: #f59e0b; font-weight: 700; }
|
||||||
|
.cal-cell { position: relative; }
|
||||||
|
|
||||||
|
/* 상세 표시 + 수정 */
|
||||||
|
.cal-edit-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.cal-edit-select { padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.8rem; flex: 1; }
|
||||||
|
.cal-changed-badge { font-size: 0.65rem; font-weight: 700; color: #f59e0b; background: #fefce8; padding: 1px 6px; border-radius: 4px; }
|
||||||
|
.cal-detail { display: none; margin-bottom: 10px; }
|
||||||
|
.cal-detail-inner {
|
||||||
|
background: white; border-radius: 10px; padding: 10px 14px;
|
||||||
|
font-size: 0.8rem; color: #374151;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 요약 카드 ===== */
|
||||||
|
.mmc-sum-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
|
||||||
|
.mmc-sum-card {
|
||||||
|
background: white; border-radius: 10px; padding: 10px 6px;
|
||||||
|
text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.mmc-sum-num { font-size: 1.1rem; font-weight: 800; color: #1f2937; }
|
||||||
|
.mmc-sum-num.ot { color: #f59e0b; }
|
||||||
|
.mmc-sum-num.vac { color: #059669; }
|
||||||
|
.mmc-sum-label { font-size: 0.65rem; color: #6b7280; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* 연차 현황 */
|
||||||
|
.mmc-vac-title { font-size: 0.8rem; font-weight: 600; color: #6b7280; margin-bottom: 6px; padding: 0 4px; }
|
||||||
|
.mmc-vac-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 160px; }
|
||||||
|
.mmc-vac-card {
|
||||||
|
background: white; border-radius: 10px; padding: 12px 8px;
|
||||||
|
text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.mmc-vac-num { font-size: 1.25rem; font-weight: 800; color: #1f2937; }
|
||||||
|
.mmc-vac-num.used { color: #f59e0b; }
|
||||||
|
.mmc-vac-num.remain { color: #059669; }
|
||||||
|
.mmc-vac-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* 확인 상태 */
|
||||||
|
.mmc-confirmed-status {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
justify-content: center; padding: 16px;
|
||||||
|
font-size: 0.85rem; color: #059669; font-weight: 600;
|
||||||
|
margin-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 하단 버튼 */
|
||||||
|
.mmc-bottom-actions {
|
||||||
|
position: fixed; bottom: 68px; left: 0; right: 0;
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: white; border-top: 1px solid #e5e7eb; z-index: 30;
|
||||||
|
max-width: 480px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.mmc-confirm-btn {
|
||||||
|
flex: 1; padding: 14px; background: #10b981; color: white;
|
||||||
|
font-size: 0.9rem; font-weight: 700;
|
||||||
|
border: none; border-radius: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mmc-confirm-btn:hover { background: #059669; }
|
||||||
|
.mmc-reject-btn {
|
||||||
|
flex: 1; padding: 14px; background: white; color: #ef4444;
|
||||||
|
font-size: 0.9rem; font-weight: 700;
|
||||||
|
border: 2px solid #fecaca; border-radius: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mmc-reject-btn:hover { background: #fef2f2; }
|
||||||
|
|
||||||
|
/* 모달 */
|
||||||
|
.mmc-modal-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.mmc-modal { background: white; border-radius: 12px; width: 100%; max-width: 400px; overflow: hidden; }
|
||||||
|
.mmc-modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 16px; border-bottom: 1px solid #f3f4f6;
|
||||||
|
font-weight: 700; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.mmc-modal-header button { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1.1rem; }
|
||||||
|
.mmc-modal-body { padding: 16px; }
|
||||||
|
.mmc-modal-desc { font-size: 0.85rem; color: #374151; margin-bottom: 8px; }
|
||||||
|
.mmc-textarea { width: 100%; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px; font-size: 0.85rem; resize: none; }
|
||||||
|
.mmc-modal-note { font-size: 0.75rem; color: #6b7280; margin-top: 8px; }
|
||||||
|
.mmc-modal-footer { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #f3f4f6; }
|
||||||
|
.mmc-modal-cancel { flex: 1; padding: 10px; border: 1px solid #e5e7eb; border-radius: 8px; background: white; cursor: pointer; font-size: 0.8rem; }
|
||||||
|
.mmc-modal-submit { flex: 1; padding: 10px; background: #ef4444; color: white; border: none; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; }
|
||||||
|
|
||||||
|
/* 빈 상태 / 스켈레톤 */
|
||||||
|
.mmc-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; }
|
||||||
|
.mmc-skeleton { height: 40px; background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); background-size: 200% 100%; animation: mmc-shimmer 1.5s infinite; border-radius: 8px; margin-bottom: 4px; }
|
||||||
|
@keyframes mmc-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
113
system1-factory/web/css/production-dashboard.css
Normal file
113
system1-factory/web/css/production-dashboard.css
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/* 생산팀 대시보드 — Sprint 003 */
|
||||||
|
|
||||||
|
.pd-main { max-width: 640px; margin: 0 auto; padding: 16px 16px 80px; }
|
||||||
|
|
||||||
|
/* 프로필 카드 */
|
||||||
|
.pd-profile-card {
|
||||||
|
background: linear-gradient(135deg, #9a3412, #ea580c);
|
||||||
|
color: white; border-radius: 16px; padding: 20px; margin-bottom: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pd-logout-btn {
|
||||||
|
position: absolute; top: 16px; right: 16px;
|
||||||
|
background: rgba(255,255,255,0.2); border: none; border-radius: 50%;
|
||||||
|
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: rgba(255,255,255,0.8); font-size: 14px; cursor: pointer; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.pd-logout-btn:hover { background: rgba(255,255,255,0.3); color: white; }
|
||||||
|
.pd-profile-header { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
|
||||||
|
.pd-avatar {
|
||||||
|
width: 48px; height: 48px; border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px; font-weight: 700; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pd-profile-name { font-size: 18px; font-weight: 700; }
|
||||||
|
.pd-profile-sub { font-size: 13px; opacity: 0.8; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* 통합 정보 리스트 */
|
||||||
|
.pd-info-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.pd-info-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
background: rgba(255,255,255,0.12); border-radius: 10px; padding: 10px 12px;
|
||||||
|
cursor: pointer; -webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.pd-info-row:active { background: rgba(255,255,255,0.18); }
|
||||||
|
.pd-info-left { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.pd-info-icon { font-size: 14px; opacity: 0.8; width: 18px; text-align: center; }
|
||||||
|
.pd-info-label { font-size: 12px; font-weight: 600; opacity: 0.9; }
|
||||||
|
.pd-info-right { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.pd-info-value { font-size: 14px; font-weight: 700; }
|
||||||
|
.pd-info-sub { font-size: 11px; opacity: 0.6; }
|
||||||
|
.pd-info-arrow { font-size: 10px; opacity: 0.5; margin-left: 2px; }
|
||||||
|
.pd-progress-bar { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.2); overflow: hidden; }
|
||||||
|
.pd-progress-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }
|
||||||
|
.pd-progress-green { background: #4ade80; }
|
||||||
|
.pd-progress-yellow { background: #fbbf24; }
|
||||||
|
.pd-progress-red { background: #f87171; }
|
||||||
|
|
||||||
|
/* 연차 상세 모달 */
|
||||||
|
.pd-detail-modal {
|
||||||
|
position: fixed; inset: 0; z-index: 100; display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.4); opacity: 0; pointer-events: none; transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.pd-detail-modal.active { opacity: 1; pointer-events: auto; }
|
||||||
|
.pd-detail-sheet {
|
||||||
|
background: linear-gradient(135deg, #9a3412, #ea580c); color: white;
|
||||||
|
border-radius: 16px 16px 0 0; width: 100%; max-width: 640px;
|
||||||
|
padding: 20px 20px calc(20px + 70px + env(safe-area-inset-bottom, 0px));
|
||||||
|
transform: translateY(100%); transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.pd-detail-modal.active .pd-detail-sheet { transform: translateY(0); }
|
||||||
|
.pd-detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.pd-detail-title { font-size: 16px; font-weight: 700; }
|
||||||
|
.pd-detail-close { background: none; border: none; color: white; opacity: 0.7; font-size: 18px; cursor: pointer; }
|
||||||
|
.pd-detail-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.15); font-size: 13px;
|
||||||
|
}
|
||||||
|
.pd-detail-label { font-weight: 600; opacity: 0.9; }
|
||||||
|
.pd-detail-value { text-align: right; opacity: 0.85; }
|
||||||
|
.pd-detail-total {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 0 0; font-size: 14px; font-weight: 700; margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 섹션 */
|
||||||
|
.pd-section { margin-bottom: 20px; }
|
||||||
|
.pd-section-title {
|
||||||
|
font-size: 12px; font-weight: 700; color: #6b7280;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 아이콘 그리드 */
|
||||||
|
.pd-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||||
|
.pd-grid-item {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.pd-grid-item:active .pd-grid-icon { transform: scale(0.93); }
|
||||||
|
.pd-grid-icon {
|
||||||
|
width: 52px; height: 52px; border-radius: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: white; font-size: 20px; transition: transform 0.15s;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.pd-grid-label {
|
||||||
|
font-size: 11px; text-align: center; color: #374151; line-height: 1.3;
|
||||||
|
max-width: 64px; overflow: hidden; display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스켈레톤 */
|
||||||
|
.pd-skeleton { background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 200% 100%; animation: pd-shimmer 1.5s infinite; border-radius: 8px; }
|
||||||
|
@keyframes pd-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
|
|
||||||
|
/* 에러 */
|
||||||
|
.pd-error { text-align: center; padding: 40px 20px; color: #6b7280; }
|
||||||
|
.pd-error i { font-size: 40px; margin-bottom: 12px; color: #d1d5db; }
|
||||||
|
.pd-error-btn { margin-top: 12px; padding: 8px 20px; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* 반응형 */
|
||||||
|
@media (min-width: 640px) { .pd-grid { grid-template-columns: repeat(6, 1fr); } }
|
||||||
|
@media (min-width: 1024px) { .pd-main { max-width: 800px; } .pd-grid { grid-template-columns: repeat(8, 1fr); } }
|
||||||
85
system1-factory/web/css/proxy-input.css
Normal file
85
system1-factory/web/css/proxy-input.css
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/* proxy-input.css — 대리입력 리뉴얼 */
|
||||||
|
|
||||||
|
/* Title Row */
|
||||||
|
.pi-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.pi-title { font-size: 18px; font-weight: 700; color: #1f2937; flex: 1; }
|
||||||
|
.pi-back-btn { background: none; border: none; font-size: 18px; color: #6b7280; cursor: pointer; padding: 4px 8px; }
|
||||||
|
.pi-date-group { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.pi-date-input { border: 1px solid #d1d5db; border-radius: 8px; padding: 6px 10px; font-size: 14px; }
|
||||||
|
.pi-refresh-btn { background: none; border: none; color: #6b7280; font-size: 14px; cursor: pointer; padding: 6px; }
|
||||||
|
|
||||||
|
/* Status Bar */
|
||||||
|
.pi-status-bar { display: flex; gap: 16px; background: white; border-radius: 10px; padding: 10px 14px; margin-bottom: 10px; font-size: 13px; color: #6b7280; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
/* Select All */
|
||||||
|
.pi-select-all { padding: 6px 2px; font-size: 13px; color: #6b7280; }
|
||||||
|
.pi-select-all input { margin-right: 6px; }
|
||||||
|
|
||||||
|
/* Worker List */
|
||||||
|
.pi-worker-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 80px; }
|
||||||
|
.pi-worker { display: flex; align-items: center; gap: 10px; background: white; border-radius: 10px; padding: 10px 12px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s; }
|
||||||
|
.pi-worker:hover { border-color: #93c5fd; }
|
||||||
|
.pi-worker.disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.pi-check { width: 18px; height: 18px; flex-shrink: 0; accent-color: #2563eb; }
|
||||||
|
.pi-worker-info { flex: 1; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.pi-worker-name { font-size: 14px; font-weight: 600; color: #1f2937; }
|
||||||
|
.pi-worker-job { font-size: 11px; color: #9ca3af; }
|
||||||
|
.pi-worker-badges { display: flex; gap: 4px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.pi-badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
|
||||||
|
.pi-badge.done { background: #dcfce7; color: #166534; }
|
||||||
|
.pi-badge.missing { background: #fee2e2; color: #991b1b; }
|
||||||
|
.pi-badge.vac { background: #dbeafe; color: #1e40af; }
|
||||||
|
.pi-badge.vac-half { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
|
/* Bottom Bar */
|
||||||
|
.pi-bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: white; padding: 12px 16px; border-top: 1px solid #e5e7eb; box-shadow: 0 -2px 8px rgba(0,0,0,0.06); }
|
||||||
|
.pi-edit-btn, .pi-save-btn { width: 100%; padding: 12px; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; color: white; cursor: pointer; }
|
||||||
|
.pi-edit-btn { background: #2563eb; }
|
||||||
|
.pi-edit-btn:hover { background: #1d4ed8; }
|
||||||
|
.pi-edit-btn:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||||
|
.pi-save-btn { background: #10b981; }
|
||||||
|
.pi-save-btn:hover { background: #059669; }
|
||||||
|
.pi-save-btn:disabled { background: #9ca3af; }
|
||||||
|
|
||||||
|
/* Edit Cards */
|
||||||
|
.pi-edit-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 80px; }
|
||||||
|
.pi-edit-card { background: white; border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||||
|
.pi-edit-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 14px; }
|
||||||
|
.pi-edit-job { font-size: 11px; color: #9ca3af; }
|
||||||
|
.pi-edit-fields { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.pi-edit-row { display: flex; gap: 6px; }
|
||||||
|
.pi-select { flex: 1; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; background: white; }
|
||||||
|
.pi-field { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||||
|
.pi-field span { font-size: 11px; color: #6b7280; font-weight: 600; }
|
||||||
|
.pi-input { padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; text-align: center; }
|
||||||
|
.pi-note-input { width: 100%; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Skeleton */
|
||||||
|
.pi-skeleton { height: 52px; border-radius: 10px; background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 200% 100%; animation: pi-shimmer 1.5s infinite; }
|
||||||
|
@keyframes pi-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
|
|
||||||
|
/* Department Label */
|
||||||
|
.pi-dept-label { font-size: 11px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 2px 4px; }
|
||||||
|
|
||||||
|
/* Bulk Form */
|
||||||
|
.pi-bulk-form { background: white; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.pi-edit-row { display: flex; gap: 8px; }
|
||||||
|
.pi-select { flex: 1; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: white; }
|
||||||
|
.pi-field { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||||
|
.pi-field span { font-size: 11px; color: #6b7280; font-weight: 600; }
|
||||||
|
.pi-input { padding: 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; text-align: center; font-weight: 600; }
|
||||||
|
.pi-note-input { width: 100%; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Target Section */
|
||||||
|
.pi-target-section { background: white; border-radius: 12px; padding: 12px; margin-bottom: 80px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||||
|
.pi-target-label { font-size: 12px; font-weight: 700; color: #6b7280; margin-bottom: 8px; }
|
||||||
|
.pi-target-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.pi-target-chip { font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px; background: #dbeafe; color: #1e40af; }
|
||||||
|
|
||||||
|
/* Empty */
|
||||||
|
.pi-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 14px; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (min-width: 640px) { .pi-bottom-bar { max-width: 640px; margin: 0 auto; } }
|
||||||
425
system1-factory/web/css/purchase-mobile.css
Normal file
425
system1-factory/web/css/purchase-mobile.css
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
/* purchase-mobile.css — 소모품 신청 모바일 전용 */
|
||||||
|
|
||||||
|
/* 메인 컨텐츠 (하단 네비 여유) */
|
||||||
|
.pm-content {
|
||||||
|
padding-bottom: calc(140px + env(safe-area-inset-bottom));
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상태 탭 */
|
||||||
|
.pm-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.pm-tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.pm-tab {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pm-tab.active {
|
||||||
|
background: #ea580c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.pm-tab .tab-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 카드 리스트 */
|
||||||
|
.pm-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.pm-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.pm-card:active { box-shadow: 0 0 0 2px rgba(234,88,12,0.2); }
|
||||||
|
.pm-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pm-card-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.pm-card-custom { font-size: 11px; color: #ea580c; margin-left: 4px; }
|
||||||
|
.pm-card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.pm-card-qty { color: #374151; font-weight: 600; }
|
||||||
|
|
||||||
|
/* FAB */
|
||||||
|
.pm-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(84px + env(safe-area-inset-bottom));
|
||||||
|
right: 20px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ea580c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(234,88,12,0.35);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.pm-fab:active { transform: scale(0.92); }
|
||||||
|
|
||||||
|
/* 바텀시트 */
|
||||||
|
.pm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
z-index: 1005;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
}
|
||||||
|
.pm-overlay.open { opacity: 1; pointer-events: auto; }
|
||||||
|
|
||||||
|
.pm-sheet {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
z-index: 1010;
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.pm-sheet.open { transform: translateY(0); }
|
||||||
|
.pm-sheet-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 8px auto;
|
||||||
|
}
|
||||||
|
.pm-sheet-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px 8px;
|
||||||
|
}
|
||||||
|
.pm-sheet-title { font-size: 17px; font-weight: 700; color: #1f2937; }
|
||||||
|
.pm-sheet-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pm-sheet-body { padding: 0 20px 20px; }
|
||||||
|
|
||||||
|
/* 검색 */
|
||||||
|
.pm-search-wrap { position: relative; margin-bottom: 12px; }
|
||||||
|
.pm-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 40px 12px 14px;
|
||||||
|
border: 1.5px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.pm-search-input:focus { border-color: #ea580c; }
|
||||||
|
.pm-search-spinner {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pm-search-spinner.show { display: block; }
|
||||||
|
.pm-search-results {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pm-search-results.open { display: block; }
|
||||||
|
.pm-search-thumb {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
.pm-search-thumb-empty {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pm-search-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.pm-search-item:last-child { border-bottom: none; }
|
||||||
|
.pm-search-item:active { background: #fff7ed; }
|
||||||
|
.pm-search-item .match-type {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pm-search-register {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ea580c;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.pm-search-register:active { background: #fff7ed; }
|
||||||
|
|
||||||
|
/* 장바구니 */
|
||||||
|
.pm-cart-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pm-cart-title { font-size: 14px; font-weight: 600; color: #374151; }
|
||||||
|
.pm-cart-count { font-size: 12px; color: #ea580c; font-weight: 600; }
|
||||||
|
.pm-cart-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.pm-cart-item-info { flex: 1; min-width: 0; }
|
||||||
|
.pm-cart-item-name { font-size: 13px; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pm-cart-item-meta { font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
||||||
|
.pm-cart-item-new { font-size: 10px; color: #ea580c; }
|
||||||
|
.pm-cart-qty {
|
||||||
|
width: 48px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pm-cart-memo {
|
||||||
|
width: 80px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pm-cart-thumb {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
.pm-cart-photo-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px dashed #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pm-cart-remove {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
/* 신규 품목 인라인 필드 */
|
||||||
|
.pm-cart-new-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.pm-cart-new-fields input, .pm-cart-new-fields select {
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 폼 필드 */
|
||||||
|
.pm-field { margin-bottom: 12px; }
|
||||||
|
.pm-label { display: block; font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 4px; }
|
||||||
|
.pm-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1.5px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.pm-input:focus { border-color: #ea580c; }
|
||||||
|
.pm-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1.5px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: white;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 사진 */
|
||||||
|
.pm-photo-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1.5px dashed #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.pm-photo-preview {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제출 */
|
||||||
|
.pm-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ea580c;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 16px;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
.pm-submit:active { background: #c2410c; }
|
||||||
|
.pm-submit:disabled { background: #d1d5db; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* 상세 시트 */
|
||||||
|
.pm-detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.pm-detail-label { color: #9ca3af; }
|
||||||
|
.pm-detail-value { color: #1f2937; font-weight: 500; }
|
||||||
|
.pm-received-photo {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 빈 상태 */
|
||||||
|
.pm-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.pm-empty i { font-size: 32px; margin-bottom: 8px; display: block; }
|
||||||
|
|
||||||
|
/* 로딩 */
|
||||||
|
.pm-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스피너 애니메이션 */
|
||||||
|
@keyframes pm-spin { to { transform: translateY(-50%) rotate(360deg); } }
|
||||||
|
.pm-search-spinner.show i { animation: pm-spin 0.8s linear infinite; }
|
||||||
@@ -15,14 +15,14 @@ button, .m-tbm-row, .m-tab, .m-new-btn, .m-detail-btn, .m-load-more,
|
|||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
body { max-width: 480px; margin: 0 auto; min-height: 100vh; }
|
body { max-width: 768px; margin: 0 auto; min-height: 100vh; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.m-header {
|
.m-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 30;
|
||||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
@@ -65,7 +65,7 @@ button, .m-tbm-row, .m-tab, .m-new-btn, .m-detail-btn, .m-load-more,
|
|||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 90;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
.m-tab {
|
.m-tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -289,53 +289,7 @@ button, .m-tbm-row, .m-tab, .m-new-btn, .m-detail-btn, .m-load-more,
|
|||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom nav */
|
/* Bottom nav → tkfb.css로 이동됨 (shared-bottom-nav.js 공통) */
|
||||||
.m-bottom-nav {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 68px;
|
|
||||||
background: #ffffff;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
box-shadow: 0 -2px 12px rgba(0,0,0,0.08);
|
|
||||||
z-index: 1000;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-around;
|
|
||||||
}
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.m-bottom-nav { max-width: 480px; margin: 0 auto; }
|
|
||||||
}
|
|
||||||
.m-nav-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #9ca3af;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem 0.25rem;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
.m-nav-item.active { color: #2563eb; }
|
|
||||||
.m-nav-item svg {
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.m-nav-item.active svg { stroke-width: 2.5; }
|
|
||||||
.m-nav-label {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.m-nav-item.active .m-nav-label { font-weight: 700; }
|
|
||||||
|
|
||||||
/* Detail badge */
|
/* Detail badge */
|
||||||
.m-detail-badge {
|
.m-detail-badge {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(r){r.forEach(function(reg){reg.unregister()});})}
|
if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(r){r.forEach(function(reg){reg.unregister()});})}
|
||||||
if('caches' in window){caches.keys().then(function(k){k.forEach(function(key){caches.delete(key)})})}
|
if('caches' in window){caches.keys().then(function(k){k.forEach(function(key){caches.delete(key)})})}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/js/sso-relay.js?v=20260401"></script>
|
||||||
<script src="/js/api-base.js?v=2026031401"></script>
|
<script src="/js/api-base.js?v=2026031401"></script>
|
||||||
<script>
|
<script>
|
||||||
// SSO 토큰 확인
|
// SSO 토큰 확인
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
window.location.replace('/pages/dashboard-new.html');
|
window.location.replace('/pages/dashboard-new.html');
|
||||||
} else {
|
} else {
|
||||||
// SSO 로그인 페이지로 리다이렉트 (gateway의 /login)
|
// SSO 로그인 페이지로 리다이렉트 (gateway의 /login)
|
||||||
window.location.replace('/login?redirect=' + encodeURIComponent('/pages/dashboard-new.html'));
|
window.location.replace('/login?redirect=' + encodeURIComponent(window.location.origin + '/pages/dashboard-new.html'));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ if ('caches' in window) {
|
|||||||
window.getLoginUrl = function() {
|
window.getLoginUrl = function() {
|
||||||
var hostname = window.location.hostname;
|
var hostname = window.location.hostname;
|
||||||
if (hostname.includes('technicalkorea.net')) {
|
if (hostname.includes('technicalkorea.net')) {
|
||||||
return window.location.protocol + '//tkds.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href);
|
return window.location.protocol + '//tkfb.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href);
|
||||||
}
|
}
|
||||||
// 개발 환경: tkds 포트 (30780)
|
// 개발 환경: tkds 포트 (30780)
|
||||||
return window.location.protocol + '//' + hostname + ':30780/dashboard?redirect=' + encodeURIComponent(window.location.href);
|
return window.location.protocol + '//' + hostname + ':30780/dashboard?redirect=' + encodeURIComponent(window.location.href);
|
||||||
|
|||||||
@@ -1,208 +1,130 @@
|
|||||||
// js/change-password.js
|
// js/change-password.js — 비밀번호 변경 (일반 스크립트, tkfb-core.js 전역 함수 사용)
|
||||||
// 개인 비밀번호 변경 페이지 JavaScript
|
(function() {
|
||||||
|
var form = document.getElementById('changePasswordForm');
|
||||||
|
var messageArea = document.getElementById('message-area');
|
||||||
|
var submitBtn = document.getElementById('submitBtn');
|
||||||
|
var resetBtn = document.getElementById('resetBtn');
|
||||||
|
|
||||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
if (!form) return;
|
||||||
|
|
||||||
// 인증 확인
|
// 비밀번호 토글
|
||||||
const token = ensureAuthenticated();
|
document.querySelectorAll('.password-toggle').forEach(function(button) {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
// DOM 요소
|
var input = document.getElementById(this.getAttribute('data-target'));
|
||||||
const form = document.getElementById('changePasswordForm');
|
if (input) {
|
||||||
const messageArea = document.getElementById('message-area');
|
var isPassword = input.type === 'password';
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
input.type = isPassword ? 'text' : 'password';
|
||||||
const resetBtn = document.getElementById('resetBtn');
|
this.textContent = isPassword ? '숨기기' : '보기';
|
||||||
|
}
|
||||||
// 비밀번호 토글 기능
|
});
|
||||||
document.querySelectorAll('.password-toggle').forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const targetId = this.getAttribute('data-target');
|
|
||||||
const input = document.getElementById(targetId);
|
|
||||||
|
|
||||||
if (input) {
|
|
||||||
const isPassword = input.type === 'password';
|
|
||||||
input.type = isPassword ? 'text' : 'password';
|
|
||||||
this.textContent = isPassword ? '👁️🗨️' : '👁️';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// 초기화 버튼
|
// 초기화
|
||||||
resetBtn?.addEventListener('click', () => {
|
if (resetBtn) resetBtn.addEventListener('click', function() {
|
||||||
form.reset();
|
form.reset();
|
||||||
clearMessages();
|
messageArea.innerHTML = '';
|
||||||
document.getElementById('passwordStrength').innerHTML = '';
|
var s = document.getElementById('passwordStrength');
|
||||||
});
|
if (s) s.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
// 메시지 표시 함수
|
function showMessage(type, msg) {
|
||||||
function showMessage(type, message) {
|
messageArea.innerHTML = '<div class="message-box ' + type + '">' +
|
||||||
messageArea.innerHTML = `
|
(type === 'error' ? '❌ ' : '✅ ') + msg + '</div>';
|
||||||
<div class="message-box ${type}">
|
if (type === 'error') setTimeout(function() { messageArea.innerHTML = ''; }, 5000);
|
||||||
${type === 'error' ? '❌' : '✅'} ${message}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 에러 메시지는 5초 후 자동 제거
|
|
||||||
if (type === 'error') {
|
|
||||||
setTimeout(clearMessages, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMessages() {
|
|
||||||
messageArea.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 강도 체크
|
|
||||||
async function checkPasswordStrength(password) {
|
|
||||||
if (!password) {
|
|
||||||
document.getElementById('passwordStrength').innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 비밀번호 강도 체크
|
||||||
const res = await fetch(`${API}/auth/check-password-strength`, {
|
var strengthTimer;
|
||||||
method: 'POST',
|
var newPwInput = document.getElementById('newPassword');
|
||||||
headers: {
|
if (newPwInput) newPwInput.addEventListener('input', function() {
|
||||||
'Content-Type': 'application/json'
|
clearTimeout(strengthTimer);
|
||||||
},
|
var pw = this.value;
|
||||||
body: JSON.stringify({ password })
|
strengthTimer = setTimeout(function() {
|
||||||
});
|
if (!pw) { document.getElementById('passwordStrength').innerHTML = ''; return; }
|
||||||
|
var token = (window.getSSOToken && window.getSSOToken()) || localStorage.getItem('sso_token') || '';
|
||||||
const result = await res.json();
|
fetch('/api/auth/check-password-strength', {
|
||||||
updatePasswordStrengthUI(result);
|
method: 'POST',
|
||||||
} catch (error) {
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
console.error('Password strength check error:', error);
|
body: JSON.stringify({ password: pw })
|
||||||
}
|
}).then(function(r) { return r.json(); }).then(function(result) {
|
||||||
}
|
if (!result.success) return;
|
||||||
|
var d = result.data;
|
||||||
|
var colors = { weak: '#f44336', medium: '#ffc107', strong: '#4caf50' };
|
||||||
|
var labels = { weak: '약함', medium: '보통', strong: '강함' };
|
||||||
|
var pct = (d.score / 5) * 100;
|
||||||
|
document.getElementById('passwordStrength').innerHTML =
|
||||||
|
'<div style="margin-top:10px"><div style="display:flex;justify-content:space-between;margin-bottom:4px">' +
|
||||||
|
'<span style="font-size:0.85rem;color:' + (colors[d.level]||'#ccc') + ';font-weight:500">' + (labels[d.level]||'') + '</span>' +
|
||||||
|
'<span style="font-size:0.8rem;color:#666">' + d.score + '/5</span></div>' +
|
||||||
|
'<div style="height:6px;background:#e0e0e0;border-radius:3px;overflow:hidden">' +
|
||||||
|
'<div style="width:' + pct + '%;height:100%;background:' + (colors[d.level]||'#ccc') + ';transition:all 0.3s"></div></div></div>';
|
||||||
|
}).catch(function() {});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
// 비밀번호 강도 UI 업데이트
|
// 폼 제출
|
||||||
function updatePasswordStrengthUI(strength) {
|
form.addEventListener('submit', function(e) {
|
||||||
const container = document.getElementById('passwordStrength');
|
e.preventDefault();
|
||||||
if (!container) return;
|
messageArea.innerHTML = '';
|
||||||
|
|
||||||
const colors = {
|
|
||||||
0: '#f44336',
|
|
||||||
1: '#ff9800',
|
|
||||||
2: '#ffc107',
|
|
||||||
3: '#4caf50',
|
|
||||||
4: '#2196f3'
|
|
||||||
};
|
|
||||||
|
|
||||||
const strengthText = strength.strengthText || '비밀번호를 입력하세요';
|
|
||||||
const color = colors[strength.strength] || '#ccc';
|
|
||||||
const percentage = (strength.score / strength.maxScore) * 100;
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div style="margin-top: 12px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|
||||||
<span style="font-size: 0.85rem; color: ${color}; font-weight: 500;">
|
|
||||||
${strengthText}
|
|
||||||
</span>
|
|
||||||
<span style="font-size: 0.8rem; color: #666;">
|
|
||||||
${strength.score}/${strength.maxScore}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style="height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden;">
|
|
||||||
<div style="width: ${percentage}%; height: 100%; background: ${color}; transition: all 0.3s;"></div>
|
|
||||||
</div>
|
|
||||||
${strength.feedback && strength.feedback.length > 0 ? `
|
|
||||||
<ul style="margin-top: 10px; font-size: 0.8rem; color: #666; padding-left: 20px;">
|
|
||||||
${strength.feedback.map(f => `<li>${f}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 입력 이벤트
|
var currentPassword = document.getElementById('currentPassword').value;
|
||||||
let strengthCheckTimer;
|
var newPassword = document.getElementById('newPassword').value;
|
||||||
document.getElementById('newPassword')?.addEventListener('input', (e) => {
|
var confirmPassword = document.getElementById('confirmPassword').value;
|
||||||
clearTimeout(strengthCheckTimer);
|
|
||||||
strengthCheckTimer = setTimeout(() => {
|
|
||||||
checkPasswordStrength(e.target.value);
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 폼 제출
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
form?.addEventListener('submit', async (e) => {
|
showMessage('error', '모든 필드를 입력해주세요.');
|
||||||
e.preventDefault();
|
return;
|
||||||
clearMessages();
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
const currentPassword = document.getElementById('currentPassword').value;
|
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
|
||||||
const newPassword = document.getElementById('newPassword').value;
|
return;
|
||||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
// 유효성 검사
|
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
|
||||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
return;
|
||||||
showMessage('error', '모든 필드를 입력해주세요.');
|
}
|
||||||
return;
|
if (currentPassword === newPassword) {
|
||||||
}
|
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
|
||||||
|
return;
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
|
||||||
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPassword === newPassword) {
|
|
||||||
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 버튼 상태 변경
|
|
||||||
const originalText = submitBtn.innerHTML;
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<span>⏳</span><span>처리 중...</span>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/auth/change-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentPassword,
|
|
||||||
newPassword
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await res.json();
|
|
||||||
|
|
||||||
if (res.ok && result.success) {
|
|
||||||
showMessage('success', '비밀번호가 성공적으로 변경되었습니다.');
|
|
||||||
form.reset();
|
|
||||||
document.getElementById('passwordStrength').innerHTML = '';
|
|
||||||
|
|
||||||
// 카운트다운 시작
|
|
||||||
let countdown = 3;
|
|
||||||
const countdownInterval = setInterval(() => {
|
|
||||||
showMessage('success',
|
|
||||||
`비밀번호가 변경되었습니다. ${countdown}초 후 로그인 페이지로 이동합니다.`
|
|
||||||
);
|
|
||||||
countdown--;
|
|
||||||
|
|
||||||
if (countdown < 0) {
|
|
||||||
clearInterval(countdownInterval);
|
|
||||||
if (window.clearSSOAuth) window.clearSSOAuth();
|
|
||||||
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
const errorMessage = result.error || '비밀번호 변경에 실패했습니다.';
|
|
||||||
showMessage('error', errorMessage);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Password change error:', error);
|
|
||||||
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
|
|
||||||
} finally {
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = originalText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 페이지 로드 시 현재 사용자 정보 표시
|
var originalText = submitBtn.innerHTML;
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
submitBtn.disabled = true;
|
||||||
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
|
submitBtn.innerHTML = '처리 중...';
|
||||||
});
|
|
||||||
|
var token = (window.getSSOToken && window.getSSOToken()) || localStorage.getItem('sso_token') || '';
|
||||||
|
fetch('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify({ currentPassword: currentPassword, newPassword: newPassword })
|
||||||
|
}).then(function(res) {
|
||||||
|
return res.json().then(function(data) { return { ok: res.ok, data: data }; });
|
||||||
|
}).then(function(result) {
|
||||||
|
if (result.ok && result.data.success) {
|
||||||
|
showMessage('success', '비밀번호가 변경되었습니다.');
|
||||||
|
form.reset();
|
||||||
|
var s = document.getElementById('passwordStrength');
|
||||||
|
if (s) s.innerHTML = '';
|
||||||
|
var countdown = 3;
|
||||||
|
var interval = setInterval(function() {
|
||||||
|
showMessage('success', '비밀번호가 변경되었습니다. ' + countdown + '초 후 로그인 페이지로 이동합니다.');
|
||||||
|
countdown--;
|
||||||
|
if (countdown < 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
// 쿠키 + localStorage 전부 삭제 (doLogout 로직 재사용)
|
||||||
|
_cookieRemove('sso_token'); _cookieRemove('sso_user'); _cookieRemove('sso_refresh_token');
|
||||||
|
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) { localStorage.removeItem(k); });
|
||||||
|
window.location.href = (window.getLoginUrl ? window.getLoginUrl() : '/login') + '&logout=1';
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
showMessage('error', result.data.message || result.data.error || '비밀번호 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
|
||||||
|
}).finally(function() {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
300
system1-factory/web/js/daily-status.js
Normal file
300
system1-factory/web/js/daily-status.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* daily-status.js — 일별 TBM/작업보고서 입력 현황 대시보드
|
||||||
|
* Sprint 002 Section B
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== Mock 설정 =====
|
||||||
|
const MOCK_ENABLED = false;
|
||||||
|
|
||||||
|
const MOCK_DATA = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
date: '2026-03-30',
|
||||||
|
summary: {
|
||||||
|
total_active_workers: 45, tbm_completed: 38, tbm_missing: 7,
|
||||||
|
report_completed: 35, report_missing: 10, both_completed: 33, both_missing: 5
|
||||||
|
},
|
||||||
|
workers: [
|
||||||
|
{ user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null },
|
||||||
|
{ user_id: 22, worker_name: '이영희', job_type: '배관', department_name: '생산2팀', has_tbm: true, has_report: false, tbm_session_id: 140, total_report_hours: 0, status: 'tbm_only', proxy_history: null },
|
||||||
|
{ user_id: 30, worker_name: '박민수', job_type: '전기', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 141, total_report_hours: 8, status: 'complete', proxy_history: { proxy_by: '관리자', proxy_at: '2026-03-30T14:30:00' } },
|
||||||
|
{ user_id: 35, worker_name: '정대호', job_type: '도장', department_name: '생산2팀', has_tbm: false, has_report: true, tbm_session_id: null, total_report_hours: 8, status: 'report_only', proxy_history: null },
|
||||||
|
{ user_id: 40, worker_name: '최윤서', job_type: '용접', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 142, total_report_hours: 9, status: 'complete', proxy_history: null },
|
||||||
|
{ user_id: 41, worker_name: '한지민', job_type: '사상', department_name: '생산2팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null },
|
||||||
|
{ user_id: 42, worker_name: '송민호', job_type: '절단', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 143, total_report_hours: 8, status: 'complete', proxy_history: null },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_DETAIL = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
worker: { user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' },
|
||||||
|
tbm_sessions: [],
|
||||||
|
work_reports: [],
|
||||||
|
proxy_history: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== State =====
|
||||||
|
let currentDate = new Date();
|
||||||
|
let workers = [];
|
||||||
|
let currentFilter = 'all';
|
||||||
|
let selectedWorkerId = null;
|
||||||
|
|
||||||
|
const DAYS_KR = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
|
||||||
|
const ALLOWED_ROLES = ['support_team', 'admin', 'system'];
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// URL 파라미터에서 날짜 가져오기
|
||||||
|
const urlDate = new URLSearchParams(location.search).get('date');
|
||||||
|
if (urlDate) currentDate = new Date(urlDate + 'T00:00:00');
|
||||||
|
|
||||||
|
// 권한 체크 (initAuth 완료 후)
|
||||||
|
setTimeout(() => {
|
||||||
|
const user = window.currentUser;
|
||||||
|
if (user && !ALLOWED_ROLES.includes(user.role)) {
|
||||||
|
document.getElementById('workerList').classList.add('hidden');
|
||||||
|
document.getElementById('bottomAction').classList.add('hidden');
|
||||||
|
document.getElementById('noPermission').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadStatus();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Date Navigation =====
|
||||||
|
function formatDateStr(d) {
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDateDisplay() {
|
||||||
|
const str = formatDateStr(currentDate);
|
||||||
|
document.getElementById('dateText').textContent = str;
|
||||||
|
document.getElementById('dayText').textContent = DAYS_KR[currentDate.getDay()];
|
||||||
|
|
||||||
|
// 미래 날짜 비활성
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const nextBtn = document.getElementById('nextDate');
|
||||||
|
nextBtn.disabled = currentDate >= today;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDate(delta) {
|
||||||
|
currentDate.setDate(currentDate.getDate() + delta);
|
||||||
|
updateDateDisplay();
|
||||||
|
loadStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatePicker() {
|
||||||
|
const picker = document.getElementById('datePicker');
|
||||||
|
picker.value = formatDateStr(currentDate);
|
||||||
|
picker.max = formatDateStr(new Date());
|
||||||
|
picker.showPicker ? picker.showPicker() : picker.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDatePicked(val) {
|
||||||
|
if (!val) return;
|
||||||
|
currentDate = new Date(val + 'T00:00:00');
|
||||||
|
updateDateDisplay();
|
||||||
|
loadStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Data Loading =====
|
||||||
|
async function loadStatus() {
|
||||||
|
const listEl = document.getElementById('workerList');
|
||||||
|
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
|
||||||
|
document.getElementById('emptyState').classList.add('hidden');
|
||||||
|
updateDateDisplay();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (MOCK_ENABLED) {
|
||||||
|
res = MOCK_DATA;
|
||||||
|
} else {
|
||||||
|
res = await window.apiCall('/proxy-input/daily-status?date=' + formatDateStr(currentDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !res.success) {
|
||||||
|
listEl.innerHTML = '<div class="ds-empty"><p>데이터를 불러올 수 없습니다</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workers = res.data.workers || [];
|
||||||
|
updateSummary(res.data.summary || {});
|
||||||
|
updateFilterCounts();
|
||||||
|
renderWorkerList();
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div class="ds-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류. 다시 시도해주세요.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary(s) {
|
||||||
|
document.getElementById('totalCount').textContent = s.total_active_workers || 0;
|
||||||
|
document.getElementById('doneCount').textContent = s.both_completed || 0;
|
||||||
|
document.getElementById('missingCount').textContent = s.both_missing || 0;
|
||||||
|
|
||||||
|
const total = s.total_active_workers || 1;
|
||||||
|
document.getElementById('donePct').textContent = Math.round((s.both_completed || 0) / total * 100) + '%';
|
||||||
|
document.getElementById('missingPct').textContent = Math.round((s.both_missing || 0) / total * 100) + '%';
|
||||||
|
|
||||||
|
// 하단 버튼 카운트
|
||||||
|
const missingWorkers = workers.filter(w => w.status !== 'complete').length;
|
||||||
|
document.getElementById('proxyCount').textContent = missingWorkers;
|
||||||
|
document.getElementById('proxyBtn').disabled = missingWorkers === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterCounts() {
|
||||||
|
document.getElementById('filterAll').textContent = workers.length;
|
||||||
|
document.getElementById('filterComplete').textContent = workers.filter(w => w.status === 'complete').length;
|
||||||
|
document.getElementById('filterMissing').textContent = workers.filter(w => w.status === 'both_missing').length;
|
||||||
|
document.getElementById('filterPartial').textContent = workers.filter(w => w.status === 'tbm_only' || w.status === 'report_only').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Filter =====
|
||||||
|
function setFilter(f) {
|
||||||
|
currentFilter = f;
|
||||||
|
document.querySelectorAll('.ds-tab').forEach(tab => {
|
||||||
|
tab.classList.toggle('active', tab.dataset.filter === f);
|
||||||
|
});
|
||||||
|
renderWorkerList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Render =====
|
||||||
|
function renderWorkerList() {
|
||||||
|
const listEl = document.getElementById('workerList');
|
||||||
|
const emptyEl = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
let filtered = workers;
|
||||||
|
if (currentFilter === 'complete') filtered = workers.filter(w => w.status === 'complete');
|
||||||
|
else if (currentFilter === 'both_missing') filtered = workers.filter(w => w.status === 'both_missing');
|
||||||
|
else if (currentFilter === 'partial') filtered = workers.filter(w => w.status === 'tbm_only' || w.status === 'report_only');
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
emptyEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyEl.classList.add('hidden');
|
||||||
|
|
||||||
|
listEl.innerHTML = filtered.map(w => {
|
||||||
|
const tbmBadge = w.has_tbm
|
||||||
|
? '<span class="ds-badge-ok">TBM ✓</span>'
|
||||||
|
: '<span class="ds-badge-no">TBM ✗</span>';
|
||||||
|
const reportBadge = w.has_report
|
||||||
|
? `<span class="ds-badge-ok">보고서 ✓${w.total_report_hours ? ' ' + w.total_report_hours + 'h' : ''}</span>`
|
||||||
|
: '<span class="ds-badge-no">보고서 ✗</span>';
|
||||||
|
const isProxy = w.tbm_sessions?.some(t => t.is_proxy_input) || false;
|
||||||
|
const proxyBadge = isProxy
|
||||||
|
? '<span class="ds-badge-proxy">대리입력</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="ds-worker-row" onclick="openSheet(${w.user_id})">
|
||||||
|
<div class="ds-status-dot ${w.status}"></div>
|
||||||
|
<div class="ds-worker-info">
|
||||||
|
<div class="ds-worker-name">${escHtml(w.worker_name)}</div>
|
||||||
|
<div class="ds-worker-dept">${escHtml(w.job_type)} · ${escHtml(w.department_name)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ds-worker-status">${tbmBadge}${reportBadge}${proxyBadge}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
|
|
||||||
|
// ===== Bottom Sheet =====
|
||||||
|
function openSheet(userId) {
|
||||||
|
selectedWorkerId = userId;
|
||||||
|
const w = workers.find(x => x.user_id === userId);
|
||||||
|
if (!w) return;
|
||||||
|
|
||||||
|
document.getElementById('sheetWorkerName').textContent = w.worker_name;
|
||||||
|
document.getElementById('sheetWorkerInfo').textContent = `${w.job_type} · ${w.department_name}`;
|
||||||
|
document.getElementById('sheetBody').innerHTML = '<div class="ds-sheet-loading"><i class="fas fa-spinner fa-spin"></i> 로딩 중...</div>';
|
||||||
|
|
||||||
|
document.getElementById('sheetOverlay').classList.remove('hidden');
|
||||||
|
document.getElementById('detailSheet').classList.remove('hidden');
|
||||||
|
setTimeout(() => document.getElementById('detailSheet').classList.add('open'), 10);
|
||||||
|
|
||||||
|
// 상세 데이터 로드
|
||||||
|
loadDetail(userId, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(userId, workerBasic) {
|
||||||
|
const bodyEl = document.getElementById('sheetBody');
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (MOCK_ENABLED) {
|
||||||
|
res = JSON.parse(JSON.stringify(MOCK_DETAIL));
|
||||||
|
res.data.worker = workerBasic;
|
||||||
|
// mock: complete 상태면 TBM/보고서 데이터 채우기
|
||||||
|
if (workerBasic.has_tbm) {
|
||||||
|
res.data.tbm_sessions = [{ session_id: workerBasic.tbm_session_id, session_date: formatDateStr(currentDate), status: 'completed', leader_name: '반장' }];
|
||||||
|
}
|
||||||
|
if (workerBasic.has_report) {
|
||||||
|
res.data.work_reports = [{ report_date: formatDateStr(currentDate), project_name: '프로젝트A', work_type_name: workerBasic.job_type, work_hours: workerBasic.total_report_hours }];
|
||||||
|
}
|
||||||
|
if (workerBasic.proxy_history) {
|
||||||
|
res.data.proxy_history = [workerBasic.proxy_history];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res = await window.apiCall('/proxy-input/daily-status/detail?date=' + formatDateStr(currentDate) + '&user_id=' + userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !res.success) { bodyEl.innerHTML = '<div class="ds-sheet-card empty">상세 정보를 불러올 수 없습니다</div>'; return; }
|
||||||
|
|
||||||
|
const d = res.data;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// TBM 섹션
|
||||||
|
html += '<div class="ds-sheet-section"><div class="ds-sheet-section-title"><i class="fas fa-clipboard-check"></i> TBM</div>';
|
||||||
|
if (d.tbm_sessions && d.tbm_sessions.length > 0) {
|
||||||
|
html += d.tbm_sessions.map(s => {
|
||||||
|
const proxyTag = s.is_proxy_input ? ` · <span class="ds-badge-proxy">대리입력(${escHtml(s.proxy_input_by_name || '-')})</span>` : '';
|
||||||
|
return `<div class="ds-sheet-card">세션 #${s.session_id} · ${s.status === 'completed' ? '완료' : '진행중'} · 리더: ${escHtml(s.leader_name || '-')}${proxyTag}</div>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
html += '<div class="ds-sheet-card empty">세션 없음</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// 작업보고서 섹션
|
||||||
|
html += '<div class="ds-sheet-section"><div class="ds-sheet-section-title"><i class="fas fa-file-alt"></i> 작업보고서</div>';
|
||||||
|
if (d.work_reports && d.work_reports.length > 0) {
|
||||||
|
html += d.work_reports.map(r => `<div class="ds-sheet-card">${escHtml(r.project_name || '-')} · ${escHtml(r.work_type_name || '-')} · ${r.work_hours || 0}시간</div>`).join('');
|
||||||
|
} else {
|
||||||
|
html += '<div class="ds-sheet-card empty">보고서 없음</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
bodyEl.innerHTML = html;
|
||||||
|
|
||||||
|
// 완료 상태면 대리입력 버튼 숨김
|
||||||
|
const btn = document.getElementById('sheetProxyBtn');
|
||||||
|
btn.style.display = workerBasic.status === 'complete' ? 'none' : 'block';
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
bodyEl.innerHTML = '<div class="ds-sheet-card empty">네트워크 오류</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSheet() {
|
||||||
|
document.getElementById('detailSheet').classList.remove('open');
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('sheetOverlay').classList.add('hidden');
|
||||||
|
document.getElementById('detailSheet').classList.add('hidden');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Navigation =====
|
||||||
|
function goProxyInput() {
|
||||||
|
location.href = '/pages/work/proxy-input.html?date=' + formatDateStr(currentDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goProxyInputSingle() {
|
||||||
|
if (selectedWorkerId) {
|
||||||
|
location.href = '/pages/work/proxy-input.html?date=' + formatDateStr(currentDate) + '&user_id=' + selectedWorkerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
833
system1-factory/web/js/monthly-comparison.js
Normal file
833
system1-factory/web/js/monthly-comparison.js
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
/**
|
||||||
|
* monthly-comparison.js — 월간 비교·확인·정산
|
||||||
|
* Sprint 004 Section B
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== Mock =====
|
||||||
|
const MOCK_ENABLED = false;
|
||||||
|
|
||||||
|
const MOCK_MY_RECORDS = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: { user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' },
|
||||||
|
period: { year: 2026, month: 3 },
|
||||||
|
summary: {
|
||||||
|
total_work_days: 22, total_work_hours: 182.5,
|
||||||
|
total_overtime_hours: 6.5, vacation_days: 1,
|
||||||
|
mismatch_count: 3,
|
||||||
|
mismatch_details: { hours_diff: 2, missing_report: 1, missing_attendance: 0 }
|
||||||
|
},
|
||||||
|
confirmation: { status: 'pending', confirmed_at: null, reject_reason: null },
|
||||||
|
daily_records: [
|
||||||
|
{ date: '2026-03-01', day_of_week: '월', is_holiday: false,
|
||||||
|
work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] },
|
||||||
|
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
|
||||||
|
status: 'match', hours_diff: 0 },
|
||||||
|
{ date: '2026-03-02', day_of_week: '화', is_holiday: false,
|
||||||
|
work_report: { total_hours: 9.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 9.0 }] },
|
||||||
|
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
|
||||||
|
status: 'mismatch', hours_diff: 1.0 },
|
||||||
|
{ date: '2026-03-03', day_of_week: '수', is_holiday: false,
|
||||||
|
work_report: null,
|
||||||
|
attendance: { total_work_hours: 0, attendance_type: '휴가근로', vacation_type: '연차' },
|
||||||
|
status: 'vacation', hours_diff: 0 },
|
||||||
|
{ date: '2026-03-04', day_of_week: '목', is_holiday: false,
|
||||||
|
work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] },
|
||||||
|
attendance: null,
|
||||||
|
status: 'report_only', hours_diff: 0 },
|
||||||
|
{ date: '2026-03-05', day_of_week: '금', is_holiday: false,
|
||||||
|
work_report: { total_hours: 8.0, entries: [{ project_name: 'B동 보수', work_type: '배관', hours: 8.0 }] },
|
||||||
|
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
|
||||||
|
status: 'match', hours_diff: 0 },
|
||||||
|
{ date: '2026-03-06', day_of_week: '토', is_holiday: true,
|
||||||
|
work_report: null, attendance: null, status: 'holiday', hours_diff: 0 },
|
||||||
|
{ date: '2026-03-07', day_of_week: '일', is_holiday: true,
|
||||||
|
work_report: null, attendance: null, status: 'holiday', hours_diff: 0 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_ADMIN_STATUS = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
period: { year: 2026, month: 3 },
|
||||||
|
summary: { total_workers: 25, confirmed: 15, pending: 8, rejected: 2 },
|
||||||
|
workers: [
|
||||||
|
{ user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀',
|
||||||
|
total_work_days: 22, total_work_hours: 182.5, total_overtime_hours: 6.5,
|
||||||
|
status: 'confirmed', confirmed_at: '2026-03-30T10:00:00', mismatch_count: 0 },
|
||||||
|
{ user_id: 11, worker_name: '이영희', job_type: '도장', department_name: '생산1팀',
|
||||||
|
total_work_days: 20, total_work_hours: 168.0, total_overtime_hours: 2.0,
|
||||||
|
status: 'pending', confirmed_at: null, mismatch_count: 0 },
|
||||||
|
{ user_id: 12, worker_name: '박민수', job_type: '배관', department_name: '생산2팀',
|
||||||
|
total_work_days: 22, total_work_hours: 190.0, total_overtime_hours: 14.0,
|
||||||
|
status: 'rejected', confirmed_at: null, reject_reason: '3/15 근무시간 오류', mismatch_count: 2 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== State =====
|
||||||
|
let currentYear, currentMonth;
|
||||||
|
let currentMode = 'my'; // 'my' | 'admin' | 'detail'
|
||||||
|
let currentUserId = null;
|
||||||
|
let comparisonData = null;
|
||||||
|
let adminData = null;
|
||||||
|
let currentFilter = 'all';
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
|
||||||
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const now = new Date();
|
||||||
|
currentYear = now.getFullYear();
|
||||||
|
currentMonth = now.getMonth() + 1;
|
||||||
|
|
||||||
|
// URL 파라미터
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (params.get('year')) currentYear = parseInt(params.get('year'));
|
||||||
|
if (params.get('month')) currentMonth = parseInt(params.get('month'));
|
||||||
|
if (params.get('user_id')) currentUserId = parseInt(params.get('user_id'));
|
||||||
|
const urlMode = params.get('mode');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser;
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// 비관리자 → 작업자 전용 확인 페이지로 리다이렉트
|
||||||
|
if (!ADMIN_ROLES.includes(user.role)) {
|
||||||
|
location.href = '/pages/attendance/my-monthly-confirm.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리자 mode 결정
|
||||||
|
if (currentUserId) {
|
||||||
|
currentMode = 'detail';
|
||||||
|
} else {
|
||||||
|
currentMode = 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리자 뷰 전환 버튼 (관리자만)
|
||||||
|
if (ADMIN_ROLES.includes(user.role)) {
|
||||||
|
document.getElementById('viewToggleBtn').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMonthLabel();
|
||||||
|
loadData();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Month Nav =====
|
||||||
|
function updateMonthLabel() {
|
||||||
|
document.getElementById('monthLabel').textContent = `${currentYear}년 ${currentMonth}월`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMonth(delta) {
|
||||||
|
currentMonth += delta;
|
||||||
|
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
|
||||||
|
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
|
||||||
|
updateMonthLabel();
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Data Load =====
|
||||||
|
async function loadData() {
|
||||||
|
if (currentMode === 'admin') {
|
||||||
|
await loadAdminStatus();
|
||||||
|
} else {
|
||||||
|
await loadMyRecords();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyRecords() {
|
||||||
|
document.getElementById('workerView').classList.remove('hidden');
|
||||||
|
document.getElementById('adminView').classList.add('hidden');
|
||||||
|
document.getElementById('pageTitle').textContent = currentMode === 'detail' ? '작업자 근무 비교' : '월간 근무 비교';
|
||||||
|
|
||||||
|
const listEl = document.getElementById('dailyList');
|
||||||
|
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (MOCK_ENABLED) {
|
||||||
|
res = JSON.parse(JSON.stringify(MOCK_MY_RECORDS));
|
||||||
|
} else {
|
||||||
|
const endpoint = currentMode === 'detail' && currentUserId
|
||||||
|
? `/monthly-comparison/records?year=${currentYear}&month=${currentMonth}&user_id=${currentUserId}`
|
||||||
|
: `/monthly-comparison/my-records?year=${currentYear}&month=${currentMonth}`;
|
||||||
|
res = await window.apiCall(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !res.success) {
|
||||||
|
listEl.innerHTML = '<div class="mc-empty"><p>데이터를 불러올 수 없습니다</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
comparisonData = res.data;
|
||||||
|
|
||||||
|
// detail 모드: 작업자 이름 + 검토완료 버튼 (상단 헤더)
|
||||||
|
if (currentMode === 'detail' && comparisonData.user) {
|
||||||
|
var isChecked = comparisonData.confirmation && comparisonData.confirmation.admin_checked;
|
||||||
|
var checkBtnHtml = '<button type="button" id="headerCheckBtn" onclick="toggleAdminCheck()" style="' +
|
||||||
|
'padding:6px 12px;border-radius:8px;font-size:0.75rem;font-weight:600;border:none;cursor:pointer;margin-left:auto;' +
|
||||||
|
(isChecked ? 'background:#dcfce7;color:#166534;' : 'background:#f3f4f6;color:#6b7280;') +
|
||||||
|
'">' + (isChecked ? '✓ 검토완료' : '검토하기') + '</button>';
|
||||||
|
document.getElementById('pageTitle').innerHTML =
|
||||||
|
(comparisonData.user.worker_name || '') + ' 근무 비교' + checkBtnHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSummaryCards(comparisonData.summary);
|
||||||
|
renderMismatchAlert(comparisonData.summary);
|
||||||
|
renderDailyList(comparisonData.daily_records || []);
|
||||||
|
renderConfirmationStatus(comparisonData.confirmation);
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminStatus() {
|
||||||
|
document.getElementById('workerView').classList.add('hidden');
|
||||||
|
document.getElementById('adminView').classList.remove('hidden');
|
||||||
|
document.getElementById('pageTitle').textContent = '월간 근무 확인 현황';
|
||||||
|
|
||||||
|
const listEl = document.getElementById('adminWorkerList');
|
||||||
|
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (MOCK_ENABLED) {
|
||||||
|
res = JSON.parse(JSON.stringify(MOCK_ADMIN_STATUS));
|
||||||
|
} else {
|
||||||
|
res = await window.apiCall(`/monthly-comparison/all-status?year=${currentYear}&month=${currentMonth}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !res.success) {
|
||||||
|
listEl.innerHTML = '<div class="mc-empty"><p>데이터를 불러올 수 없습니다</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminData = res.data;
|
||||||
|
renderAdminSummary(adminData.summary);
|
||||||
|
renderWorkerList(adminData.workers || []);
|
||||||
|
updateExportButton(adminData.summary, adminData.workers || []);
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Render: Worker View =====
|
||||||
|
function renderSummaryCards(s) {
|
||||||
|
document.getElementById('totalDays').textContent = s.total_work_days || 0;
|
||||||
|
document.getElementById('totalHours').textContent = (s.total_work_hours || 0) + 'h';
|
||||||
|
document.getElementById('overtimeHours').textContent = (s.total_overtime_hours || 0) + 'h';
|
||||||
|
document.getElementById('vacationDays').textContent = (s.vacation_days || 0) + '일';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMismatchAlert(s) {
|
||||||
|
const el = document.getElementById('mismatchAlert');
|
||||||
|
if (!s.mismatch_count || s.mismatch_count === 0) {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
const details = s.mismatch_details || {};
|
||||||
|
const parts = [];
|
||||||
|
if (details.hours_diff) parts.push(`시간차이 ${details.hours_diff}건`);
|
||||||
|
if (details.missing_report) parts.push(`보고서만 ${details.missing_report}건`);
|
||||||
|
if (details.missing_attendance) parts.push(`근태만 ${details.missing_attendance}건`);
|
||||||
|
document.getElementById('mismatchText').textContent =
|
||||||
|
`${s.mismatch_count}건의 불일치가 있습니다` + (parts.length ? ` (${parts.join(' | ')})` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDailyList(records) {
|
||||||
|
const el = document.getElementById('dailyList');
|
||||||
|
if (!records.length) {
|
||||||
|
el.innerHTML = '<div class="mc-empty"><p>데이터가 없습니다</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = records.map(r => {
|
||||||
|
const dateStr = r.date.substring(5); // "03-01"
|
||||||
|
const dayStr = r.day_of_week || '';
|
||||||
|
const icon = getStatusIcon(r.status);
|
||||||
|
const label = getStatusLabel(r.status, r);
|
||||||
|
|
||||||
|
let reportLine = '';
|
||||||
|
let attendLine = '';
|
||||||
|
let diffLine = '';
|
||||||
|
|
||||||
|
if (r.work_report) {
|
||||||
|
const entries = (r.work_report.entries || []).map(e => `${e.project_name}-${e.work_type}`).join(', ');
|
||||||
|
reportLine = `<div class="mc-daily-row">작업보고: <strong>${r.work_report.total_hours}h</strong> <span>(${escHtml(entries)})</span></div>`;
|
||||||
|
} else if (r.status !== 'holiday') {
|
||||||
|
reportLine = '<div class="mc-daily-row" style="color:#9ca3af">작업보고: -</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.attendance) {
|
||||||
|
const vacInfo = r.attendance.vacation_type ? ` (${r.attendance.vacation_type})` : '';
|
||||||
|
// 주말+0h → 편집 불필요
|
||||||
|
const showEdit = currentMode === 'detail' && !(r.is_holiday && r.attendance.total_work_hours === 0);
|
||||||
|
const editBtn = showEdit ? `<button class="mc-edit-btn" onclick="editAttendance('${r.date}', ${r.attendance.total_work_hours}, ${r.attendance.vacation_type_id || 'null'})" title="근태 수정"><i class="fas fa-pen"></i></button>` : '';
|
||||||
|
// 주말+0h → 근태 행 숨김 (주말로 표시)
|
||||||
|
if (r.is_holiday && r.attendance.total_work_hours === 0) {
|
||||||
|
// 주말 표시만, 근태 행 생략
|
||||||
|
} else {
|
||||||
|
attendLine = `<div class="mc-daily-row mc-attend-row" id="attend-${r.date}">근태관리: <strong>${r.attendance.total_work_hours}h</strong> <span>(${escHtml(r.attendance.attendance_type)}${vacInfo})</span>${editBtn}</div>`;
|
||||||
|
}
|
||||||
|
} else if (r.status !== 'holiday') {
|
||||||
|
const addBtn = currentMode === 'detail' ? `<button class="mc-edit-btn" onclick="editAttendance('${r.date}', 0, null)" title="근태 입력"><i class="fas fa-plus"></i></button>` : '';
|
||||||
|
attendLine = `<div class="mc-daily-row mc-attend-row" id="attend-${r.date}" style="color:#9ca3af">근태관리: 미입력${addBtn}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.hours_diff && r.hours_diff !== 0) {
|
||||||
|
const sign = r.hours_diff > 0 ? '+' : '';
|
||||||
|
diffLine = `<div class="mc-daily-diff"><i class="fas fa-thumbtack"></i> 차이: ${sign}${r.hours_diff}h</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mc-daily-card ${r.status}">
|
||||||
|
<div class="mc-daily-header">
|
||||||
|
<div class="mc-daily-date">${dateStr}(${dayStr})</div>
|
||||||
|
<div class="mc-daily-status">${icon} ${label}</div>
|
||||||
|
</div>
|
||||||
|
${reportLine}${attendLine}${diffLine}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfirmationStatus(conf) {
|
||||||
|
const actions = document.getElementById('bottomActions');
|
||||||
|
const statusEl = document.getElementById('confirmedStatus');
|
||||||
|
const badge = document.getElementById('statusBadge');
|
||||||
|
|
||||||
|
// 관리자 페이지: 확인/문제 버튼 항상 숨김 (작업자는 my-monthly-confirm에서 처리)
|
||||||
|
actions.classList.add('hidden');
|
||||||
|
|
||||||
|
if (!conf) {
|
||||||
|
statusEl.classList.add('hidden');
|
||||||
|
badge.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayStatus = (conf.status === 'pending' && conf.admin_checked) ? 'admin_checked' : conf.status;
|
||||||
|
var labels = { pending: '미검토', admin_checked: '검토완료', review_sent: '확인요청', confirmed: '확인완료', change_request: '수정요청', rejected: '반려' };
|
||||||
|
badge.textContent = labels[displayStatus] || '';
|
||||||
|
badge.className = 'mc-status-badge ' + displayStatus;
|
||||||
|
|
||||||
|
if (conf.status === 'confirmed') {
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : '';
|
||||||
|
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
|
||||||
|
} else if (conf.status === 'rejected') {
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
document.getElementById('confirmedText').textContent = '반려: ' + (conf.reject_reason || '-');
|
||||||
|
} else if (conf.status === 'change_request') {
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
document.getElementById('confirmedText').innerHTML = '수정요청 접수됨';
|
||||||
|
// detail 모드: 수정 내역 + 승인/거부 버튼 표시
|
||||||
|
if (currentMode === 'detail') {
|
||||||
|
renderChangeRequestPanel(conf);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Render: Admin View =====
|
||||||
|
function renderAdminSummary(s) {
|
||||||
|
const total = s.total_workers || 1;
|
||||||
|
const pct = Math.round((s.confirmed || 0) / total * 100);
|
||||||
|
document.getElementById('progressFill').style.width = pct + '%';
|
||||||
|
document.getElementById('progressText').textContent = `확인 현황: ${s.confirmed || 0}/${total}명 완료`;
|
||||||
|
document.getElementById('statusCounts').innerHTML =
|
||||||
|
`<span>✅ ${s.confirmed || 0} 확인</span>` +
|
||||||
|
`<span>📩 ${s.review_sent || 0} 확인요청</span>` +
|
||||||
|
`<span>⏳ ${s.pending || 0} 미검토</span>` +
|
||||||
|
`<span>📝 ${s.change_request || 0} 수정요청</span>` +
|
||||||
|
`<span>❌ ${s.rejected || 0} 반려</span>`;
|
||||||
|
|
||||||
|
// 확인요청 일괄 발송 버튼 — 전원 검토완료 시만 활성화
|
||||||
|
var reviewBtn = document.getElementById('reviewSendBtn');
|
||||||
|
if (reviewBtn) {
|
||||||
|
var pendingCount = (s.pending || 0);
|
||||||
|
var uncheckedCount = (adminData?.workers || []).filter(function(w) { return !w.admin_checked && w.status === 'pending'; }).length;
|
||||||
|
if (pendingCount > 0 && uncheckedCount === 0) {
|
||||||
|
reviewBtn.classList.remove('hidden');
|
||||||
|
reviewBtn.disabled = false;
|
||||||
|
reviewBtn.textContent = `${pendingCount}명 확인요청 발송`;
|
||||||
|
reviewBtn.style.background = '#2563eb';
|
||||||
|
} else if (pendingCount > 0 && uncheckedCount > 0) {
|
||||||
|
reviewBtn.classList.remove('hidden');
|
||||||
|
reviewBtn.disabled = true;
|
||||||
|
reviewBtn.textContent = `${uncheckedCount}명 미검토 — 전원 검토 후 발송 가능`;
|
||||||
|
reviewBtn.style.background = '#9ca3af';
|
||||||
|
} else {
|
||||||
|
reviewBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkerList(workers) {
|
||||||
|
const el = document.getElementById('adminWorkerList');
|
||||||
|
let filtered = workers;
|
||||||
|
if (currentFilter !== 'all') {
|
||||||
|
filtered = workers.filter(w => w.status === currentFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
el.innerHTML = '<div class="mc-empty"><p>해당 조건의 작업자가 없습니다</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = filtered.map(w => {
|
||||||
|
// admin_checked면 "미검토" → "검토완료"로 표시
|
||||||
|
var displayStatus = (w.status === 'pending' && w.admin_checked) ? 'admin_checked' : w.status;
|
||||||
|
const statusLabels = { confirmed: '확인완료', pending: '미검토', admin_checked: '검토완료', review_sent: '확인요청', change_request: '수정요청', rejected: '반려' };
|
||||||
|
const statusBadge = `<span class="mc-worker-status-badge ${displayStatus}">${statusLabels[displayStatus] || ''}</span>`;
|
||||||
|
const mismatchBadge = w.mismatch_count > 0
|
||||||
|
? `<span class="mc-worker-mismatch">⚠️ 불일치${w.mismatch_count}</span>` : '';
|
||||||
|
const rejectReason = w.status === 'rejected' && w.reject_reason
|
||||||
|
? `<div class="mc-worker-reject-reason">사유: ${escHtml(w.reject_reason)}</div>` : '';
|
||||||
|
const changeSummary = w.status === 'change_request' && w.change_details
|
||||||
|
? `<div class="mc-worker-change-summary"><i class="fas fa-edit" style="font-size:10px"></i> ${escHtml(formatChangeDetailsSummary(w.change_details))}</div>` : '';
|
||||||
|
const confirmedAt = w.confirmed_at ? `(${new Date(w.confirmed_at).toLocaleDateString('ko')})` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mc-worker-card" onclick="viewWorkerDetail(${w.user_id})">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<div>
|
||||||
|
<div class="mc-worker-name">${escHtml(w.worker_name)} ${mismatchBadge}</div>
|
||||||
|
<div class="mc-worker-dept">${escHtml(w.department_name)} · ${escHtml(w.job_type)}</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-gray-300"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mc-worker-stats">${w.total_work_days}일 | ${w.total_work_hours}h | 연장 ${w.total_overtime_hours}h</div>
|
||||||
|
<div class="mc-worker-status">
|
||||||
|
${statusBadge} <span style="font-size:0.7rem;color:#9ca3af">${confirmedAt}</span>
|
||||||
|
</div>
|
||||||
|
${rejectReason}${changeSummary}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterWorkers(status) {
|
||||||
|
currentFilter = status;
|
||||||
|
document.querySelectorAll('.mc-tab').forEach(t => {
|
||||||
|
t.classList.toggle('active', t.dataset.filter === status);
|
||||||
|
});
|
||||||
|
if (adminData) renderWorkerList(adminData.workers || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExportButton(summary, workers) {
|
||||||
|
const btn = document.getElementById('exportBtn');
|
||||||
|
const note = document.getElementById('exportNote');
|
||||||
|
const pendingCount = (workers || []).filter(w => !w.status || w.status === 'pending').length;
|
||||||
|
const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length;
|
||||||
|
const allConfirmed = pendingCount === 0 && rejectedCount === 0;
|
||||||
|
|
||||||
|
if (allConfirmed) {
|
||||||
|
btn.disabled = false;
|
||||||
|
note.textContent = '모든 작업자가 확인을 완료했습니다';
|
||||||
|
} else {
|
||||||
|
btn.disabled = true;
|
||||||
|
const parts = [];
|
||||||
|
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
|
||||||
|
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
|
||||||
|
note.textContent = `${parts.join(', ')} — 전원 확인 후 다운로드 가능합니다`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Actions =====
|
||||||
|
let isProcessing = false;
|
||||||
|
|
||||||
|
async function confirmMonth() {
|
||||||
|
if (isProcessing) return;
|
||||||
|
if (!confirm(`${currentYear}년 ${currentMonth}월 근무 내역을 확인하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (MOCK_ENABLED) {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
res = { success: true, message: '확인이 완료되었습니다.' };
|
||||||
|
} else {
|
||||||
|
res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
||||||
|
year: currentYear, month: currentMonth, status: 'confirmed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast(res.message || '확인 완료', 'success');
|
||||||
|
loadMyRecords();
|
||||||
|
} else {
|
||||||
|
showToast(res?.message || '처리 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('네트워크 오류', 'error');
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRejectModal() {
|
||||||
|
document.getElementById('rejectReason').value = '';
|
||||||
|
document.getElementById('rejectModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRejectModal() {
|
||||||
|
document.getElementById('rejectModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReject() {
|
||||||
|
if (isProcessing) return;
|
||||||
|
const reason = document.getElementById('rejectReason').value.trim();
|
||||||
|
if (!reason) {
|
||||||
|
showToast('반려 사유를 입력해주세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (MOCK_ENABLED) {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
res = { success: true, message: '이의가 접수되었습니다. 지원팀에 알림이 전달됩니다.' };
|
||||||
|
} else {
|
||||||
|
res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
||||||
|
year: currentYear, month: currentMonth, status: 'rejected', reject_reason: reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast(res.message || '반려 제출 완료', 'success');
|
||||||
|
closeRejectModal();
|
||||||
|
loadMyRecords();
|
||||||
|
} else {
|
||||||
|
showToast(res?.message || '처리 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('네트워크 오류', 'error');
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadExcel() {
|
||||||
|
try {
|
||||||
|
if (MOCK_ENABLED) {
|
||||||
|
showToast('Mock 모드에서는 다운로드를 지원하지 않습니다', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = (window.getSSOToken && window.getSSOToken()) || '';
|
||||||
|
const response = await fetch(`/api/monthly-comparison/export?year=${currentYear}&month=${currentMonth}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
if (typeof _safeRedirect === 'function') _safeRedirect();
|
||||||
|
else location.href = '/pages/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error('다운로드 실패');
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `월간근무_${currentYear}년${currentMonth}월.xlsx`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('엑셀 다운로드 실패', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Admin Check (검토완료 토글) =====
|
||||||
|
async function toggleAdminCheck() {
|
||||||
|
if (!currentUserId || isProcessing) return;
|
||||||
|
var isCurrentlyChecked = comparisonData?.confirmation?.admin_checked;
|
||||||
|
var newChecked = !isCurrentlyChecked;
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
var res = await window.apiCall('/monthly-comparison/admin-check', 'POST', {
|
||||||
|
user_id: currentUserId, year: currentYear, month: currentMonth, checked: newChecked
|
||||||
|
});
|
||||||
|
if (res && res.success) {
|
||||||
|
// 상태 업데이트
|
||||||
|
if (comparisonData.confirmation) {
|
||||||
|
comparisonData.confirmation.admin_checked = newChecked ? 1 : 0;
|
||||||
|
}
|
||||||
|
var btn = document.getElementById('headerCheckBtn');
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = newChecked ? '✓ 검토완료' : '검토하기';
|
||||||
|
btn.style.background = newChecked ? '#dcfce7' : '#f3f4f6';
|
||||||
|
btn.style.color = newChecked ? '#166534' : '#6b7280';
|
||||||
|
}
|
||||||
|
showToast(newChecked ? '검토완료' : '검토 해제', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(res?.message || '처리 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
||||||
|
finally { isProcessing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록으로 복귀 (월 유지)
|
||||||
|
function goBackToList() {
|
||||||
|
location.href = '/pages/attendance/monthly-comparison.html?mode=admin&year=' + currentYear + '&month=' + currentMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Review Send (확인요청 일괄 발송) =====
|
||||||
|
async function sendReviewAll() {
|
||||||
|
if (isProcessing) return;
|
||||||
|
if (!confirm(currentYear + '년 ' + currentMonth + '월 미검토 작업자 전체에게 확인요청을 발송하시겠습니까?')) return;
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
var res = await window.apiCall('/monthly-comparison/review-send', 'POST', {
|
||||||
|
year: currentYear, month: currentMonth
|
||||||
|
});
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast(res.message || '확인요청 발송 완료', 'success');
|
||||||
|
loadAdminStatus();
|
||||||
|
} else {
|
||||||
|
showToast(res && res.message || '발송 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('네트워크 오류', 'error');
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== View Toggle =====
|
||||||
|
function toggleViewMode() {
|
||||||
|
if (currentMode === 'admin') {
|
||||||
|
currentMode = 'my';
|
||||||
|
} else {
|
||||||
|
currentMode = 'admin';
|
||||||
|
}
|
||||||
|
currentFilter = 'all';
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewWorkerDetail(userId) {
|
||||||
|
location.href = `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${currentYear}&month=${currentMonth}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
function getStatusIcon(status) {
|
||||||
|
const icons = {
|
||||||
|
match: '<i class="fas fa-check-circle text-green-500"></i>',
|
||||||
|
mismatch: '<i class="fas fa-exclamation-triangle text-amber-500"></i>',
|
||||||
|
report_only: '<i class="fas fa-file-alt text-blue-500"></i>',
|
||||||
|
attend_only: '<i class="fas fa-clock text-purple-500"></i>',
|
||||||
|
vacation: '<i class="fas fa-umbrella-beach text-green-400"></i>',
|
||||||
|
holiday: '<i class="fas fa-calendar text-gray-400"></i>',
|
||||||
|
none: '<i class="fas fa-minus-circle text-red-400"></i>'
|
||||||
|
};
|
||||||
|
return icons[status] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status, record) {
|
||||||
|
const labels = {
|
||||||
|
match: '일치', mismatch: '불일치', report_only: '보고서만',
|
||||||
|
attend_only: '근태만', holiday: '주말', none: '미입력'
|
||||||
|
};
|
||||||
|
if (status === 'vacation') {
|
||||||
|
return record?.attendance?.vacation_type || '연차';
|
||||||
|
}
|
||||||
|
return labels[status] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
|
||||||
|
|
||||||
|
// ===== Inline Attendance Edit (detail mode) =====
|
||||||
|
function getAttendanceTypeId(hours, vacTypeId) {
|
||||||
|
if (vacTypeId) return 4; // VACATION
|
||||||
|
if (hours >= 8) return 1; // REGULAR
|
||||||
|
if (hours > 0) return 3; // PARTIAL
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editAttendance(date, currentHours, currentVacTypeId) {
|
||||||
|
const el = document.getElementById('attend-' + date);
|
||||||
|
if (!el) return;
|
||||||
|
const vacTypeId = currentVacTypeId === 'null' || currentVacTypeId === null ? '' : currentVacTypeId;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="mc-edit-form">
|
||||||
|
<div class="mc-edit-row">
|
||||||
|
<label>시간</label>
|
||||||
|
<input type="number" id="editHours-${date}" value="${currentHours}" step="0.5" min="0" max="24" class="mc-edit-input">
|
||||||
|
<span>h</span>
|
||||||
|
</div>
|
||||||
|
<div class="mc-edit-row">
|
||||||
|
<label>휴가</label>
|
||||||
|
<select id="editVacType-${date}" class="mc-edit-select" onchange="onVacTypeChange('${date}')">
|
||||||
|
<option value="">없음</option>
|
||||||
|
<option value="1" ${vacTypeId == 1 ? 'selected' : ''}>연차</option>
|
||||||
|
<option value="2" ${vacTypeId == 2 ? 'selected' : ''}>반차</option>
|
||||||
|
<option value="3" ${vacTypeId == 3 ? 'selected' : ''}>반반차</option>
|
||||||
|
<option value="10" ${vacTypeId == 10 ? 'selected' : ''}>조퇴</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mc-edit-actions">
|
||||||
|
<button class="mc-edit-save" onclick="saveAttendance('${date}')"><i class="fas fa-check"></i> 저장</button>
|
||||||
|
<button class="mc-edit-cancel" onclick="loadData()">취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVacTypeChange(date) {
|
||||||
|
const vacType = document.getElementById('editVacType-' + date).value;
|
||||||
|
const hoursInput = document.getElementById('editHours-' + date);
|
||||||
|
if (vacType === '1') hoursInput.value = '0'; // 연차 → 0시간
|
||||||
|
else if (vacType === '2') hoursInput.value = '4'; // 반차 → 4시간
|
||||||
|
else if (vacType === '3') hoursInput.value = '6'; // 반반차 → 6시간
|
||||||
|
else if (vacType === '10') hoursInput.value = '2'; // 조퇴 → 2시간
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAttendance(date) {
|
||||||
|
const hours = parseFloat(document.getElementById('editHours-' + date).value) || 0;
|
||||||
|
const vacTypeVal = document.getElementById('editVacType-' + date).value;
|
||||||
|
const vacTypeId = vacTypeVal ? parseInt(vacTypeVal) : null;
|
||||||
|
const attTypeId = getAttendanceTypeId(hours, vacTypeId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.apiCall('/attendance/records', 'POST', {
|
||||||
|
record_date: date,
|
||||||
|
user_id: currentUserId,
|
||||||
|
total_work_hours: hours,
|
||||||
|
vacation_type_id: vacTypeId,
|
||||||
|
attendance_type_id: attTypeId
|
||||||
|
});
|
||||||
|
showToast('근태 수정 완료', 'success');
|
||||||
|
await loadData(); // 전체 새로고침
|
||||||
|
} catch (e) {
|
||||||
|
showToast('저장 실패: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Change Request Panel (detail mode) =====
|
||||||
|
function renderChangeRequestPanel(conf) {
|
||||||
|
var panel = document.getElementById('changeRequestPanel');
|
||||||
|
if (!panel) {
|
||||||
|
// 동적 생성: mismatchAlert 뒤에 삽입
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.id = 'changeRequestPanel';
|
||||||
|
var anchor = document.getElementById('mismatchAlert');
|
||||||
|
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
var details = null;
|
||||||
|
if (conf.change_details) {
|
||||||
|
try { details = typeof conf.change_details === 'string' ? JSON.parse(conf.change_details) : conf.change_details; }
|
||||||
|
catch (e) { details = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<div class="mc-change-panel">';
|
||||||
|
html += '<div class="mc-change-header"><i class="fas fa-edit text-orange-500"></i> 수정요청 내역</div>';
|
||||||
|
|
||||||
|
if (details && details.changes && details.changes.length) {
|
||||||
|
html += '<div class="mc-change-list">';
|
||||||
|
details.changes.forEach(function(c) {
|
||||||
|
var dateLabel = c.date ? c.date.substring(5).replace('-', '/') : '';
|
||||||
|
html += '<div class="mc-change-item">' + escHtml(dateLabel) + ': ' +
|
||||||
|
'<span class="mc-change-from">' + escHtml(c.from) + '</span>' +
|
||||||
|
' <i class="fas fa-arrow-right" style="font-size:10px;color:#9ca3af"></i> ' +
|
||||||
|
'<span class="mc-change-to">' + escHtml(c.to) + '</span></div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
} else if (details && details.description) {
|
||||||
|
html += '<div class="mc-change-desc">' + escHtml(details.description) + '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="mc-change-desc" style="color:#9ca3af">상세 내역 없음</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="mc-change-actions">';
|
||||||
|
html += '<button type="button" class="mc-change-approve" onclick="approveChangeRequest()"><i class="fas fa-check"></i> 승인 (재확인 요청)</button>';
|
||||||
|
html += '<button type="button" class="mc-change-reject" onclick="openRejectChangeModal()"><i class="fas fa-times"></i> 거부</button>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
panel.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveChangeRequest() {
|
||||||
|
if (!currentUserId || isProcessing) return;
|
||||||
|
if (!confirm('수정요청을 승인하시겠습니까? 작업자에게 재확인 요청이 발송됩니다.')) return;
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
var res = await window.apiCall('/monthly-comparison/review-respond', 'POST', {
|
||||||
|
user_id: currentUserId, year: currentYear, month: currentMonth, action: 'approve'
|
||||||
|
});
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast(res.message || '승인 완료', 'success');
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
showToast(res?.message || '처리 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
||||||
|
finally { isProcessing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRejectChangeModal() {
|
||||||
|
document.getElementById('rejectReason').value = '';
|
||||||
|
// 모달 텍스트를 수정요청 거부용으로 변경
|
||||||
|
var headerSpan = document.querySelector('#rejectModal .mc-modal-header span');
|
||||||
|
if (headerSpan) headerSpan.innerHTML = '<i class="fas fa-times-circle text-red-500 mr-2"></i>수정요청 거부';
|
||||||
|
var desc = document.querySelector('#rejectModal .mc-modal-desc');
|
||||||
|
if (desc) desc.textContent = '거부 사유를 입력해주세요:';
|
||||||
|
var submitBtn = document.getElementById('rejectSubmitBtn');
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.textContent = '거부 제출';
|
||||||
|
submitBtn.onclick = submitRejectChange;
|
||||||
|
}
|
||||||
|
document.getElementById('rejectModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRejectChange() {
|
||||||
|
if (!currentUserId || isProcessing) return;
|
||||||
|
var reason = document.getElementById('rejectReason').value.trim();
|
||||||
|
if (!reason) { showToast('거부 사유를 입력해주세요', 'error'); return; }
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
var res = await window.apiCall('/monthly-comparison/review-respond', 'POST', {
|
||||||
|
user_id: currentUserId, year: currentYear, month: currentMonth,
|
||||||
|
action: 'reject', reject_reason: reason
|
||||||
|
});
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast(res.message || '거부 완료', 'success');
|
||||||
|
closeRejectModal();
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
showToast(res?.message || '처리 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
||||||
|
finally { isProcessing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChangeDetailsSummary(changeDetails) {
|
||||||
|
var details = null;
|
||||||
|
if (!changeDetails) return '';
|
||||||
|
try { details = typeof changeDetails === 'string' ? JSON.parse(changeDetails) : changeDetails; }
|
||||||
|
catch (e) { return ''; }
|
||||||
|
|
||||||
|
if (details.changes && details.changes.length) {
|
||||||
|
var items = details.changes.map(function(c) {
|
||||||
|
var d = c.date ? c.date.substring(5).replace('-', '/') : '';
|
||||||
|
return d + ' ' + (c.from || '') + '\u2192' + (c.to || '');
|
||||||
|
});
|
||||||
|
return items.join(', ');
|
||||||
|
}
|
||||||
|
if (details.description) return details.description;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC로 모달 닫기
|
||||||
|
function handleEscKey(e) {
|
||||||
|
if (e.key === 'Escape') closeRejectModal();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleEscKey);
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
document.removeEventListener('keydown', handleEscKey);
|
||||||
|
});
|
||||||
400
system1-factory/web/js/my-monthly-confirm.js
Normal file
400
system1-factory/web/js/my-monthly-confirm.js
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* my-monthly-confirm.js — 작업자 월간 근무 확인 (모바일 캘린더)
|
||||||
|
*/
|
||||||
|
|
||||||
|
var DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
var currentYear, currentMonth;
|
||||||
|
var isProcessing = false;
|
||||||
|
var selectedCell = null;
|
||||||
|
var currentConfStatus = null; // 현재 confirmation 상태
|
||||||
|
var pendingChanges = {}; // 수정 내역 { 'YYYY-MM-DD': { from: '반차', to: '정시', hours: 8 } }
|
||||||
|
var loadedRecords = []; // 로드된 daily_records
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var now = new Date();
|
||||||
|
currentYear = now.getFullYear();
|
||||||
|
currentMonth = now.getMonth() + 1;
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
if (params.get('year')) currentYear = parseInt(params.get('year'));
|
||||||
|
if (params.get('month')) currentMonth = parseInt(params.get('month'));
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
var user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser;
|
||||||
|
if (!user) return;
|
||||||
|
window._mmcUser = user;
|
||||||
|
updateMonthLabel();
|
||||||
|
loadData();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateMonthLabel() {
|
||||||
|
document.getElementById('monthLabel').textContent = currentYear + '년 ' + currentMonth + '월';
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMonth(delta) {
|
||||||
|
currentMonth += delta;
|
||||||
|
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
|
||||||
|
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
|
||||||
|
selectedCell = null;
|
||||||
|
updateMonthLabel();
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Data Load =====
|
||||||
|
async function loadData() {
|
||||||
|
var calWrap = document.getElementById('tableWrap');
|
||||||
|
calWrap.innerHTML = '<div class="mmc-skeleton"></div><div class="mmc-skeleton"></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var user = window._mmcUser || (typeof getCurrentUser === 'function' ? getCurrentUser() : null) || {};
|
||||||
|
var userId = user.user_id || user.id;
|
||||||
|
var [recordsRes, balanceRes] = await Promise.all([
|
||||||
|
window.apiCall('/monthly-comparison/my-records?year=' + currentYear + '&month=' + currentMonth),
|
||||||
|
window.apiCall('/vacation-balances/worker/' + userId + '/year/' + currentYear).catch(function() { return { success: true, data: [] }; })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!recordsRes || !recordsRes.success) {
|
||||||
|
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-calendar-xmark text-2xl text-gray-300"></i><p>데이터가 없습니다</p></div>';
|
||||||
|
document.getElementById('bottomActions').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = recordsRes.data;
|
||||||
|
renderUserInfo(data.user);
|
||||||
|
renderCalendar(data.daily_records || []);
|
||||||
|
renderSummaryCards(data.daily_records || []);
|
||||||
|
loadedRecords = data.daily_records || [];
|
||||||
|
currentConfStatus = data.confirmation ? data.confirmation.status : 'pending';
|
||||||
|
pendingChanges = {};
|
||||||
|
renderVacationBalance(balanceRes.data || []);
|
||||||
|
renderConfirmStatus(data.confirmation);
|
||||||
|
} catch (e) {
|
||||||
|
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Render =====
|
||||||
|
function renderUserInfo(user) {
|
||||||
|
if (!user) return;
|
||||||
|
document.getElementById('userName').textContent = user.worker_name || user.name || '-';
|
||||||
|
document.getElementById('userDept').textContent =
|
||||||
|
(user.job_type ? user.job_type + ' · ' : '') + (user.department_name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 셀 텍스트 판정
|
||||||
|
// 8h 기준 고정 (scheduled_hours 미존재 — 단축근무 미대응)
|
||||||
|
function getCellInfo(r) {
|
||||||
|
var hrs = r.attendance ? parseFloat(r.attendance.total_work_hours) || 0 : 0;
|
||||||
|
var vacType = r.attendance ? r.attendance.vacation_type : null;
|
||||||
|
var isHoliday = r.is_holiday;
|
||||||
|
|
||||||
|
if (vacType) return { text: vacType, cls: 'vac', detail: vacType };
|
||||||
|
if (isHoliday && hrs <= 0) return { text: '휴무', cls: 'off', detail: r.holiday_name || '휴무' };
|
||||||
|
if (isHoliday && hrs > 0) return { text: '특 ' + hrs + 'h', cls: 'special', detail: '특근 ' + hrs + '시간' };
|
||||||
|
if (hrs === 8) return { text: '정시', cls: 'normal', detail: '정시근로 8시간' };
|
||||||
|
if (hrs > 8) return { text: '+' + (hrs - 8) + 'h', cls: 'overtime', detail: '연장근로 ' + hrs + '시간 (+' + (hrs - 8) + ')' };
|
||||||
|
if (hrs > 0) return { text: hrs + 'h', cls: 'partial', detail: hrs + '시간 근무' };
|
||||||
|
return { text: '-', cls: 'none', detail: '미입력' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar(records) {
|
||||||
|
var el = document.getElementById('tableWrap');
|
||||||
|
if (!records.length) {
|
||||||
|
el.innerHTML = '<div class="mmc-empty"><p>해당 월 데이터가 없습니다</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜별 맵
|
||||||
|
var recMap = {};
|
||||||
|
records.forEach(function(r) { recMap[parseInt(r.date.substring(8))] = r; });
|
||||||
|
|
||||||
|
var firstDay = new Date(currentYear, currentMonth - 1, 1).getDay();
|
||||||
|
var daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
|
||||||
|
|
||||||
|
// 헤더
|
||||||
|
var html = '<div class="cal-grid">';
|
||||||
|
html += '<div class="cal-header">';
|
||||||
|
DAYS_KR.forEach(function(d, i) {
|
||||||
|
var cls = i === 0 ? ' sun' : i === 6 ? ' sat' : '';
|
||||||
|
html += '<div class="cal-dow' + cls + '">' + d + '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// 셀
|
||||||
|
html += '<div class="cal-body">';
|
||||||
|
// 빈 셀 (월 시작 전)
|
||||||
|
for (var i = 0; i < firstDay; i++) {
|
||||||
|
html += '<div class="cal-cell empty"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var day = 1; day <= daysInMonth; day++) {
|
||||||
|
var r = recMap[day];
|
||||||
|
var info = r ? getCellInfo(r) : { text: '-', cls: 'none', detail: '데이터 없음' };
|
||||||
|
var dow = (firstDay + day - 1) % 7;
|
||||||
|
var dowCls = dow === 0 ? ' sun' : dow === 6 ? ' sat' : '';
|
||||||
|
|
||||||
|
html += '<div class="cal-cell ' + info.cls + dowCls + '" onclick="selectDay(' + day + ')">';
|
||||||
|
html += '<span class="cal-day">' + day + '</span>';
|
||||||
|
html += '<span class="cal-val">' + escHtml(info.text) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</div></div>';
|
||||||
|
|
||||||
|
// 상세 영역
|
||||||
|
html += '<div class="cal-detail" id="calDetail"></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDay(day) {
|
||||||
|
selectedCell = day;
|
||||||
|
var el = document.getElementById('calDetail');
|
||||||
|
var cells = document.querySelectorAll('.cal-cell');
|
||||||
|
cells.forEach(function(c) { c.classList.remove('selected'); });
|
||||||
|
|
||||||
|
var allCells = document.querySelectorAll('.cal-cell:not(.empty)');
|
||||||
|
if (allCells[day - 1]) allCells[day - 1].classList.add('selected');
|
||||||
|
|
||||||
|
var dateStr = currentYear + '-' + String(currentMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
||||||
|
var d = new Date(currentYear, currentMonth - 1, day);
|
||||||
|
var dow = DAYS_KR[d.getDay()];
|
||||||
|
var record = loadedRecords.find(function(r) { return parseInt(r.date.substring(8)) === day; });
|
||||||
|
var currentVal = record ? getCellInfo(record).text : '-';
|
||||||
|
|
||||||
|
var html = '<div class="cal-detail-inner">';
|
||||||
|
html += '<strong>' + currentMonth + '/' + day + ' (' + dow + ')</strong> — ' + escHtml(currentVal);
|
||||||
|
|
||||||
|
// review_sent 상태에서만 수정 드롭다운 표시
|
||||||
|
if (currentConfStatus === 'review_sent') {
|
||||||
|
var changed = pendingChanges[dateStr];
|
||||||
|
html += '<div class="cal-edit-row">';
|
||||||
|
html += '<select id="editType-' + day + '" onchange="onCellChange(' + day + ')" class="cal-edit-select">';
|
||||||
|
html += '<option value="">변경 없음</option>';
|
||||||
|
html += '<option value="정시"' + (changed && changed.to === '정시' ? ' selected' : '') + '>정시 (8h)</option>';
|
||||||
|
html += '<option value="연차"' + (changed && changed.to === '연차' ? ' selected' : '') + '>연차 (0h)</option>';
|
||||||
|
html += '<option value="반차"' + (changed && changed.to === '반차' ? ' selected' : '') + '>반차 (4h)</option>';
|
||||||
|
html += '<option value="반반차"' + (changed && changed.to === '반반차' ? ' selected' : '') + '>반반차 (6h)</option>';
|
||||||
|
html += '<option value="조퇴"' + (changed && changed.to === '조퇴' ? ' selected' : '') + '>조퇴 (2h)</option>';
|
||||||
|
html += '<option value="휴무"' + (changed && changed.to === '휴무' ? ' selected' : '') + '>휴무 (0h)</option>';
|
||||||
|
html += '</select>';
|
||||||
|
if (changed) html += ' <span class="cal-changed-badge">수정</span>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.style.display = 'block';
|
||||||
|
updateChangeRequestBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCellChange(day) {
|
||||||
|
var dateStr = currentYear + '-' + String(currentMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
||||||
|
var sel = document.getElementById('editType-' + day);
|
||||||
|
var newType = sel ? sel.value : '';
|
||||||
|
var record = loadedRecords.find(function(r) { return parseInt(r.date.substring(8)) === day; });
|
||||||
|
var currentType = record ? getCellInfo(record).text : '-';
|
||||||
|
|
||||||
|
if (newType && newType !== currentType) {
|
||||||
|
var hoursMap = { '정시': 8, '연차': 0, '반차': 4, '반반차': 6, '조퇴': 2, '휴무': 0 };
|
||||||
|
pendingChanges[dateStr] = { from: currentType, to: newType, hours: hoursMap[newType] || 0 };
|
||||||
|
// 셀에 수정 뱃지
|
||||||
|
var allCells = document.querySelectorAll('.cal-cell:not(.empty)');
|
||||||
|
if (allCells[day - 1]) allCells[day - 1].classList.add('changed');
|
||||||
|
} else {
|
||||||
|
delete pendingChanges[dateStr];
|
||||||
|
var allCells2 = document.querySelectorAll('.cal-cell:not(.empty)');
|
||||||
|
if (allCells2[day - 1]) allCells2[day - 1].classList.remove('changed');
|
||||||
|
}
|
||||||
|
updateChangeRequestBtn();
|
||||||
|
// 상세 영역 재렌더
|
||||||
|
selectDay(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChangeRequestBtn() {
|
||||||
|
var rejectBtn = document.getElementById('rejectBtn');
|
||||||
|
if (!rejectBtn) return;
|
||||||
|
var changeCount = Object.keys(pendingChanges).length;
|
||||||
|
if (currentConfStatus === 'review_sent' && changeCount > 0) {
|
||||||
|
rejectBtn.disabled = false;
|
||||||
|
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청 (' + changeCount + '건)';
|
||||||
|
} else if (currentConfStatus === 'review_sent') {
|
||||||
|
rejectBtn.disabled = true;
|
||||||
|
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummaryCards(records) {
|
||||||
|
var workDays = 0, overtimeHours = 0, vacDays = 0;
|
||||||
|
|
||||||
|
records.forEach(function(r) {
|
||||||
|
var hrs = r.attendance ? parseFloat(r.attendance.total_work_hours) || 0 : 0;
|
||||||
|
var vacType = r.attendance ? r.attendance.vacation_type : null;
|
||||||
|
var isHoliday = r.is_holiday;
|
||||||
|
|
||||||
|
if (!isHoliday && (hrs > 0 || vacType)) workDays++;
|
||||||
|
if (hrs > 8) overtimeHours += (hrs - 8);
|
||||||
|
if (vacType) {
|
||||||
|
var vd = r.attendance.vacation_days ? parseFloat(r.attendance.vacation_days) : 0;
|
||||||
|
if (vd > 0) { vacDays += vd; }
|
||||||
|
else {
|
||||||
|
// fallback: vacation_type 이름으로 차감일수 매핑
|
||||||
|
var deductMap = { '연차': 1, '반차': 0.5, '반반차': 0.25, '조퇴': 0.75, '병가': 1 };
|
||||||
|
vacDays += deductMap[vacType] || 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var el = document.getElementById('summaryCards');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML =
|
||||||
|
'<div class="mmc-sum-card"><div class="mmc-sum-num">' + workDays + '</div><div class="mmc-sum-label">근무일</div></div>' +
|
||||||
|
'<div class="mmc-sum-card"><div class="mmc-sum-num ot">' + fmtNum(overtimeHours) + 'h</div><div class="mmc-sum-label">연장근로</div></div>' +
|
||||||
|
'<div class="mmc-sum-card"><div class="mmc-sum-num vac">' + fmtNum(vacDays) + '일</div><div class="mmc-sum-label">연차</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVacationBalance(balances) {
|
||||||
|
var el = document.getElementById('vacationCards');
|
||||||
|
var total = 0, used = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(balances)) {
|
||||||
|
balances.forEach(function(b) {
|
||||||
|
total += parseFloat(b.total_days || 0);
|
||||||
|
used += parseFloat(b.used_days || 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = total - used;
|
||||||
|
el.innerHTML =
|
||||||
|
'<div class="mmc-vac-title">연차 현황</div>' +
|
||||||
|
'<div class="mmc-vac-grid">' +
|
||||||
|
'<div class="mmc-vac-card"><div class="mmc-vac-num">' + fmtNum(total) + '</div><div class="mmc-vac-label">부여</div></div>' +
|
||||||
|
'<div class="mmc-vac-card"><div class="mmc-vac-num used">' + fmtNum(used) + '</div><div class="mmc-vac-label">사용</div></div>' +
|
||||||
|
'<div class="mmc-vac-card"><div class="mmc-vac-num remain">' + fmtNum(remaining) + '</div><div class="mmc-vac-label">잔여</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfirmStatus(conf) {
|
||||||
|
var actions = document.getElementById('bottomActions');
|
||||||
|
var statusEl = document.getElementById('confirmedStatus');
|
||||||
|
var badge = document.getElementById('statusBadge');
|
||||||
|
var confirmBtn = document.getElementById('confirmBtn');
|
||||||
|
var rejectBtn = document.getElementById('rejectBtn');
|
||||||
|
var status = conf ? conf.status : 'pending';
|
||||||
|
|
||||||
|
// 기본: 버튼 숨김 + 상태 숨김
|
||||||
|
actions.classList.add('hidden');
|
||||||
|
statusEl.classList.add('hidden');
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
badge.textContent = '검토대기';
|
||||||
|
badge.className = 'mmc-status-badge pending';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
document.getElementById('confirmedText').textContent = '관리자 검토 대기 중입니다';
|
||||||
|
} else if (status === 'review_sent') {
|
||||||
|
badge.textContent = '확인요청';
|
||||||
|
badge.className = 'mmc-status-badge review_sent';
|
||||||
|
actions.classList.remove('hidden');
|
||||||
|
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>확인 완료';
|
||||||
|
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
|
||||||
|
rejectBtn.disabled = true; // 수정 내역 없으면 비활성화
|
||||||
|
rejectBtn.onclick = function() { submitChangeRequest(); };
|
||||||
|
} else if (status === 'confirmed') {
|
||||||
|
badge.textContent = '확인완료';
|
||||||
|
badge.className = 'mmc-status-badge confirmed';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : '';
|
||||||
|
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
|
||||||
|
} else if (status === 'change_request') {
|
||||||
|
badge.textContent = '수정요청';
|
||||||
|
badge.className = 'mmc-status-badge change_request';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
document.getElementById('confirmedText').textContent = '수정요청이 제출되었습니다. 관리자 확인 대기 중';
|
||||||
|
} else if (status === 'rejected') {
|
||||||
|
badge.textContent = '반려';
|
||||||
|
badge.className = 'mmc-status-badge rejected';
|
||||||
|
actions.classList.remove('hidden');
|
||||||
|
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>동의(재확인)';
|
||||||
|
rejectBtn.classList.add('hidden');
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
document.getElementById('confirmedText').textContent = '반려 사유: ' + (conf.reject_reason || '-') + '\n반려 사유를 확인하고 동의하시면 확인 완료 버튼을 눌러주세요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChangeRequestModal() {
|
||||||
|
document.getElementById('rejectReason').value = '';
|
||||||
|
document.getElementById('rejectModal').classList.remove('hidden');
|
||||||
|
// 모달 제목/버튼 수정요청용으로 변경
|
||||||
|
var header = document.querySelector('.mmc-modal-header span');
|
||||||
|
if (header) header.innerHTML = '<i class="fas fa-edit text-blue-500 mr-2"></i>수정요청';
|
||||||
|
var submitBtn = document.querySelector('.mmc-modal-submit');
|
||||||
|
if (submitBtn) submitBtn.textContent = '수정요청 제출';
|
||||||
|
var desc = document.querySelector('.mmc-modal-desc');
|
||||||
|
if (desc) desc.textContent = '수정이 필요한 내용을 입력해주세요:';
|
||||||
|
var note = document.querySelector('.mmc-modal-note');
|
||||||
|
if (note) note.innerHTML = '<i class="fas fa-info-circle text-blue-400 mr-1"></i>수정요청 시 관리자에게 알림이 전달됩니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Actions =====
|
||||||
|
async function confirmMonth() {
|
||||||
|
if (isProcessing) return;
|
||||||
|
if (!confirm(currentYear + '년 ' + currentMonth + '월 근무 내역을 확인하시겠습니까?')) return;
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
||||||
|
year: currentYear, month: currentMonth, status: 'confirmed'
|
||||||
|
});
|
||||||
|
if (res && res.success) { showToast(res.message || '확인 완료', 'success'); loadData(); }
|
||||||
|
else { showToast(res && res.message || '처리 실패', 'error'); }
|
||||||
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
||||||
|
finally { isProcessing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChangeRequest() {
|
||||||
|
if (isProcessing) return;
|
||||||
|
var changeCount = Object.keys(pendingChanges).length;
|
||||||
|
if (changeCount === 0) { showToast('수정 내역이 없습니다', 'error'); return; }
|
||||||
|
if (!confirm(changeCount + '건의 수정요청을 제출하시겠습니까?')) return;
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
var changes = Object.keys(pendingChanges).map(function(date) {
|
||||||
|
return { date: date, from: pendingChanges[date].from, to: pendingChanges[date].to };
|
||||||
|
});
|
||||||
|
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
||||||
|
year: currentYear, month: currentMonth, status: 'change_request',
|
||||||
|
change_details: { changes: changes }
|
||||||
|
});
|
||||||
|
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); loadData(); }
|
||||||
|
else { showToast(res && res.message || '처리 실패', 'error'); }
|
||||||
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
||||||
|
finally { isProcessing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRejectModal() {
|
||||||
|
document.getElementById('rejectReason').value = '';
|
||||||
|
document.getElementById('rejectModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeRejectModal() { document.getElementById('rejectModal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
async function submitReject() {
|
||||||
|
if (isProcessing) return;
|
||||||
|
var reason = document.getElementById('rejectReason').value.trim();
|
||||||
|
if (!reason) { showToast('수정 내용을 입력해주세요', 'error'); return; }
|
||||||
|
isProcessing = true;
|
||||||
|
try {
|
||||||
|
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
||||||
|
year: currentYear, month: currentMonth, status: 'change_request',
|
||||||
|
change_details: { description: reason }
|
||||||
|
});
|
||||||
|
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); closeRejectModal(); loadData(); }
|
||||||
|
else { showToast(res && res.message || '처리 실패', 'error'); }
|
||||||
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
||||||
|
finally { isProcessing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
function fmtNum(v) { var n = parseFloat(v) || 0; return n % 1 === 0 ? n.toString() : n.toFixed(1); }
|
||||||
|
function escHtml(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
|
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
|
||||||
|
function handleEscKey(e) { if (e.key === 'Escape') closeRejectModal(); }
|
||||||
|
document.addEventListener('keydown', handleEscKey);
|
||||||
|
window.addEventListener('beforeunload', function() { document.removeEventListener('keydown', handleEscKey); });
|
||||||
245
system1-factory/web/js/production-dashboard.js
Normal file
245
system1-factory/web/js/production-dashboard.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* 생산팀 대시보드 — Sprint 003
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PAGE_ICONS = {
|
||||||
|
'dashboard': 'fa-home',
|
||||||
|
'work.tbm': 'fa-clipboard-list',
|
||||||
|
'work.report_create': 'fa-file-alt',
|
||||||
|
'work.analysis': 'fa-chart-bar',
|
||||||
|
'work.nonconformity': 'fa-exclamation-triangle',
|
||||||
|
'work.schedule': 'fa-calendar-alt',
|
||||||
|
'work.meetings': 'fa-users',
|
||||||
|
'work.daily_status': 'fa-chart-bar',
|
||||||
|
'work.proxy_input': 'fa-user-edit',
|
||||||
|
'factory.repair_management': 'fa-tools',
|
||||||
|
'inspection.daily_patrol': 'fa-route',
|
||||||
|
'inspection.checkin': 'fa-user-check',
|
||||||
|
'inspection.work_status': 'fa-briefcase',
|
||||||
|
'purchase.request': 'fa-shopping-cart',
|
||||||
|
'purchase.analysis': 'fa-chart-line',
|
||||||
|
'attendance.monthly': 'fa-calendar',
|
||||||
|
'attendance.vacation_request': 'fa-paper-plane',
|
||||||
|
'attendance.vacation_management': 'fa-cog',
|
||||||
|
'attendance.vacation_allocation': 'fa-plus-circle',
|
||||||
|
'attendance.annual_overview': 'fa-chart-pie',
|
||||||
|
'attendance.monthly_comparison': 'fa-scale-balanced',
|
||||||
|
'admin.user_management': 'fa-users-cog',
|
||||||
|
'admin.projects': 'fa-project-diagram',
|
||||||
|
'admin.tasks': 'fa-tasks',
|
||||||
|
'admin.workplaces': 'fa-building',
|
||||||
|
'admin.equipments': 'fa-cogs',
|
||||||
|
'admin.departments': 'fa-sitemap',
|
||||||
|
'admin.notifications': 'fa-bell',
|
||||||
|
'admin.attendance_report': 'fa-clipboard-check',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 내 메뉴에서 제외 (대시보드에서 직접 확인)
|
||||||
|
const HIDDEN_PAGES = ['dashboard', 'attendance.my_vacation_info'];
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
'작업 관리': '#3b82f6',
|
||||||
|
'공장 관리': '#f59e0b',
|
||||||
|
'소모품 관리': '#10b981',
|
||||||
|
'근태 관리': '#8b5cf6',
|
||||||
|
'시스템 관리': '#6b7280',
|
||||||
|
};
|
||||||
|
const DEFAULT_COLOR = '#06b6d4';
|
||||||
|
|
||||||
|
function isExpired(expiresAt) {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
const exp = new Date(expiresAt); exp.setHours(0,0,0,0);
|
||||||
|
return today > exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
function fmtDays(n) { return n % 1 === 0 ? n.toString() : n.toFixed(1); }
|
||||||
|
|
||||||
|
let _dashboardData = null;
|
||||||
|
|
||||||
|
async function initDashboard() {
|
||||||
|
showSkeleton();
|
||||||
|
try {
|
||||||
|
const result = await api('/dashboard/my-summary');
|
||||||
|
if (!result.success) throw new Error(result.message || '데이터 로드 실패');
|
||||||
|
_dashboardData = result.data;
|
||||||
|
renderDashboard(result.data);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard(data) {
|
||||||
|
const { user, vacation, overtime, quick_access } = data;
|
||||||
|
|
||||||
|
const card = document.getElementById('profileCard');
|
||||||
|
const initial = (user.worker_name || user.name || '?').charAt(0);
|
||||||
|
const vacRemaining = vacation.remaining_days;
|
||||||
|
const vacTotal = vacation.total_days;
|
||||||
|
const vacUsed = vacation.used_days;
|
||||||
|
const vacPct = vacTotal > 0 ? Math.round((vacUsed / Math.max(vacTotal, 1)) * 100) : 0;
|
||||||
|
const vacColor = vacRemaining >= 5 ? 'green' : vacRemaining >= 3 ? 'yellow' : 'red';
|
||||||
|
|
||||||
|
const otHours = overtime.total_overtime_hours;
|
||||||
|
const otDays = overtime.overtime_days;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<button onclick="doLogout()" class="pd-logout-btn" title="로그아웃">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
<div class="pd-profile-header">
|
||||||
|
<div class="pd-avatar">${escHtml(initial)}</div>
|
||||||
|
<div>
|
||||||
|
<div class="pd-profile-name">${escHtml(user.worker_name || user.name)}</div>
|
||||||
|
<div class="pd-profile-sub">${escHtml(user.job_type || '')}${user.job_type ? ' · ' : ''}${escHtml(user.department_name)}
|
||||||
|
<a href="/pages/profile/password.html" style="margin-left:8px;color:rgba(255,255,255,0.7);font-size:11px;text-decoration:underline" title="비밀번호 변경"><i class="fas fa-key" style="margin-right:2px"></i>비밀번호 변경</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-info-list">
|
||||||
|
<div class="pd-info-row" onclick="openVacDetailModal()">
|
||||||
|
<div class="pd-info-left">
|
||||||
|
<i class="fas fa-umbrella-beach pd-info-icon"></i>
|
||||||
|
<span class="pd-info-label">연차</span>
|
||||||
|
</div>
|
||||||
|
${vacTotal > 0 ? `
|
||||||
|
<div class="pd-info-right">
|
||||||
|
<span class="pd-info-value">잔여 <strong>${fmtDays(vacRemaining)}일</strong></span>
|
||||||
|
<span class="pd-info-sub">/ ${fmtDays(vacTotal)}일</span>
|
||||||
|
<i class="fas fa-chevron-right pd-info-arrow"></i>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="pd-info-right">
|
||||||
|
<span class="pd-info-sub">미등록</span>
|
||||||
|
<i class="fas fa-chevron-right pd-info-arrow"></i>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
${vacTotal > 0 ? `<div class="pd-progress-bar" style="margin:0 12px 8px"><div class="pd-progress-fill pd-progress-${vacColor}" style="width:${Math.min(vacPct, 100)}%"></div></div>` : ''}
|
||||||
|
<div class="pd-info-row">
|
||||||
|
<div class="pd-info-left">
|
||||||
|
<i class="fas fa-clock pd-info-icon"></i>
|
||||||
|
<span class="pd-info-label">연장근로</span>
|
||||||
|
</div>
|
||||||
|
<div class="pd-info-right">
|
||||||
|
<span class="pd-info-value"><strong>${otHours.toFixed(1)}h</strong></span>
|
||||||
|
<span class="pd-info-sub">이번달 ${otDays}일</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
renderGrid('deptPagesGrid', 'deptPagesSection', quick_access.department_pages);
|
||||||
|
renderGrid('personalPagesGrid', 'personalPagesSection', quick_access.personal_pages);
|
||||||
|
renderGrid('adminPagesGrid', 'adminPagesSection', quick_access.admin_pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVacDetailModal() {
|
||||||
|
if (!_dashboardData) return;
|
||||||
|
const { vacation } = _dashboardData;
|
||||||
|
const details = vacation.details || [];
|
||||||
|
|
||||||
|
const groups = {};
|
||||||
|
details.forEach(d => {
|
||||||
|
const bt = d.balance_type || 'AUTO';
|
||||||
|
if (!groups[bt]) groups[bt] = { total: 0, used: 0, remaining: 0, expires_at: d.expires_at, items: [] };
|
||||||
|
groups[bt].total += d.total;
|
||||||
|
groups[bt].used += d.used;
|
||||||
|
groups[bt].remaining += d.remaining;
|
||||||
|
if (d.expires_at) groups[bt].expires_at = d.expires_at;
|
||||||
|
groups[bt].items.push(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
const LABELS = { CARRY_OVER: '이월연차', AUTO: '정기연차', MANUAL: '추가부여', LONG_SERVICE: '장기근속', COMPANY_GRANT: '경조사/특별' };
|
||||||
|
const ORDER = ['CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT'];
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
ORDER.forEach(bt => {
|
||||||
|
const g = groups[bt];
|
||||||
|
if (!g || (g.total === 0 && g.used === 0)) return;
|
||||||
|
const label = LABELS[bt] || bt;
|
||||||
|
const expired = bt === 'CARRY_OVER' && isExpired(g.expires_at);
|
||||||
|
const lapsed = expired ? Math.max(0, g.total - g.used) : 0;
|
||||||
|
|
||||||
|
html += `<div class="pd-detail-row">
|
||||||
|
<span class="pd-detail-label">${label}</span>
|
||||||
|
<span class="pd-detail-value">
|
||||||
|
${g.total !== 0 ? `배정 ${fmtDays(g.total)}` : ''}
|
||||||
|
${g.used > 0 ? ` · 사용 ${fmtDays(g.used)}` : ''}
|
||||||
|
${expired && lapsed > 0 ? ` · <span style="color:#9ca3af;text-decoration:line-through">만료 ${fmtDays(lapsed)}</span>` : ''}
|
||||||
|
${!expired && g.remaining !== 0 ? ` · 잔여 <strong>${fmtDays(g.remaining)}</strong>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!html) html = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.6)">연차 정보가 없습니다</div>';
|
||||||
|
|
||||||
|
html += `<div class="pd-detail-total">
|
||||||
|
<span>합계</span>
|
||||||
|
<span>배정 ${fmtDays(vacation.total_days)} · 사용 ${fmtDays(vacation.used_days)} · 잔여 <strong>${fmtDays(vacation.remaining_days)}</strong></span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.getElementById('vacDetailContent').innerHTML = html;
|
||||||
|
document.getElementById('vacDetailModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVacDetail() {
|
||||||
|
document.getElementById('vacDetailModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(gridId, sectionId, pages) {
|
||||||
|
const grid = document.getElementById(gridId);
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
if (!pages || pages.length === 0) { section.classList.add('hidden'); return; }
|
||||||
|
section.classList.remove('hidden');
|
||||||
|
|
||||||
|
const filtered = pages.filter(p => !HIDDEN_PAGES.includes(p.page_key));
|
||||||
|
if (filtered.length === 0) { section.classList.add('hidden'); return; }
|
||||||
|
|
||||||
|
grid.innerHTML = filtered.map(p => {
|
||||||
|
const icon = PAGE_ICONS[p.page_key] || p.icon || 'fa-circle';
|
||||||
|
const color = CATEGORY_COLORS[p.category] || DEFAULT_COLOR;
|
||||||
|
return `<a href="${escHtml(p.page_path)}" class="pd-grid-item">
|
||||||
|
<div class="pd-grid-icon" style="background:${color}">
|
||||||
|
<i class="fas ${icon}"></i>
|
||||||
|
</div>
|
||||||
|
<span class="pd-grid-label">${escHtml(p.page_name)}</span>
|
||||||
|
</a>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSkeleton() {
|
||||||
|
const card = document.getElementById('profileCard');
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="pd-profile-header">
|
||||||
|
<div class="pd-skeleton" style="width:48px;height:48px;border-radius:50%"></div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="pd-skeleton" style="width:100px;height:18px;margin-bottom:6px"></div>
|
||||||
|
<div class="pd-skeleton" style="width:140px;height:14px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-skeleton" style="height:50px;margin-top:12px"></div>
|
||||||
|
<div class="pd-skeleton" style="height:50px;margin-top:6px"></div>
|
||||||
|
`;
|
||||||
|
['deptPagesGrid'].forEach(id => {
|
||||||
|
const g = document.getElementById(id);
|
||||||
|
if (g) g.innerHTML = Array(8).fill('<div style="display:flex;flex-direction:column;align-items:center;gap:6px"><div class="pd-skeleton" style="width:52px;height:52px;border-radius:14px"></div><div class="pd-skeleton" style="width:40px;height:12px"></div></div>').join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
document.getElementById('profileCard').innerHTML = `
|
||||||
|
<div class="pd-error">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<p>${escHtml(msg || '정보를 불러올 수 없습니다.')}</p>
|
||||||
|
<button class="pd-error-btn" onclick="initDashboard()"><i class="fas fa-redo mr-1"></i>새로고침</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => setTimeout(initDashboard, 300));
|
||||||
|
} else {
|
||||||
|
setTimeout(initDashboard, 300);
|
||||||
|
}
|
||||||
262
system1-factory/web/js/proxy-input.js
Normal file
262
system1-factory/web/js/proxy-input.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* proxy-input.js — 대리입력 리뉴얼
|
||||||
|
* Step 1: 날짜 선택 → 작업자 목록 (체크박스)
|
||||||
|
* Step 2: 공통 입력 1개 → 선택된 전원 일괄 적용
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentDate = '';
|
||||||
|
let allWorkers = [];
|
||||||
|
let selectedIds = new Set();
|
||||||
|
let projects = [];
|
||||||
|
let workTypes = [];
|
||||||
|
let defectCategories = []; // { category_id, category_name, items: [{ item_id, item_name }] }
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
currentDate = new Date().toISOString().substring(0, 10);
|
||||||
|
document.getElementById('dateInput').value = currentDate;
|
||||||
|
setTimeout(async () => {
|
||||||
|
await loadDropdownData();
|
||||||
|
await loadWorkers();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadDropdownData() {
|
||||||
|
try {
|
||||||
|
const [pRes, wRes] = await Promise.all([
|
||||||
|
window.apiCall('/projects'),
|
||||||
|
window.apiCall('/daily-work-reports/work-types')
|
||||||
|
]);
|
||||||
|
projects = (pRes.data || pRes || []).filter(p => p.is_active !== 0);
|
||||||
|
workTypes = (wRes.data || wRes || []).map(w => ({ id: w.id || w.work_type_id, name: w.name || w.work_type_name, ...w }));
|
||||||
|
|
||||||
|
// 부적합 대분류/소분류 로드
|
||||||
|
const cRes = await window.apiCall('/work-issues/categories/type/nonconformity');
|
||||||
|
const cats = cRes.data || cRes || [];
|
||||||
|
for (const c of cats) {
|
||||||
|
const iRes = await window.apiCall('/work-issues/items/category/' + c.category_id);
|
||||||
|
defectCategories.push({
|
||||||
|
category_id: c.category_id,
|
||||||
|
category_name: c.category_name,
|
||||||
|
items: (iRes.data || iRes || [])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('드롭다운 로드 실패:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Step 1: Worker List =====
|
||||||
|
async function loadWorkers() {
|
||||||
|
currentDate = document.getElementById('dateInput').value;
|
||||||
|
if (!currentDate) return;
|
||||||
|
|
||||||
|
const list = document.getElementById('workerList');
|
||||||
|
list.innerHTML = '<div class="pi-skeleton"></div><div class="pi-skeleton"></div>';
|
||||||
|
selectedIds.clear();
|
||||||
|
updateEditButton();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await window.apiCall('/proxy-input/daily-status?date=' + currentDate);
|
||||||
|
if (!res.success) throw new Error(res.message);
|
||||||
|
|
||||||
|
allWorkers = res.data.workers || [];
|
||||||
|
const s = res.data.summary || {};
|
||||||
|
document.getElementById('totalNum').textContent = s.total_active_workers || allWorkers.length;
|
||||||
|
document.getElementById('doneNum').textContent = s.report_completed || 0;
|
||||||
|
document.getElementById('missingNum').textContent = s.report_missing || 0;
|
||||||
|
document.getElementById('vacNum').textContent = allWorkers.filter(w => w.vacation_type_code === 'ANNUAL_FULL').length;
|
||||||
|
|
||||||
|
renderWorkerList();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = '<div class="pi-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>데이터 로드 실패</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkerList() {
|
||||||
|
const list = document.getElementById('workerList');
|
||||||
|
if (!allWorkers.length) {
|
||||||
|
list.innerHTML = '<div class="pi-empty"><p>작업자가 없습니다</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부서별 그룹핑
|
||||||
|
const byDept = {};
|
||||||
|
allWorkers.forEach(w => {
|
||||||
|
const dept = w.department_name || '미배정';
|
||||||
|
if (!byDept[dept]) byDept[dept] = [];
|
||||||
|
byDept[dept].push(w);
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
Object.keys(byDept).sort().forEach(dept => {
|
||||||
|
html += `<div class="pi-dept-label">${esc(dept)}</div>`;
|
||||||
|
byDept[dept].forEach(w => {
|
||||||
|
const isFullVac = w.vacation_type_code === 'ANNUAL_FULL';
|
||||||
|
const hasVac = !!w.vacation_type_code;
|
||||||
|
const vacBadge = isFullVac ? '<span class="pi-badge vac">연차</span>'
|
||||||
|
: hasVac ? `<span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
|
||||||
|
const doneBadge = w.has_report ? `<span class="pi-badge done">${w.total_report_hours}h</span>` : '<span class="pi-badge missing">미입력</span>';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<label class="pi-worker ${isFullVac ? 'disabled' : ''}">
|
||||||
|
<input type="checkbox" class="pi-check" value="${w.user_id}"
|
||||||
|
${isFullVac ? 'disabled' : ''}
|
||||||
|
onchange="onWorkerCheck(${w.user_id}, this.checked)">
|
||||||
|
<div class="pi-worker-info">
|
||||||
|
<span class="pi-worker-name">${esc(w.worker_name)}</span>
|
||||||
|
<span class="pi-worker-job">${esc(w.job_type || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pi-worker-badges">${vacBadge}${doneBadge}</div>
|
||||||
|
</label>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
list.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWorkerCheck(userId, checked) {
|
||||||
|
if (checked) selectedIds.add(userId);
|
||||||
|
else selectedIds.delete(userId);
|
||||||
|
updateEditButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll(checked) {
|
||||||
|
allWorkers.forEach(w => {
|
||||||
|
if (w.vacation_type_code === 'ANNUAL_FULL') return;
|
||||||
|
const cb = document.querySelector(`.pi-check[value="${w.user_id}"]`);
|
||||||
|
if (cb) { cb.checked = checked; onWorkerCheck(w.user_id, checked); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditButton() {
|
||||||
|
const btn = document.getElementById('editBtn');
|
||||||
|
const n = selectedIds.size;
|
||||||
|
btn.disabled = n === 0;
|
||||||
|
document.getElementById('editBtnText').textContent = n > 0 ? `선택 작업자 편집 (${n}명)` : '작업자를 선택하세요';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Step 2: Bulk Edit (공통 입력 1개) =====
|
||||||
|
function openEditMode() {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
|
||||||
|
const selected = allWorkers.filter(w => selectedIds.has(w.user_id));
|
||||||
|
document.getElementById('editTitle').textContent = `일괄 편집 (${selected.length}명)`;
|
||||||
|
|
||||||
|
// 프로젝트/공종 드롭다운 채우기
|
||||||
|
const projSel = document.getElementById('bulkProject');
|
||||||
|
projSel.innerHTML = '<option value="">프로젝트 선택 *</option>' + projects.map(p => `<option value="${p.project_id}">${esc(p.project_name)}</option>`).join('');
|
||||||
|
|
||||||
|
const typeSel = document.getElementById('bulkWorkType');
|
||||||
|
typeSel.innerHTML = '<option value="">공종 선택 *</option>' + workTypes.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
|
||||||
|
|
||||||
|
// 적용 대상 목록
|
||||||
|
document.getElementById('targetWorkers').innerHTML = selected.map(w =>
|
||||||
|
`<span class="pi-target-chip">${esc(w.worker_name)}</span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// 기본값
|
||||||
|
document.getElementById('bulkHours').value = '8';
|
||||||
|
document.getElementById('bulkDefect').value = '0';
|
||||||
|
document.getElementById('bulkNote').value = '';
|
||||||
|
|
||||||
|
document.getElementById('step1').classList.add('hidden');
|
||||||
|
document.getElementById('step2').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditMode() {
|
||||||
|
document.getElementById('step2').classList.add('hidden');
|
||||||
|
document.getElementById('step1').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Save =====
|
||||||
|
async function saveAll() {
|
||||||
|
const projId = document.getElementById('bulkProject').value;
|
||||||
|
const wtypeId = document.getElementById('bulkWorkType').value;
|
||||||
|
const hours = parseFloat(document.getElementById('bulkHours').value) || 0;
|
||||||
|
const defect = parseFloat(document.getElementById('bulkDefect').value) || 0;
|
||||||
|
const note = document.getElementById('bulkNote').value.trim();
|
||||||
|
|
||||||
|
if (!projId || !wtypeId) {
|
||||||
|
showToast('프로젝트와 공종을 선택하세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hours <= 0) {
|
||||||
|
showToast('근무시간을 입력하세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (defect > hours) {
|
||||||
|
showToast('부적합 시간이 근무시간을 초과합니다', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defectCategoryId = defect > 0 ? (parseInt(document.getElementById('bulkDefectCategory').value) || null) : null;
|
||||||
|
const defectItemId = defect > 0 ? (parseInt(document.getElementById('bulkDefectItem').value) || null) : null;
|
||||||
|
if (defect > 0 && !defectCategoryId) {
|
||||||
|
showToast('부적합 대분류를 선택하세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('saveBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
document.getElementById('saveBtnText').textContent = '저장 중...';
|
||||||
|
|
||||||
|
const entries = Array.from(selectedIds).map(uid => ({
|
||||||
|
user_id: uid,
|
||||||
|
project_id: parseInt(projId),
|
||||||
|
work_type_id: parseInt(wtypeId),
|
||||||
|
work_hours: hours,
|
||||||
|
defect_hours: defect,
|
||||||
|
defect_category_id: defectCategoryId,
|
||||||
|
defect_item_id: defectItemId,
|
||||||
|
note: note,
|
||||||
|
start_time: '08:00',
|
||||||
|
end_time: '17:00',
|
||||||
|
work_status_id: defect > 0 ? 2 : 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await window.apiCall('/proxy-input', 'POST', {
|
||||||
|
session_date: currentDate,
|
||||||
|
entries
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
showToast(res.message || `${entries.length}명 저장 완료`, 'success');
|
||||||
|
closeEditMode();
|
||||||
|
selectedIds.clear();
|
||||||
|
updateEditButton();
|
||||||
|
await loadWorkers();
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '저장 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('저장 실패: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
document.getElementById('saveBtnText').textContent = '전체 저장';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Defect Category/Item =====
|
||||||
|
function onDefectChange() {
|
||||||
|
const val = parseFloat(document.getElementById('bulkDefect').value) || 0;
|
||||||
|
const row = document.getElementById('defectCategoryRow');
|
||||||
|
if (val > 0) {
|
||||||
|
row.classList.remove('hidden');
|
||||||
|
const catSel = document.getElementById('bulkDefectCategory');
|
||||||
|
if (catSel.options.length <= 1) {
|
||||||
|
catSel.innerHTML = '<option value="">부적합 대분류 *</option>' +
|
||||||
|
defectCategories.map(c => `<option value="${c.category_id}">${esc(c.category_name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDefectCategoryChange() {
|
||||||
|
const catId = parseInt(document.getElementById('bulkDefectCategory').value);
|
||||||
|
const itemSel = document.getElementById('bulkDefectItem');
|
||||||
|
const cat = defectCategories.find(c => c.category_id === catId);
|
||||||
|
itemSel.innerHTML = '<option value="">소분류 *</option>' +
|
||||||
|
(cat ? cat.items.map(i => `<option value="${i.item_id}">${esc(i.item_name)}</option>`).join('') : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
39
system1-factory/web/js/sso-relay.js
Normal file
39
system1-factory/web/js/sso-relay.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
|
||||||
|
*
|
||||||
|
* Canonical source: shared/frontend/sso-relay.js
|
||||||
|
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
|
||||||
|
* system1-factory/web/js/sso-relay.js
|
||||||
|
* system2-report/web/js/sso-relay.js
|
||||||
|
* system3-nonconformance/web/static/js/sso-relay.js
|
||||||
|
* user-management/web/static/js/sso-relay.js
|
||||||
|
* tkpurchase/web/static/js/sso-relay.js
|
||||||
|
* tksafety/web/static/js/sso-relay.js
|
||||||
|
* tksupport/web/static/js/sso-relay.js
|
||||||
|
*
|
||||||
|
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
|
||||||
|
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
var hash = location.hash;
|
||||||
|
if (!hash || hash.indexOf('_sso=') === -1) return;
|
||||||
|
|
||||||
|
var match = hash.match(/[#&]_sso=([^&]*)/);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
var token = decodeURIComponent(match[1]);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
// 로컬(1st-party) 쿠키 설정
|
||||||
|
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
|
||||||
|
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
|
||||||
|
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
|
||||||
|
}
|
||||||
|
document.cookie = cookie;
|
||||||
|
|
||||||
|
// localStorage 폴백
|
||||||
|
try { localStorage.setItem('sso_token', token); } catch (e) {}
|
||||||
|
|
||||||
|
// URL에서 hash 제거
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
})();
|
||||||
@@ -41,12 +41,14 @@
|
|||||||
W.sessionDate = window.TbmUtils.getTodayKST();
|
W.sessionDate = window.TbmUtils.getTodayKST();
|
||||||
var user = window.TbmState.getUser();
|
var user = window.TbmState.getUser();
|
||||||
if (user) {
|
if (user) {
|
||||||
if (user.user_id) {
|
var uid = user.user_id || user.id;
|
||||||
var worker = window.TbmState.allWorkers.find(function(w) { return w.user_id === user.user_id; });
|
if (uid) {
|
||||||
|
var worker = window.TbmState.allWorkers.find(function(w) { return String(w.user_id) === String(uid); });
|
||||||
if (worker) {
|
if (worker) {
|
||||||
W.leaderId = worker.user_id;
|
W.leaderId = worker.user_id;
|
||||||
W.leaderName = worker.worker_name;
|
W.leaderName = worker.worker_name;
|
||||||
} else {
|
} else {
|
||||||
|
W.leaderId = uid;
|
||||||
W.leaderName = user.name || '';
|
W.leaderName = user.name || '';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -304,7 +306,7 @@
|
|||||||
var skipSelected = W.projectId === null ? ' selected' : '';
|
var skipSelected = W.projectId === null ? ' selected' : '';
|
||||||
var projectItems = projects.map(function(p) {
|
var projectItems = projects.map(function(p) {
|
||||||
var selected = W.projectId === p.project_id ? ' selected' : '';
|
var selected = W.projectId === p.project_id ? ' selected' : '';
|
||||||
return '<div class="list-item' + selected + '" onclick="selectProject(' + p.project_id + ', \'' + esc(p.project_name).replace(/'/g, "\\'") + '\')">' +
|
return '<div class="list-item' + selected + '" data-action="selectProject" data-project-id="' + p.project_id + '" data-project-name="' + esc(p.project_name) + '">' +
|
||||||
'<div class="item-title">' + esc(p.project_name) + '</div>' +
|
'<div class="item-title">' + esc(p.project_name) + '</div>' +
|
||||||
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
|
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -313,7 +315,7 @@
|
|||||||
// 공정 pill 버튼
|
// 공정 pill 버튼
|
||||||
var pillHtml = workTypes.map(function(wt) {
|
var pillHtml = workTypes.map(function(wt) {
|
||||||
var selected = W.workTypeId === wt.id ? ' selected' : '';
|
var selected = W.workTypeId === wt.id ? ' selected' : '';
|
||||||
return '<button type="button" class="pill-btn' + selected + '" onclick="selectWorkType(' + wt.id + ', \'' + esc(wt.name).replace(/'/g, "\\'") + '\')">' + esc(wt.name) + '</button>';
|
return '<button type="button" class="pill-btn' + selected + '" data-action="selectWorkType" data-wt-id="' + wt.id + '" data-wt-name="' + esc(wt.name) + '">' + esc(wt.name) + '</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
|
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
|
||||||
|
|
||||||
@@ -333,7 +335,7 @@
|
|||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<div class="wizard-section">' +
|
'<div class="wizard-section">' +
|
||||||
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
|
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
|
||||||
'<div class="list-item-skip' + skipSelected + '" onclick="selectProject(null, \'\')">' +
|
'<div class="list-item-skip' + skipSelected + '" data-action="selectProject" data-project-id="" data-project-name="">' +
|
||||||
'선택 안함' +
|
'선택 안함' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
|
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
|
||||||
@@ -355,6 +357,19 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event delegation for project/workType selection
|
||||||
|
container.onclick = function(e) {
|
||||||
|
var el = e.target.closest('[data-action]');
|
||||||
|
if (!el) return;
|
||||||
|
var action = el.getAttribute('data-action');
|
||||||
|
if (action === 'selectProject') {
|
||||||
|
var pid = el.getAttribute('data-project-id');
|
||||||
|
selectProject(pid ? parseInt(pid) : null, el.getAttribute('data-project-name') || '');
|
||||||
|
} else if (action === 'selectWorkType') {
|
||||||
|
selectWorkType(parseInt(el.getAttribute('data-wt-id')), el.getAttribute('data-wt-name') || '');
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
window.selectProject = function(projectId, projectName) {
|
window.selectProject = function(projectId, projectName) {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUser = JSON.parse(localStorage.getItem('sso_user') || '{}');
|
currentUser = (typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser) || {};
|
||||||
await loadData();
|
await loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,11 +123,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function isMySession(s) {
|
function isMySession(s) {
|
||||||
var userId = currentUser.user_id;
|
var role = (currentUser.role || '').toLowerCase();
|
||||||
var workerId = currentUser.user_id;
|
if (role === 'admin' || role === 'system' || role === 'support_team') return true;
|
||||||
|
var userId = currentUser.user_id || currentUser.id;
|
||||||
var userName = currentUser.name;
|
var userName = currentUser.name;
|
||||||
return (userId && String(s.created_by) === String(userId)) ||
|
return (userId && (String(s.created_by) === String(userId) ||
|
||||||
(workerId && String(s.leader_user_id) === String(workerId)) ||
|
String(s.leader_user_id) === String(userId))) ||
|
||||||
(userName && s.created_by_name === userName);
|
(userName && s.created_by_name === userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,8 +813,13 @@
|
|||||||
});
|
});
|
||||||
if (res && res.success) {
|
if (res && res.success) {
|
||||||
closeCompleteSheet();
|
closeCompleteSheet();
|
||||||
window.showToast('TBM이 완료 처리되었습니다.', 'success');
|
|
||||||
await loadData();
|
await loadData();
|
||||||
|
var session = allSessions.find(function(s) { return s.session_id === completeSessionId; });
|
||||||
|
var sDate = session && session.session_date ? session.session_date.split('T')[0] : '';
|
||||||
|
if (confirm('TBM이 완료되었습니다.\n작업보고서를 작성하시겠습니까?')) {
|
||||||
|
location.href = '/pages/work/report-create-mobile.html' + (sDate ? '?date=' + sDate : '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.showToast('완료 처리에 실패했습니다.', 'error');
|
window.showToast('완료 처리에 실패했습니다.', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "TK 공장관리 - 테크니컬코리아",
|
"name": "TK 공장관리 - 테크니컬코리아",
|
||||||
"short_name": "TK공장",
|
"short_name": "TK공장",
|
||||||
"description": "테크니컬코리아 공장관리 시스템",
|
"description": "테크니컬코리아 공장관리 시스템",
|
||||||
"start_url": "/pages/dashboard.html",
|
"start_url": "/pages/dashboard-new.html",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#1e40af",
|
"theme_color": "#1e40af",
|
||||||
|
|||||||
@@ -18,12 +18,31 @@ server {
|
|||||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SW, manifest 캐시 비활성화 (PWA 업데이트 즉시 반영)
|
||||||
|
location = /sw.js {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
location = /manifest.json {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
# 정적 파일 캐시 (JS, CSS, 이미지 등)
|
# 정적 파일 캐시 (JS, CSS, 이미지 등)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public, no-transform";
|
add_header Cache-Control "public, no-transform";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SSO Auth API 프록시 (/api/auth/* → sso-auth)
|
||||||
|
location /api/auth/ {
|
||||||
|
set $upstream http://sso-auth:3000;
|
||||||
|
proxy_pass $upstream$request_uri;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
# API 프록시 (System 1 API)
|
# API 프록시 (System 1 API)
|
||||||
location /api/ {
|
location /api/ {
|
||||||
set $upstream http://system1-api:3005;
|
set $upstream http://system1-api:3005;
|
||||||
@@ -81,12 +100,19 @@ server {
|
|||||||
proxy_send_timeout 180s;
|
proxy_send_timeout 180s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 레거시 /login, /dashboard → gateway(tkds) 리다이렉트
|
# /login → 로그인 페이지 (gateway 대시보드)
|
||||||
|
# tkfb.technicalkorea.net이 system1-web을 직접 가리키므로
|
||||||
|
# 외부 리다이렉트 대신 gateway 내부 프록시로 처리
|
||||||
location = /login {
|
location = /login {
|
||||||
return 302 $scheme://tkds.technicalkorea.net/dashboard$is_args$args;
|
set $gw http://gateway:80;
|
||||||
|
rewrite ^/login$ /dashboard break;
|
||||||
|
proxy_pass $gw;
|
||||||
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
location = /dashboard {
|
location = /dashboard {
|
||||||
return 301 $scheme://tkds.technicalkorea.net/dashboard;
|
set $gw http://gateway:80;
|
||||||
|
proxy_pass $gw;
|
||||||
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>출퇴근-작업보고서 대조 - TK 공장관리</title>
|
<title>출퇴근-작업보고서 대조 - TK 공장관리</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031601">
|
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||||
<style>
|
<style>
|
||||||
.comparison-grid {
|
.comparison-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -190,7 +190,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
|
<script src="/js/sso-relay.js?v=20260401"></script>
|
||||||
|
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
|
||||||
<script src="/js/api-base.js?v=2026031401"></script>
|
<script src="/js/api-base.js?v=2026031401"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>설비 상세 - TK 공장관리</title>
|
<title>설비 상세 - TK 공장관리</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031601">
|
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||||
<link rel="stylesheet" href="/css/equipment-detail.css?v=2026031401">
|
<link rel="stylesheet" href="/css/equipment-detail.css?v=2026031401">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50">
|
<body class="bg-gray-50">
|
||||||
@@ -314,7 +314,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
|
<script src="/js/sso-relay.js?v=20260401"></script>
|
||||||
|
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
|
||||||
<script src="/js/api-base.js?v=2026031401"></script>
|
<script src="/js/api-base.js?v=2026031401"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>설비 관리 - TK 공장관리</title>
|
<title>설비 관리 - TK 공장관리</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031601">
|
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||||
<link rel="stylesheet" href="/css/equipment-management.css?v=2026031401">
|
<link rel="stylesheet" href="/css/equipment-management.css?v=2026031401">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50">
|
<body class="bg-gray-50">
|
||||||
@@ -190,7 +190,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
|
<script src="/js/sso-relay.js?v=20260401"></script>
|
||||||
|
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
|
||||||
<script src="/js/api-base.js?v=2026031401"></script>
|
<script src="/js/api-base.js?v=2026031401"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user