내 신고 현황
-내가 신고한 안전, 시설설비, 부적합 현황을 확인합니다.
-diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
deleted file mode 100644
index 8857677..0000000
--- a/ARCHITECTURE.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# TK Factory Services - 데이터 아키텍처 가이드
-
-## 서비스 구조
-
-| 서비스 | 서브도메인 | 역할 | 포트 |
-|--------|-----------|------|------|
-| **tkuser** | tkuser.technicalkorea.net | 통합 관리 (기본 데이터) | API 30300, Web 30380 |
-| **tkfactory** (System 1) | tkfactory.technicalkorea.net | 공장 관리 | API 30005, Web 30080 |
-| **tkreport** (System 2) | tkreport.technicalkorea.net | 안전 신고 | API 30105, Web 30180 |
-| **tkqc** (System 3) | tkqc.technicalkorea.net | 부적합 관리 | API 30200, Web 30280 |
-| **SSO Auth** | - | 인증 (로그인/토큰) | 30050 |
-| **Gateway** | technicalkorea.net | SSO 라우팅 | 30000 |
-
-## 데이터 소유권 (Data Ownership)
-
-### 1. 기본 데이터 → tkuser API
-
-모든 시스템에서 공통으로 사용하는 마스터 데이터는 **tkuser**에서 관리합니다.
-
-| 데이터 | 설명 | DB 테이블 |
-|--------|------|-----------|
-| **사용자** | 계정, 역할, 비밀번호 | `sso_users` |
-| **페이지 권한** | 사용자별 페이지 접근 권한 | `user_page_permissions` |
-| **프로젝트** | 프로젝트 목록 및 설정 | `projects` |
-| **작업장** | 공장(카테고리) → 작업장 계층, 구역지도 | `workplace_categories`, `workplaces`, `workplace_map_regions` |
-| **설비** | 설비 마스터, 사진, 배치도 위치 | `equipments`, `equipment_photos` |
-| **부서** | 부서/조직 구조 | `departments` |
-| **작업자** | 작업자 인력 관리 | `workers` |
-| **작업/공정** | 공정(work_types) → 작업(tasks) 계층 | `work_types`, `tasks` |
-| **휴가 유형** | 연차/반차/특별휴가 유형 정의 | `vacation_types` |
-| **연차 배정** | 작업자별 연간 연차 일수 배정/사용 추적 | `vacation_balance_details` |
-
-다른 시스템은 tkuser API를 호출하여 기본 데이터를 조회합니다.
-
-### 2. 신고 데이터 → tkreport (System 2) API
-
-안전 신고와 관련된 트랜잭션 데이터는 **System 2**에서 관리합니다.
-
-| 데이터 | 설명 |
-|--------|------|
-| 안전 신고 | 신고 접수, 처리 현황 |
-| 작업 이슈 | 작업 관련 이슈 리포트 |
-| 신고 첨부파일 | 사진, 문서 등 |
-
-### 3. 관리 현황 데이터 → 해당 시스템 API
-
-각 시스템 고유의 운영 데이터는 해당 시스템에서 관리합니다.
-
-**System 1 (tkfactory)**
-| 데이터 | 설명 |
-|--------|------|
-| TBM 기록 | 일일 TBM 체크리스트 |
-| 작업 보고서 | 일일/주간 작업 보고 |
-| 출퇴근 기록 | 체크인/체크아웃 |
-| 근태/휴가 | 휴가 신청 및 관리 |
-| 순회 점검 | 일일 순회점검 결과 |
-
-**System 3 (tkqc)**
-| 데이터 | 설명 |
-|--------|------|
-| 부적합 이슈 | NCR 접수, 처리, 폐기 |
-| 일일 공수 | 작업자별 일일 공수 입력 |
-| 보고서 | 일일/주간/월간 보고서 |
-
-## tkuser API 엔드포인트
-
-| 경로 | 설명 |
-|------|------|
-| `GET/POST /api/users` | 사용자 CRUD |
-| `GET/POST /api/permissions` | 페이지 권한 관리 |
-| `GET/POST /api/projects` | 프로젝트 관리 |
-| `GET/POST /api/workers` | 작업자 관리 |
-| `GET/POST /api/departments` | 부서 관리 |
-| `GET/POST /api/workplaces` | 작업장·카테고리·구역지도 관리 |
-| `GET/POST /api/equipments` | 설비·사진 관리 |
-| `GET/POST /api/tasks` | 공정(work_types)·작업(tasks) 관리 |
-| `GET/POST /api/vacations` | 휴가 유형·연차 배정 관리 |
-
-## 데이터 흐름
-
-```
-┌───────────────────────────────────────────────────────────┐
-│ tkuser (통합 관리) │
-│ 사용자 / 프로젝트 / 작업장·설비 / 부서 / 작업·공정 / 휴가 │
-│ [MariaDB] │
-└──────────┬──────────────┬──────────────┬──────────────────┘
- │ │ │
- API 조회 API 조회 API 조회
- │ │ │
- ┌──────▼──────┐ ┌─────▼──────┐ ┌────▼───────────┐
- │ tkfactory │ │ tkreport │ │ tkqc │
- │ 공장 관리 │ │ 안전 신고 │ │ 부적합 관리 │
- │ [MariaDB] │ │ [MariaDB] │ │ [PostgreSQL] │
- └─────────────┘ └────────────┘ └────────────────┘
-```
-
-## 인증 구조
-
-```
-사용자 로그인 → SSO Auth → JWT 토큰 (sso_token 쿠키)
- ↓
- .technicalkorea.net 전체 공유
- ↓
- 각 시스템에서 쿠키로 인증 확인
-```
-
-- JWT 토큰은 `.technicalkorea.net` 도메인 쿠키로 설정
-- 모든 서브도메인에서 자동으로 인증 공유
-- 각 시스템 API는 동일한 `SSO_JWT_SECRET`으로 토큰 검증
-
-## 페이지 권한 체계
-
-권한은 **tkuser**에서 중앙 관리하며, 각 시스템은 API를 호출하여 권한을 확인합니다.
-
-| 시스템 | 권한 키 접두사 | 예시 |
-|--------|---------------|------|
-| System 1 | `s1.*` | `s1.work.tbm`, `s1.admin.projects` |
-| System 2 | - | 전체 허용 (권한 관리 불필요) |
-| System 3 | (접두사 없음) | `issues_dashboard`, `daily_work` |
-
-권한 우선순위:
-1. `user_page_permissions` 테이블에 명시적 설정이 있으면 해당 값 사용
-2. 없으면 `DEFAULT_PAGES`의 `default_access` 값 사용
-
-## 배포
-
-```bash
-# 전체 서비스
-docker compose up -d --build
-
-# 개별 서비스
-docker compose up -d --build tkuser-api tkuser-web
-docker compose up -d --build system1-api system1-web
-docker compose up -d --build system2-api system2-web
-docker compose up -d --build system3-api system3-web
-```
diff --git a/DEPLOY-GUIDE.md b/DEPLOY-GUIDE.md
index 66aaa05..1c85a4b 100644
--- a/DEPLOY-GUIDE.md
+++ b/DEPLOY-GUIDE.md
@@ -1,6 +1,6 @@
# TK Factory Services - NAS 배포 가이드
-> 최종 업데이트: 2026-02-12
+> 최종 업데이트: 2026-02-09
## 아키텍처 개요
@@ -27,7 +27,6 @@
| `tkfb.technicalkorea.net` | `http://tk-gateway:80` | 통합 포털 + System 1 (공장관리) |
| `tkreport.technicalkorea.net` | `http://tk-system2-web:80` | System 2 (신고) |
| `tkqc.technicalkorea.net` | `http://tk-system3-web:80` | System 3 (부적합관리) |
-| `tkuser.technicalkorea.net` | `http://tk-tkuser-web:80` | 통합 관리 (사용자/부서/프로젝트/이슈유형) |
### SSO 인증 방식
@@ -52,8 +51,6 @@
| System 3 Web | tk-system3-web | 30280:80 |
| phpMyAdmin | tk-phpmyadmin | 30880:80 |
| Cloudflared | tk-cloudflared | - |
-| TKUser API | tk-tkuser-api | 30300:3000 |
-| TKUser Web | tk-tkuser-web | 30380:80 |
| Gateway | tk-gateway | 30000:80 |
---
@@ -243,41 +240,6 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up -d
---
-## 시스템 간 연동
-
-### tkreport → tkqc 부적합 자동 전달
-
-부적합 유형으로 신고 시 system2-api가 system3-api로 자동 전달:
-
-- **인증**: SSO 토큰 그대로 전달 (원래 신고자 유지)
-- **fallback 인증**: `api_service` 계정 (system3 users 테이블에 등록 필요)
-- **사진**: system2에 저장된 파일을 base64로 읽어서 system3에 전달
-- **프로젝트**: 같은 MariaDB `projects` 테이블 공유, project_id 그대로 전달
-- **환경변수**: `M_PROJECT_API_URL=http://system3-api:8000`
-
-### system2-web nginx 프록시 라우팅
-
-tkreport의 nginx는 여러 백엔드로 프록시:
-
-| 경로 | 대상 | 용도 |
-|------|------|------|
-| `/api/workplaces/` | system1-api:3005 | 공장/작업장 |
-| `/api/projects/` | system1-api:3005 | 프로젝트 목록 |
-| `/api/tbm/` | system1-api:3005 | TBM 세션 |
-| `/api/workplace-visits/` | system1-api:3005 | 출입관리 |
-| `/api/` (catch-all) | system2-api:3005 | 신고(work-issues) |
-
-### 신고 페이지 (tkreport) 흐름
-
-모바일 최적화 5단계 위자드:
-1. **유형** — 부적합 / 시설설비 / 안전
-2. **위치** — 지도에서 작업장 터치 선택
-3. **프로젝트** — TBM등록 / 활성프로젝트 / 모름 (아코디언)
-4. **항목** — 카테고리 → 세부항목 또는 직접입력
-5. **사진** — 최대 5장 (클라이언트 1280px 리사이징), 추가설명
-
----
-
## 트러블슈팅
| 증상 | 원인 | 해결 |
@@ -288,6 +250,3 @@ tkreport의 nginx는 여러 백엔드로 프록시:
| Cloudflare 502 | 컨테이너 미기동 | `docker compose ps`로 상태 확인, `docker compose logs <서비스>` |
| SCP 실패 | Synology subsystem | `scp -O` 옵션 사용 |
| sudo 파이프 실패 | Synology 제한 | `echo 'password' \| sudo -S` 패턴 사용 |
-| 신고 사진 업로드 느림/실패 | nginx body size 제한 | `client_max_body_size 50m;` 확인 |
-| tkqc 부적합 연동 실패 | api_service 계정 미등록 | system3 DB에 api_service 사용자 생성 |
-| 신고 사진 컨테이너 재빌드 후 유실 | uploads 경로 불일치 | imageUploadService 경로가 Docker 볼륨과 일치하는지 확인 |
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
deleted file mode 100644
index 3c02cdf..0000000
--- a/DEPLOYMENT.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# TK Factory Services - 배포 가이드
-
-## NAS 접속
-
-| 환경 | 접속 주소 |
-|------|-----------|
-| **사내 네트워크 (로컬)** | `ssh hyungi@192.168.0.3` |
-| **외부 네트워크 (Tailscale VPN)** | `ssh hyungi@100.71.132.52` |
-
-> 외부에서 작업할 때는 Tailscale VPN IP(`100.71.132.52`)로 접속하세요.
-> Tailscale은 WireGuard 암호화를 사용하며, 인증된 기기만 접근 가능합니다.
-
-## 프로젝트 경로
-
-- **NAS 프로젝트**: `/volume1/docker/tk-factory-services`
-- **Docker 바이너리**: `/volume2/@appstore/ContainerManager/usr/bin`
-
-## 배포 방법
-
-### 1. 파일 전송 (scp)
-
-```bash
-# Synology NAS는 -O (레거시 모드) 플래그 필요
-scp -O <로컬파일> hyungi@100.71.132.52:/volume1/docker/tk-factory-services/<경로>
-```
-
-### 2. 컨테이너 빌드 및 재시작
-
-```bash
-ssh hyungi@100.71.132.52 "\
- export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && \
- cd /volume1/docker/tk-factory-services && \
- docker compose up -d --build <서비스명>"
-```
-
-### 서비스명 목록
-
-| 서비스 | 도메인 | 컨테이너 |
-|--------|--------|----------|
-| tkuser-web | tkuser.technicalkorea.net | tk-tkuser-web |
-| tkuser-api | - | tk-tkuser-api |
-| system1-web | tkfactory.technicalkorea.net | tk-system1-web |
-| system1-api | - | tk-system1-api |
-| system2-web | tkreport.technicalkorea.net | tk-system2-web |
-| system2-api | - | tk-system2-api |
-| system3-web | tkqc.technicalkorea.net | tk-system3-web |
-| system3-api | - | tk-system3-api |
-| gateway | technicalkorea.net | tk-gateway |
-| sso-auth | - | tk-sso-auth |
-
-### 3. DB 접속
-
-```bash
-ssh hyungi@100.71.132.52 "\
- export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && \
- docker exec -it tk-mariadb mysql -uhyungi_user -p hyungi"
-```
-
-## 예시: System 2 전체 배포
-
-```bash
-NAS=hyungi@100.71.132.52
-PROJECT=/volume1/docker/tk-factory-services
-
-# 파일 전송
-scp -O system2-report/web/nginx.conf $NAS:$PROJECT/system2-report/web/nginx.conf
-scp -O system2-report/web/pages/safety/issue-report.html $NAS:$PROJECT/system2-report/web/pages/safety/issue-report.html
-scp -O system2-report/web/js/work-issue-report.js $NAS:$PROJECT/system2-report/web/js/work-issue-report.js
-scp -O system2-report/api/controllers/workIssueController.js $NAS:$PROJECT/system2-report/api/controllers/workIssueController.js
-
-# 컨테이너 재빌드
-ssh $NAS "export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && \
- cd $PROJECT && docker compose up -d --build system2-web system2-api"
-```
-
-## 주의사항
-
-- **Synology SCP**: 반드시 `-O` 플래그 사용 (레거시 프로토콜)
-- **Docker 권한**: `sudo chmod 666 /var/run/docker.sock` (NAS 재부팅 시 리셋됨)
-- **Tailscale**: NAS에서 로그아웃될 수 있음 → `sudo tailscale up`으로 재연결
diff --git a/PROGRESS.md b/PROGRESS.md
index 50e6745..df0b2bc 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -1,6 +1,6 @@
# TK 공장관리 3-System 분리 및 통합 - 진행 상황
-> 최종 업데이트: 2026-02-27
+> 최종 업데이트: 2026-02-09
---
@@ -8,150 +8,190 @@
TK-FB-Project(공장관리+신고)와 M-Project(부적합관리)를 **3개 독립 시스템**으로 분리하고 **SSO + 데이터 연동**으로 통합.
-| 시스템 | 설명 | 서브도메인 |
+| 시스템 | 설명 | 기반 코드 |
|--------|------|-----------|
-| System 1 - 공장관리 | TBM, 출근, 작업보고, 설비, 모바일 대시보드 | `tkfb.technicalkorea.net` |
-| System 2 - 안전신고 | 5단계 신고 위자드, 작업장 연동 | `tkreport.technicalkorea.net` |
-| System 3 - 부적합관리 (TKQC) | NCR, 일일공수, 보고서 | `tkqc.technicalkorea.net` |
-| tkuser - 통합 관리 | 사용자/권한/마스터데이터 관리 | `tkuser.technicalkorea.net` |
+| System 1 - 공장관리 | TK-FB에서 신고 기능 제거 | TK-FB-Project |
+| System 2 - 신고 | TK-FB에서 추출한 독립 서비스 | TK-FB-Project (workIssue 코드) |
+| System 3 - 부적합관리 (TKQC) | M-Project 기반 | M-Project |
-**배포 환경**: Synology DS923+ NAS (LAN: 192.168.0.3, Tailscale: 100.71.132.52)
+**배포 대상**: Synology DS923+ NAS (192.168.0.3)
---
-## 현재 NAS 운영 상태 (통합 배포 완료)
+## 현재 NAS 운영 상태
-**배포일**: 2026-02-11 | **위치**: `/volume1/docker_1/tk-factory-services`
+### TK-FB (공장관리+신고 - 기존 시스템, 운영 중)
+| 컨테이너 | 상태 | 포트 |
+|-----------|------|------|
+| tkfb_web | Up | - |
+| tkfb_api | Up (healthy) | - |
+| tkfb_fastapi | Up | - |
+| tkfb_db (MariaDB) | Up (healthy) | - |
+| tkfb_redis | Up | - |
+| tkfb_phpmyadmin | Up | - |
+| tkfb_cloudflared | Up | - |
+- 도메인: `tkfb.technicalkorea.net` (Cloudflare Tunnel)
+- 위치: `/volume1/Technicalkorea Document/tkfb-package/`
-| 컨테이너 | 이미지 | 역할 |
-|-----------|--------|------|
-| tk-gateway | nginx:alpine | 서브도메인 라우팅 + 포털/로그인 |
-| tk-sso-auth | node:18-alpine | SSO 인증 (JWT + 쿠키) |
-| tk-tkuser-api | node:18-alpine | 마스터데이터 API |
-| tk-tkuser-web | nginx:alpine | tkuser 웹 |
-| tk-system1-api | node:18-alpine | 공장관리 API |
-| tk-system1-web | nginx:alpine | 공장관리 웹 |
-| tk-system1-fastapi | python:3.9-slim | FastAPI 캐싱 브릿지 |
-| tk-system2-api | node:18-alpine | 안전신고 API |
-| tk-system2-web | nginx:alpine | 안전신고 웹 |
-| tk-system3-api | python:3.9-slim | 부적합관리 API (FastAPI) |
-| tk-system3-web | nginx:alpine | 부적합관리 웹 |
-| tk-mariadb | mariadb:10.9 | MariaDB (System 1, 2, tkuser 공유) |
-| tk-postgres | postgres:15-alpine | PostgreSQL (System 3 전용) |
-| tk-redis | redis:6-alpine | 캐시 |
-| tk-phpmyadmin | phpmyadmin | DB 관리 (LAN only, 30880) |
-| tk-cloudflared | cloudflare/cloudflared | Cloudflare Tunnel |
+### TKQC (부적합관리 - 신규 배포 완료)
+| 컨테이너 | 상태 | 포트 |
+|-----------|------|------|
+| tkqc-db (PostgreSQL 15) | Up (healthy) | 16432 |
+| tkqc-backend (FastAPI) | Up | 16000 |
+| tkqc-nginx | Up | 16080 |
+- 도메인: `tkqc.technicalkorea.net` (Cloudflare Tunnel)
+- 위치: `/volume1/docker/tkqc/tkqc-package/`
+- cloudflared가 tkfb_network + tkqc-network 양쪽 연결됨
---
## 단계별 진행 상황
-### Phase 0: TKQC NAS 배포 ✅ 완료
+### Phase 0: M-Project(TKQC) NAS 배포 ✅ 완료
-- [x] M-Project 배포 패키지 → NAS 설치 → Docker 기동
-- [x] PostgreSQL 마이그레이션 (001~020)
-- [x] Cloudflare Tunnel `tkqc.technicalkorea.net` 설정
-- [x] 컨테이너 리네임 (m-project → tkqc)
+- [x] M-Project 배포 패키지 생성 (deploy/package.sh)
+- [x] NAS로 패키지 전송 및 설치
+- [x] Docker 컨테이너 빌드 및 실행 (tkqc-db, tkqc-backend, tkqc-nginx)
+- [x] PostgreSQL 마이그레이션 실행 (001~020 SQL)
+- [x] nginx.conf 수정 (Docker 내부 IP 허용, uploads 프록시)
+- [x] Cloudflare Tunnel에 `tkqc.technicalkorea.net` 추가
+- [x] cloudflared를 tkqc-network에 연결
+- [x] 컨테이너 이름 m-project → tkqc로 리네임
+- [x] 헬스체크 확인 (API: healthy, Web: 200)
+- [ ] **Cloudflare 대시보드에서 서비스 URL 변경 필요**
+ - 변경: `http://m-project-nginx:80` → `http://tkqc-nginx:80`
+ - (사용자가 직접 Cloudflare 대시보드에서 수정)
-### Phase 1~4: 코드 분리 + SSO + Gateway ✅ 완료
+### Phase 1: 준비 - 로컬 코드 구조 생성 ✅ 완료
-- [x] 3개 시스템 코드 분리 (TK-FB → System 1+2, M-Project → System 3)
-- [x] SSO Auth 서비스 (bcrypt + pbkdf2 이중 해시, JWT 7일/30일)
-- [x] 프론트엔드 SSO 쿠키 통합 (52+ 파일, `domain=.technicalkorea.net`)
-- [x] Gateway 서브도메인 라우팅 (path-based에서 전환)
-- [x] tkuser 통합 관리 서비스 (사용자, 권한, 프로젝트, 작업장, 설비, 부서, 작업자)
+- [x] `tk-factory-services/` 디렉토리 구조 생성
+- [x] 통합 `docker-compose.yml` 작성 (13개 서비스, 포트 30000~30880)
+- [x] `.env.example` 작성
-### Phase 5: 마이그레이션 스크립트 ✅ 완료
+### Phase 2: SSO 인증 서비스 - 로컬 코드 작성 ✅ 완료
-- [x] `scripts/migrate-users.sql`, `backup.sh`, `deploy.sh`, `health-check.sh`
+- [x] `sso-auth-service/` 코드 작성
+ - `index.js` - Express 서버
+ - `routes/authRoutes.js` - 인증 라우트
+ - `controllers/authController.js` - 로그인/검증/리프레시/유저 CRUD
+ - `models/userModel.js` - MariaDB 유저 모델
+ - `Dockerfile`, `package.json`
+- [x] bcrypt + pbkdf2_sha256 이중 비밀번호 해시 지원
+- [x] JWT 기반 인증 (SSO_JWT_SECRET 공유)
+- [ ] **실제 테스트 미완료** (NAS 배포 전)
+- [ ] **sso_users 테이블 생성 및 기존 유저 마이그레이션 미완료**
-### Phase 6: NAS 통합 배포 ✅ 완료 (2026-02-11)
+### Phase 3: 시스템별 코드 분리 - 로컬 코드 작성 ✅ 완료
-- [x] 전체 백업 (MariaDB, PostgreSQL, uploads)
-- [x] tk-factory-services NAS 전송 + .env 설정
-- [x] 기존 TK-FB/TKQC 중지 → 통합 docker-compose up (16 컨테이너)
-- [x] SSO 유저 마이그레이션
-- [x] Cloudflare Tunnel 서브도메인 4개 설정
+#### System 1 - 공장관리
+- [x] TK-FB-Project에서 복사 (api, web, fastapi-bridge)
+- [x] `config/routes.js`에서 workIssue 라우트 제거
+- [x] auth 미들웨어 SSO JWT 시크릿 연동 (백엔드: JWT_SECRET=SSO_JWT_SECRET 이미 설정)
+- [x] 프론트엔드 토큰 키 통일 (`token`/`user` → `sso_token`/`sso_user`, 52개 파일 변경)
+- [x] SSO 쿠키 기반 인증 통합 (api-base.js, app-init.js, auth-check.js, auth.js)
+ - `window.getSSOToken()`, `window.getSSOUser()`, `window.getLoginUrl()` 글로벌 함수
+ - 쿠키 `sso_token` 우선 → localStorage `sso_token` 폴백
+- [x] nginx.conf 생성 (API, uploads, FastAPI 프록시)
+- [x] 신고 관련 프론트엔드 페이지 → System 2로 서브도메인 리다이렉트
+ - `issue-report.html`, `issue-detail.html`, `report-status.html` → 동적 서브도메인 리다이렉트
+ - `sidebar-nav.html` 크로스시스템 링크 → `data-system` 속성 + JS 동적 해석
+- [x] 로그인 플로우: 인증 없으면 중앙 로그인 (`tkfb.technicalkorea.net/login`)으로 리다이렉트
+- [ ] **실제 테스트 미완료**
-### Phase 7: 테스트 및 Go-Live ✅ 완료 (2026-02-12)
+#### System 2 - 신고
+- [x] TK-FB에서 workIssue 관련 코드 추출
+ - `workIssueModel.js`, `workIssueController.js`, `workIssueRoutes.js`
+ - `mProjectService.js` (baseUrl → system3-api:8000으로 변경)
+ - `imageUploadService.js`, `dateUtils.js`, `logger.js`, `errors.js`
+- [x] 독립 Express 서버 구성 (index.js, package.json, Dockerfile)
+- [x] 프론트엔드 페이지 복사 (issue-report, issue-detail, report-status)
+- [x] auth 미들웨어 SSO JWT 시크릿 연동 (백엔드: JWT_SECRET=SSO_JWT_SECRET 이미 설정)
+- [x] 프론트엔드 인프라 구축: `api-base.js`, `app-init.js` 신규 생성
+- [x] SSO 쿠키 기반 인증 통합 (쿠키 우선, localStorage 폴백, 중앙 로그인 리다이렉트)
+- [x] 인라인 JS 토큰 읽기 → `window.getSSOToken()` 폴백 패턴 적용 (28곳)
+- [ ] **M-Project(TKQC) 연동 실제 테스트 미완료**
+- [ ] **실제 테스트 미완료**
-- [x] SSO 로그인 → 3개 시스템 간 이동 (재로그인 없음)
-- [x] 신고 생성 → 부적합 자동 전달 (tkreport → tkqc)
-- [x] 사진 업로드, 알림, 데이터 정합성 확인
-- [x] 프로덕션 Go-Live
+#### System 3 - 부적합관리 (TKQC)
+- [x] M-Project에서 복사 (api, web)
+- [x] `auth_service.py` → bcrypt + pbkdf2 이중 지원 코드 추가
+- [x] `requirements.txt`에 bcrypt 추가
+- [x] SSO JWT 연동: `verify_token()` 전체 payload 반환, DB에 없는 SSO 유저 임시 객체 생성
+- [x] SSO 쿠키 기반 인증 통합 (api.js, auth-manager.js, app.js, common-header.js)
+ - `_cookieGet('sso_token')` 우선 → localStorage 폴백
+ - 인증 실패 시 중앙 로그인 리다이렉트
+- [x] 인라인 JS `localStorage.getItem('access_token')` → `TokenManager.getToken()` 일괄 치환 (9개 HTML, 81곳)
+- [ ] **SSO JWT 시크릿 연동 실제 테스트 미완료**
+
+### Phase 4: Gateway + 서브도메인 라우팅 ✅ 코드 완료
+
+> **아키텍처 변경**: Path-based(`/factory/`, `/report/`, `/nc/`) → **서브도메인 기반** 라우팅으로 전환.
+>
+> **이유**: 프론트엔드 JS가 root-relative URL (`/api/...`, `/pages/...`)을 사용하므로 경로 기반 라우팅이 근본적으로 불가능.
+>
+> **해결**: 각 시스템에 독립 서브도메인을 부여하고, SSO 쿠키(`domain=.technicalkorea.net`)로 인증 공유.
+
+- [x] `gateway/nginx.conf` - **서브도메인 기반 라우팅**
+ - `tkfb.technicalkorea.net` → Gateway (포털/로그인) + System 1 프록시
+ - `tkreport.technicalkorea.net` → System 2 (Cloudflare Tunnel 직접)
+ - `tkqc.technicalkorea.net` → System 3 (Cloudflare Tunnel 직접)
+- [x] `gateway/html/portal.html` - 서브도메인 기반 시스템 카드 링크
+- [x] `gateway/html/login.html` - SSO 쿠키 설정 (`domain=.technicalkorea.net`, 7일 만료)
+ - 로그인 성공 → 쿠키 + localStorage 이중 저장, `?redirect=` 파라미터 지원
+- [x] `gateway/html/shared/nav-header.js` - SSOAuth 글로벌 객체 (쿠키 우선)
+- [x] `gateway/Dockerfile`
+- [x] `system1-factory/web/nginx.conf` 신규 생성 (API/uploads/FastAPI 프록시)
+- [x] `docker-compose.yml`에 cloudflared 서비스 추가
+- [ ] **Cloudflare Tunnel 서브도메인 설정** (사용자 대시보드 작업)
+- [ ] **실제 배포 및 테스트 미완료**
+
+### Phase 5: 마이그레이션 스크립트 - 로컬 코드 작성 ✅ 완료
+
+- [x] `scripts/migrate-users.sql` - SSO 통합 유저 테이블 생성 + 데이터 이전
+- [x] `scripts/backup.sh` - 전체 백업 스크립트
+- [x] `scripts/deploy.sh` - 배포 스크립트
+- [x] `scripts/health-check.sh` - 헬스체크 스크립트
+- [ ] **실제 실행 미완료**
+
+### Phase 6: NAS 통합 배포 ❌ 미착수
+
+- [ ] 전체 백업 (MariaDB, PostgreSQL, uploads, git)
+- [ ] tk-factory-services를 NAS로 전송
+- [ ] .env 파일 생성 (NAS 기존 DB 패스워드 등 수집)
+- [ ] 기존 TK-FB 중지 + TKQC 중지
+- [ ] 통합 docker-compose.yml로 전체 서비스 기동
+- [ ] SSO 유저 마이그레이션 실행
+- [ ] 전체 서비스 헬스체크
+- [ ] Cloudflare Tunnel 서브도메인 설정 업데이트:
+ - `tkfb.technicalkorea.net` → `http://tk-gateway:80`
+ - `tkreport.technicalkorea.net` → `http://tk-system2-web:80`
+ - `tkqc.technicalkorea.net` → `http://tk-system3-web:80`
+
+### Phase 7: 테스트 및 Go-Live ❌ 미착수
+
+- [ ] SSO 로그인 → 3개 시스템 간 이동 테스트 (재로그인 없이)
+- [ ] 신고 생성 → 부적합 자동 생성 연동 테스트
+- [ ] 사진 업로드 테스트
+- [ ] 알림 기능 테스트
+- [ ] 기존 데이터 정합성 확인
+- [ ] 프로덕션 Go-Live
---
-## 배포 이후 개발 (Phase 8+)
+## 완료율 요약
-### 2026-02-13: System 2/3 업데이트 + 문서 재구성 ✅
+| 단계 | 상태 | 완료도 |
+|------|------|--------|
+| Phase 0: TKQC NAS 배포 | ✅ 거의 완료 | 95% (CF 대시보드 URL 변경만 남음) |
+| Phase 1: 디렉토리 구조 | ✅ 완료 | 100% |
+| Phase 2: SSO 코드 작성 | ✅ 코드 완료 | 70% (코드만 완료, 테스트/배포 미완) |
+| Phase 3: 시스템 분리 + SSO 쿠키 통합 | ✅ 코드 완료 | 80% (코드+프론트엔드 SSO 완료, 실제 테스트 미완) |
+| Phase 4: Gateway + 서브도메인 라우팅 | ✅ 코드 완료 | 80% (코드 완료, CF Tunnel 설정/테스트 미완) |
+| Phase 5: 스크립트 | ✅ 코드 완료 | 50% (코드만 완료, 실행 미완) |
+| Phase 6: NAS 통합 배포 | ❌ 미착수 | 0% |
+| Phase 7: 테스트/Go-Live | ❌ 미착수 | 0% |
-- [x] System 2, 3 웹/API 업데이트 및 재빌드
-- [x] 문서 4개 MD → `docs/` 폴더 7개 파일로 재구성
-
-### 2026-02-14: 모바일 UX + PWA ✅
-
-- [x] 모바일 대시보드 v1 (카테고리 탭 → 작업장 리스트 → 요약 숫자)
-- [x] PWA 구현 (manifest, service worker)
-- [x] 모바일 CSS 전면 최적화 (터치 타겟, 레이아웃)
-
-### 2026-02-24~25: TBM 시스템 전면 개편 ✅
-
-- [x] 4단계 워크플로우: draft → detail_edit → completed → work_report
-- [x] 모바일 전용 TBM 페이지 (`tbm-mobile.html`) + 3단계 생성 위자드
-- [x] 작업자 작업 분할 (`work_hours` + `split_seq`)
-- [x] 작업자 이동 보내기/빼오기 (`tbm_transfers` 테이블)
-- [x] 중복 배정 방지 (당일 배정 현황 조회)
-- [x] 모바일 작업보고서 페이지 (`report-create-mobile.html`)
-- [x] DB 마이그레이션 4개 (`attendance_type`, `work_hours`, `tbm_transfers`, `split_seq`)
-
-### 2026-02-25: 권한 시스템 통합 ✅
-
-- [x] tkuser `user_page_permissions` → system1 페이지 접근 연동
-- [x] 라우트 우선순위 수정 (`pageAccessRoutes` > `userRoutes`)
-- [x] 3단계 폴백: 개인 권한 → 부서 기본 → `TKUSER_DEFAULT_ACCESS`
-- [x] 40+ 페이지 키 매핑 (`PAGEKEY_TO_TKUSER`)
-
-### 2026-02-25: 모바일 대시보드 v2 — 작업장 카드 확장 뷰 ✅
-
-- [x] 카드 탭 → 아코디언 확장 (TBM 작업, 방문, 신고, 이동설비 상세)
-- [x] 캐시 데이터 활용 (추가 API 호출 없음)
-- [x] NAS 배포 완료
-
-### 2026-02-25~26: 코드 품질 개선 ✅
-
-- [x] System 1 콜백→async/await 리팩토링 (11개 모델+컨트롤러, 22파일, -3,600줄)
-- [x] 보안 수정: SSO 데드쿠키 제거, open redirect 방어
-- [x] 프론트엔드 유틸리티 통합 (`showToast`, `waitForApi`, `generateUUID`)
-- [x] 미사용 의존성 제거 + deprecated API 수정
-
-### 2026-02-27: TBM 모바일 UX 개선 ✅
-
-- [x] 모바일 버튼 반응성 개선 (`touch-action: manipulation`, 더블탭 줌 방지)
-- [x] 비동기 함수 중복 호출 방지 (busy guard 패턴)
-- [x] API 초기화 대기 개선 (`waitForApi` + 에러 UI)
-- [x] CSS 로딩 오버레이 추가 (세부편집/완료/분할/빼오기 시트 열기, TBM 저장)
-
----
-
-## 포트 매핑
-
-| 포트 | 서비스 |
-|------|--------|
-| 30000 | Gateway (nginx) |
-| 30005 | System 1 API (Express) |
-| 30008 | System 1 FastAPI Bridge |
-| 30050 | SSO Auth (Express) |
-| 30080 | System 1 Web (nginx) |
-| 30105 | System 2 API (Express) |
-| 30180 | System 2 Web (nginx) |
-| 30200 | System 3 API (FastAPI) |
-| 30280 | System 3 Web (nginx) |
-| 30300 | tkuser API (Express) |
-| 30380 | tkuser Web (nginx) |
-| 30306 | MariaDB |
-| 30432 | PostgreSQL |
-| 30880 | phpMyAdmin |
+**전체 진행률: 약 55%** (코드 작성 + SSO 프론트엔드 통합 완료, NAS 배포/테스트 남음)
---
@@ -159,14 +199,76 @@ TK-FB-Project(공장관리+신고)와 M-Project(부적합관리)를 **3개 독
### 로컬 (Mac)
- 통합 프로젝트: `/Users/hyungiahn/Documents/code/tk-factory-services/`
-- 가이드 문서: `~/Library/CloudStorage/SynologyDrive-Technicalkorea/tkfb-package/docs/`
-- Git: `https://git.hyungi.net/hyungi/tk-factory-services.git`
+- TK-FB 원본: `/Users/hyungiahn/Documents/code/TK-FB-Project/`
+- M-Project 원본: `/Users/hyungiahn/Documents/code/M-Project/`
+- M-Project 배포 도구: `/Users/hyungiahn/Documents/code/M-Project/deploy/`
-### NAS (Synology DS923+)
-- 통합 배포: `/volume1/docker_1/tk-factory-services`
-- SSH: `hyungi@100.71.132.52` (Tailscale)
-- Docker: `echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker`
+### NAS (192.168.0.3)
+- TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-package/`
+- TKQC 운영: `/volume1/docker/tkqc/tkqc-package/`
+- SSH: `hyungi` / `fukdon-riwbaq-fiQfy2`
-### 레거시 (참고, 중지됨)
-- TK-FB 원본: `/volume1/Technicalkorea Document/tkfb-package/`
-- TKQC 원본: `/volume1/docker/tkqc/tkqc-package/`
+---
+
+## 포트 계획 (통합 배포 시)
+
+| 현재 포트 | 통합 후 포트 | 서비스 |
+|-----------|-------------|--------|
+| - | 30000 | Gateway Nginx (통합 포털) |
+| tkfb API | 30005 | System 1 API |
+| tkfb FastAPI | 30008 | System 1 FastAPI Bridge |
+| - | 30050 | SSO Auth |
+| tkfb Web | 30080 | System 1 Web |
+| - | 30105 | System 2 API |
+| - | 30180 | System 2 Web |
+| 16000 → 30200 | 30200 | System 3 API |
+| 16080 → 30280 | 30280 | System 3 Web |
+| tkfb MariaDB | 30306 | MariaDB (공유) |
+| 16432 → 30432 | 30432 | PostgreSQL |
+| tkfb phpMyAdmin | 30880 | phpMyAdmin |
+
+---
+
+## 다음에 할 일 (우선순위)
+
+1. **NAS 접속 확인** (현재 LAN/Tailscale 모두 타임아웃)
+2. **.env 파일 생성** - NAS에서 기존 DB 패스워드 등 수집
+3. **Phase 6: NAS 통합 배포**
+ - 전체 백업 → 프로젝트 전송 → docker-compose up
+4. **Cloudflare Tunnel 서브도메인 설정** (사용자 대시보드 작업)
+ - `tkfb.technicalkorea.net` → `http://tk-gateway:80`
+ - `tkreport.technicalkorea.net` → `http://tk-system2-web:80`
+ - `tkqc.technicalkorea.net` → `http://tk-system3-web:80`
+5. **Phase 7**: 전체 SSO 로그인/시스템 간 이동 테스트
+
+---
+
+## 아키텍처 변경 이력
+
+### 2026-02-09: Path-based → 서브도메인 기반 라우팅
+
+**문제**: Gateway에서 `/factory/`, `/report/`, `/nc/` 경로 기반 라우팅 시, 프론트엔드 JS가 root-relative URL (`fetch('/api/...')`, ``)을 사용하여 경로 프리픽스 없이 요청 → 라우팅 불가.
+
+**해결**: 서브도메인 기반 라우팅 + 쿠키 기반 SSO
+
+| 서브도메인 | Cloudflare Tunnel 대상 | 시스템 |
+|-----------|----------------------|--------|
+| `tkfb.technicalkorea.net` | `http://tk-gateway:80` | 포털 + System 1 (프록시) |
+| `tkreport.technicalkorea.net` | `http://tk-system2-web:80` | System 2 |
+| `tkqc.technicalkorea.net` | `http://tk-system3-web:80` | System 3 |
+
+**SSO 쿠키 전략**: `sso_token`, `sso_user` 쿠키를 `domain=.technicalkorea.net`으로 설정하여 서브도메인 간 공유. localStorage는 폴백용으로 유지 (개발 환경 및 하위호환).
+
+---
+
+## 해결했던 이슈 (참고)
+
+| 이슈 | 해결 방법 |
+|------|-----------|
+| nginx uploads 볼륨 read-only 에러 | nginx에서 uploads 볼륨 제거, backend 프록시로 변경 |
+| `users.department` 컬럼 누락 | 마이그레이션 수동 실행 (ALTER TABLE) |
+| nginx 403 Forbidden (Docker IP 차단) | `allow 172.16.0.0/12`, `allow 10.0.0.0/8` 추가 |
+| SCP subsystem 실패 | `scp -O` (레거시 프로토콜) 사용 |
+| sudo 패스워드 파이프 실패 (for 루프) | SQL 파일 합쳐서 한번에 실행 |
+| 네트워크 충돌 (리네임 시) | cloudflared 연결 해제 후 네트워크 삭제 |
+| 새 볼륨 비어있음 (리네임 후) | 마이그레이션 재실행 |
diff --git a/docker-compose.yml b/docker-compose.yml
index 80ae705..e82b43b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -144,6 +144,8 @@ services:
restart: unless-stopped
ports:
- "30080:80"
+ volumes:
+ - ./system1-factory/web:/usr/share/nginx/html:ro
depends_on:
- system1-api
networks:
diff --git a/system1-factory/api/db/migrations/20260119120002_create_accounts_for_existing_workers.js b/system1-factory/api/db/migrations/20260119120002_create_accounts_for_existing_workers.js
index 70cf78f..6a45e11 100644
--- a/system1-factory/api/db/migrations/20260119120002_create_accounts_for_existing_workers.js
+++ b/system1-factory/api/db/migrations/20260119120002_create_accounts_for_existing_workers.js
@@ -10,7 +10,7 @@
* 5. 현재 연도 연차 잔액 초기화 (workers.annual_leave 사용)
*/
-const bcrypt = require('bcryptjs');
+const bcrypt = require('bcrypt');
const { generateUniqueUsername } = require('../../utils/hangulToRoman');
exports.up = async function(knex) {
diff --git a/system1-factory/api/db/migrations/20260212_add_department_to_users.js b/system1-factory/api/db/migrations/20260212_add_department_to_users.js
new file mode 100644
index 0000000..f2f2954
--- /dev/null
+++ b/system1-factory/api/db/migrations/20260212_add_department_to_users.js
@@ -0,0 +1,40 @@
+/**
+ * users 테이블에 department_id 컬럼 추가
+ * 사용자-부서 직접 연결을 위한 마이그레이션
+ */
+
+exports.up = async function(knex) {
+ const hasColumn = await knex.schema.hasColumn('users', 'department_id');
+
+ if (!hasColumn) {
+ await knex.schema.table('users', (table) => {
+ table.integer('department_id').unsigned().defaultTo(null).after('worker_id')
+ .comment('소속 부서 ID');
+ table.foreign('department_id').references('department_id').inTable('departments')
+ .onDelete('SET NULL');
+ });
+
+ // 기존 데이터 backfill: 작업자가 연결된 사용자는 해당 작업자의 department_id를 복사
+ await knex.raw(`
+ UPDATE users u
+ INNER JOIN workers w ON u.worker_id = w.worker_id
+ SET u.department_id = w.department_id
+ WHERE u.worker_id IS NOT NULL AND w.department_id IS NOT NULL
+ `);
+
+ console.log('✅ users.department_id 컬럼 추가 및 기존 데이터 backfill 완료');
+ } else {
+ console.log('⏭️ users.department_id 컬럼이 이미 존재합니다');
+ }
+};
+
+exports.down = async function(knex) {
+ const hasColumn = await knex.schema.hasColumn('users', 'department_id');
+
+ if (hasColumn) {
+ await knex.schema.table('users', (table) => {
+ table.dropForeign('department_id');
+ table.dropColumn('department_id');
+ });
+ }
+};
diff --git a/system1-factory/api/db/migrations/20260224000001_add_attendance_type_to_tbm_assignments.js b/system1-factory/api/db/migrations/20260224000001_add_attendance_type_to_tbm_assignments.js
deleted file mode 100644
index 153ef7f..0000000
--- a/system1-factory/api/db/migrations/20260224000001_add_attendance_type_to_tbm_assignments.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * TBM 팀 배정에 근태 유형 컬럼 추가
- * - attendance_type: 근태유형 (overtime/regular/annual/half/quarter/early)
- * - attendance_hours: 추가시간(overtime) 또는 실제근무시간(early)
- *
- * @since 2026-02-24
- */
-
-exports.up = function(knex) {
- return knex.raw(`
- ALTER TABLE tbm_team_assignments
- ADD COLUMN attendance_type ENUM('overtime','regular','annual','half','quarter','early')
- DEFAULT NULL
- COMMENT '근태유형',
- ADD COLUMN attendance_hours DECIMAL(4,1) DEFAULT NULL
- COMMENT '추가시간(overtime) 또는 실제근무시간(early)'
- `);
-};
-
-exports.down = function(knex) {
- return knex.raw(`
- ALTER TABLE tbm_team_assignments
- DROP COLUMN attendance_type,
- DROP COLUMN attendance_hours
- `);
-};
diff --git a/system1-factory/api/db/migrations/20260225000003_add_split_seq.js b/system1-factory/api/db/migrations/20260225000003_add_split_seq.js
deleted file mode 100644
index 7f6643e..0000000
--- a/system1-factory/api/db/migrations/20260225000003_add_split_seq.js
+++ /dev/null
@@ -1,43 +0,0 @@
-// 20260225000003_add_split_seq.js
-// 같은 작업자가 같은 세션에서 여러 분할 항목을 가질 수 있도록 split_seq 추가
-
-exports.up = function(knex) {
- return knex.raw(`
- ALTER TABLE tbm_team_assignments
- ADD COLUMN split_seq INT UNSIGNED DEFAULT 0 AFTER work_hours
- `).then(() => {
- return knex.raw(`
- ALTER TABLE tbm_team_assignments
- DROP INDEX tbm_team_assignments_session_id_worker_id_unique
- `);
- }).then(() => {
- return knex.raw(`
- ALTER TABLE tbm_team_assignments
- ADD UNIQUE INDEX uniq_session_worker_seq (session_id, worker_id, split_seq)
- `);
- });
-};
-
-exports.down = function(knex) {
- return knex.raw(`
- ALTER TABLE tbm_team_assignments
- DROP INDEX uniq_session_worker_seq
- `).then(() => {
- return knex.raw(`
- DELETE t1 FROM tbm_team_assignments t1
- INNER JOIN tbm_team_assignments t2
- WHERE t1.assignment_id > t2.assignment_id
- AND t1.session_id = t2.session_id
- AND t1.worker_id = t2.worker_id
- `);
- }).then(() => {
- return knex.raw(`
- ALTER TABLE tbm_team_assignments
- ADD UNIQUE INDEX tbm_team_assignments_session_id_worker_id_unique (session_id, worker_id)
- `);
- }).then(() => {
- return knex.raw(`
- ALTER TABLE tbm_team_assignments DROP COLUMN split_seq
- `);
- });
-};
diff --git a/system1-factory/api/models/pageAccessModel.js b/system1-factory/api/models/pageAccessModel.js
new file mode 100644
index 0000000..0d50190
--- /dev/null
+++ b/system1-factory/api/models/pageAccessModel.js
@@ -0,0 +1,160 @@
+// models/pageAccessModel.js
+const db = require('../db/connection');
+
+const PageAccessModel = {
+ // 사용자의 페이지 권한 조회
+ getUserPageAccess: (userId, callback) => {
+ const sql = `
+ SELECT
+ p.id,
+ p.page_key,
+ p.page_name,
+ p.page_path,
+ p.category,
+ p.is_admin_only,
+ COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access,
+ upa.granted_at,
+ upa.granted_by,
+ granter.username as granted_by_username
+ FROM pages p
+ LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
+ LEFT JOIN users granter ON upa.granted_by = granter.user_id
+ WHERE p.is_admin_only = 0
+ ORDER BY p.category, p.display_order
+ `;
+
+ db.query(sql, [userId], callback);
+ },
+
+ // 모든 페이지 목록 조회
+ getAllPages: (callback) => {
+ const sql = `
+ SELECT
+ id,
+ page_key,
+ page_name,
+ page_path,
+ category,
+ description,
+ is_admin_only,
+ display_order
+ FROM pages
+ WHERE is_admin_only = 0
+ ORDER BY category, display_order
+ `;
+
+ db.query(sql, callback);
+ },
+
+ // 페이지 권한 부여
+ grantPageAccess: (userId, pageId, grantedBy, callback) => {
+ const sql = `
+ INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at)
+ VALUES (?, ?, 1, ?, NOW())
+ ON DUPLICATE KEY UPDATE
+ can_access = 1,
+ granted_by = ?,
+ granted_at = NOW()
+ `;
+
+ db.query(sql, [userId, pageId, grantedBy, grantedBy], callback);
+ },
+
+ // 페이지 권한 회수
+ revokePageAccess: (userId, pageId, callback) => {
+ const sql = `
+ DELETE FROM user_page_access
+ WHERE user_id = ? AND page_id = ?
+ `;
+
+ db.query(sql, [userId, pageId], callback);
+ },
+
+ // 여러 페이지 권한 일괄 설정
+ setUserPageAccess: (userId, pageIds, grantedBy, callback) => {
+ db.beginTransaction((err) => {
+ if (err) return callback(err);
+
+ // 기존 권한 모두 삭제
+ const deleteSql = 'DELETE FROM user_page_access WHERE user_id = ?';
+
+ db.query(deleteSql, [userId], (err) => {
+ if (err) {
+ return db.rollback(() => callback(err));
+ }
+
+ // 새 권한이 없으면 커밋하고 종료
+ if (!pageIds || pageIds.length === 0) {
+ return db.commit((err) => {
+ if (err) return db.rollback(() => callback(err));
+ callback(null, { affectedRows: 0 });
+ });
+ }
+
+ // 새 권한 추가
+ const values = pageIds.map(pageId => [userId, pageId, 1, grantedBy]);
+ const insertSql = `
+ INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at)
+ VALUES ?
+ `;
+
+ db.query(insertSql, [values], (err, result) => {
+ if (err) {
+ return db.rollback(() => callback(err));
+ }
+
+ db.commit((err) => {
+ if (err) return db.rollback(() => callback(err));
+ callback(null, result);
+ });
+ });
+ });
+ });
+ },
+
+ // 특정 페이지 접근 권한 확인
+ checkPageAccess: (userId, pageKey, callback) => {
+ const sql = `
+ SELECT
+ COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access,
+ p.is_admin_only
+ FROM pages p
+ LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
+ WHERE p.page_key = ?
+ `;
+
+ db.query(sql, [userId, pageKey], (err, results) => {
+ if (err) return callback(err);
+ if (results.length === 0) return callback(null, { can_access: false });
+ callback(null, results[0]);
+ });
+ },
+
+ // 계정이 있는 작업자 목록 조회 (권한 관리용)
+ getUsersWithAccounts: (callback) => {
+ const sql = `
+ SELECT
+ u.user_id,
+ u.username,
+ u.name,
+ u.role_id,
+ r.name as role_name,
+ u.worker_id,
+ w.worker_name,
+ w.job_type,
+ COUNT(upa.page_id) as granted_pages_count
+ FROM users u
+ LEFT JOIN roles r ON u.role_id = r.id
+ LEFT JOIN workers w ON u.worker_id = w.worker_id
+ LEFT JOIN user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1
+ WHERE u.is_active = 1
+ AND u.role_id IN (4, 5)
+ GROUP BY u.user_id
+ ORDER BY w.worker_name, u.username
+ `;
+
+ db.query(sql, callback);
+ }
+};
+
+module.exports = PageAccessModel;
diff --git a/system1-factory/api/models/tbmModel.js b/system1-factory/api/models/tbmModel.js
index 6ee7e04..27704a9 100644
--- a/system1-factory/api/models/tbmModel.js
+++ b/system1-factory/api/models/tbmModel.js
@@ -616,11 +616,13 @@ const TbmModel = {
t.task_name,
wp.workplace_name,
wc.category_name,
- creator.name as created_by_name
+ creator.name as created_by_name,
+ lw.worker_name as leader_name
FROM tbm_team_assignments ta
INNER JOIN tbm_sessions s ON ta.session_id = s.session_id
INNER JOIN workers w ON ta.worker_id = w.worker_id
- LEFT JOIN users creator ON s.created_by = creator.user_id
+ LEFT JOIN sso_users creator ON s.created_by = creator.user_id
+ LEFT JOIN workers lw ON s.leader_id = lw.worker_id
LEFT JOIN projects p ON ta.project_id = p.project_id
LEFT JOIN work_types wt ON ta.work_type_id = wt.id
LEFT JOIN tasks t ON ta.task_id = t.task_id
diff --git a/system1-factory/api/models/visitRequestController.js b/system1-factory/api/models/visitRequestController.js
new file mode 100644
index 0000000..948be3b
--- /dev/null
+++ b/system1-factory/api/models/visitRequestController.js
@@ -0,0 +1,284 @@
+const visitRequestModel = require('../models/visitRequestModel');
+const logger = require('../utils/logger');
+
+// ==================== 출입 신청 관리 ====================
+
+exports.createVisitRequest = async (req, res) => {
+ try {
+ const requester_id = req.user.user_id;
+ const requestData = { requester_id, ...req.body };
+
+ const requiredFields = ['visitor_company', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id'];
+ for (const field of requiredFields) {
+ if (!requestData[field]) {
+ return res.status(400).json({ success: false, message: `${field}는 필수 입력 항목입니다.` });
+ }
+ }
+
+ const requestId = await visitRequestModel.createVisitRequest(requestData);
+ res.status(201).json({
+ success: true,
+ message: '출입 신청이 성공적으로 생성되었습니다.',
+ data: { request_id: requestId }
+ });
+ } catch (err) {
+ logger.error('출입 신청 생성 오류:', err);
+ res.status(500).json({ success: false, message: '출입 신청 생성 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.getAllVisitRequests = async (req, res) => {
+ try {
+ const filters = {
+ status: req.query.status,
+ visit_date: req.query.visit_date,
+ start_date: req.query.start_date,
+ end_date: req.query.end_date,
+ requester_id: req.query.requester_id,
+ category_id: req.query.category_id
+ };
+
+ const requests = await visitRequestModel.getAllVisitRequests(filters);
+ res.json({ success: true, data: requests });
+ } catch (err) {
+ logger.error('출입 신청 목록 조회 오류:', err);
+ res.status(500).json({ success: false, message: '출입 신청 목록 조회 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.getVisitRequestById = async (req, res) => {
+ try {
+ const request = await visitRequestModel.getVisitRequestById(req.params.id);
+ if (!request) {
+ return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
+ }
+ res.json({ success: true, data: request });
+ } catch (err) {
+ logger.error('출입 신청 조회 오류:', err);
+ res.status(500).json({ success: false, message: '출입 신청 조회 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.updateVisitRequest = async (req, res) => {
+ try {
+ const result = await visitRequestModel.updateVisitRequest(req.params.id, req.body);
+ if (result.affectedRows === 0) {
+ return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
+ }
+ res.json({ success: true, message: '출입 신청이 수정되었습니다.' });
+ } catch (err) {
+ logger.error('출입 신청 수정 오류:', err);
+ res.status(500).json({ success: false, message: '출입 신청 수정 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.deleteVisitRequest = async (req, res) => {
+ try {
+ const result = await visitRequestModel.deleteVisitRequest(req.params.id);
+ if (result.affectedRows === 0) {
+ return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
+ }
+ res.json({ success: true, message: '출입 신청이 삭제되었습니다.' });
+ } catch (err) {
+ logger.error('출입 신청 삭제 오류:', err);
+ res.status(500).json({ success: false, message: '출입 신청 삭제 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.approveVisitRequest = async (req, res) => {
+ try {
+ const result = await visitRequestModel.approveVisitRequest(req.params.id, req.user.user_id);
+ if (result.affectedRows === 0) {
+ return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
+ }
+ res.json({ success: true, message: '출입 신청이 승인되었습니다.' });
+ } catch (err) {
+ logger.error('출입 신청 승인 오류:', err);
+ res.status(500).json({ success: false, message: '출입 신청 승인 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.rejectVisitRequest = async (req, res) => {
+ try {
+ const rejectionData = {
+ approved_by: req.user.user_id,
+ rejection_reason: req.body.rejection_reason || '사유 없음'
+ };
+ const result = await visitRequestModel.rejectVisitRequest(req.params.id, rejectionData);
+ if (result.affectedRows === 0) {
+ return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
+ }
+ res.json({ success: true, message: '출입 신청이 반려되었습니다.' });
+ } catch (err) {
+ logger.error('출입 신청 반려 오류:', err);
+ res.status(500).json({ success: false, message: '출입 신청 반려 중 오류가 발생했습니다.' });
+ }
+};
+
+// ==================== 방문 목적 관리 ====================
+
+exports.getAllVisitPurposes = async (req, res) => {
+ try {
+ const purposes = await visitRequestModel.getAllVisitPurposes();
+ res.json({ success: true, data: purposes });
+ } catch (err) {
+ logger.error('방문 목적 조회 오류:', err);
+ res.status(500).json({ success: false, message: '방문 목적 조회 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.getActiveVisitPurposes = async (req, res) => {
+ try {
+ const purposes = await visitRequestModel.getActiveVisitPurposes();
+ res.json({ success: true, data: purposes });
+ } catch (err) {
+ logger.error('활성 방문 목적 조회 오류:', err);
+ res.status(500).json({ success: false, message: '활성 방문 목적 조회 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.createVisitPurpose = async (req, res) => {
+ try {
+ if (!req.body.purpose_name) {
+ return res.status(400).json({ success: false, message: 'purpose_name은 필수 입력 항목입니다.' });
+ }
+ const purposeId = await visitRequestModel.createVisitPurpose(req.body);
+ res.status(201).json({
+ success: true,
+ message: '방문 목적이 추가되었습니다.',
+ data: { purpose_id: purposeId }
+ });
+ } catch (err) {
+ logger.error('방문 목적 추가 오류:', err);
+ res.status(500).json({ success: false, message: '방문 목적 추가 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.updateVisitPurpose = async (req, res) => {
+ try {
+ const result = await visitRequestModel.updateVisitPurpose(req.params.id, req.body);
+ if (result.affectedRows === 0) {
+ return res.status(404).json({ success: false, message: '방문 목적을 찾을 수 없습니다.' });
+ }
+ res.json({ success: true, message: '방문 목적이 수정되었습니다.' });
+ } catch (err) {
+ logger.error('방문 목적 수정 오류:', err);
+ res.status(500).json({ success: false, message: '방문 목적 수정 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.deleteVisitPurpose = async (req, res) => {
+ try {
+ const result = await visitRequestModel.deleteVisitPurpose(req.params.id);
+ if (result.affectedRows === 0) {
+ return res.status(404).json({ success: false, message: '방문 목적을 찾을 수 없습니다.' });
+ }
+ res.json({ success: true, message: '방문 목적이 삭제되었습니다.' });
+ } catch (err) {
+ logger.error('방문 목적 삭제 오류:', err);
+ res.status(500).json({ success: false, message: '방문 목적 삭제 중 오류가 발생했습니다.' });
+ }
+};
+
+// ==================== 안전교육 기록 관리 ====================
+
+exports.createTrainingRecord = async (req, res) => {
+ try {
+ const trainingData = { trainer_id: req.user.user_id, ...req.body };
+
+ const requiredFields = ['request_id', 'training_date', 'training_start_time'];
+ for (const field of requiredFields) {
+ if (!trainingData[field]) {
+ return res.status(400).json({ success: false, message: `${field}는 필수 입력 항목입니다.` });
+ }
+ }
+
+ const trainingId = await visitRequestModel.createTrainingRecord(trainingData);
+
+ // 안전교육 기록 생성 후 출입 신청 상태를 training_completed로 변경
+ try {
+ await visitRequestModel.updateVisitRequestStatus(trainingData.request_id, 'training_completed');
+ } catch (statusErr) {
+ logger.error('출입 신청 상태 업데이트 오류:', statusErr);
+ }
+
+ res.status(201).json({
+ success: true,
+ message: '안전교육 기록이 생성되었습니다.',
+ data: { training_id: trainingId }
+ });
+ } catch (err) {
+ logger.error('안전교육 기록 생성 오류:', err);
+ res.status(500).json({ success: false, message: '안전교육 기록 생성 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.getTrainingRecordByRequestId = async (req, res) => {
+ try {
+ const record = await visitRequestModel.getTrainingRecordByRequestId(req.params.requestId);
+ res.json({ success: true, data: record || null });
+ } catch (err) {
+ logger.error('안전교육 기록 조회 오류:', err);
+ res.status(500).json({ success: false, message: '안전교육 기록 조회 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.updateTrainingRecord = async (req, res) => {
+ try {
+ const result = await visitRequestModel.updateTrainingRecord(req.params.id, req.body);
+ if (result.affectedRows === 0) {
+ return res.status(404).json({ success: false, message: '안전교육 기록을 찾을 수 없습니다.' });
+ }
+ res.json({ success: true, message: '안전교육 기록이 수정되었습니다.' });
+ } catch (err) {
+ logger.error('안전교육 기록 수정 오류:', err);
+ res.status(500).json({ success: false, message: '안전교육 기록 수정 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.completeTraining = async (req, res) => {
+ try {
+ const trainingId = req.params.id;
+ const signatureData = req.body.signature_data;
+
+ if (!signatureData) {
+ return res.status(400).json({ success: false, message: '서명 데이터가 필요합니다.' });
+ }
+
+ const result = await visitRequestModel.completeTraining(trainingId, signatureData);
+ if (result.affectedRows === 0) {
+ return res.status(404).json({ success: false, message: '안전교육 기록을 찾을 수 없습니다.' });
+ }
+
+ // 교육 완료 후 출입 신청 상태 변경
+ try {
+ const record = await visitRequestModel.getTrainingRecordByRequestId(trainingId);
+ if (record) {
+ await visitRequestModel.updateVisitRequestStatus(record.request_id, 'training_completed');
+ }
+ } catch (statusErr) {
+ logger.error('출입 신청 상태 업데이트 오류:', statusErr);
+ }
+
+ res.json({ success: true, message: '안전교육이 완료되었습니다.' });
+ } catch (err) {
+ logger.error('안전교육 완료 처리 오류:', err);
+ res.status(500).json({ success: false, message: '안전교육 완료 처리 중 오류가 발생했습니다.' });
+ }
+};
+
+exports.getTrainingRecords = async (req, res) => {
+ try {
+ const filters = {
+ training_date: req.query.training_date,
+ start_date: req.query.start_date,
+ end_date: req.query.end_date,
+ trainer_id: req.query.trainer_id
+ };
+ const records = await visitRequestModel.getTrainingRecords(filters);
+ res.json({ success: true, data: records });
+ } catch (err) {
+ logger.error('안전교육 기록 목록 조회 오류:', err);
+ res.status(500).json({ success: false, message: '안전교육 기록 목록 조회 중 오류가 발생했습니다.' });
+ }
+};
diff --git a/system1-factory/api/routes.js b/system1-factory/api/routes.js
new file mode 100644
index 0000000..6bc8d7e
--- /dev/null
+++ b/system1-factory/api/routes.js
@@ -0,0 +1,189 @@
+/**
+ * 라우트 설정
+ *
+ * 애플리케이션의 모든 라우트를 등록하는 중앙화된 설정 파일
+ *
+ * @author TK-FB-Project
+ * @since 2025-12-11
+ */
+
+const swaggerUi = require('swagger-ui-express');
+const swaggerSpec = require('./swagger');
+const { verifyToken } = require('../middlewares/auth');
+const { activityLogger } = require('../middlewares/activityLogger');
+const logger = require('../utils/logger');
+
+/**
+ * 모든 라우트를 Express 앱에 등록
+ * @param {Express.Application} app - Express 애플리케이션 인스턴스
+ */
+function setupRoutes(app) {
+ // 라우터 가져오기
+ const authRoutes = require('../routes/authRoutes');
+ const projectRoutes = require('../routes/projectRoutes');
+ const workerRoutes = require('../routes/workerRoutes');
+ const workReportRoutes = require('../routes/workReportRoutes');
+ const toolsRoute = require('../routes/toolsRoute');
+ const uploadRoutes = require('../routes/uploadRoutes');
+ const uploadBgRoutes = require('../routes/uploadBgRoutes');
+ const dailyIssueReportRoutes = require('../routes/dailyIssueReportRoutes');
+ const issueTypeRoutes = require('../routes/issueTypeRoutes');
+ const healthRoutes = require('../routes/healthRoutes');
+ const dailyWorkReportRoutes = require('../routes/dailyWorkReportRoutes');
+ const workAnalysisRoutes = require('../routes/workAnalysisRoutes');
+ const analysisRoutes = require('../routes/analysisRoutes');
+ const systemRoutes = require('../routes/systemRoutes');
+ const performanceRoutes = require('../routes/performanceRoutes');
+ const userRoutes = require('../routes/userRoutes');
+ const setupRoutes = require('../routes/setupRoutes');
+ const workReportAnalysisRoutes = require('../routes/workReportAnalysisRoutes');
+ const attendanceRoutes = require('../routes/attendanceRoutes');
+ const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes');
+ const pageAccessRoutes = require('../routes/pageAccessRoutes');
+ const workplaceRoutes = require('../routes/workplaceRoutes');
+ const equipmentRoutes = require('../routes/equipmentRoutes');
+ const taskRoutes = require('../routes/taskRoutes');
+ const tbmRoutes = require('../routes/tbmRoutes');
+ const vacationRequestRoutes = require('../routes/vacationRequestRoutes');
+ const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
+ const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
+ const visitRequestRoutes = require('../routes/visitRequestRoutes');
+ const workIssueRoutes = require('../routes/workIssueRoutes');
+ const departmentRoutes = require('../routes/departmentRoutes');
+ const patrolRoutes = require('../routes/patrolRoutes');
+ const notificationRoutes = require('../routes/notificationRoutes');
+ const notificationRecipientRoutes = require('../routes/notificationRecipientRoutes');
+
+ // Rate Limiters 설정
+ const rateLimit = require('express-rate-limit');
+
+ const loginLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15분
+ max: 5, // 최대 5회
+ message: '너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.',
+ standardHeaders: true,
+ legacyHeaders: false
+ });
+
+ const apiLimiter = rateLimit({
+ windowMs: 1 * 60 * 1000, // 1분
+ max: 1000, // 최대 1000회 (기존 100회에서 대폭 증가)
+ message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
+ standardHeaders: true,
+ legacyHeaders: false,
+ // 관리자 및 시스템 계정은 rate limit 제외
+ skip: (req) => {
+ // 인증된 사용자 정보 확인
+ if (req.user && (req.user.access_level === 'system' || req.user.access_level === 'admin')) {
+ return true; // rate limit 건너뛰기
+ }
+ return false;
+ }
+ });
+
+ // 모든 API 요청에 활동 로거 적용
+ app.use('/api/*', activityLogger);
+
+ // 인증 불필요 경로 - 로그인
+ app.use('/api/auth', loginLimiter, authRoutes);
+
+ // DB 설정 라우트 (개발용)
+ app.use('/api/setup', setupRoutes);
+
+ // Health check
+ app.use('/api/health', healthRoutes);
+
+ // 인증이 필요 없는 공개 경로 목록
+ const publicPaths = [
+ '/api/auth/login',
+ '/api/auth/refresh-token',
+ '/api/auth/check-password-strength',
+ '/api/health',
+ '/api/ping',
+ '/api/status',
+ '/api/setup/setup-attendance-db',
+ '/api/setup/setup-monthly-status',
+ '/api/setup/add-overtime-warning',
+ '/api/setup/migrate-existing-data',
+ '/api/setup/check-data-status',
+ '/api/monthly-status/calendar',
+ '/api/monthly-status/daily-details'
+ ];
+
+ // 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
+ app.use('/api/*', (req, res, next) => {
+ const isPublicPath = publicPaths.some(path => {
+ return req.originalUrl === path ||
+ req.originalUrl.startsWith(path + '?') ||
+ req.originalUrl.startsWith(path + '/');
+ });
+
+ if (isPublicPath) {
+ logger.debug('공개 경로 허용', { url: req.originalUrl });
+ return next();
+ }
+
+ logger.debug('인증 필요 경로', { url: req.originalUrl });
+ verifyToken(req, res, next);
+ });
+
+ // 인증 후 일반 API에 속도 제한 적용 (인증된 사용자 정보로 skip 판단)
+ app.use('/api/', apiLimiter);
+
+ // 인증된 사용자만 접근 가능한 라우트들
+ app.use('/api/issue-reports', dailyIssueReportRoutes);
+ app.use('/api/issue-types', issueTypeRoutes);
+ app.use('/api/workers', workerRoutes);
+ app.use('/api/daily-work-reports', dailyWorkReportRoutes);
+ app.use('/api/work-analysis', workAnalysisRoutes);
+ app.use('/api/analysis', analysisRoutes);
+ app.use('/api/daily-work-reports-analysis', workReportAnalysisRoutes);
+ app.use('/api/attendance', attendanceRoutes);
+ app.use('/api/monthly-status', monthlyStatusRoutes);
+ app.use('/api/workreports', workReportRoutes);
+ app.use('/api/system', systemRoutes);
+ app.use('/api/uploads', uploadRoutes);
+ app.use('/api/performance', performanceRoutes);
+ app.use('/api/projects', projectRoutes);
+ app.use('/api/tools', toolsRoute);
+ app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 (userRoutes보다 먼저 등록 - /users/:id/page-access 매칭 우선)
+ app.use('/api/users', userRoutes);
+ app.use('/api/workplaces', workplaceRoutes);
+ app.use('/api/equipments', equipmentRoutes);
+ app.use('/api/tasks', taskRoutes);
+ app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리
+ app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
+ app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
+ app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
+ app.use('/api/tbm', tbmRoutes); // TBM 시스템
+ app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
+ app.use('/api/departments', departmentRoutes); // 부서 관리
+ app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
+ app.use('/api/notifications', notificationRoutes); // 알림 시스템
+ app.use('/api/notification-recipients', notificationRecipientRoutes); // 알림 수신자 설정
+ app.use('/api', uploadBgRoutes);
+
+ // Swagger API 문서
+ app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
+ explorer: true,
+ customCss: '.swagger-ui .topbar { display: none }',
+ customSiteTitle: 'TK Work Management API',
+ swaggerOptions: {
+ persistAuthorization: true,
+ displayRequestDuration: true,
+ docExpansion: 'none',
+ filter: true,
+ showExtensions: true,
+ showCommonExtensions: true
+ }
+ }));
+
+ app.get('/api-docs.json', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.send(swaggerSpec);
+ });
+
+ logger.info('라우트 설정 완료');
+}
+
+module.exports = setupRoutes;
diff --git a/system1-factory/api/routes/pageAccessRoutes.js b/system1-factory/api/routes/pageAccessRoutes.js
index 14779b4..9ef1b17 100644
--- a/system1-factory/api/routes/pageAccessRoutes.js
+++ b/system1-factory/api/routes/pageAccessRoutes.js
@@ -3,71 +3,6 @@ const router = express.Router();
const { getDb } = require('../dbPool');
const { requireAuth, requireAdmin } = require('../middlewares/auth');
-// tkuser page_name → default_access 매핑 (permissionModel.js의 DEFAULT_PAGES와 동기화)
-const TKUSER_DEFAULT_ACCESS = {
- 's1.dashboard': true,
- 's1.work.tbm': true,
- 's1.work.report_create': true,
- 's1.work.analysis': false,
- 's1.work.nonconformity': true,
- 's1.factory.repair_management': false,
- 's1.inspection.daily_patrol': false,
- 's1.inspection.checkin': true,
- 's1.inspection.work_status': false,
- 's1.safety.visit_request': true,
- 's1.safety.management': false,
- 's1.safety.checklist_manage': false,
- 's1.attendance.my_vacation_info': true,
- 's1.attendance.monthly': true,
- 's1.attendance.vacation_request': true,
- 's1.attendance.vacation_management': false,
- 's1.attendance.vacation_allocation': false,
- 's1.attendance.annual_overview': false,
- 's1.admin.workers': false,
- 's1.admin.projects': false,
- 's1.admin.tasks': false,
- 's1.admin.workplaces': false,
- 's1.admin.equipments': false,
- 's1.admin.issue_categories': false,
- 's1.admin.attendance_report': false,
-};
-
-// system1 page_key → tkuser page_name 매핑
-const PAGEKEY_TO_TKUSER = {
- 'dashboard': 's1.dashboard',
- 'work.tbm': 's1.work.tbm',
- 'work.report-create': 's1.work.report_create',
- 'work.report-view': 's1.work.report_create',
- 'work.analysis': 's1.work.analysis',
- 'work.visit-request': 's1.safety.visit_request',
- 'work.issue-report': 's1.work.nonconformity',
- 'work.issue-list': 's1.work.nonconformity',
- 'work.issue-detail': 's1.work.nonconformity',
- 'safety.issue_report': 's1.work.nonconformity',
- 'safety.issue_list': 's1.work.nonconformity',
- 'safety.issue_detail': 's1.work.nonconformity',
- 'safety.checklist_manage': 's1.safety.checklist_manage',
- 'admin.workers': 's1.admin.workers',
- 'admin.projects': 's1.admin.projects',
- 'admin.tasks': 's1.admin.tasks',
- 'admin.workplaces': 's1.admin.workplaces',
- 'admin.equipments': 's1.admin.equipments',
- 'admin.codes': 's1.admin.tasks',
- 'admin.safety-management': 's1.safety.management',
- 'admin.safety-training-conduct': 's1.safety.management',
- 'admin.attendance-report-comparison': 's1.admin.attendance_report',
- 'admin.departments': 's1.admin.workers',
- 'common.daily-attendance': 's1.inspection.checkin',
- 'common.monthly-attendance': 's1.attendance.monthly',
- 'common.vacation-request': 's1.attendance.vacation_request',
- 'common.vacation-management': 's1.attendance.vacation_management',
- 'common.annual-vacation-overview': 's1.attendance.annual_overview',
- 'common.vacation-allocation': 's1.attendance.vacation_allocation',
- 'inspection.daily_patrol': 's1.inspection.daily_patrol',
- 'attendance.vacation_approval': 's1.attendance.vacation_management',
- 'attendance.vacation_input': 's1.attendance.vacation_allocation',
-};
-
/**
* 모든 페이지 목록 조회
* GET /api/pages
@@ -94,22 +29,25 @@ router.get('/pages', requireAuth, async (req, res) => {
*/
router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
try {
- const ssoUserId = req.params.userId;
+ const { userId } = req.params;
const db = await getDb();
- // SSO 사용자 조회 (department_id 포함)
- const [ssoRows] = await db.query(
- 'SELECT user_id, username, name, role, department_id FROM sso_users WHERE user_id = ?',
- [ssoUserId]
- );
- if (ssoRows.length === 0) {
+ // 사용자의 역할 확인
+ const [userRows] = await db.query(`
+ SELECT u.user_id, u.username, 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 = ?
+ `, [userId]);
+
+ if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
}
- const ssoUser = ssoRows[0];
- // SSO role로 Admin 체크
- const ssoRole = (ssoUser.role || '').toLowerCase();
- if (ssoRole === 'admin' || ssoRole === 'system') {
+ const user = userRows[0];
+
+ // Admin/System Admin인 경우 모든 페이지 접근 가능
+ if (user.role_name === 'Admin' || user.role_name === 'System Admin') {
const [allPages] = await db.query(`
SELECT id, page_key, page_name, page_path, category, is_admin_only
FROM pages
@@ -124,66 +62,32 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
category: page.category,
is_admin_only: page.is_admin_only,
can_access: true,
- is_default: true
+ is_default: true // Admin은 기본적으로 모든 권한 보유
}));
- return res.json({ success: true, data: { user: ssoUser, pageAccess } });
+ return res.json({ success: true, data: { user, pageAccess } });
}
- // 일반 사용자: tkuser 권한 테이블에서 조회
- // 1) 개인 권한 (user_page_permissions)
- const [userPerms] = await db.query(
- 'SELECT page_name, can_access FROM user_page_permissions WHERE user_id = ?',
- [ssoUserId]
- );
- const userPermMap = {};
- userPerms.forEach(p => { userPermMap[p.page_name] = !!p.can_access; });
+ // 일반 사용자의 페이지 접근 권한 조회
+ const [pageAccess] = await db.query(`
+ SELECT
+ p.id as page_id,
+ p.page_key,
+ p.page_name,
+ p.page_path,
+ p.category,
+ p.is_admin_only,
+ COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access,
+ upa.granted_at,
+ u2.username as granted_by_username
+ FROM pages p
+ LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
+ LEFT JOIN users u2 ON upa.granted_by = u2.user_id
+ WHERE p.is_admin_only = 0
+ ORDER BY p.display_order, p.page_name
+ `, [userId]);
- // 2) 부서 권한 (department_page_permissions)
- const deptPermMap = {};
- if (ssoUser.department_id) {
- const [deptPerms] = await db.query(
- 'SELECT page_name, can_access FROM department_page_permissions WHERE department_id = ?',
- [ssoUser.department_id]
- );
- deptPerms.forEach(p => { deptPermMap[p.page_name] = !!p.can_access; });
- }
-
- // 3) 페이지 목록 조회 + 권한 매핑
- const [pages] = await db.query(`
- SELECT id, page_key, page_name, page_path, category, is_admin_only
- FROM pages
- WHERE is_admin_only = 0
- ORDER BY display_order, page_name
- `);
-
- const pageAccess = pages.map(page => {
- const tkuserKey = PAGEKEY_TO_TKUSER[page.page_key];
- let canAccess = false;
-
- if (tkuserKey) {
- // 우선순위: 개인 권한 > 부서 권한 > default_access
- if (tkuserKey in userPermMap) {
- canAccess = userPermMap[tkuserKey];
- } else if (tkuserKey in deptPermMap) {
- canAccess = deptPermMap[tkuserKey];
- } else if (tkuserKey in TKUSER_DEFAULT_ACCESS) {
- canAccess = TKUSER_DEFAULT_ACCESS[tkuserKey];
- }
- }
-
- return {
- page_id: page.id,
- page_key: page.page_key,
- page_name: page.page_name,
- page_path: page.page_path,
- category: page.category,
- is_admin_only: page.is_admin_only,
- can_access: canAccess ? 1 : 0
- };
- });
-
- res.json({ success: true, data: { user: ssoUser, pageAccess } });
+ res.json({ success: true, data: { user, pageAccess } });
} catch (error) {
console.error('페이지 접근 권한 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });
diff --git a/system1-factory/api/tests/unit/services/workReportService.test.js b/system1-factory/api/tests/unit/services/workReportService.test.js
index aab243a..7567d31 100644
--- a/system1-factory/api/tests/unit/services/workReportService.test.js
+++ b/system1-factory/api/tests/unit/services/workReportService.test.js
@@ -19,6 +19,7 @@ describe('WorkReportService', () => {
describe('createWorkReportService', () => {
it('단일 보고서를 성공적으로 생성해야 함', async () => {
+ // Arrange
const reportData = {
report_date: '2025-12-11',
worker_id: 1,
@@ -28,32 +29,46 @@ describe('WorkReportService', () => {
work_content: '기능 개발'
};
- workReportModel.create.mockResolvedValue(123);
+ // workReportModel.create가 콜백 형태이므로 모킹 설정
+ workReportModel.create = jest.fn((data, callback) => {
+ callback(null, 123); // insertId = 123
+ });
+ // Act
const result = await workReportService.createWorkReportService(reportData);
+ // Assert
expect(result).toEqual({ workReport_ids: [123] });
expect(workReportModel.create).toHaveBeenCalledTimes(1);
- expect(workReportModel.create).toHaveBeenCalledWith(reportData);
+ expect(workReportModel.create).toHaveBeenCalledWith(
+ reportData,
+ expect.any(Function)
+ );
});
it('다중 보고서를 성공적으로 생성해야 함', async () => {
+ // Arrange
const reportsData = [
{ report_date: '2025-12-11', worker_id: 1, work_hours: 8 },
{ report_date: '2025-12-11', worker_id: 2, work_hours: 7 }
];
- workReportModel.create
- .mockResolvedValueOnce(101)
- .mockResolvedValueOnce(102);
+ let callCount = 0;
+ workReportModel.create = jest.fn((data, callback) => {
+ callCount++;
+ callback(null, 100 + callCount);
+ });
+ // Act
const result = await workReportService.createWorkReportService(reportsData);
+ // Assert
expect(result).toEqual({ workReport_ids: [101, 102] });
expect(workReportModel.create).toHaveBeenCalledTimes(2);
});
it('빈 배열이면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.createWorkReportService([]))
.rejects.toThrow(ValidationError);
@@ -62,10 +77,14 @@ describe('WorkReportService', () => {
});
it('DB 오류 시 DatabaseError를 던져야 함', async () => {
+ // Arrange
const reportData = { report_date: '2025-12-11', worker_id: 1 };
- workReportModel.create.mockRejectedValue(new Error('DB connection failed'));
+ workReportModel.create = jest.fn((data, callback) => {
+ callback(new Error('DB connection failed'), null);
+ });
+ // Act & Assert
await expect(workReportService.createWorkReportService(reportData))
.rejects.toThrow(DatabaseError);
});
@@ -73,18 +92,24 @@ describe('WorkReportService', () => {
describe('getWorkReportsByDateService', () => {
it('날짜로 보고서를 조회해야 함', async () => {
+ // Arrange
const date = '2025-12-11';
const mockReports = mockWorkReports.filter(r => r.report_date === date);
- workReportModel.getAllByDate.mockResolvedValue(mockReports);
+ workReportModel.getAllByDate = jest.fn((date, callback) => {
+ callback(null, mockReports);
+ });
+ // Act
const result = await workReportService.getWorkReportsByDateService(date);
+ // Assert
expect(result).toEqual(mockReports);
- expect(workReportModel.getAllByDate).toHaveBeenCalledWith(date);
+ expect(workReportModel.getAllByDate).toHaveBeenCalledWith(date, expect.any(Function));
});
it('날짜가 없으면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.getWorkReportsByDateService(null))
.rejects.toThrow(ValidationError);
@@ -93,8 +118,12 @@ describe('WorkReportService', () => {
});
it('DB 오류 시 DatabaseError를 던져야 함', async () => {
- workReportModel.getAllByDate.mockRejectedValue(new Error('DB error'));
+ // Arrange
+ workReportModel.getAllByDate = jest.fn((date, callback) => {
+ callback(new Error('DB error'), null);
+ });
+ // Act & Assert
await expect(workReportService.getWorkReportsByDateService('2025-12-11'))
.rejects.toThrow(DatabaseError);
});
@@ -102,19 +131,28 @@ describe('WorkReportService', () => {
describe('getWorkReportByIdService', () => {
it('ID로 보고서를 조회해야 함', async () => {
+ // Arrange
const mockReport = mockWorkReports[0];
- workReportModel.getById.mockResolvedValue(mockReport);
+ workReportModel.getById = jest.fn((id, callback) => {
+ callback(null, mockReport);
+ });
+ // Act
const result = await workReportService.getWorkReportByIdService(1);
+ // Assert
expect(result).toEqual(mockReport);
- expect(workReportModel.getById).toHaveBeenCalledWith(1);
+ expect(workReportModel.getById).toHaveBeenCalledWith(1, expect.any(Function));
});
it('보고서가 없으면 NotFoundError를 던져야 함', async () => {
- workReportModel.getById.mockResolvedValue(null);
+ // Arrange
+ workReportModel.getById = jest.fn((id, callback) => {
+ callback(null, null);
+ });
+ // Act & Assert
await expect(workReportService.getWorkReportByIdService(999))
.rejects.toThrow(NotFoundError);
@@ -123,6 +161,7 @@ describe('WorkReportService', () => {
});
it('ID가 없으면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.getWorkReportByIdService(null))
.rejects.toThrow(ValidationError);
});
@@ -130,24 +169,38 @@ describe('WorkReportService', () => {
describe('updateWorkReportService', () => {
it('보고서를 성공적으로 수정해야 함', async () => {
+ // Arrange
const updateData = { work_hours: 9 };
- workReportModel.update.mockResolvedValue(1);
+ workReportModel.update = jest.fn((id, data, callback) => {
+ callback(null, 1); // affectedRows = 1
+ });
+ // Act
const result = await workReportService.updateWorkReportService(123, updateData);
+ // Assert
expect(result).toEqual({ changes: 1 });
- expect(workReportModel.update).toHaveBeenCalledWith(123, updateData);
+ expect(workReportModel.update).toHaveBeenCalledWith(
+ 123,
+ updateData,
+ expect.any(Function)
+ );
});
it('수정할 보고서가 없으면 NotFoundError를 던져야 함', async () => {
- workReportModel.update.mockResolvedValue(0);
+ // Arrange
+ workReportModel.update = jest.fn((id, data, callback) => {
+ callback(null, 0); // affectedRows = 0
+ });
+ // Act & Assert
await expect(workReportService.updateWorkReportService(999, {}))
.rejects.toThrow(NotFoundError);
});
it('ID가 없으면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.updateWorkReportService(null, {}))
.rejects.toThrow(ValidationError);
});
@@ -155,22 +208,32 @@ describe('WorkReportService', () => {
describe('removeWorkReportService', () => {
it('보고서를 성공적으로 삭제해야 함', async () => {
- workReportModel.remove.mockResolvedValue(1);
+ // Arrange
+ workReportModel.remove = jest.fn((id, callback) => {
+ callback(null, 1);
+ });
+ // Act
const result = await workReportService.removeWorkReportService(123);
+ // Assert
expect(result).toEqual({ changes: 1 });
- expect(workReportModel.remove).toHaveBeenCalledWith(123);
+ expect(workReportModel.remove).toHaveBeenCalledWith(123, expect.any(Function));
});
it('삭제할 보고서가 없으면 NotFoundError를 던져야 함', async () => {
- workReportModel.remove.mockResolvedValue(0);
+ // Arrange
+ workReportModel.remove = jest.fn((id, callback) => {
+ callback(null, 0);
+ });
+ // Act & Assert
await expect(workReportService.removeWorkReportService(999))
.rejects.toThrow(NotFoundError);
});
it('ID가 없으면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.removeWorkReportService(null))
.rejects.toThrow(ValidationError);
});
@@ -178,23 +241,34 @@ describe('WorkReportService', () => {
describe('getWorkReportsInRangeService', () => {
it('기간으로 보고서를 조회해야 함', async () => {
+ // Arrange
const start = '2025-12-01';
const end = '2025-12-31';
- workReportModel.getByRange.mockResolvedValue(mockWorkReports);
+ workReportModel.getByRange = jest.fn((start, end, callback) => {
+ callback(null, mockWorkReports);
+ });
+ // Act
const result = await workReportService.getWorkReportsInRangeService(start, end);
+ // Assert
expect(result).toEqual(mockWorkReports);
- expect(workReportModel.getByRange).toHaveBeenCalledWith(start, end);
+ expect(workReportModel.getByRange).toHaveBeenCalledWith(
+ start,
+ end,
+ expect.any(Function)
+ );
});
it('시작일이 없으면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.getWorkReportsInRangeService(null, '2025-12-31'))
.rejects.toThrow(ValidationError);
});
it('종료일이 없으면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.getWorkReportsInRangeService('2025-12-01', null))
.rejects.toThrow(ValidationError);
});
@@ -202,30 +276,41 @@ describe('WorkReportService', () => {
describe('getSummaryService', () => {
it('월간 요약을 조회해야 함', async () => {
+ // Arrange
const year = '2025';
const month = '12';
- workReportModel.getByRange.mockResolvedValue(mockWorkReports);
+ workReportModel.getByRange = jest.fn((start, end, callback) => {
+ callback(null, mockWorkReports);
+ });
+ // Act
const result = await workReportService.getSummaryService(year, month);
+ // Assert
expect(result).toEqual(mockWorkReports);
expect(workReportModel.getByRange).toHaveBeenCalled();
});
it('연도가 없으면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.getSummaryService(null, '12'))
.rejects.toThrow(ValidationError);
});
it('월이 없으면 ValidationError를 던져야 함', async () => {
+ // Act & Assert
await expect(workReportService.getSummaryService('2025', null))
.rejects.toThrow(ValidationError);
});
it('데이터가 없으면 NotFoundError를 던져야 함', async () => {
- workReportModel.getByRange.mockResolvedValue([]);
+ // Arrange
+ workReportModel.getByRange = jest.fn((start, end, callback) => {
+ callback(null, []);
+ });
+ // Act & Assert
await expect(workReportService.getSummaryService('2025', '12'))
.rejects.toThrow(NotFoundError);
diff --git a/system1-factory/web/css/daily-work-report.css b/system1-factory/web/css/daily-work-report.css
index 3998af4..11b5959 100644
--- a/system1-factory/web/css/daily-work-report.css
+++ b/system1-factory/web/css/daily-work-report.css
@@ -2016,3 +2016,374 @@
color: #6b7280;
font-size: 0.875rem;
}
+
+/* ================================================
+ 모바일 카드 레이아웃 + 터치 최적화
+ ================================================ */
+
+/* 전역 터치 최적화 - 더블탭 줌 방지 */
+* {
+ touch-action: manipulation;
+}
+
+/* 로딩 상태 버튼 */
+.btn-submit-compact.is-loading,
+.btn-batch-submit.is-loading {
+ pointer-events: none;
+ opacity: 0.7;
+ position: relative;
+}
+
+.btn-submit-compact.is-loading::after,
+.btn-batch-submit.is-loading::after {
+ content: '';
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(255,255,255,0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ margin-left: 6px;
+ vertical-align: middle;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ========== 모바일 768px 이하 ========== */
+@media (max-width: 768px) {
+
+ /* --- 날짜 그룹 헤더 모바일 --- */
+ .date-group-header {
+ flex-wrap: wrap;
+ padding: 0.75rem 1rem;
+ gap: 0.25rem;
+ }
+
+ .date-header-left {
+ width: 100%;
+ }
+
+ .date-header-center {
+ gap: 0.5rem;
+ font-size: 0.8rem;
+ }
+
+ .date-header-right {
+ margin-left: auto;
+ }
+
+ /* --- TBM 세션 헤더 모바일 --- */
+ .tbm-session-header {
+ flex-wrap: wrap;
+ padding: 0.625rem 1rem;
+ gap: 0.375rem;
+ font-size: 0.8rem;
+ }
+
+ .tbm-session-count {
+ margin-left: 0;
+ }
+
+ /* --- 테이블 → 카드 전환 --- */
+ .tbm-table-container {
+ overflow-x: visible;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ }
+
+ .tbm-work-table {
+ display: block;
+ }
+
+ .tbm-work-table thead {
+ display: none;
+ }
+
+ .tbm-work-table tbody {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 0.75rem;
+ }
+
+ .tbm-work-table tbody tr[data-type] {
+ display: flex;
+ flex-wrap: wrap;
+ background: white;
+ border: 1px solid #e5e7eb;
+ border-radius: 10px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
+ padding: 0;
+ overflow: hidden;
+ }
+
+ .tbm-work-table tbody tr[data-type] td {
+ border-bottom: none;
+ padding: 0;
+ }
+
+ /* 작업자 이름 (카드 상단 헤더) */
+ .tbm-work-table tbody tr[data-type] td:nth-child(1) {
+ width: 100%;
+ background: linear-gradient(135deg, #f8fafc, #f1f5f9);
+ padding: 0.625rem 0.875rem;
+ border-bottom: 1px solid #e5e7eb;
+ }
+
+ .tbm-work-table tbody tr[data-type] td:nth-child(1) .worker-cell {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ }
+
+ .tbm-work-table tbody tr[data-type] td:nth-child(1) .worker-cell strong {
+ margin-bottom: 0;
+ font-size: 0.95rem;
+ }
+
+ /* 프로젝트/공정/작업/작업장소 → 2열 그리드 */
+ .tbm-work-table tbody tr[data-type] td:nth-child(2),
+ .tbm-work-table tbody tr[data-type] td:nth-child(3),
+ .tbm-work-table tbody tr[data-type] td:nth-child(4),
+ .tbm-work-table tbody tr[data-type] td:nth-child(5) {
+ width: 50%;
+ padding: 0.5rem 0.875rem;
+ font-size: 0.8rem;
+ box-sizing: border-box;
+ }
+
+ .tbm-work-table tbody tr[data-type] td:nth-child(2)::before,
+ .tbm-work-table tbody tr[data-type] td:nth-child(3)::before,
+ .tbm-work-table tbody tr[data-type] td:nth-child(4)::before,
+ .tbm-work-table tbody tr[data-type] td:nth-child(5)::before {
+ content: attr(data-label);
+ display: block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: #6b7280;
+ margin-bottom: 0.125rem;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ }
+
+ /* 작업시간 + 부적합 + 제출 → 하단 풀 영역 */
+ .tbm-work-table tbody tr[data-type] td:nth-child(6),
+ .tbm-work-table tbody tr[data-type] td:nth-child(7),
+ .tbm-work-table tbody tr[data-type] td:nth-child(8) {
+ padding: 0.5rem 0.875rem;
+ box-sizing: border-box;
+ }
+
+ /* 작업시간 */
+ .tbm-work-table tbody tr[data-type] td:nth-child(6) {
+ width: 40%;
+ border-top: 1px solid #f3f4f6;
+ }
+
+ .tbm-work-table tbody tr[data-type] td:nth-child(6)::before {
+ content: '작업시간';
+ display: block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: #6b7280;
+ margin-bottom: 0.25rem;
+ }
+
+ /* 부적합 */
+ .tbm-work-table tbody tr[data-type] td:nth-child(7) {
+ width: 30%;
+ border-top: 1px solid #f3f4f6;
+ }
+
+ /* 제출 */
+ .tbm-work-table tbody tr[data-type] td:nth-child(8) {
+ width: 30%;
+ border-top: 1px solid #f3f4f6;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ }
+
+ /* 수동 입력의 날짜 컬럼 처리 (9개 컬럼) */
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(2) {
+ width: 50%;
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(2)::before {
+ content: '날짜';
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(3) {
+ width: 50%;
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(3)::before {
+ content: '프로젝트';
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(4)::before {
+ content: '공정';
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(5)::before {
+ content: '작업';
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(6) {
+ width: 50%;
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(6)::before {
+ content: '작업장소';
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(7) {
+ width: 50%;
+ border-top: 1px solid #f3f4f6;
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(7)::before {
+ content: '작업시간';
+ display: block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: #6b7280;
+ margin-bottom: 0.25rem;
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(8) {
+ width: 25%;
+ border-top: 1px solid #f3f4f6;
+ }
+ .manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(9) {
+ width: 25%;
+ border-top: 1px solid #f3f4f6;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 0.5rem 0.875rem;
+ }
+
+ /* 수동 입력 select/input 모바일 크기 조정 */
+ .manual-input-section .form-input-compact {
+ width: 100% !important;
+ min-width: 0;
+ font-size: 0.8rem;
+ }
+
+ /* 부적합 행 (defect-row) 카드 모바일 */
+ .tbm-work-table tbody tr.defect-row {
+ display: block;
+ margin-top: -0.75rem;
+ border: 1px solid #fde68a;
+ border-top: none;
+ border-radius: 0 0 10px 10px;
+ background: #fef3c7;
+ padding: 0;
+ }
+
+ .tbm-work-table tbody tr.defect-row td {
+ display: block;
+ width: 100%;
+ padding: 0.75rem;
+ }
+
+ .tbm-work-table tbody tr.defect-row td[colspan] {
+ padding: 0.75rem;
+ }
+
+ /* 시간 입력 트리거 모바일 확대 */
+ .time-input-trigger {
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.95rem;
+ padding: 0.5rem 0.75rem;
+ }
+
+ /* 제출 버튼 터치 타겟 확대 */
+ .btn-submit-compact {
+ min-height: 44px;
+ min-width: 60px;
+ padding: 0.625rem 1rem;
+ font-size: 0.875rem;
+ }
+
+ /* 부적합 버튼 터치 타겟 확대 */
+ .btn-defect-toggle {
+ min-height: 44px;
+ min-width: 60px;
+ padding: 0.5rem 0.625rem;
+ font-size: 0.8rem;
+ }
+
+ /* 일괄제출 버튼 모바일 full-width */
+ .batch-submit-container {
+ padding: 0.75rem;
+ }
+
+ .btn-batch-submit {
+ width: 100%;
+ min-height: 48px;
+ font-size: 0.95rem;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ }
+
+ /* --- 시간 선택 피커 모바일 --- */
+ .time-picker-popup {
+ max-width: 340px;
+ padding: 1.25rem;
+ }
+
+ .quick-time-grid {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.625rem;
+ }
+
+ .time-btn {
+ min-height: 56px;
+ padding: 0.75rem 0.375rem;
+ font-size: 0.95rem;
+ border-radius: 8px;
+ }
+
+ .time-btn .time-value {
+ font-size: 1rem;
+ }
+
+ .adjust-btn {
+ min-height: 48px;
+ font-size: 0.95rem;
+ }
+
+ .confirm-btn {
+ min-height: 52px;
+ font-size: 1rem;
+ }
+
+ /* --- 작업장소 모달 모바일 --- */
+ #workplaceModal .modal-container {
+ width: 95% !important;
+ max-width: none !important;
+ }
+
+ /* --- 신고 리마인더 모바일 --- */
+ .issue-reminder-section {
+ margin: 0 0 0.75rem 0;
+ border-radius: 8px;
+ }
+
+ .issue-reminder-item {
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ }
+
+ /* --- 작업 추가 버튼 모바일 --- */
+ .btn-add-work {
+ min-height: 44px;
+ padding: 0.625rem 1rem;
+ }
+
+ /* --- 삭제 버튼 모바일 --- */
+ .btn-delete-compact {
+ min-height: 44px;
+ min-width: 44px;
+ }
+}
diff --git a/system1-factory/web/css/tbm-mobile.css b/system1-factory/web/css/tbm-mobile.css
new file mode 100644
index 0000000..729d527
--- /dev/null
+++ b/system1-factory/web/css/tbm-mobile.css
@@ -0,0 +1,851 @@
+/* TBM Mobile Styles */
+* { box-sizing: border-box; }
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
+ background: #f3f4f6;
+ margin: 0;
+ padding: 0;
+ -webkit-font-smoothing: antialiased;
+ touch-action: manipulation;
+}
+button, .m-tbm-row, .m-tab, .m-new-btn, .m-detail-btn, .m-load-more,
+.picker-item, .split-radio-item, .split-session-item, .pull-btn,
+.de-save-btn, .de-group-btn, .de-split-btn, .pill-btn, .worker-card,
+[onclick] {
+ touch-action: manipulation;
+}
+@media (min-width: 480px) {
+ body { max-width: 480px; margin: 0 auto; min-height: 100vh; }
+}
+
+/* Header */
+.m-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: linear-gradient(135deg, #2563eb, #1d4ed8);
+ color: white;
+ padding: 0.875rem 1rem;
+ padding-top: calc(0.875rem + env(safe-area-inset-top));
+}
+.m-header-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.m-header h1 {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 700;
+}
+.m-header .m-date {
+ font-size: 0.75rem;
+ opacity: 0.8;
+}
+.m-new-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.5rem 1rem;
+ background: rgba(255,255,255,0.2);
+ color: white;
+ border: 1.5px solid rgba(255,255,255,0.4);
+ border-radius: 2rem;
+ font-size: 0.8125rem;
+ font-weight: 700;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+.m-new-btn:active { background: rgba(255,255,255,0.3); }
+
+/* Tabs */
+.m-tabs {
+ display: flex;
+ background: white;
+ border-bottom: 1px solid #e5e7eb;
+ position: sticky;
+ top: 0;
+ z-index: 90;
+}
+.m-tab {
+ flex: 1;
+ padding: 0.75rem;
+ text-align: center;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #9ca3af;
+ border: none;
+ background: none;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ -webkit-tap-highlight-color: transparent;
+}
+.m-tab.active {
+ color: #2563eb;
+ border-bottom-color: #2563eb;
+}
+.m-tab .tab-count {
+ display: inline-block;
+ min-width: 18px;
+ height: 18px;
+ line-height: 18px;
+ border-radius: 9px;
+ background: #e5e7eb;
+ color: #6b7280;
+ font-size: 0.6875rem;
+ font-weight: 700;
+ text-align: center;
+ margin-left: 0.25rem;
+ padding: 0 0.25rem;
+}
+.m-tab.active .tab-count {
+ background: #dbeafe;
+ color: #1d4ed8;
+}
+
+/* Content area */
+.m-content {
+ padding-bottom: calc(76px + env(safe-area-inset-bottom));
+ min-height: 60vh;
+}
+
+/* Date group */
+.m-date-group {
+ padding: 0.5rem 1rem 0.25rem;
+}
+.m-date-label {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ color: #6b7280;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+/* TBM list row */
+.m-tbm-row {
+ display: flex;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ background: white;
+ border-bottom: 1px solid #f3f4f6;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+.m-tbm-row:active { background: #f9fafb; }
+.m-tbm-row:first-child { border-top: 1px solid #e5e7eb; }
+
+.m-row-status {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ margin-right: 0.75rem;
+}
+.m-row-status.draft { background: #f59e0b; }
+.m-row-status.completed { background: #10b981; }
+.m-row-status.cancelled { background: #ef4444; }
+
+.m-row-body {
+ flex: 1;
+ min-width: 0;
+}
+.m-row-main {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #1f2937;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.m-row-sub {
+ font-size: 0.6875rem;
+ color: #9ca3af;
+ margin-top: 0.125rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.m-row-right {
+ flex-shrink: 0;
+ text-align: right;
+ margin-left: 0.75rem;
+}
+.m-row-count {
+ font-size: 0.8125rem;
+ font-weight: 700;
+ color: #1f2937;
+}
+.m-row-count-label {
+ font-size: 0.625rem;
+ color: #9ca3af;
+}
+.m-row-time {
+ font-size: 0.625rem;
+ color: #9ca3af;
+ margin-top: 0.125rem;
+}
+
+/* TBM detail expanded */
+.m-tbm-detail {
+ display: none;
+ background: #f9fafb;
+ padding: 0.75rem 1rem 0.75rem 2.75rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+.m-tbm-row.expanded + .m-tbm-detail { display: block; }
+.m-detail-row {
+ display: flex;
+ padding: 0.25rem 0;
+ font-size: 0.75rem;
+}
+.m-detail-label {
+ color: #6b7280;
+ width: 50px;
+ flex-shrink: 0;
+}
+.m-detail-value {
+ color: #1f2937;
+ font-weight: 500;
+ flex: 1;
+}
+.m-detail-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid #e5e7eb;
+}
+.m-detail-btn {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.5rem;
+ background: white;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ text-align: center;
+ -webkit-tap-highlight-color: transparent;
+}
+.m-detail-btn:active { background: #f3f4f6; }
+.m-detail-btn.primary {
+ background: #2563eb;
+ color: white;
+ border-color: #2563eb;
+}
+.m-detail-btn.primary:active { background: #1d4ed8; }
+.m-detail-btn.danger {
+ color: #ef4444;
+ border-color: #fca5a5;
+}
+
+/* Empty state */
+.m-empty {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #9ca3af;
+}
+.m-empty-icon {
+ font-size: 2.5rem;
+ margin-bottom: 0.75rem;
+ opacity: 0.5;
+}
+.m-empty-text {
+ font-size: 0.875rem;
+}
+.m-empty-sub {
+ font-size: 0.75rem;
+ margin-top: 0.25rem;
+}
+
+/* Load more */
+.m-load-more {
+ display: block;
+ width: calc(100% - 2rem);
+ margin: 0.75rem 1rem;
+ padding: 0.75rem;
+ border: 1px dashed #d1d5db;
+ border-radius: 0.75rem;
+ background: white;
+ font-size: 0.8125rem;
+ color: #6b7280;
+ cursor: pointer;
+ text-align: center;
+ -webkit-tap-highlight-color: transparent;
+}
+.m-load-more:active { background: #f3f4f6; }
+
+/* Loading skeleton */
+.m-skeleton {
+ height: 56px;
+ background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+ border-bottom: 1px solid #f3f4f6;
+}
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* Bottom nav */
+.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 */
+.m-detail-badge {
+ display: inline-block;
+ font-size: 0.625rem;
+ font-weight: 700;
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ margin-left: 0.375rem;
+ vertical-align: middle;
+}
+.m-detail-badge.incomplete {
+ background: #fef3c7;
+ color: #92400e;
+}
+.m-detail-badge.complete {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+/* Worker card status indicator */
+.de-worker-card.filled {
+ background: #f0fdf4;
+ border-left: 3px solid #10b981;
+ padding-left: calc(0.625rem - 3px);
+}
+.de-worker-card.unfilled {
+ border-left: 3px solid #f59e0b;
+ padding-left: calc(0.625rem - 3px);
+}
+.de-worker-status {
+ font-size: 0.625rem;
+ font-weight: 600;
+ margin-left: 0.375rem;
+}
+.de-worker-status.ok { color: #059669; }
+.de-worker-status.missing { color: #d97706; }
+
+/* Group select */
+.de-worker-check {
+ width: 20px;
+ height: 20px;
+ accent-color: #2563eb;
+ margin-right: 0.5rem;
+ flex-shrink: 0;
+}
+.de-group-bar {
+ display: none;
+ background: #eff6ff;
+ border: 1px solid #bfdbfe;
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.625rem;
+ margin: 0 1rem 0.5rem;
+ font-size: 0.75rem;
+ color: #1e40af;
+}
+.de-group-bar.visible { display: flex; align-items: center; gap: 0.375rem; flex-wrap: wrap; }
+.de-group-btn {
+ padding: 0.25rem 0.5rem;
+ background: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 0.25rem;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+.de-select-all-row {
+ display: flex;
+ align-items: center;
+ padding: 0.375rem 1rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+/* Detail edit bottom sheet */
+.detail-edit-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 9000;
+}
+.detail-edit-sheet {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 9001;
+ background: white;
+ border-radius: 1rem 1rem 0 0;
+ max-height: 90vh;
+ overflow-y: auto;
+ padding-bottom: env(safe-area-inset-bottom);
+ box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
+}
+.de-header {
+ position: sticky;
+ top: 0;
+ background: white;
+ padding: 1rem 1rem 0;
+ border-radius: 1rem 1rem 0 0;
+ z-index: 1;
+}
+.de-header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+.de-header h3 { margin: 0; font-size: 1rem; font-weight: 700; }
+.de-close {
+ background: none;
+ border: none;
+ font-size: 1.25rem;
+ color: #6b7280;
+ cursor: pointer;
+ padding: 0.25rem;
+}
+/* Picker popup */
+.picker-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.4);
+ z-index: 9100;
+}
+.picker-sheet {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 9101;
+ background: white;
+ border-radius: 1rem 1rem 0 0;
+ max-height: 70vh;
+ overflow-y: auto;
+ padding-bottom: env(safe-area-inset-bottom);
+ box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
+}
+.picker-header {
+ position: sticky;
+ top: 0;
+ background: white;
+ padding: 0.875rem 1rem 0.5rem;
+ border-radius: 1rem 1rem 0 0;
+ border-bottom: 1px solid #e5e7eb;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.picker-header h4 { margin: 0; font-size: 0.9375rem; font-weight: 700; }
+.picker-close {
+ background: none;
+ border: none;
+ font-size: 1.125rem;
+ color: #6b7280;
+ cursor: pointer;
+ padding: 0.25rem;
+}
+.picker-list { padding: 0.25rem 0; }
+.picker-item {
+ display: flex;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ font-size: 0.875rem;
+ color: #1f2937;
+ cursor: pointer;
+ border-bottom: 1px solid #f3f4f6;
+ -webkit-tap-highlight-color: transparent;
+}
+.picker-item:active { background: #f3f4f6; }
+.picker-item.selected { background: #eff6ff; color: #1d4ed8; font-weight: 600; }
+.picker-item-sub { font-size: 0.6875rem; color: #9ca3af; margin-left: 0.375rem; }
+.picker-divider {
+ padding: 0.375rem 1rem;
+ font-size: 0.6875rem;
+ font-weight: 700;
+ color: #6b7280;
+ background: #f9fafb;
+ border-bottom: 1px solid #e5e7eb;
+}
+.picker-add-row {
+ display: flex;
+ gap: 0.375rem;
+ padding: 0.625rem 1rem;
+ border-top: 1px solid #e5e7eb;
+ background: #f9fafb;
+ position: sticky;
+ bottom: 0;
+}
+.picker-add-input {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+}
+.picker-add-btn {
+ padding: 0.5rem 0.75rem;
+ background: #10b981;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.de-worker-list { padding: 0 1rem; }
+.de-worker-card {
+ padding: 0.625rem 0;
+ border-bottom: 1px solid #f3f4f6;
+}
+.de-worker-card:last-child { border-bottom: none; }
+.de-worker-name {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #1f2937;
+}
+.de-worker-job {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+.de-worker-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ margin-top: 0.375rem;
+}
+.de-field-row {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+}
+.de-field-label {
+ font-size: 0.6875rem;
+ color: #6b7280;
+ width: 32px;
+ flex-shrink: 0;
+}
+.de-field-row select {
+ flex: 1;
+ padding: 0.4375rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ background: white;
+}
+.de-save-area {
+ padding: 0.75rem 1rem 1rem;
+ position: sticky;
+ bottom: 0;
+ background: white;
+ border-top: 1px solid #e5e7eb;
+}
+.de-save-btn {
+ width: 100%;
+ padding: 0.75rem;
+ background: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 0.5rem;
+ font-size: 0.9375rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+.de-save-btn:disabled { opacity: 0.5; }
+
+/* My TBM highlight */
+.m-tbm-row.my-tbm {
+ border-left: 3px solid #2563eb;
+}
+.m-leader-badge {
+ display: inline-block;
+ font-size: 0.625rem;
+ font-weight: 700;
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ margin-left: 0.25rem;
+ background: #eff6ff;
+ color: #1d4ed8;
+}
+.m-transfer-badge {
+ display: inline-block;
+ font-size: 0.5625rem;
+ font-weight: 600;
+ padding: 0.0625rem 0.3125rem;
+ border-radius: 0.25rem;
+ margin-left: 0.25rem;
+ background: #fef3c7;
+ color: #92400e;
+}
+.m-work-hours-tag {
+ display: inline-block;
+ font-size: 0.625rem;
+ font-weight: 600;
+ padding: 0.0625rem 0.25rem;
+ border-radius: 0.25rem;
+ margin-left: 0.25rem;
+ background: #dbeafe;
+ color: #1d4ed8;
+}
+
+/* Split sheet */
+.split-sheet {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 9201;
+ background: white;
+ border-radius: 1rem 1rem 0 0;
+ max-height: 85vh;
+ overflow-y: auto;
+ padding-bottom: env(safe-area-inset-bottom);
+ box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
+}
+.split-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 9200;
+}
+.split-header {
+ padding: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+.split-header h4 { margin: 0 0 0.25rem; font-size: 0.9375rem; font-weight: 700; }
+.split-header p { margin: 0; font-size: 0.75rem; color: #6b7280; }
+.split-body { padding: 0.75rem 1rem; }
+.split-field { margin-bottom: 0.75rem; }
+.split-field label { display: block; font-size: 0.75rem; font-weight: 600; color: #374151; margin-bottom: 0.25rem; }
+.split-input {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+}
+.split-radio-group { display: flex; gap: 0.5rem; margin-top: 0.25rem; }
+.split-radio-item {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1.5px solid #d1d5db;
+ border-radius: 0.5rem;
+ text-align: center;
+ font-size: 0.8125rem;
+ cursor: pointer;
+ background: white;
+}
+.split-radio-item.active {
+ border-color: #2563eb;
+ background: #eff6ff;
+ color: #1d4ed8;
+ font-weight: 600;
+}
+.split-session-list { margin-top: 0.5rem; }
+.split-session-item {
+ padding: 0.625rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ margin-bottom: 0.375rem;
+ cursor: pointer;
+ font-size: 0.8125rem;
+}
+.split-session-item:active, .split-session-item.active {
+ border-color: #2563eb;
+ background: #eff6ff;
+}
+.split-footer {
+ padding: 0.75rem 1rem;
+ border-top: 1px solid #e5e7eb;
+}
+.split-btn {
+ width: 100%;
+ padding: 0.75rem;
+ background: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+.split-btn:disabled { opacity: 0.5; }
+
+/* Pull sheet */
+.pull-sheet {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 9101;
+ background: white;
+ border-radius: 1rem 1rem 0 0;
+ max-height: 85vh;
+ overflow-y: auto;
+ padding-bottom: env(safe-area-inset-bottom);
+ box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
+}
+.pull-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 9100;
+}
+.pull-header {
+ position: sticky;
+ top: 0;
+ background: white;
+ padding: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ border-radius: 1rem 1rem 0 0;
+ z-index: 1;
+}
+.pull-header h4 { margin: 0; font-size: 0.9375rem; font-weight: 700; }
+.pull-header p { margin: 0.25rem 0 0; font-size: 0.75rem; color: #6b7280; }
+.pull-member-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #f3f4f6;
+}
+.pull-member-info { flex: 1; }
+.pull-member-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
+.pull-member-sub { font-size: 0.6875rem; color: #9ca3af; margin-top: 0.125rem; }
+.pull-btn {
+ padding: 0.375rem 0.75rem;
+ background: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.pull-btn:disabled {
+ background: #d1d5db;
+ color: #9ca3af;
+ cursor: default;
+}
+
+/* de-split-btn in detail edit */
+.de-split-btn {
+ padding: 0.25rem 0.5rem;
+ background: #f3f4f6;
+ border: 1px solid #d1d5db;
+ border-radius: 0.25rem;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: #374151;
+ cursor: pointer;
+ margin-left: auto;
+}
+.de-split-btn:active { background: #e5e7eb; }
+
+/* Toast */
+.toast-container {
+ position: fixed;
+ top: 60px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10001;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ pointer-events: none;
+}
+@keyframes slideIn {
+ from { opacity: 0; transform: translateY(-10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@keyframes slideOut {
+ from { opacity: 1; transform: translateY(0); }
+ to { opacity: 0; transform: translateY(-10px); }
+}
+
+/* Loading overlay */
+.m-loading-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ background: rgba(255,255,255,0.75);
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+}
+.m-loading-overlay.active { display: flex; }
+.m-loading-spinner {
+ width: 36px;
+ height: 36px;
+ border: 3px solid #e5e7eb;
+ border-top-color: #2563eb;
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+.m-loading-text {
+ font-size: 0.875rem;
+ color: #6b7280;
+}
diff --git a/system1-factory/web/js/auth.js b/system1-factory/web/js/auth.js
index 299b14a..45cb9fa 100644
--- a/system1-factory/web/js/auth.js
+++ b/system1-factory/web/js/auth.js
@@ -7,7 +7,6 @@
*/
export function parseJwt(token) {
try {
- // 토큰의 두 번째 부분(payload)을 base64 디코딩하고 JSON으로 파싱
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
console.error("잘못된 토큰입니다.", e);
@@ -20,7 +19,7 @@ export function parseJwt(token) {
*/
export function getToken() {
if (window.getSSOToken) return window.getSSOToken();
- return localStorage.getItem('sso_token');
+ return localStorage.getItem('sso_token') || localStorage.getItem('token');
}
/**
@@ -28,9 +27,9 @@ export function getToken() {
*/
export function getUser() {
if (window.getSSOUser) return window.getSSOUser();
- const user = localStorage.getItem('sso_user');
+ const raw = localStorage.getItem('sso_user') || localStorage.getItem('user');
try {
- return user ? JSON.parse(user) : null;
+ return raw ? JSON.parse(raw) : null;
} catch(e) {
return null;
}
@@ -38,10 +37,16 @@ export function getUser() {
/**
* 로그인 성공 후 토큰과 사용자 정보를 저장합니다.
+ * 하위 호환성을 위해 sso_token/sso_user와 token/user 모두에 저장합니다.
*/
export function saveAuthData(token, user) {
+ const userStr = JSON.stringify(user);
+ // SSO 키
localStorage.setItem('sso_token', token);
- localStorage.setItem('sso_user', JSON.stringify(user));
+ localStorage.setItem('sso_user', userStr);
+ // 하위 호환 키 (캐시된 구버전 app-init.js 대응)
+ localStorage.setItem('token', token);
+ localStorage.setItem('user', userStr);
}
/**
@@ -51,6 +56,9 @@ export function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
+ localStorage.removeItem('userPageAccess');
}
/**
@@ -59,4 +67,4 @@ export function clearAuthData() {
export function isLoggedIn() {
const token = getToken();
return !!token && token !== 'undefined' && token !== 'null';
-}
\ No newline at end of file
+}
diff --git a/system1-factory/web/js/config.js b/system1-factory/web/js/config.js
index 3127196..f2d5a54 100644
--- a/system1-factory/web/js/config.js
+++ b/system1-factory/web/js/config.js
@@ -15,7 +15,7 @@ export const config = {
// 페이지 경로 설정
paths: {
// 로그인 페이지 경로
- loginPage: '/login',
+ loginPage: '/index.html',
// 메인 대시보드 경로 (모든 사용자 공통)
dashboard: '/pages/dashboard.html',
// 하위 호환성을 위한 별칭들
@@ -39,4 +39,4 @@ export const config = {
// 토큰 만료 확인 주기 (밀리초 단위, 예: 5분)
tokenRefreshInterval: 5 * 60 * 1000,
}
-};
\ No newline at end of file
+};
diff --git a/system1-factory/web/js/daily-work-report-mobile.js b/system1-factory/web/js/daily-work-report-mobile.js
index 8eac46f..e167209 100644
--- a/system1-factory/web/js/daily-work-report-mobile.js
+++ b/system1-factory/web/js/daily-work-report-mobile.js
@@ -70,17 +70,10 @@ const MobileReport = (function() {
}
}
- // ===== 유틸리티 =====
- function getKoreaToday() {
- const today = new Date();
- return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
- }
-
- function formatDateForApi(date) {
- if (!date) return null;
- const d = date instanceof Date ? date : new Date(date);
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
- }
+ // ===== 유틸리티 (CommonUtils 위임) =====
+ var CU = window.CommonUtils;
+ function getKoreaToday() { return CU.getTodayKST(); }
+ function formatDateForApi(date) { return CU.formatDate(date) || null; }
function formatDate(dateString) {
if (!dateString) return '';
@@ -88,10 +81,7 @@ const MobileReport = (function() {
return `${d.getMonth() + 1}/${d.getDate()}`;
}
- function getDayOfWeek(dateString) {
- const days = ['일', '월', '화', '수', '목', '금', '토'];
- return days[new Date(dateString).getDay()];
- }
+ function getDayOfWeek(dateString) { return CU.getDayOfWeek(dateString); }
function formatHours(val) {
if (!val || val <= 0) return '선택';
diff --git a/system1-factory/web/js/daily-work-report.js b/system1-factory/web/js/daily-work-report.js
index 0d191a7..c83becf 100644
--- a/system1-factory/web/js/daily-work-report.js
+++ b/system1-factory/web/js/daily-work-report.js
@@ -5,41 +5,24 @@
// =================================================================
// API 설정은 api-config.js에서 window 객체에 설정됨
-// 전역 변수
-let workTypes = [];
-let workStatusTypes = [];
-let errorTypes = []; // 레거시 호환용
-let issueCategories = []; // 신고 카테고리 (nonconformity)
-let issueItems = []; // 신고 아이템
-let workers = [];
-let projects = [];
-let selectedWorkers = new Set();
-let workEntryCounter = 0;
-let currentStep = 1;
-let editingWorkId = null; // 수정 중인 작업 ID
-let incompleteTbms = []; // 미완료 TBM 작업 목록
-let currentTab = 'tbm'; // 현재 활성 탭
+// 전역 변수 → DailyWorkReportState 프록시 사용 (state.js에서 window 프록시 정의)
+// workTypes, workStatusTypes, errorTypes, issueCategories, issueItems,
+// workers, projects, selectedWorkers, incompleteTbms, tempDefects,
+// dailyIssuesCache, currentTab, currentStep, editingWorkId, workEntryCounter,
+// currentDefectIndex, currentEditingField, currentTimeValue,
+// selectedWorkplace, selectedWorkplaceName, selectedWorkplaceCategory, selectedWorkplaceCategoryName
-// 부적합 원인 관리
-let currentDefectIndex = null; // 현재 편집 중인 행 인덱스
-let tempDefects = {}; // 임시 부적합 원인 저장 { index: [{ error_type_id, defect_hours, note }] }
-
-// 작업장소 지도 관련 변수
-let mapCanvas = null;
-let mapCtx = null;
-let mapImage = null;
-let mapRegions = [];
-let selectedWorkplace = null;
-let selectedWorkplaceName = null;
-let selectedWorkplaceCategory = null;
-let selectedWorkplaceCategoryName = null;
+// 지도 관련 변수 (프록시 아님)
+var mapCanvas = null;
+var mapCtx = null;
+var mapImage = null;
+var mapRegions = [];
// 시간 선택 관련 변수
-let currentEditingField = null; // { index, type: 'total' | 'error' }
-let currentTimeValue = 0;
+// currentEditingField, currentTimeValue → DailyWorkReportState 프록시 사용
// 당일 신고 리마인더 관련 변수
-let dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] } - 날짜별 신고 캐시
+// dailyIssuesCache → DailyWorkReportState 프록시 사용
// =================================================================
// TBM 작업보고 관련 함수
@@ -182,75 +165,23 @@ function getRelatedIssues(dateStr, workplaceId, projectId) {
}
/**
- * 날짜를 API 형식(YYYY-MM-DD)으로 변환 - 로컬 시간대 기준
+ * 날짜를 API 형식(YYYY-MM-DD)으로 변환
*/
function formatDateForApi(date) {
+ if (window.CommonUtils) return window.CommonUtils.formatDate(date) || null;
if (!date) return null;
-
- let dateObj;
- if (date instanceof Date) {
- dateObj = date;
- } else if (typeof date === 'string') {
- // 문자열인 경우 Date 객체로 변환
- dateObj = new Date(date);
- } else {
- return null;
- }
-
- // 로컬 시간대 기준으로 날짜 추출 (UTC 변환 방지)
- const year = dateObj.getFullYear();
- const month = String(dateObj.getMonth() + 1).padStart(2, '0');
- const day = String(dateObj.getDate()).padStart(2, '0');
-
- return `${year}-${month}-${day}`;
+ const d = date instanceof Date ? date : new Date(date);
+ if (isNaN(d.getTime())) return null;
+ return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
}
/**
* 사용자 정보 가져오기 (auth-check.js와 동일한 로직)
*/
function getUser() {
- const user = localStorage.getItem('sso_user');
- return user ? JSON.parse(user) : null;
-}
-
-/**
- * 근태 유형에 따른 기본 작업시간 반환
- */
-function getDefaultHoursFromAttendance(tbm) {
- // work_hours가 있으면 (분할 배정) 해당 값 우선 사용
- if (tbm.work_hours != null && parseFloat(tbm.work_hours) > 0) {
- return parseFloat(tbm.work_hours);
- }
- switch (tbm.attendance_type) {
- case 'overtime': return 8 + (parseFloat(tbm.attendance_hours) || 0);
- case 'regular': return 8;
- case 'half': return 4;
- case 'quarter': return 6;
- case 'early': return parseFloat(tbm.attendance_hours) || 0;
- default: return 0;
- }
-}
-
-/**
- * 근태 유형 뱃지 HTML 반환
- */
-function getAttendanceBadgeHtml(type) {
- const labels = { overtime: '연장근무', regular: '정시근로', annual: '연차', half: '반차', quarter: '반반차', early: '조퇴' };
- const colors = { overtime: '#7c3aed', regular: '#2563eb', annual: '#ef4444', half: '#f59e0b', quarter: '#f97316', early: '#6b7280' };
- if (!type || !labels[type]) return '';
- return ` ${labels[type]}`;
-}
-
-/**
- * 시간 표시 포맷
- */
-function formatHoursDisplay(val) {
- if (!val || val <= 0) return '시간 선택';
- val = parseFloat(val);
- if (val === Math.floor(val)) return val + '시간';
- const hours = Math.floor(val);
- const mins = Math.round((val - hours) * 60);
- return hours > 0 ? hours + '시간 ' + mins + '분' : mins + '분';
+ if (window.getSSOUser) return window.getSSOUser();
+ const raw = localStorage.getItem('sso_user') || localStorage.getItem('user');
+ try { return raw ? JSON.parse(raw) : null; } catch(e) { return null; }
}
/**
@@ -278,7 +209,7 @@ function renderTbmWorkList() {
byDate[dateStr].sessions[sessionKey] = {
session_id: tbm.session_id,
session_date: tbm.session_date,
- created_by_name: tbm.created_by_name,
+ created_by_name: tbm.leader_name || tbm.created_by_name || '-',
items: []
};
}
@@ -462,34 +393,29 @@ function renderTbmWorkList() {
}
return false;
});
- // 근태 기반 자동 시간 채움
- const defaultHours = tbm.attendance_type ? getDefaultHoursFromAttendance(tbm) : 0;
- const hasDefaultHours = defaultHours > 0;
- const attendanceBadgeHtml = tbm.attendance_type ? getAttendanceBadgeHtml(tbm.attendance_type) : '';
return `
팀원이 없습니다.
- ${tbm.project_name || '-'}
- ${tbm.work_type_name || '-'}
- ${tbm.task_name || '-'}
-
+ ${tbm.project_name || '-'}
+ ${tbm.work_type_name || '-'}
+ ${tbm.task_name || '-'}
+
-
-
@@ -593,6 +519,10 @@ window.calculateRegularHours = function(index) {
* TBM 작업보고서 제출
*/
window.submitTbmWorkReport = async function(index) {
+ // busy guard - 중복 제출 방지
+ const submitBtn = document.querySelector(`tr[data-index="${index}"][data-type="tbm"] .btn-submit-compact`);
+ if (submitBtn && submitBtn.classList.contains('is-loading')) return;
+
const tbm = incompleteTbms[index];
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`).value);
@@ -614,6 +544,13 @@ window.submitTbmWorkReport = async function(index) {
return;
}
+ // 로딩 상태 시작
+ if (submitBtn) {
+ submitBtn.classList.add('is-loading');
+ submitBtn.disabled = true;
+ submitBtn.textContent = '제출 중';
+ }
+
// 부적합 원인 유효성 검사 (issue_report_id 또는 category_id 또는 error_type_id 필요)
console.log('🔍 부적합 검증 시작:', defects.map(d => ({
defect_hours: d.defect_hours,
@@ -722,6 +659,13 @@ window.submitTbmWorkReport = async function(index) {
} catch (error) {
console.error('TBM 작업보고서 제출 오류:', error);
showSaveResultModal('error', '제출 실패', error.message);
+ } finally {
+ // 로딩 상태 해제
+ if (submitBtn) {
+ submitBtn.classList.remove('is-loading');
+ submitBtn.disabled = false;
+ submitBtn.textContent = '제출';
+ }
}
};
@@ -729,6 +673,10 @@ window.submitTbmWorkReport = async function(index) {
* TBM 세션 일괄제출
*/
window.batchSubmitTbmSession = async function(sessionKey) {
+ // busy guard - 일괄제출 버튼
+ const batchBtn = document.querySelector(`[data-session-key="${sessionKey}"] ~ .batch-submit-container .btn-batch-submit, .tbm-session-group[data-session-key="${sessionKey}"] .btn-batch-submit`);
+ if (batchBtn && batchBtn.classList.contains('is-loading')) return;
+
// 해당 세션의 모든 항목 가져오기
const sessionRows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"]`);
@@ -804,7 +752,8 @@ window.batchSubmitTbmSession = async function(sessionKey) {
}
// 2단계: 모든 항목 제출
- const submitBtn = event.target;
+ const submitBtn = batchBtn || event.target;
+ submitBtn.classList.add('is-loading');
submitBtn.disabled = true;
submitBtn.textContent = '제출 중...';
@@ -869,6 +818,7 @@ window.batchSubmitTbmSession = async function(sessionKey) {
console.error('일괄제출 오류:', error);
showSaveResultModal('error', '일괄제출 오류', error.message);
} finally {
+ submitBtn.classList.remove('is-loading');
submitBtn.disabled = false;
submitBtn.textContent = `📤 이 세션 일괄제출 (${sessionRows.length}건)`;
}
@@ -1160,7 +1110,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath, workplaces) {
mapCtx = mapCanvas.getContext('2d');
// 이미지 URL 생성
- const baseUrl = window.API_BASE_URL || 'http://localhost:30005';
+ const baseUrl = window.API_BASE_URL || 'http://localhost:20005';
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
const fullImageUrl = layoutImagePath.startsWith('http')
? layoutImagePath
@@ -1690,12 +1640,8 @@ window.submitAllManualWorkReports = async function() {
* 날짜 포맷 함수
*/
function formatDate(dateString) {
- if (!dateString) return '';
- const date = new Date(dateString);
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
+ if (window.CommonUtils) return window.CommonUtils.formatDate(dateString);
+ return formatDateForApi(dateString);
}
/**
@@ -2015,38 +1961,36 @@ window.deleteWorkReport = async function(reportId) {
// 기존 함수들
// =================================================================
-// 한국 시간 기준 오늘 날짜 가져오기
+// 한국 시간 기준 오늘 날짜
function getKoreaToday() {
- const today = new Date();
- const year = today.getFullYear();
- const month = String(today.getMonth() + 1).padStart(2, '0');
- const day = String(today.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
+ if (window.CommonUtils) return window.CommonUtils.getTodayKST();
+ const now = new Date();
+ return now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0');
}
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
- try {
- const token = localStorage.getItem('sso_token');
- if (!token) return null;
+ // SSO 사용자 정보 우선
+ if (window.getSSOUser) {
+ const ssoUser = window.getSSOUser();
+ if (ssoUser) return ssoUser;
+ }
- const payloadBase64 = token.split('.')[1];
- if (payloadBase64) {
- const payload = JSON.parse(atob(payloadBase64));
- console.log('토큰에서 추출한 사용자 정보:', payload);
- return payload;
+ try {
+ const token = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token') || localStorage.getItem('token'));
+ if (token) {
+ const payloadBase64 = token.split('.')[1];
+ if (payloadBase64) {
+ return JSON.parse(atob(payloadBase64));
+ }
}
} catch (error) {
console.log('토큰에서 사용자 정보 추출 실패:', error);
}
try {
- const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
- if (userInfo) {
- const parsed = JSON.parse(userInfo);
- console.log('localStorage에서 가져온 사용자 정보:', parsed);
- return parsed;
- }
+ const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('user') || localStorage.getItem('userInfo');
+ if (userInfo) return JSON.parse(userInfo);
} catch (error) {
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
}
@@ -3183,14 +3127,15 @@ function setupEventListeners() {
// 초기화
async function init() {
try {
- const token = localStorage.getItem('sso_token');
- if (!token || token === 'undefined') {
- showMessage('로그인이 필요합니다.', 'error');
- localStorage.removeItem('sso_token');
- setTimeout(() => {
- window.location.href = '/';
- }, 2000);
- return;
+ // app-init.js(defer)가 토큰/apiCall 설정 완료할 때까지 대기
+ if (window.waitForApi) {
+ await window.waitForApi(8000);
+ } else if (!window.apiCall) {
+ // waitForApi 없으면 간단 폴링
+ await new Promise((resolve, reject) => {
+ let elapsed = 0;
+ const iv = setInterval(() => { elapsed += 50; if (window.apiCall) { clearInterval(iv); resolve(); } else if (elapsed >= 8000) { clearInterval(iv); reject(new Error('apiCall timeout')); } }, 50);
+ });
}
await loadData();
@@ -3207,8 +3152,12 @@ async function init() {
}
}
-// 페이지 로드 시 초기화
-document.addEventListener('DOMContentLoaded', init);
+// 페이지 로드 시 초기화 (module 스크립트는 DOMContentLoaded 이후 실행될 수 있음)
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+} else {
+ init();
+}
// 전역 함수로 노출
window.removeWorkEntry = removeWorkEntry;
diff --git a/system1-factory/web/js/daily-work-report/index.js b/system1-factory/web/js/daily-work-report/index.js
deleted file mode 100644
index 03184dd..0000000
--- a/system1-factory/web/js/daily-work-report/index.js
+++ /dev/null
@@ -1,318 +0,0 @@
-/**
- * Daily Work Report - Module Loader
- * 작업보고서 모듈을 초기화하고 연결하는 메인 진입점
- *
- * 로드 순서:
- * 1. state.js - 전역 상태 관리
- * 2. utils.js - 유틸리티 함수
- * 3. api.js - API 클라이언트
- * 4. index.js - 이 파일 (메인 컨트롤러)
- */
-
-class DailyWorkReportController {
- constructor() {
- this.state = window.DailyWorkReportState;
- this.api = window.DailyWorkReportAPI;
- this.utils = window.DailyWorkReportUtils;
- this.initialized = false;
-
- console.log('[Controller] DailyWorkReportController 생성');
- }
-
- /**
- * 초기화
- */
- async init() {
- if (this.initialized) {
- console.log('[Controller] 이미 초기화됨');
- return;
- }
-
- console.log('[Controller] 초기화 시작...');
-
- try {
- // 이벤트 리스너 설정
- this.setupEventListeners();
-
- // 기본 데이터 로드
- await this.api.loadAllData();
-
- // TBM 탭이 기본
- await this.switchTab('tbm');
-
- this.initialized = true;
- console.log('[Controller] 초기화 완료');
-
- } catch (error) {
- console.error('[Controller] 초기화 실패:', error);
- window.showMessage?.('초기화 중 오류가 발생했습니다: ' + error.message, 'error');
- }
- }
-
- /**
- * 이벤트 리스너 설정
- */
- setupEventListeners() {
- // 탭 버튼
- const tbmBtn = document.getElementById('tbmReportTab');
- const completedBtn = document.getElementById('completedReportTab');
-
- if (tbmBtn) {
- tbmBtn.addEventListener('click', () => this.switchTab('tbm'));
- }
- if (completedBtn) {
- completedBtn.addEventListener('click', () => this.switchTab('completed'));
- }
-
- // 완료 보고서 날짜 변경
- const completedDateInput = document.getElementById('completedReportDate');
- if (completedDateInput) {
- completedDateInput.addEventListener('change', () => this.loadCompletedReports());
- }
-
- console.log('[Controller] 이벤트 리스너 설정 완료');
- }
-
- /**
- * 탭 전환
- */
- async switchTab(tab) {
- this.state.setCurrentTab(tab);
-
- const tbmBtn = document.getElementById('tbmReportTab');
- const completedBtn = document.getElementById('completedReportTab');
- const tbmSection = document.getElementById('tbmReportSection');
- const completedSection = document.getElementById('completedReportSection');
-
- // 모든 탭 버튼 비활성화
- tbmBtn?.classList.remove('active');
- completedBtn?.classList.remove('active');
-
- // 모든 섹션 숨기기
- if (tbmSection) tbmSection.style.display = 'none';
- if (completedSection) completedSection.style.display = 'none';
-
- // 선택된 탭 활성화
- if (tab === 'tbm') {
- tbmBtn?.classList.add('active');
- if (tbmSection) tbmSection.style.display = 'block';
- await this.loadTbmData();
- } else if (tab === 'completed') {
- completedBtn?.classList.add('active');
- if (completedSection) completedSection.style.display = 'block';
-
- // 오늘 날짜로 초기화
- const dateInput = document.getElementById('completedReportDate');
- if (dateInput) {
- dateInput.value = this.utils.getKoreaToday();
- }
- await this.loadCompletedReports();
- }
- }
-
- /**
- * TBM 데이터 로드
- */
- async loadTbmData() {
- try {
- await this.api.loadIncompleteTbms();
- await this.api.loadDailyIssuesForTbms();
-
- // 렌더링은 기존 함수 사용 (점진적 마이그레이션)
- if (typeof window.renderTbmWorkList === 'function') {
- window.renderTbmWorkList();
- }
- } catch (error) {
- console.error('[Controller] TBM 데이터 로드 오류:', error);
- window.showMessage?.('TBM 데이터를 불러오는 중 오류가 발생했습니다.', 'error');
- }
- }
-
- /**
- * 완료 보고서 로드
- */
- async loadCompletedReports() {
- try {
- const dateInput = document.getElementById('completedReportDate');
- const date = dateInput?.value || this.utils.getKoreaToday();
-
- const reports = await this.api.loadCompletedReports(date);
-
- // 렌더링은 기존 함수 사용
- if (typeof window.renderCompletedReports === 'function') {
- window.renderCompletedReports(reports);
- }
- } catch (error) {
- console.error('[Controller] 완료 보고서 로드 오류:', error);
- window.showMessage?.('완료 보고서를 불러오는 중 오류가 발생했습니다.', 'error');
- }
- }
-
- /**
- * TBM 작업보고서 제출
- */
- async submitTbmWorkReport(index) {
- try {
- const tbm = this.state.incompleteTbms[index];
- if (!tbm) {
- throw new Error('TBM 데이터를 찾을 수 없습니다.');
- }
-
- // 유효성 검사
- const totalHoursInput = document.getElementById(`totalHours_${index}`);
- const totalHours = parseFloat(totalHoursInput?.value);
-
- if (!totalHours || totalHours <= 0) {
- window.showMessage?.('작업시간을 입력해주세요.', 'warning');
- return;
- }
-
- // 부적합 시간 계산
- const defects = this.state.tempDefects[index] || [];
- const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
- const regularHours = totalHours - errorHours;
-
- if (regularHours < 0) {
- window.showMessage?.('부적합 시간이 총 작업시간을 초과할 수 없습니다.', 'warning');
- return;
- }
-
- // API 데이터 구성
- const user = this.state.getCurrentUser();
- const reportData = {
- tbm_session_id: tbm.session_id,
- tbm_assignment_id: tbm.assignment_id,
- worker_id: tbm.worker_id,
- project_id: tbm.project_id,
- work_type_id: tbm.work_type_id,
- report_date: this.utils.formatDateForApi(tbm.session_date),
- total_hours: totalHours,
- regular_hours: regularHours,
- error_hours: errorHours,
- work_status_id: errorHours > 0 ? 2 : 1,
- created_by: user?.user_id || user?.id,
- defects: defects.map(d => ({
- category_id: d.category_id,
- item_id: d.item_id,
- issue_report_id: d.issue_report_id,
- defect_hours: d.defect_hours,
- note: d.note
- }))
- };
-
- const result = await this.api.submitTbmWorkReport(reportData);
-
- window.showSaveResultModal?.(
- 'success',
- '제출 완료',
- `${tbm.worker_name}의 작업보고서가 제출되었습니다.`
- );
-
- // 목록 새로고침
- await this.loadTbmData();
-
- } catch (error) {
- console.error('[Controller] 제출 오류:', error);
- window.showSaveResultModal?.(
- 'error',
- '제출 실패',
- error.message || '작업보고서 제출 중 오류가 발생했습니다.'
- );
- }
- }
-
- /**
- * 세션 일괄 제출
- */
- async batchSubmitSession(sessionKey) {
- const rows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"][data-type="tbm"]`);
- const indices = [];
-
- rows.forEach(row => {
- const index = parseInt(row.dataset.index);
- const totalHoursInput = document.getElementById(`totalHours_${index}`);
- if (totalHoursInput?.value && parseFloat(totalHoursInput.value) > 0) {
- indices.push(index);
- }
- });
-
- if (indices.length === 0) {
- window.showMessage?.('제출할 항목이 없습니다. 작업시간을 입력해주세요.', 'warning');
- return;
- }
-
- const confirmed = confirm(`${indices.length}건의 작업보고서를 일괄 제출하시겠습니까?`);
- if (!confirmed) return;
-
- let successCount = 0;
- let failCount = 0;
-
- for (const index of indices) {
- try {
- await this.submitTbmWorkReport(index);
- successCount++;
- } catch (error) {
- failCount++;
- console.error(`[Controller] 일괄 제출 오류 (index: ${index}):`, error);
- }
- }
-
- if (failCount === 0) {
- window.showSaveResultModal?.('success', '일괄 제출 완료', `${successCount}건이 성공적으로 제출되었습니다.`);
- } else {
- window.showSaveResultModal?.('warning', '일괄 제출 부분 완료', `성공: ${successCount}건, 실패: ${failCount}건`);
- }
- }
-
- /**
- * 상태 디버그
- */
- debug() {
- console.log('[Controller] 상태 디버그:');
- this.state.debug();
- }
-}
-
-// 전역 인스턴스 생성
-window.DailyWorkReportController = new DailyWorkReportController();
-
-// 하위 호환성: 기존 전역 함수들
-window.switchTab = (tab) => window.DailyWorkReportController.switchTab(tab);
-window.submitTbmWorkReport = (index) => window.DailyWorkReportController.submitTbmWorkReport(index);
-window.batchSubmitTbmSession = (sessionKey) => window.DailyWorkReportController.batchSubmitSession(sessionKey);
-
-// 사용자 정보 함수
-window.getUser = () => window.DailyWorkReportState.getUser();
-window.getCurrentUser = () => window.DailyWorkReportState.getCurrentUser();
-
-// 날짜 그룹 토글 (UI 함수)
-window.toggleDateGroup = function(dateStr) {
- const group = document.querySelector(`.date-group[data-date="${dateStr}"]`);
- if (!group) return;
-
- const isExpanded = group.classList.contains('expanded');
- const content = group.querySelector('.date-group-content');
- const icon = group.querySelector('.date-toggle-icon');
-
- if (isExpanded) {
- group.classList.remove('expanded');
- group.classList.add('collapsed');
- if (content) content.style.display = 'none';
- if (icon) icon.textContent = '▶';
- } else {
- group.classList.remove('collapsed');
- group.classList.add('expanded');
- if (content) content.style.display = 'block';
- if (icon) icon.textContent = '▼';
- }
-};
-
-// DOMContentLoaded 이벤트에서 초기화
-document.addEventListener('DOMContentLoaded', () => {
- // 약간의 지연 후 초기화 (다른 스크립트 로드 대기)
- setTimeout(() => {
- window.DailyWorkReportController.init();
- }, 100);
-});
-
-console.log('[Module] daily-work-report/index.js 로드 완료');
diff --git a/system1-factory/web/js/daily-work-report/state.js b/system1-factory/web/js/daily-work-report/state.js
index ff44fbf..2df535c 100644
--- a/system1-factory/web/js/daily-work-report/state.js
+++ b/system1-factory/web/js/daily-work-report/state.js
@@ -1,10 +1,12 @@
/**
* Daily Work Report - State Manager
- * 작업보고서 페이지의 전역 상태 관리
+ * 작업보고서 페이지의 전역 상태 관리 (BaseState 상속)
*/
-class DailyWorkReportState {
+class DailyWorkReportState extends BaseState {
constructor() {
+ super();
+
// 마스터 데이터
this.workTypes = [];
this.workStatusTypes = [];
@@ -45,53 +47,9 @@ class DailyWorkReportState {
// 캐시
this.dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] }
- // 리스너
- this.listeners = new Map();
-
console.log('[State] DailyWorkReportState 초기화 완료');
}
- /**
- * 상태 업데이트
- */
- update(key, value) {
- const prevValue = this[key];
- this[key] = value;
- this.notifyListeners(key, value, prevValue);
- }
-
- /**
- * 리스너 등록
- */
- subscribe(key, callback) {
- if (!this.listeners.has(key)) {
- this.listeners.set(key, []);
- }
- this.listeners.get(key).push(callback);
- }
-
- /**
- * 리스너에게 알림
- */
- notifyListeners(key, newValue, prevValue) {
- const keyListeners = this.listeners.get(key) || [];
- keyListeners.forEach(callback => {
- try {
- callback(newValue, prevValue);
- } catch (error) {
- console.error(`[State] 리스너 오류 (${key}):`, error);
- }
- });
- }
-
- /**
- * 현재 사용자 정보 가져오기
- */
- getUser() {
- const user = localStorage.getItem('sso_user');
- return user ? JSON.parse(user) : null;
- }
-
/**
* 토큰에서 사용자 정보 추출
*/
diff --git a/system1-factory/web/js/daily-work-report/utils.js b/system1-factory/web/js/daily-work-report/utils.js
index 2d96e2a..370f482 100644
--- a/system1-factory/web/js/daily-work-report/utils.js
+++ b/system1-factory/web/js/daily-work-report/utils.js
@@ -1,67 +1,29 @@
/**
* Daily Work Report - Utilities
- * 작업보고서 관련 유틸리티 함수들
+ * 작업보고서 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class DailyWorkReportUtils {
constructor() {
+ this._common = window.CommonUtils;
console.log('[Utils] DailyWorkReportUtils 초기화');
}
- /**
- * 한국 시간 기준 오늘 날짜 (YYYY-MM-DD)
- */
- getKoreaToday() {
- const today = new Date();
- const year = today.getFullYear();
- const month = String(today.getMonth() + 1).padStart(2, '0');
- const day = String(today.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
- }
+ // --- CommonUtils 위임 ---
+ getKoreaToday() { return this._common.getTodayKST(); }
+ formatDateForApi(date) { return this._common.formatDate(date); }
+ formatDate(date) { return this._common.formatDate(date) || '-'; }
+ getDayOfWeek(date) { return this._common.getDayOfWeek(date); }
+ isToday(date) { return this._common.isToday(date); }
+ generateUUID() { return this._common.generateUUID(); }
+ escapeHtml(text) { return this._common.escapeHtml(text); }
+ debounce(func, wait) { return this._common.debounce(func, wait); }
+ throttle(func, limit) { return this._common.throttle(func, limit); }
+ deepClone(obj) { return this._common.deepClone(obj); }
+ isEmpty(value) { return this._common.isEmpty(value); }
+ groupBy(array, key) { return this._common.groupBy(array, key); }
- /**
- * 날짜를 API 형식(YYYY-MM-DD)으로 변환
- */
- formatDateForApi(date) {
- if (!date) return null;
-
- let dateObj;
- if (date instanceof Date) {
- dateObj = date;
- } else if (typeof date === 'string') {
- dateObj = new Date(date);
- } else {
- return null;
- }
-
- const year = dateObj.getFullYear();
- const month = String(dateObj.getMonth() + 1).padStart(2, '0');
- const day = String(dateObj.getDate()).padStart(2, '0');
-
- return `${year}-${month}-${day}`;
- }
-
- /**
- * 날짜 포맷팅 (표시용)
- */
- formatDate(date) {
- if (!date) return '-';
-
- let dateObj;
- if (date instanceof Date) {
- dateObj = date;
- } else if (typeof date === 'string') {
- dateObj = new Date(date);
- } else {
- return '-';
- }
-
- const year = dateObj.getFullYear();
- const month = String(dateObj.getMonth() + 1).padStart(2, '0');
- const day = String(dateObj.getDate()).padStart(2, '0');
-
- return `${year}-${month}-${day}`;
- }
+ // --- 작업보고 전용 ---
/**
* 시간 포맷팅 (HH:mm)
@@ -104,24 +66,6 @@ class DailyWorkReportUtils {
return Number(num).toFixed(decimals);
}
- /**
- * 요일 반환
- */
- getDayOfWeek(date) {
- const days = ['일', '월', '화', '수', '목', '금', '토'];
- const dateObj = date instanceof Date ? date : new Date(date);
- return days[dateObj.getDay()];
- }
-
- /**
- * 오늘인지 확인
- */
- isToday(date) {
- const today = this.getKoreaToday();
- const targetDate = this.formatDateForApi(date);
- return today === targetDate;
- }
-
/**
* 두 날짜 사이 일수 계산
*/
@@ -132,63 +76,6 @@ class DailyWorkReportUtils {
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
- /**
- * 디바운스 함수
- */
- debounce(func, wait) {
- let timeout;
- return function executedFunction(...args) {
- const later = () => {
- clearTimeout(timeout);
- func(...args);
- };
- clearTimeout(timeout);
- timeout = setTimeout(later, wait);
- };
- }
-
- /**
- * 쓰로틀 함수
- */
- throttle(func, limit) {
- let inThrottle;
- return function(...args) {
- if (!inThrottle) {
- func.apply(this, args);
- inThrottle = true;
- setTimeout(() => inThrottle = false, limit);
- }
- };
- }
-
- /**
- * HTML 이스케이프
- */
- escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
- /**
- * 객체 깊은 복사
- */
- deepClone(obj) {
- return JSON.parse(JSON.stringify(obj));
- }
-
- /**
- * 빈 값 확인
- */
- isEmpty(value) {
- if (value === null || value === undefined) return true;
- if (typeof value === 'string') return value.trim() === '';
- if (Array.isArray(value)) return value.length === 0;
- if (typeof value === 'object') return Object.keys(value).length === 0;
- return false;
- }
-
/**
* 숫자 유효성 검사
*/
@@ -249,20 +136,6 @@ class DailyWorkReportUtils {
}
}
- /**
- * 배열 그룹화
- */
- groupBy(array, key) {
- return array.reduce((result, item) => {
- const groupKey = typeof key === 'function' ? key(item) : item[key];
- if (!result[groupKey]) {
- result[groupKey] = [];
- }
- result[groupKey].push(item);
- return result;
- }, {});
- }
-
/**
* 배열 정렬 (다중 키)
*/
@@ -280,17 +153,6 @@ class DailyWorkReportUtils {
return 0;
});
}
-
- /**
- * UUID 생성
- */
- generateUUID() {
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
- }
}
// 전역 인스턴스 생성
diff --git a/system1-factory/web/js/tbm-mobile.js b/system1-factory/web/js/tbm-mobile.js
new file mode 100644
index 0000000..0be6f5e
--- /dev/null
+++ b/system1-factory/web/js/tbm-mobile.js
@@ -0,0 +1,1329 @@
+/**
+ * TBM Mobile - Main UI Logic
+ * tbm-mobile.html에서 추출된 인라인 JS (로직 변경 없음)
+ */
+(function() {
+ 'use strict';
+
+ var currentTab = 'today';
+ var allSessions = [];
+ var todaySessions = [];
+ var currentUser = null;
+ var loadedDays = 7;
+ var esc = window.escapeHtml || function(s) { return s || ''; };
+ var todayAssignments = []; // 당일 배정 현황
+
+ // 세부 편집 상태
+ var deSessionId = null;
+ var deSession = null;
+ var deMembers = [];
+ var deTasks = [];
+ var deWpCats = [];
+ var deWpMap = {}; // category_id -> [workplaces]
+ var deSelected = {}; // index -> boolean (그룹 선택용)
+
+ // 피커 상태
+ var pickerMode = ''; // 'task' | 'workplace'
+ var pickerWpStep = 'category'; // 'category' | 'place'
+ var pickerSelectedCatId = null;
+
+ // busy guard - 비동기 함수 중복 호출 방지
+ var _busy = {};
+ function isBusy(key) { return !!_busy[key]; }
+ function setBusy(key) { _busy[key] = true; }
+ function clearBusy(key) { delete _busy[key]; }
+
+ function showLoading(msg) {
+ var el = document.getElementById('loadingOverlay');
+ if (el) {
+ document.getElementById('loadingText').textContent = msg || '불러오는 중...';
+ el.classList.add('active');
+ }
+ }
+ function hideLoading() {
+ var el = document.getElementById('loadingOverlay');
+ if (el) el.classList.remove('active');
+ }
+
+ // 초기화
+ document.addEventListener('DOMContentLoaded', async function() {
+ var now = new Date();
+ var days = ['일','월','화','수','목','금','토'];
+ var dateEl = document.getElementById('headerDate');
+ if (dateEl) {
+ dateEl.textContent = now.getFullYear() + '.' +
+ String(now.getMonth()+1).padStart(2,'0') + '.' +
+ String(now.getDate()).padStart(2,'0') + ' (' + days[now.getDay()] + ')';
+ }
+
+ try {
+ await window.waitForApi(8000);
+ } catch(e) {
+ document.getElementById('tbmContent').innerHTML =
+ '
- ${leaderName}
- ${leaderRole}
-
-
내가 신고한 안전, 시설설비, 부적합 현황을 확인합니다.
-