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.worker_name || '작업자'}${attendanceBadgeHtml} + ${tbm.worker_name || '작업자'}
${tbm.job_type || '-'}
- ${tbm.project_name || '-'} - ${tbm.work_type_name || '-'} - ${tbm.task_name || '-'} - + ${tbm.project_name || '-'} + ${tbm.work_type_name || '-'} + ${tbm.task_name || '-'} +
${tbm.category_name || ''}
${tbm.workplace_name || '-'}
- -
+
- ${hasDefaultHours ? formatHoursDisplay(defaultHours) : '시간 선택'} + onclick="openTimePicker(${index}, 'total')"> + 시간 선택
@@ -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 = + '
서버 연결에 실패했습니다
페이지를 새로고침해 주세요
'; + return; + } + + currentUser = JSON.parse(localStorage.getItem('sso_user') || '{}'); + await loadData(); + }); + + function getTodayStr() { + var now = new Date(); + return now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); + } + + async function loadData() { + try { + var today = new Date(); + var todayStr = getTodayStr(); + var dates = []; + for (var i = 0; i < loadedDays; i++) { + var d = new Date(today); + d.setDate(d.getDate() - i); + dates.push(d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0')); + } + + var API = window.TbmAPI; + var promises = dates.map(function(date) { + return API.fetchSessionsByDate(date); + }); + var results = await Promise.all(promises); + + allSessions = []; + results.forEach(function(sessions) { + if (sessions && sessions.length > 0) { + allSessions = allSessions.concat(sessions); + } + }); + + // 당일 세션 = 오늘 날짜만 + todaySessions = allSessions.filter(function(s) { + var sDate = s.session_date ? s.session_date.split('T')[0] : ''; + return sDate === todayStr; + }); + + document.getElementById('todayCount').textContent = todaySessions.length; + document.getElementById('allCount').textContent = allSessions.length; + + renderList(); + } catch (error) { + console.error('TBM 로드 오류:', error); + document.getElementById('tbmContent').innerHTML = + '
데이터를 불러올 수 없습니다
'; + } + } + + window.switchTab = function(tab) { + currentTab = tab; + document.querySelectorAll('.m-tab').forEach(function(el) { + el.classList.toggle('active', el.dataset.tab === tab); + }); + renderList(); + }; + + function isMySession(s) { + var userId = currentUser.user_id; + var workerId = currentUser.worker_id; + var userName = currentUser.name; + return (userId && String(s.created_by) === String(userId)) || + (workerId && String(s.leader_id) === String(workerId)) || + (userName && s.created_by_name === userName); + } + + function renderList() { + var sessions = currentTab === 'today' ? todaySessions : allSessions; + var content = document.getElementById('tbmContent'); + + if (sessions.length === 0) { + var emptyMsg = currentTab === 'today' ? + '오늘 등록된 TBM이 없습니다' : '등록된 TBM이 없습니다'; + content.innerHTML = + '
' + + '
📝
' + + '
' + emptyMsg + '
' + + (currentTab === 'all' ? '
최근 ' + loadedDays + '일 기준
' : '') + + '
'; + return; + } + + var grouped = {}; + sessions.forEach(function(s) { + var date = s.session_date ? s.session_date.split('T')[0] : ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { /* ok */ } + else if (s.session_date) { date = new Date(s.session_date).toISOString().split('T')[0]; } + if (!grouped[date]) grouped[date] = []; + grouped[date].push(s); + }); + + var sortedDates = Object.keys(grouped).sort().reverse(); + var todayStr = getTodayStr(); + var yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + + var html = ''; + sortedDates.forEach(function(date) { + var label = date; + if (date === todayStr) label = '오늘'; + else if (date === yesterday) label = '어제'; + else { + var parts = date.split('-'); + var dayNames = ['일','월','화','수','목','금','토']; + var dObj = new Date(date + 'T00:00:00'); + label = parseInt(parts[1]) + '/' + parseInt(parts[2]) + ' (' + dayNames[dObj.getDay()] + ')'; + } + + html += '
' + label + '
'; + + grouped[date].forEach(function(s) { + var sid = s.session_id; + var status = s.status || 'draft'; + var leaderName = s.leader_name || s.created_by_name || '미지정'; + var memberCount = (parseInt(s.team_member_count) || 0); + var memberNames = s.team_member_names || ''; + var subText = memberNames || '팀원 없음'; + var isMine = isMySession(s); + var transferCount = parseInt(s.transfer_count) || 0; + + var createdTime = ''; + if (s.created_at) { + try { + var t = new Date(s.created_at); + createdTime = String(t.getHours()).padStart(2,'0') + ':' + String(t.getMinutes()).padStart(2,'0'); + } catch(e) {} + } + + var statusLabel = status === 'completed' ? '완료' : (status === 'cancelled' ? '취소' : '진행'); + + var badge = ''; + if (status === 'draft') { + if (!s.task_id) { + badge = '세부 미입력'; + } else { + badge = '입력 완료'; + } + } + + // 이동 뱃지 + var transferBadge = ''; + if (transferCount > 0) { + transferBadge = '' + transferCount + '건 이동'; + } + + // 당일 탭에서 다른 반장의 draft TBM 클릭 → 빼오기 시트 + var clickAction; + if (isMine && status === 'draft') { + clickAction = 'openDetailEditSheet(' + sid + ')'; + } else if (!isMine && status === 'draft' && currentTab === 'today') { + clickAction = 'openPullSheet(' + sid + ')'; + } else if (status !== 'draft') { + clickAction = 'toggleDetail(' + sid + ')'; + } else { + clickAction = 'toggleDetail(' + sid + ')'; + } + + var myTbmClass = isMine ? ' my-tbm' : ''; + var leaderDisplay = esc(leaderName); + if (!isMine && currentTab === 'today') { + leaderDisplay += '타 반장'; + } + + html += '
' + + '
' + + '
' + + '
' + leaderDisplay + badge + transferBadge + '
' + + '
' + esc(subText) + '
' + + '
' + + '
' + + '
' + memberCount + '
' + + (createdTime ? '
' + createdTime + '
' : '') + + '
' + + '
'; + + if (status !== 'draft') { + var taskName = s.task_name || ''; + var workplaceName = s.work_location || ''; + html += '
' + + '
상태' + statusLabel + '
' + + '
입력자' + esc(leaderName) + '
' + + (taskName ? '
작업' + esc(taskName) + '
' : '') + + (workplaceName ? '
장소' + esc(workplaceName) + '
' : '') + + '
인원' + esc(memberNames || '없음') + ' (' + memberCount + '명)
' + + '
' + + '' + + '
' + + '
'; + } + }); + }); + + if (currentTab === 'all') { + html += ''; + } + content.innerHTML = html; + } + + window.toggleDetail = function(sid) { + var row = document.querySelector('.m-tbm-row[data-sid="' + sid + '"]'); + if (!row) return; + document.querySelectorAll('.m-tbm-row.expanded').forEach(function(el) { + if (el !== row) el.classList.remove('expanded'); + }); + row.classList.toggle('expanded'); + }; + + window.loadMore = function() { + loadedDays += 7; + loadData(); + }; + + // ─── 세부 편집 바텀시트 ─── + + window.openDetailEditSheet = async function(sid) { + if (isBusy('detailEdit')) return; + setBusy('detailEdit'); + showLoading('불러오는 중...'); + deSessionId = sid; + deSelected = {}; + try { + var API = window.TbmAPI; + var results = await Promise.all([ + API.getSession(sid).catch(function() { return null; }), + API.getTeamMembers(sid).catch(function() { return []; }), + API.loadTasks().catch(function() { return []; }), + API.loadWorkplaceCategories().catch(function() { return []; }), + API.loadActiveWorkplacesList().catch(function() { return []; }) + ]); + + deSession = results[0]; + deMembers = results[1]; + deTasks = results[2] || window.TbmState.allTasks || []; + deWpCats = results[3] || window.TbmState.allWorkplaceCategories || []; + var allWorkplaces = results[4]; + + if (!deSession) { window.showToast('TBM 정보를 불러올 수 없습니다.', 'error'); return; } + if (deMembers.length === 0) { window.showToast('팀원이 없습니다.', 'error'); return; } + + // work_type 필터 + var workTypeId = deSession.work_type_id || (deMembers[0] && deMembers[0].work_type_id); + if (workTypeId) { + deTasks = deTasks.filter(function(t) { return t.work_type_id == workTypeId; }); + } + + // 작업장소 맵 (category_id 기준) + deWpMap = {}; + allWorkplaces.forEach(function(wp) { + var catId = wp.category_id || 0; + if (!deWpMap[catId]) deWpMap[catId] = []; + deWpMap[catId].push(wp); + }); + + renderDetailEditSheet(); + document.getElementById('deSelectAll').checked = false; + updateGroupBar(); + document.getElementById('detailEditOverlay').style.display = 'block'; + document.getElementById('detailEditSheet').style.display = 'block'; + } catch(e) { + console.error('세부 편집 로드 오류:', e); + window.showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error'); + } finally { + hideLoading(); + clearBusy('detailEdit'); + } + }; + + function renderDetailEditSheet() { + var html = ''; + deMembers.forEach(function(m, i) { + var hasBoth = m.task_id && m.workplace_id; + var cardClass = hasBoth ? 'filled' : 'unfilled'; + var statusHtml = hasBoth + ? '입력완료' + : '미입력'; + + // work_hours 표시 + var workHoursTag = ''; + if (m.work_hours !== null && m.work_hours !== undefined) { + workHoursTag = '' + parseFloat(m.work_hours) + 'h'; + } + // 분할 항목이면 프로젝트명 표시 + var projectTag = ''; + if (m.split_seq > 0 && m.project_name) { + projectTag = '' + esc(m.project_name) + ''; + } else if (m.project_name && m.project_id !== deSession.project_id) { + projectTag = '' + esc(m.project_name) + ''; + } + + var taskOptions = ''; + deTasks.forEach(function(t) { + var sel = (m.task_id && m.task_id == t.task_id) ? ' selected' : ''; + taskOptions += ''; + }); + + var currentCatId = m.workplace_category_id || ''; + var catOptions = ''; + deWpCats.forEach(function(c) { + var sel = (currentCatId && currentCatId == c.category_id) ? ' selected' : ''; + catOptions += ''; + }); + + var wpOptions = ''; + if (currentCatId && deWpMap[currentCatId]) { + deWpMap[currentCatId].forEach(function(wp) { + var sel = (m.workplace_id && m.workplace_id == wp.workplace_id) ? ' selected' : ''; + wpOptions += ''; + }); + } + + html += '
' + + '
' + + '' + + '' + esc(m.worker_name) + ' ' + + '' + esc(m.job_type || '') + '' + + workHoursTag + + projectTag + + statusHtml + + '' + + '
' + + '
' + + '
' + + '작업' + + '' + + '
' + + '
' + + '장소' + + '' + + '' + + '
' + + '
' + + '
'; + }); + document.getElementById('deWorkerList').innerHTML = html; + } + + window.updateCardStatus = function(idx) { + var card = document.getElementById('de_card_' + idx); + var taskVal = document.getElementById('de_task_' + idx).value; + var wpVal = document.getElementById('de_wp_' + idx).value; + var statusEl = card.querySelector('.de-worker-status'); + if (taskVal && wpVal) { + card.className = 'de-worker-card filled'; + statusEl.className = 'de-worker-status ok'; + statusEl.textContent = '입력완료'; + } else { + card.className = 'de-worker-card unfilled'; + statusEl.className = 'de-worker-status missing'; + statusEl.textContent = '미입력'; + } + }; + + window.onDeWpCatChange = function(idx) { + var catId = document.getElementById('de_wpcat_' + idx).value; + var wpSel = document.getElementById('de_wp_' + idx); + wpSel.innerHTML = ''; + if (catId && deWpMap[catId]) { + deWpMap[catId].forEach(function(wp) { + wpSel.innerHTML += ''; + }); + } + updateCardStatus(idx); + }; + + // ─── 그룹 선택 ─── + + window.onWorkerCheck = function(idx) { + deSelected[idx] = document.getElementById('de_check_' + idx).checked; + var allChecked = true; + for (var i = 0; i < deMembers.length; i++) { + if (!deSelected[i]) { allChecked = false; break; } + } + document.getElementById('deSelectAll').checked = allChecked; + updateGroupBar(); + }; + + window.toggleSelectAll = function() { + var checked = document.getElementById('deSelectAll').checked; + for (var i = 0; i < deMembers.length; i++) { + deSelected[i] = checked; + document.getElementById('de_check_' + i).checked = checked; + } + updateGroupBar(); + }; + + function getSelectedIndices() { + var arr = []; + for (var i = 0; i < deMembers.length; i++) { + if (deSelected[i]) arr.push(i); + } + return arr; + } + + function updateGroupBar() { + var indices = getSelectedIndices(); + var bar = document.getElementById('deGroupBar'); + var countEl = document.getElementById('deSelectedCount'); + var labelEl = document.getElementById('deGroupLabel'); + if (indices.length > 0) { + bar.className = 'de-group-bar visible'; + labelEl.textContent = indices.length + '명 선택'; + countEl.textContent = indices.length + '명'; + } else { + bar.className = 'de-group-bar'; + countEl.textContent = ''; + } + } + + // ─── 피커 (작업/장소 선택 팝업) ─── + + window.openPicker = function(mode) { + var indices = getSelectedIndices(); + if (indices.length === 0) { + window.showToast('작업자를 먼저 선택하세요.', 'error'); + return; + } + pickerMode = mode; + pickerWpStep = 'category'; + pickerSelectedCatId = null; + + if (mode === 'task') { + renderTaskPicker(); + } else { + renderWorkplaceCatPicker(); + } + document.getElementById('pickerOverlay').style.display = 'block'; + document.getElementById('pickerSheet').style.display = 'block'; + }; + + window.closePicker = function() { + document.getElementById('pickerOverlay').style.display = 'none'; + document.getElementById('pickerSheet').style.display = 'none'; + }; + + function renderTaskPicker() { + document.getElementById('pickerTitle').textContent = '작업 선택'; + var listEl = document.getElementById('pickerList'); + var html = ''; + deTasks.forEach(function(t) { + html += '
' + + esc(t.task_name) + + '
'; + }); + if (deTasks.length === 0) { + html = '
등록된 작업이 없습니다
'; + } + listEl.innerHTML = html; + // 새 작업 추가 영역 + var addRow = document.getElementById('pickerAddRow'); + addRow.style.display = 'flex'; + document.getElementById('pickerAddInput').placeholder = '새 작업명 입력...'; + document.getElementById('pickerAddInput').value = ''; + document.getElementById('pickerAddBtn').onclick = function() { addNewTask(); }; + } + + function renderWorkplaceCatPicker() { + pickerWpStep = 'category'; + document.getElementById('pickerTitle').textContent = '장소 분류 선택'; + var listEl = document.getElementById('pickerList'); + var html = ''; + deWpCats.forEach(function(c) { + var count = deWpMap[c.category_id] ? deWpMap[c.category_id].length : 0; + html += '
' + + esc(c.category_name) + + '' + count + '개 장소' + + '
'; + }); + if (deWpCats.length === 0) { + html = '
등록된 분류가 없습니다
'; + } + listEl.innerHTML = html; + document.getElementById('pickerAddRow').style.display = 'none'; + } + + function renderWorkplacePicker(catId) { + pickerWpStep = 'place'; + pickerSelectedCatId = catId; + var catName = ''; + deWpCats.forEach(function(c) { if (c.category_id == catId) catName = c.category_name; }); + document.getElementById('pickerTitle').textContent = esc(catName) + ' - 장소 선택'; + var listEl = document.getElementById('pickerList'); + var workplaces = deWpMap[catId] || []; + var html = '
← 분류 다시 선택
'; + workplaces.forEach(function(wp) { + html += '
' + + esc(wp.workplace_name) + + '
'; + }); + if (workplaces.length === 0) { + html += '
등록된 장소가 없습니다
'; + } + listEl.innerHTML = html; + document.getElementById('pickerAddRow').style.display = 'none'; + } + + window.pickTask = function(taskId) { + var indices = getSelectedIndices(); + indices.forEach(function(i) { + document.getElementById('de_task_' + i).value = taskId; + updateCardStatus(i); + }); + closePicker(); + window.showToast(indices.length + '명에게 작업 적용', 'success'); + }; + + window.pickWpCategory = function(catId) { + renderWorkplacePicker(catId); + }; + + window.pickWorkplace = function(catId, wpId) { + var indices = getSelectedIndices(); + indices.forEach(function(i) { + // 분류 설정 + document.getElementById('de_wpcat_' + i).value = catId; + // 장소 옵션 갱신 + var wpSel = document.getElementById('de_wp_' + i); + wpSel.innerHTML = ''; + if (deWpMap[catId]) { + deWpMap[catId].forEach(function(wp) { + wpSel.innerHTML += ''; + }); + } + wpSel.value = wpId; + updateCardStatus(i); + }); + closePicker(); + window.showToast(indices.length + '명에게 장소 적용', 'success'); + }; + + // ─── 새 작업/공정 추가 ─── + + async function addNewTask() { + var name = document.getElementById('pickerAddInput').value.trim(); + if (!name) { window.showToast('작업명을 입력하세요.', 'error'); return; } + + var workTypeId = deSession.work_type_id || (deMembers[0] && deMembers[0].work_type_id) || null; + try { + var res = await window.TbmAPI.createTask({ + task_name: name, + work_type_id: workTypeId + }); + if (res && res.success) { + var newId = res.data.task_id; + // deTasks에 추가 + deTasks.push({ task_id: newId, task_name: name, work_type_id: workTypeId }); + // 모든 작업자 드롭다운 갱신 + for (var i = 0; i < deMembers.length; i++) { + var sel = document.getElementById('de_task_' + i); + var opt = document.createElement('option'); + opt.value = newId; + opt.textContent = name; + sel.appendChild(opt); + } + // 피커 다시 렌더링 + renderTaskPicker(); + window.showToast('작업 "' + name + '" 추가됨', 'success'); + } else { + window.showToast('작업 추가 실패', 'error'); + } + } catch(e) { + console.error(e); + window.showToast('오류가 발생했습니다.', 'error'); + } + } + + window.closeDetailEditSheet = function() { + document.getElementById('detailEditOverlay').style.display = 'none'; + document.getElementById('detailEditSheet').style.display = 'none'; + clearBusy('detailEdit'); + }; + + // 저장 (부분 입력도 허용) + window.saveDetailEdit = async function() { + var members = []; + for (var i = 0; i < deMembers.length; i++) { + var m = deMembers[i]; + var taskId = document.getElementById('de_task_' + i).value || null; + var wpCatId = document.getElementById('de_wpcat_' + i).value || null; + var wpId = document.getElementById('de_wp_' + i).value || null; + + members.push({ + worker_id: m.worker_id, + project_id: m.project_id || deSession.project_id || null, + work_type_id: m.work_type_id || deSession.work_type_id || null, + task_id: taskId ? parseInt(taskId) : null, + workplace_category_id: wpCatId ? parseInt(wpCatId) : null, + workplace_id: wpId ? parseInt(wpId) : null, + work_detail: m.work_detail || null + }); + } + + var btn = document.getElementById('deSaveBtn'); + btn.disabled = true; + btn.textContent = '저장 중...'; + + try { + await window.TbmAPI.clearTeamMembers(deSessionId); + var res = await window.TbmAPI.addTeamMembers(deSessionId, members); + if (res && res.success) { + closeDetailEditSheet(); + window.showToast('세부 내역이 저장되었습니다.', 'success'); + await loadData(); + } else { + window.showToast('저장에 실패했습니다.', 'error'); + } + } catch(e) { + console.error('세부 편집 저장 오류:', e); + window.showToast('오류가 발생했습니다.', 'error'); + } finally { + btn.disabled = false; + btn.textContent = '저장'; + } + }; + + // 완료 (미입력 있으면 차단) + window.completeFromDetailSheet = function() { + var incomplete = []; + for (var i = 0; i < deMembers.length; i++) { + var taskVal = document.getElementById('de_task_' + i).value; + var wpVal = document.getElementById('de_wp_' + i).value; + if (!taskVal || !wpVal) { + incomplete.push(deMembers[i].worker_name); + } + } + if (incomplete.length > 0) { + window.showToast('미입력: ' + incomplete.join(', '), 'error'); + return; + } + var sid = deSessionId; + saveDetailEdit().then(function() { + window.completeTbm(sid); + }); + }; + + window.deleteFromDetailSheet = function() { + var sid = deSessionId; + closeDetailEditSheet(); + window.deleteTbm(sid); + }; + + // ─── TBM 완료 바텀시트 ─── + + var completeSessionId = null; + var completeTeamMembers = []; + + window.completeTbm = async function(sid) { + if (isBusy('complete')) return; + setBusy('complete'); + showLoading('확인 중...'); + completeSessionId = sid; + try { + completeTeamMembers = await window.TbmAPI.getTeamMembers(sid).catch(function() { return []; }); + if (completeTeamMembers.length === 0) { + window.showToast('팀원이 없습니다.', 'error'); + return; + } + + // 세부 미입력 작업자 체크 + var incomplete = completeTeamMembers.filter(function(m) { return !m.task_id || !m.workplace_id; }); + if (incomplete.length > 0) { + var names = incomplete.map(function(m) { return m.worker_name; }).join(', '); + window.showToast('세부 미입력: ' + names + ' - 세부 내역을 먼저 입력하세요.', 'error'); + return; + } + + renderCompleteSheet(); + document.getElementById('completeOverlay').style.display = 'block'; + document.getElementById('completeSheet').style.display = 'block'; + } catch(e) { + console.error(e); + window.showToast('팀원 조회 중 오류가 발생했습니다.', 'error'); + } finally { + hideLoading(); + clearBusy('complete'); + } + }; + + function renderCompleteSheet() { + var html = ''; + completeTeamMembers.forEach(function(m, i) { + html += '
' + + '
' + + '
' + esc(m.worker_name) + ' (' + esc(m.job_type || '') + ')
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + }); + document.getElementById('completeWorkerList').innerHTML = html; + } + + window.onAttTypeChange = function(idx) { + var sel = document.getElementById('att_type_' + idx); + var inp = document.getElementById('att_hours_' + idx); + var hint = document.getElementById('att_hint_' + idx); + var val = sel.value; + if (val === 'overtime') { + inp.style.display = 'block'; inp.placeholder = '+시간'; inp.value = ''; hint.textContent = ''; + } else if (val === 'early') { + inp.style.display = 'block'; inp.placeholder = '근무시간'; inp.value = ''; hint.textContent = ''; + } else { + inp.style.display = 'none'; inp.value = ''; + var labels = { regular:'8h', annual:'연차 자동처리', half:'4h', quarter:'6h' }; + hint.textContent = labels[val] || ''; + } + }; + + window.closeCompleteSheet = function() { + document.getElementById('completeOverlay').style.display = 'none'; + document.getElementById('completeSheet').style.display = 'none'; + }; + + window.submitCompleteSheet = async function() { + var attendanceData = []; + for (var i = 0; i < completeTeamMembers.length; i++) { + var type = document.getElementById('att_type_' + i).value; + var hoursVal = document.getElementById('att_hours_' + i).value; + var hours = hoursVal ? parseFloat(hoursVal) : null; + if (type === 'overtime' && (!hours || hours <= 0)) { + window.showToast(esc(completeTeamMembers[i].worker_name) + '의 추가 시간을 입력해주세요.', 'error'); return; + } + if (type === 'early' && (!hours || hours <= 0)) { + window.showToast(esc(completeTeamMembers[i].worker_name) + '의 근무 시간을 입력해주세요.', 'error'); return; + } + attendanceData.push({ worker_id: completeTeamMembers[i].worker_id, attendance_type: type, attendance_hours: hours }); + } + + var btn = document.getElementById('completeSheetBtn'); + btn.disabled = true; btn.textContent = '처리 중...'; + + try { + var now = new Date(); + var endTime = String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0'); + var res = await window.apiCall('/tbm/sessions/' + completeSessionId + '/complete', 'POST', { + end_time: endTime, attendance_data: attendanceData + }); + if (res && res.success) { + closeCompleteSheet(); + window.showToast('TBM이 완료 처리되었습니다.', 'success'); + await loadData(); + } else { + window.showToast('완료 처리에 실패했습니다.', 'error'); + } + } catch(e) { + console.error(e); + window.showToast('오류가 발생했습니다.', 'error'); + } finally { + btn.disabled = false; btn.textContent = '완료 처리'; + } + }; + + window.deleteTbm = async function(sid) { + if (!confirm('이 TBM을 삭제하시겠습니까?')) return; + try { + var res = await window.TbmAPI.deleteSession(sid); + if (res && res.success) { + window.showToast('TBM이 삭제되었습니다.', 'success'); + await loadData(); + } else { + window.showToast('삭제에 실패했습니다.', 'error'); + } + } catch(e) { + window.showToast('오류가 발생했습니다.', 'error'); + } + }; + + // ─── 분할 기능 ─── + + var splitMemberIdx = null; + var splitOption = 'keep'; // 'keep' | 'send' + var splitTargetSessionId = null; + var cachedProjects = null; + var cachedWorkTypes = null; + + // 프로젝트/공정 목록 로딩 (캐시) + async function loadProjectsAndWorkTypes() { + if (!cachedProjects) { + try { + cachedProjects = await window.TbmAPI.loadProjects() || []; + } catch(e) { cachedProjects = []; } + } + if (!cachedWorkTypes) { + try { + cachedWorkTypes = await window.TbmAPI.loadWorkTypes() || []; + } catch(e) { cachedWorkTypes = []; } + } + } + + function populateProjectSelect(selectId, currentProjectId) { + var sel = document.getElementById(selectId); + var html = ''; + (cachedProjects || []).forEach(function(p) { + html += ''; + }); + sel.innerHTML = html; + } + + function populateWorkTypeSelect(selectId, currentWorkTypeId) { + var sel = document.getElementById(selectId); + var html = ''; + (cachedWorkTypes || []).forEach(function(wt) { + html += ''; + }); + sel.innerHTML = html; + } + + window.openSplitSheet = async function(memberIdx) { + if (isBusy('split')) return; + setBusy('split'); + showLoading('불러오는 중...'); + splitMemberIdx = memberIdx; + splitOption = 'keep'; + splitTargetSessionId = null; + var m = deMembers[memberIdx]; + var currentHours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours); + + document.getElementById('splitTitle').textContent = esc(m.worker_name) + ' 작업 분할'; + document.getElementById('splitSubtitle').textContent = '현재 ' + currentHours + 'h 배정'; + document.getElementById('splitHours').value = ''; + document.getElementById('splitHours').max = currentHours - 0.5; + document.getElementById('splitRemainder').textContent = ''; + document.getElementById('splitOptKeep').className = 'split-radio-item active'; + document.getElementById('splitOptSend').className = 'split-radio-item'; + document.getElementById('splitSessionPicker').style.display = 'none'; + + // 시간 입력 시 나머지 자동 계산 + document.getElementById('splitHours').oninput = function() { + var val = parseFloat(this.value); + if (val && val > 0 && val < currentHours) { + document.getElementById('splitRemainder').textContent = '나머지: ' + (currentHours - val) + 'h'; + } else { + document.getElementById('splitRemainder').textContent = ''; + } + }; + + // 프로젝트/공정 목록 로드 + 드롭다운 채우기 + await loadProjectsAndWorkTypes(); + populateProjectSelect('splitProjectId', null); + populateWorkTypeSelect('splitWorkTypeId', null); + + // 다른 세션 목록 로드 (당일) + loadSplitSessionList(); + + document.getElementById('splitOverlay').style.display = 'block'; + document.getElementById('splitSheet').style.display = 'block'; + hideLoading(); + clearBusy('split'); + }; + + async function loadSplitSessionList() { + var todayStr = getTodayStr(); + try { + var sessions = await window.TbmAPI.fetchSessionsByDate(todayStr); + if (sessions && sessions.length > 0) { + var html = ''; + sessions.forEach(function(s) { + if (s.session_id === deSessionId) return; // 현재 세션 제외 + if (s.status !== 'draft') return; // draft만 + var leaderName = s.leader_name || s.created_by_name || '미지정'; + var workType = s.work_type_name || ''; + html += '
' + + esc(leaderName) + (workType ? ' - ' + esc(workType) : '') + + ' (' + (parseInt(s.team_member_count)||0) + '명)' + + '
'; + }); + if (!html) html = '
다른 TBM이 없습니다
'; + document.getElementById('splitSessionList').innerHTML = html; + } + } catch(e) { + console.error(e); + } + } + + window.setSplitOption = function(opt) { + splitOption = opt; + splitTargetSessionId = null; + document.getElementById('splitOptKeep').className = 'split-radio-item' + (opt === 'keep' ? ' active' : ''); + document.getElementById('splitOptSend').className = 'split-radio-item' + (opt === 'send' ? ' active' : ''); + document.getElementById('splitSessionPicker').style.display = opt === 'send' ? 'block' : 'none'; + // 세션 선택 초기화 + document.querySelectorAll('.split-session-item').forEach(function(el) { el.classList.remove('active'); }); + }; + + window.selectSplitSession = function(sid) { + splitTargetSessionId = sid; + document.querySelectorAll('.split-session-item').forEach(function(el) { + el.classList.toggle('active', parseInt(el.dataset.sid) === sid); + }); + }; + + window.closeSplitSheet = function() { + document.getElementById('splitOverlay').style.display = 'none'; + document.getElementById('splitSheet').style.display = 'none'; + clearBusy('split'); + }; + + window.saveSplit = async function() { + var m = deMembers[splitMemberIdx]; + var currentHours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours); + var splitHours = parseFloat(document.getElementById('splitHours').value); + + if (!splitHours || splitHours <= 0 || splitHours >= currentHours) { + window.showToast('올바른 시간을 입력하세요 (0 < 시간 < ' + currentHours + ')', 'error'); + return; + } + + var btn = document.getElementById('splitSaveBtn'); + btn.disabled = true; + btn.textContent = '처리 중...'; + + try { + // 프로젝트/공정 선택값 + var selProjectId = document.getElementById('splitProjectId').value; + var selWorkTypeId = document.getElementById('splitWorkTypeId').value; + + if (splitOption === 'keep') { + var remainHoursKeep = currentHours - splitHours; + var newProjectId = selProjectId ? parseInt(selProjectId) : (m.project_id || null); + var newWorkTypeId = selWorkTypeId ? parseInt(selWorkTypeId) : (m.work_type_id || null); + + // 1) 기존 항목: 시간만 줄이기 (프로젝트/공정 유지) + await window.TbmAPI.updateTeamMember(deSessionId, { + worker_id: m.worker_id, + project_id: m.project_id || null, + work_type_id: m.work_type_id || null, + task_id: m.task_id || null, + workplace_category_id: m.workplace_category_id || null, + workplace_id: m.workplace_id || null, + work_detail: m.work_detail || null, + is_present: true, + work_hours: splitHours + }); + + // 2) 나머지 시간으로 새 항목 추가 (프로젝트/공정 변경 가능) + await window.TbmAPI.splitAssignment(deSessionId, { + worker_id: m.worker_id, + work_hours: remainHoursKeep, + project_id: newProjectId, + work_type_id: newWorkTypeId + }); + + closeSplitSheet(); + // 세부 편집 데이터 다시 로드 + deMembers = await window.TbmAPI.getTeamMembers(deSessionId).catch(function() { return deMembers; }); + renderDetailEditSheet(); + window.showToast('분할 완료: ' + splitHours + 'h + ' + remainHoursKeep + 'h', 'success'); + } else if (splitOption === 'send') { + if (!splitTargetSessionId) { + window.showToast('이동할 TBM을 선택하세요.', 'error'); + btn.disabled = false; + btn.textContent = '분할 저장'; + return; + } + var remainHours = currentHours - splitHours; + var destProjectId = selProjectId ? parseInt(selProjectId) : (m.project_id || null); + var destWorkTypeId = selWorkTypeId ? parseInt(selWorkTypeId) : (m.work_type_id || null); + // transfer API 호출 + var res = await window.TbmAPI.transfer({ + transfer_type: 'send', + worker_id: m.worker_id, + source_session_id: deSessionId, + dest_session_id: splitTargetSessionId, + hours: remainHours, + project_id: destProjectId, + work_type_id: destWorkTypeId + }); + + if (res && res.success) { + closeSplitSheet(); + closeDetailEditSheet(); + window.showToast('이동 완료' + (res.data && res.data.warning ? ' (' + res.data.warning + ')' : ''), 'success'); + await loadData(); + } else { + window.showToast(res?.message || '이동 실패', 'error'); + } + } + } catch(e) { + console.error('분할 오류:', e); + window.showToast('오류가 발생했습니다.', 'error'); + } finally { + btn.disabled = false; + btn.textContent = '분할 저장'; + } + }; + + // ─── 빼오기 기능 ─── + + var pullSessionId = null; + var pullMembers = []; + var pullWorker = null; // 빼오기 대상 + var myDraftSession = null; // 내 draft TBM + + window.openPullSheet = async function(sid) { + if (isBusy('pull')) return; + setBusy('pull'); + showLoading('불러오는 중...'); + pullSessionId = sid; + try { + pullMembers = await window.TbmAPI.getTeamMembers(sid).catch(function() { return []; }); + + var session = await window.TbmAPI.getSession(sid).catch(function() { return null; }); + var leaderName = session ? (session.leader_name || session.created_by_name || '미지정') : '미지정'; + + document.getElementById('pullTitle').textContent = esc(leaderName) + ' 반장 팀'; + document.getElementById('pullSubtitle').textContent = pullMembers.length + '명 배정'; + + // 내 draft TBM 확인 + myDraftSession = todaySessions.find(function(s) { + return isMySession(s) && s.status === 'draft'; + }); + + var html = ''; + pullMembers.forEach(function(m) { + var hours = m.work_hours === null || m.work_hours === undefined ? 8 : parseFloat(m.work_hours); + var hoursText = hours + 'h'; + + var btnHtml = ''; + if (!myDraftSession) { + btnHtml = ''; + } else { + btnHtml = ''; + } + + html += '
' + + '
' + + '
' + esc(m.worker_name) + ' ' + hoursText + '
' + + '
' + esc(m.job_type || '') + '
' + + '
' + + btnHtml + + '
'; + }); + + if (pullMembers.length === 0) { + html = '
팀원이 없습니다
'; + } + + document.getElementById('pullMemberList').innerHTML = html; + document.getElementById('pullOverlay').style.display = 'block'; + document.getElementById('pullSheet').style.display = 'block'; + } catch(e) { + console.error('빼오기 로드 오류:', e); + window.showToast('데이터를 불러올 수 없습니다.', 'error'); + } finally { + hideLoading(); + clearBusy('pull'); + } + }; + + window.closePullSheet = function() { + document.getElementById('pullOverlay').style.display = 'none'; + document.getElementById('pullSheet').style.display = 'none'; + clearBusy('pull'); + }; + + window.startPull = async function(workerId, workerName, maxHours) { + pullWorker = { worker_id: workerId, worker_name: workerName, max_hours: maxHours }; + document.getElementById('pullHoursTitle').textContent = esc(workerName) + ' 빼오기'; + document.getElementById('pullHoursSubtitle').textContent = '최대 ' + maxHours + 'h 가능'; + document.getElementById('pullHoursInput').value = maxHours; + document.getElementById('pullHoursInput').max = maxHours; + + // 프로젝트/공정 드롭다운 채우기 + await loadProjectsAndWorkTypes(); + var myProject = myDraftSession ? myDraftSession.project_id : null; + var myWorkType = myDraftSession ? myDraftSession.work_type_id : null; + populateProjectSelect('pullProjectId', myProject); + populateWorkTypeSelect('pullWorkTypeId', myWorkType); + + document.getElementById('pullHoursOverlay').style.display = 'block'; + document.getElementById('pullHoursSheet').style.display = 'block'; + }; + + window.closePullHoursModal = function() { + document.getElementById('pullHoursOverlay').style.display = 'none'; + document.getElementById('pullHoursSheet').style.display = 'none'; + }; + + window.confirmPull = async function() { + var hours = parseFloat(document.getElementById('pullHoursInput').value); + if (!hours || hours <= 0 || hours > pullWorker.max_hours) { + window.showToast('올바른 시간을 입력하세요 (0 < 시간 <= ' + pullWorker.max_hours + ')', 'error'); + return; + } + + var btn = document.getElementById('pullHoursSaveBtn'); + btn.disabled = true; + btn.textContent = '처리 중...'; + + try { + var pullProjectId = document.getElementById('pullProjectId').value || null; + var pullWorkTypeId = document.getElementById('pullWorkTypeId').value || null; + var res = await window.TbmAPI.transfer({ + transfer_type: 'pull', + worker_id: pullWorker.worker_id, + source_session_id: pullSessionId, + dest_session_id: myDraftSession.session_id, + hours: hours, + project_id: pullProjectId ? parseInt(pullProjectId) : null, + work_type_id: pullWorkTypeId ? parseInt(pullWorkTypeId) : null + }); + + if (res && res.success) { + closePullHoursModal(); + closePullSheet(); + window.showToast(esc(pullWorker.worker_name) + ' ' + hours + 'h 빼오기 완료' + + (res.data && res.data.warning ? ' (' + res.data.warning + ')' : ''), 'success'); + await loadData(); + } else { + window.showToast(res?.message || '빼오기 실패', 'error'); + } + } catch(e) { + console.error('빼오기 오류:', e); + window.showToast('오류가 발생했습니다.', 'error'); + } finally { + btn.disabled = false; + btn.textContent = '빼오기 실행'; + } + }; + + // ─── 인계 바텀시트 ─── + + var handoverSessionId = null; + var handoverSession = null; + + window.handoverFromDetailSheet = function() { + var sid = deSessionId; + closeDetailEditSheet(); + openHandoverSheet(sid); + }; + + async function openHandoverSheet(sid) { + if (isBusy('handover')) return; + setBusy('handover'); + showLoading('인계 정보 불러오는 중...'); + handoverSessionId = sid; + + try { + var API = window.TbmAPI; + var results = await Promise.all([ + API.getSession(sid).catch(function() { return null; }), + API.getTeamMembers(sid).catch(function() { return []; }), + API.loadWorkers().catch(function() { return []; }) + ]); + + handoverSession = results[0]; + var team = results[1]; + var workers = results[2]; + + if (!handoverSession) { + window.showToast('세션 정보를 불러올 수 없습니다.', 'error'); + return; + } + + // 현재 세션 리더를 제외한 반장/그룹장 목록 + var leaders = workers.filter(function(w) { + return (w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin') && + w.worker_id !== handoverSession.leader_id; + }); + + var leaderSelect = document.getElementById('handoverLeaderId'); + leaderSelect.innerHTML = '' + + leaders.map(function(w) { + return ''; + }).join(''); + + // 인계할 팀원 체크리스트 + var listEl = document.getElementById('handoverWorkerList'); + if (team.length === 0) { + listEl.innerHTML = '

팀원이 없습니다.

'; + } else { + listEl.innerHTML = team.map(function(m) { + return ''; + }).join(''); + } + + document.getElementById('handoverNotes').value = ''; + + document.getElementById('handoverOverlay').style.display = 'block'; + document.getElementById('handoverSheet').style.display = 'block'; + } catch(e) { + console.error('인계 시트 열기 오류:', e); + window.showToast('인계 정보를 불러오는 중 오류가 발생했습니다.', 'error'); + } finally { + hideLoading(); + clearBusy('handover'); + } + } + window.openHandoverSheet = openHandoverSheet; + + window.closeHandoverSheet = function() { + document.getElementById('handoverOverlay').style.display = 'none'; + document.getElementById('handoverSheet').style.display = 'none'; + }; + + window.submitHandover = async function() { + var toLeaderId = parseInt(document.getElementById('handoverLeaderId').value); + var notes = document.getElementById('handoverNotes').value; + + if (!toLeaderId) { + window.showToast('인계 대상 반장을 선택해주세요.', 'error'); + return; + } + + var workerIds = []; + document.querySelectorAll('.handover-worker-cb:checked').forEach(function(cb) { + workerIds.push(parseInt(cb.value)); + }); + + if (workerIds.length === 0) { + window.showToast('인계할 팀원을 최소 1명 선택해주세요.', 'error'); + return; + } + + var btn = document.querySelector('#handoverSheet .split-btn'); + btn.disabled = true; + btn.textContent = '처리 중...'; + + try { + var today = getTodayStr(); + var now = new Date().toTimeString().slice(0, 5); + + var handoverData = { + session_id: handoverSessionId, + from_leader_id: handoverSession.leader_id, + to_leader_id: toLeaderId, + handover_date: today, + handover_time: now, + reason: '모바일 인계', + handover_notes: notes, + worker_ids: workerIds + }; + + var res = await window.TbmAPI.saveHandover(handoverData); + if (res && res.success) { + window.closeHandoverSheet(); + window.showToast('작업 인계가 요청되었습니다.', 'success'); + await loadData(); + } else { + window.showToast(res?.message || '인계 요청에 실패했습니다.', 'error'); + } + } catch(e) { + console.error('인계 저장 오류:', e); + window.showToast('인계 중 오류가 발생했습니다.', 'error'); + } finally { + btn.disabled = false; + btn.textContent = '인계 요청'; + } + }; +})(); diff --git a/system1-factory/web/js/tbm/index.js b/system1-factory/web/js/tbm/index.js deleted file mode 100644 index a295074..0000000 --- a/system1-factory/web/js/tbm/index.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * TBM - Module Loader - * TBM 모듈을 초기화하고 연결하는 메인 진입점 - * - * 로드 순서: - * 1. state.js - 전역 상태 관리 - * 2. utils.js - 유틸리티 함수 - * 3. api.js - API 클라이언트 - * 4. index.js - 이 파일 (메인 컨트롤러) - */ - -class TbmController { - constructor() { - this.state = window.TbmState; - this.api = window.TbmAPI; - this.utils = window.TbmUtils; - this.initialized = false; - - console.log('[TbmController] 생성'); - } - - /** - * 초기화 - */ - async init() { - if (this.initialized) { - console.log('[TbmController] 이미 초기화됨'); - return; - } - - console.log('🛠️ TBM 관리 페이지 초기화'); - - // API 함수가 로드될 때까지 대기 - let retryCount = 0; - while (!window.apiCall && retryCount < 50) { - await new Promise(resolve => setTimeout(resolve, 100)); - retryCount++; - } - - if (!window.apiCall) { - window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error'); - return; - } - - // 오늘 날짜 설정 (서울 시간대 기준) - const today = this.utils.getTodayKST(); - const tbmDateEl = document.getElementById('tbmDate'); - const sessionDateEl = document.getElementById('sessionDate'); - if (tbmDateEl) tbmDateEl.value = today; - if (sessionDateEl) sessionDateEl.value = today; - - // 이벤트 리스너 설정 - this.setupEventListeners(); - - // 초기 데이터 로드 - await this.api.loadInitialData(); - await this.api.loadTodayOnlyTbm(); - - // 렌더링 - this.displayTodayTbmSessions(); - - this.initialized = true; - console.log('[TbmController] 초기화 완료'); - } - - /** - * 이벤트 리스너 설정 - */ - setupEventListeners() { - // 탭 버튼들 - document.querySelectorAll('.tbm-tab-btn').forEach(btn => { - btn.addEventListener('click', () => { - const tabName = btn.dataset.tab; - if (tabName) this.switchTbmTab(tabName); - }); - }); - } - - /** - * 탭 전환 - */ - async switchTbmTab(tabName) { - this.state.setCurrentTab(tabName); - - // 탭 버튼 활성화 상태 변경 - document.querySelectorAll('.tbm-tab-btn').forEach(btn => { - if (btn.dataset.tab === tabName) { - btn.classList.add('active'); - } else { - btn.classList.remove('active'); - } - }); - - // 탭 컨텐츠 표시 변경 - document.querySelectorAll('.tbm-tab-content').forEach(content => { - content.classList.remove('active'); - }); - const tabContent = document.getElementById(`${tabName}-tab`); - if (tabContent) tabContent.classList.add('active'); - - // 탭에 따라 데이터 로드 - if (tabName === 'tbm-input') { - await this.api.loadTodayOnlyTbm(); - this.displayTodayTbmSessions(); - } else if (tabName === 'tbm-manage') { - await this.api.loadRecentTbmGroupedByDate(); - this.displayTbmGroupedByDate(); - this.updateViewModeIndicator(); - } - } - - /** - * 오늘의 TBM 세션 표시 - */ - displayTodayTbmSessions() { - const grid = document.getElementById('todayTbmGrid'); - const emptyState = document.getElementById('todayEmptyState'); - const todayTotalEl = document.getElementById('todayTotalSessions'); - const todayCompletedEl = document.getElementById('todayCompletedSessions'); - const todayActiveEl = document.getElementById('todayActiveSessions'); - - const sessions = this.state.todaySessions; - - if (sessions.length === 0) { - if (grid) grid.innerHTML = ''; - if (emptyState) emptyState.style.display = 'flex'; - if (todayTotalEl) todayTotalEl.textContent = '0'; - if (todayCompletedEl) todayCompletedEl.textContent = '0'; - if (todayActiveEl) todayActiveEl.textContent = '0'; - return; - } - - if (emptyState) emptyState.style.display = 'none'; - - const completedCount = sessions.filter(s => s.status === 'completed').length; - const activeCount = sessions.filter(s => s.status === 'draft').length; - - if (todayTotalEl) todayTotalEl.textContent = sessions.length; - if (todayCompletedEl) todayCompletedEl.textContent = completedCount; - if (todayActiveEl) todayActiveEl.textContent = activeCount; - - if (grid) { - grid.innerHTML = sessions.map(session => this.createSessionCard(session)).join(''); - } - } - - /** - * 날짜별 그룹으로 TBM 표시 - */ - displayTbmGroupedByDate() { - const container = document.getElementById('tbmDateGroupsContainer'); - const emptyState = document.getElementById('emptyState'); - const totalSessionsEl = document.getElementById('totalSessions'); - const completedSessionsEl = document.getElementById('completedSessions'); - - if (!container) return; - - const sortedDates = Object.keys(this.state.dateGroupedSessions).sort((a, b) => - new Date(b) - new Date(a) - ); - - if (sortedDates.length === 0 || this.state.allLoadedSessions.length === 0) { - container.innerHTML = ''; - if (emptyState) emptyState.style.display = 'flex'; - if (totalSessionsEl) totalSessionsEl.textContent = '0'; - if (completedSessionsEl) completedSessionsEl.textContent = '0'; - return; - } - - if (emptyState) emptyState.style.display = 'none'; - - // 통계 업데이트 - const completedCount = this.state.allLoadedSessions.filter(s => s.status === 'completed').length; - if (totalSessionsEl) totalSessionsEl.textContent = this.state.allLoadedSessions.length; - if (completedSessionsEl) completedSessionsEl.textContent = completedCount; - - // 날짜별 그룹 HTML 생성 - const today = this.utils.getTodayKST(); - const dayNames = ['일', '월', '화', '수', '목', '금', '토']; - - container.innerHTML = sortedDates.map(date => { - const sessions = this.state.dateGroupedSessions[date]; - const dateObj = new Date(date + 'T00:00:00'); - const dayName = dayNames[dateObj.getDay()]; - const isToday = date === today; - - const [year, month, day] = date.split('-'); - const displayDate = `${parseInt(month)}월 ${parseInt(day)}일`; - - return ` -
-
- - ${displayDate} - ${dayName}요일 - ${isToday ? '오늘' : ''} - ${sessions.length}건 -
-
-
- ${sessions.map(session => this.createSessionCard(session)).join('')} -
-
-
- `; - }).join(''); - } - - /** - * 뷰 모드 표시 업데이트 - */ - updateViewModeIndicator() { - const indicator = document.getElementById('viewModeIndicator'); - const text = document.getElementById('viewModeText'); - - if (indicator && text) { - if (this.state.isAdminUser()) { - indicator.style.display = 'none'; - } else { - indicator.style.display = 'inline-flex'; - text.textContent = '내 TBM'; - } - } - } - - /** - * TBM 세션 카드 생성 - */ - createSessionCard(session) { - const statusBadge = this.utils.getStatusBadge(session.status); - - const leaderName = session.leader_name || session.created_by_name || '작업 책임자'; - const leaderRole = session.leader_name - ? (session.leader_job_type || '작업자') - : '관리자'; - - return ` -
-
-
-
-

- ${leaderName} - ${leaderRole} -

-
- ${statusBadge} -
-
- 📅 - ${this.utils.formatDate(session.session_date)} ${session.start_time ? '| ' + session.start_time : ''} -
-
- -
-
-
- 프로젝트 - ${session.project_name || '-'} -
-
- 공정 - ${session.work_type_name || '-'} -
-
- 작업장 - ${session.work_location || '-'} -
-
- 팀원 - ${session.team_member_count || 0}명 -
-
-
- - ${session.status === 'draft' ? ` - - ` : ''} -
- `; - } - - /** - * 디버그 - */ - debug() { - console.log('[TbmController] 상태 디버그:'); - this.state.debug(); - } -} - -// 전역 인스턴스 생성 -window.TbmController = new TbmController(); - -// 하위 호환성: 기존 전역 함수들 -window.switchTbmTab = (tabName) => window.TbmController.switchTbmTab(tabName); -window.displayTodayTbmSessions = () => window.TbmController.displayTodayTbmSessions(); -window.displayTbmGroupedByDate = () => window.TbmController.displayTbmGroupedByDate(); -window.displayTbmSessions = () => window.TbmController.displayTbmGroupedByDate(); -window.createSessionCard = (session) => window.TbmController.createSessionCard(session); -window.updateViewModeIndicator = () => window.TbmController.updateViewModeIndicator(); - -// 날짜 그룹 토글 -window.toggleDateGroup = function(date) { - const group = document.querySelector(`.tbm-date-group[data-date="${date}"]`); - if (group) { - group.classList.toggle('collapsed'); - } -}; - -// DOMContentLoaded 이벤트에서 초기화 -document.addEventListener('DOMContentLoaded', () => { - setTimeout(() => { - window.TbmController.init(); - }, 100); -}); - -console.log('[Module] tbm/index.js 로드 완료'); diff --git a/system1-factory/web/js/tbm/state.js b/system1-factory/web/js/tbm/state.js index e2d9a9d..925cc2f 100644 --- a/system1-factory/web/js/tbm/state.js +++ b/system1-factory/web/js/tbm/state.js @@ -1,10 +1,12 @@ /** * TBM - State Manager - * TBM 페이지의 전역 상태 관리 + * TBM 페이지의 전역 상태 관리 (BaseState 상속) */ -class TbmState { +class TbmState extends BaseState { constructor() { + super(); + // 세션 데이터 this.allSessions = []; this.todaySessions = []; @@ -48,56 +50,9 @@ class TbmState { this.mapImage = null; this.mapRegions = []; - // 리스너 - this.listeners = new Map(); - console.log('[TbmState] 초기화 완료'); } - /** - * 상태 업데이트 - */ - 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(`[TbmState] 리스너 오류 (${key}):`, error); - } - }); - } - - /** - * 현재 사용자 정보 가져오기 - */ - getUser() { - if (!this.currentUser) { - const userInfo = localStorage.getItem('sso_user'); - this.currentUser = userInfo ? JSON.parse(userInfo) : null; - } - return this.currentUser; - } - /** * Admin 여부 확인 */ @@ -135,7 +90,7 @@ class TbmState { */ createEmptyTaskLine() { return { - task_line_id: this.generateUUID(), + task_line_id: window.CommonUtils.generateUUID(), project_id: null, work_type_id: null, task_id: null, @@ -207,7 +162,7 @@ class TbmState { this.allLoadedSessions = []; sessions.forEach(session => { - const date = this.formatDate(session.session_date); + const date = window.CommonUtils.formatDate(session.session_date); if (!this.dateGroupedSessions[date]) { this.dateGroupedSessions[date] = []; } @@ -216,32 +171,6 @@ class TbmState { }); } - /** - * 날짜 포맷팅 - */ - formatDate(dateString) { - if (!dateString) return ''; - if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { - return dateString; - } - 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}`; - } - - /** - * 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/utils.js b/system1-factory/web/js/tbm/utils.js index c0ff8c4..ef1c90a 100644 --- a/system1-factory/web/js/tbm/utils.js +++ b/system1-factory/web/js/tbm/utils.js @@ -1,46 +1,23 @@ /** * TBM - Utilities - * TBM 관련 유틸리티 함수들 + * TBM 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임) */ class TbmUtils { constructor() { + this._common = window.CommonUtils; console.log('[TbmUtils] 초기화 완료'); } - /** - * 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환 - */ - getTodayKST() { - const now = new Date(); - const kstOffset = 9 * 60; - const utc = now.getTime() + (now.getTimezoneOffset() * 60000); - const kstTime = new Date(utc + (kstOffset * 60000)); + // --- CommonUtils 위임 --- + getTodayKST() { return this._common.getTodayKST(); } + formatDate(dateString) { return this._common.formatDate(dateString); } + getDayOfWeek(dateString) { return this._common.getDayOfWeek(dateString); } + isToday(dateString) { return this._common.isToday(dateString); } + generateUUID() { return this._common.generateUUID(); } + escapeHtml(text) { return this._common.escapeHtml(text); } - const year = kstTime.getFullYear(); - const month = String(kstTime.getMonth() + 1).padStart(2, '0'); - const day = String(kstTime.getDate()).padStart(2, '0'); - - return `${year}-${month}-${day}`; - } - - /** - * ISO 날짜 문자열을 YYYY-MM-DD 형식으로 변환 - */ - formatDate(dateString) { - if (!dateString) return ''; - - if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { - return dateString; - } - - 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}`; - } + // --- TBM 전용 --- /** * 날짜 표시용 포맷 (MM월 DD일) @@ -56,30 +33,11 @@ class TbmUtils { */ formatDateFull(dateString) { if (!dateString) return ''; - const dayNames = ['일', '월', '화', '수', '목', '금', '토']; const [year, month, day] = dateString.split('-'); - const dateObj = new Date(dateString); - const dayName = dayNames[dateObj.getDay()]; + const dayName = this._common.getDayOfWeek(dateString); return `${year}년 ${parseInt(month)}월 ${parseInt(day)}일 (${dayName})`; } - /** - * 요일 반환 - */ - getDayOfWeek(dateString) { - const dayNames = ['일', '월', '화', '수', '목', '금', '토']; - const dateObj = new Date(dateString + 'T00:00:00'); - return dayNames[dateObj.getDay()]; - } - - /** - * 오늘인지 확인 - */ - isToday(dateString) { - const today = this.getTodayKST(); - return this.formatDate(dateString) === today; - } - /** * 현재 시간을 HH:MM 형식으로 반환 */ @@ -87,40 +45,13 @@ class TbmUtils { return new Date().toTimeString().slice(0, 5); } - /** - * 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); - }); - } - - /** - * HTML 이스케이프 - */ - escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - /** * 날씨 조건명 반환 */ getWeatherConditionName(code) { const names = { - clear: '맑음', - rain: '비', - snow: '눈', - heat: '폭염', - cold: '한파', - wind: '강풍', - fog: '안개', - dust: '미세먼지' + clear: '맑음', rain: '비', snow: '눈', heat: '폭염', + cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지' }; return names[code] || code; } @@ -130,14 +61,8 @@ class TbmUtils { */ getWeatherIcon(code) { const icons = { - clear: '☀️', - rain: '🌧️', - snow: '❄️', - heat: '🔥', - cold: '🥶', - wind: '💨', - fog: '🌫️', - dust: '😷' + clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥', + cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷' }; return icons[code] || '🌤️'; } @@ -147,12 +72,9 @@ class TbmUtils { */ getCategoryName(category) { const names = { - 'PPE': '개인 보호 장비', - 'EQUIPMENT': '장비 점검', - 'ENVIRONMENT': '작업 환경', - 'EMERGENCY': '비상 대응', - 'WEATHER': '날씨', - 'TASK': '작업' + 'PPE': '개인 보호 장비', 'EQUIPMENT': '장비 점검', + 'ENVIRONMENT': '작업 환경', 'EMERGENCY': '비상 대응', + 'WEATHER': '날씨', 'TASK': '작업' }; return names[category] || category; } diff --git a/system1-factory/web/js/work-report-api.js b/system1-factory/web/js/work-report-api.js deleted file mode 100644 index 4adc18d..0000000 --- a/system1-factory/web/js/work-report-api.js +++ /dev/null @@ -1,51 +0,0 @@ -// /js/work-report-api.js -import { apiGet, apiPost } from './api-helper.js'; - -/** - * 작업 보고서 작성을 위해 필요한 초기 데이터(작업자, 프로젝트, 태스크)를 가져옵니다. - * Promise.all을 사용하여 병렬로 API를 호출합니다. - * @returns {Promise<{workers: Array, projects: Array, tasks: Array}>} - */ -export async function getInitialData() { - try { - const [allWorkers, projects, tasks] = await Promise.all([ - apiGet('/workers'), - apiGet('/projects'), - apiGet('/tasks') - ]); - - // 활성화된 작업자만 필터링 - const workers = allWorkers.filter(worker => { - return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true; - }); - - // 데이터 형식 검증 - if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks)) { - throw new Error('서버에서 받은 데이터 형식이 올바르지 않습니다.'); - } - - // 작업자 목록은 ID 기준으로 정렬 - workers.sort((a, b) => a.worker_id - b.worker_id); - - return { workers, projects, tasks }; - } catch (error) { - console.error('초기 데이터 로딩 중 오류 발생:', error); - // 에러를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함 - throw error; - } -} - -/** - * 작성된 작업 보고서 데이터를 서버에 전송합니다. - * @param {Array} reportData - 전송할 작업 보고서 데이터 배열 - * @returns {Promise} - 서버의 응답 결과 - */ -export async function createWorkReport(reportData) { - try { - const result = await apiPost('/workreports', reportData); - return result; - } catch (error) { - console.error('작업 보고서 생성 요청 실패:', error); - throw error; - } -} \ No newline at end of file diff --git a/system1-factory/web/js/work-report-create.js b/system1-factory/web/js/work-report-create.js deleted file mode 100644 index d61ed79..0000000 --- a/system1-factory/web/js/work-report-create.js +++ /dev/null @@ -1,79 +0,0 @@ -// /js/work-report-create.js -import { renderCalendar } from './calendar.js'; -import { getInitialData, createWorkReport } from './work-report-api.js'; -import { initializeReportTable, getReportData } from './work-report-ui.js'; - -// 전역 상태 변수 -let selectedDate = ''; - -/** - * 날짜가 선택되었을 때 실행되는 콜백 함수. - * 초기 데이터를 로드하고 테이블을 렌더링합니다. - * @param {string} date - 선택된 날짜 (YYYY-MM-DD 형식) - */ -async function onDateSelect(date) { - selectedDate = date; - const tableBody = document.getElementById('reportBody'); - tableBody.innerHTML = '데이터를 불러오는 중...'; - - try { - const initialData = await getInitialData(); - initializeReportTable(initialData); - } catch (error) { - alert('데이터를 불러오는 데 실패했습니다: ' + error.message); - tableBody.innerHTML = '오류 발생! 데이터를 불러올 수 없습니다.'; - } -} - -/** - * '전체 등록' 버튼 클릭 시 실행되는 이벤트 핸들러. - * 폼 데이터를 서버에 전송합니다. - */ -async function handleSubmit() { - if (!selectedDate) { - alert('먼저 달력에서 날짜를 선택해주세요.'); - return; - } - - const reportData = getReportData(); - if (!reportData) { - // getReportData 내부에서 이미 alert으로 사용자에게 알림 - return; - } - - // 각 항목에 선택된 날짜 추가 - const payload = reportData.map(item => ({ ...item, date: selectedDate })); - - const submitBtn = document.getElementById('submitBtn'); - submitBtn.disabled = true; - submitBtn.textContent = '등록 중...'; - - try { - const result = await createWorkReport(payload); - if (result.success) { - alert('✅ 작업 보고서가 성공적으로 등록되었습니다!'); - // 성공 후 폼을 다시 로드하거나, 다른 페이지로 이동 등의 로직 추가 가능 - onDateSelect(selectedDate); // 현재 날짜의 폼을 다시 로드 - } else { - throw new Error(result.error || '알 수 없는 오류로 등록에 실패했습니다.'); - } - } catch (error) { - alert('❌ 등록 실패: ' + error.message); - } finally { - submitBtn.disabled = false; - submitBtn.textContent = '전체 등록'; - } -} - -/** - * 페이지 초기화 함수 - */ -function initializePage() { - renderCalendar('calendar', onDateSelect); - - const submitBtn = document.getElementById('submitBtn'); - submitBtn.addEventListener('click', handleSubmit); -} - -// DOM이 로드되면 페이지 초기화를 시작합니다. -document.addEventListener('DOMContentLoaded', initializePage); \ No newline at end of file diff --git a/system1-factory/web/js/work-report-ui.js b/system1-factory/web/js/work-report-ui.js deleted file mode 100644 index bdc6042..0000000 --- a/system1-factory/web/js/work-report-ui.js +++ /dev/null @@ -1,141 +0,0 @@ -// /js/work-report-ui.js - -const DEFAULT_PROJECT_ID = '13'; // 나중에는 API나 설정에서 받아오는 것이 좋음 -const DEFAULT_TASK_ID = '15'; - -/** - * 주어진 데이터를 바탕으로 - ${worker.worker_name} - - - - - - - - - - - - `; - - // 이벤트 리스너 설정 - const workTypeSelect = tr.querySelector('[name="work_type"]'); - const projectSelect = tr.querySelector('[name="project_id"]'); - const taskSelect = tr.querySelector('[name="task_id"]'); - - workTypeSelect.addEventListener('change', () => { - const isDisabled = ['연차', '휴무', '유급'].includes(workTypeSelect.value); - projectSelect.disabled = isDisabled; - taskSelect.disabled = isDisabled; - if (isDisabled) { - projectSelect.value = DEFAULT_PROJECT_ID; - taskSelect.value = DEFAULT_TASK_ID; - } - }); - - tr.querySelector('.remove-btn').addEventListener('click', () => { - tr.remove(); - updateRowNumbers(tr.parentElement); - }); - - return tr; -} - -/** - * 작업 보고서 테이블을 초기화하고 데이터를 채웁니다. - * @param {{workers: Array, projects: Array, tasks: Array}} initialData - 초기 데이터 - */ -export function initializeReportTable(initialData) { - const tableBody = document.getElementById('reportBody'); - if (!tableBody) return; - - tableBody.innerHTML = ''; // 기존 내용 초기화 - const { workers, projects, tasks } = initialData; - - if (!workers || workers.length === 0) { - tableBody.innerHTML = '등록할 작업자 정보가 없습니다.'; - return; - } - - workers.forEach((worker, index) => { - const row = createReportRow(worker, projects, tasks, index); - tableBody.appendChild(row); - }); -} - -/** - * 테이블에서 폼 데이터를 추출하여 배열로 반환합니다. - * @returns {Array|null} - 추출된 데이터 배열 또는 유효성 검사 실패 시 null - */ -export function getReportData() { - const tableBody = document.getElementById('reportBody'); - const rows = tableBody.querySelectorAll('tr'); - - if (rows.length === 0 || (rows.length === 1 && rows[0].cells.length < 2)) { - alert('등록할 내용이 없습니다.'); - return null; - } - - const reportData = []; - const workerIds = new Set(); - - for (const tr of rows) { - const workerId = tr.querySelector('[name="worker_id"]').value; - if (workerIds.has(workerId)) { - alert(`오류: 작업자 '${tr.cells[1].textContent.trim()}'가 중복 등록되었습니다.`); - return null; - } - workerIds.add(workerId); - - reportData.push({ - worker_id: workerId, - project_id: tr.querySelector('[name="project_id"]').value, - task_id: tr.querySelector('[name="task_id"]').value, - overtime_hours: tr.querySelector('[name="overtime"]').value || 0, - work_details: tr.querySelector('[name="work_type"]').value, - memo: tr.querySelector('[name="memo"]').value - }); - } - - return reportData; -} \ No newline at end of file diff --git a/system1-factory/web/pages/admin/accounts.html b/system1-factory/web/pages/admin/accounts.html index d16ccb3..8cdc639 100644 --- a/system1-factory/web/pages/admin/accounts.html +++ b/system1-factory/web/pages/admin/accounts.html @@ -278,8 +278,8 @@
- - + + diff --git a/system1-factory/web/pages/admin/attendance-report.html b/system1-factory/web/pages/admin/attendance-report.html index 9fc4b94..95f399e 100644 --- a/system1-factory/web/pages/admin/attendance-report.html +++ b/system1-factory/web/pages/admin/attendance-report.html @@ -7,8 +7,8 @@ - - + + - - -
- - -
-
- - - -
-
-
-
-
신고
-
-
-
-
-
접수
-
-
-
-
-
처리중
-
-
-
-
-
완료
-
-
- - -
- - - - - - - - + 신고하기 -
- - -
-
-
로딩 중...
-
-
-
-
-
- - - - diff --git a/system3-nonconformance/web/admin.html b/system3-nonconformance/web/admin.html index a9ddfbe..133240d 100644 --- a/system3-nonconformance/web/admin.html +++ b/system3-nonconformance/web/admin.html @@ -714,6 +714,8 @@ 'issues_inbox': { title: '수신함', defaultAccess: true }, 'issues_management': { title: '관리함', defaultAccess: false }, 'issues_archive': { title: '폐기함', defaultAccess: false }, + 'projects_manage': { title: '프로젝트 관리', defaultAccess: false }, + 'daily_work': { title: '일일 공수', defaultAccess: false }, 'reports': { title: '보고서', defaultAccess: false } }; @@ -767,7 +769,10 @@ 'issues_archive': { title: '🗃️ 폐기함', icon: 'fas fa-archive', color: 'text-gray-600' } }, '시스템 관리': { - 'reports': { title: '보고서', icon: 'fas fa-chart-bar', color: 'text-red-600' } + 'projects_manage': { title: '프로젝트 관리', icon: 'fas fa-folder-open', color: 'text-indigo-600' }, + 'daily_work': { title: '일일 공수', icon: 'fas fa-calendar-check', color: 'text-blue-600' }, + 'reports': { title: '보고서', icon: 'fas fa-chart-bar', color: 'text-red-600' }, + 'users_manage': { title: '사용자 관리', icon: 'fas fa-users-cog', color: 'text-purple-600' } } }; @@ -834,7 +839,7 @@ const allPages = [ 'issues_create', 'issues_view', 'issues_manage', 'issues_inbox', 'issues_management', 'issues_archive', - 'reports' + 'projects_manage', 'daily_work', 'reports', 'users_manage' ]; const permissions = {}; diff --git a/system3-nonconformance/web/daily-work.html b/system3-nonconformance/web/daily-work.html index bb7bd93..2602895 100644 --- a/system3-nonconformance/web/daily-work.html +++ b/system3-nonconformance/web/daily-work.html @@ -6,10 +6,6 @@ 일일 공수 입력 - - - - -
+
-
+

diff --git a/system3-nonconformance/web/favicon.ico b/system3-nonconformance/web/favicon.ico new file mode 100644 index 0000000..0c7accd Binary files /dev/null and b/system3-nonconformance/web/favicon.ico differ diff --git a/system3-nonconformance/web/index.html b/system3-nonconformance/web/index.html deleted file mode 100644 index 116a8d0..0000000 --- a/system3-nonconformance/web/index.html +++ /dev/null @@ -1,2164 +0,0 @@ - - - - - - 작업보고서 시스템 - - - - - - -
-
-
- -
-

처리 중입니다...

-

잠시만 기다려주세요

-
-
-
-
-
-
-
- - - - - -
- - - -
- -
-

- - 부적합 등록 -

-

현장에서 발견한 부적합 사항을 등록해주세요

-
- -
- -
-
- 등록 진행률 - 0/6 -
-
-
-
-
- -
- -
-
- - - 선택사항 • 최대 5장 - -
- - - - - -
- - - - - -
- - -
-

사진 추가 (0/5)

-
- - - - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
-
-
- - - - - - -
- - - - - - - - - - - - - \ No newline at end of file diff --git a/system3-nonconformance/web/project-management.html b/system3-nonconformance/web/project-management.html index 497f300..a14ed2a 100644 --- a/system3-nonconformance/web/project-management.html +++ b/system3-nonconformance/web/project-management.html @@ -6,10 +6,6 @@ 프로젝트 관리 - 작업보고서 시스템 - - - - -
+

diff --git a/system3-nonconformance/web/reports-monthly.html b/system3-nonconformance/web/reports-monthly.html index 580da68..d98e22b 100644 --- a/system3-nonconformance/web/reports-monthly.html +++ b/system3-nonconformance/web/reports-monthly.html @@ -10,15 +10,21 @@ - - - + + + - + -
+
diff --git a/system3-nonconformance/web/reports-weekly.html b/system3-nonconformance/web/reports-weekly.html index 887cbc3..76bbaed 100644 --- a/system3-nonconformance/web/reports-weekly.html +++ b/system3-nonconformance/web/reports-weekly.html @@ -10,15 +10,21 @@ - - - + + + - + -
+
diff --git a/system3-nonconformance/web/reports.html b/system3-nonconformance/web/reports.html index 173be11..f79e1b1 100644 --- a/system3-nonconformance/web/reports.html +++ b/system3-nonconformance/web/reports.html @@ -10,44 +10,49 @@ - - - - + - +
-
+
diff --git a/system3-nonconformance/web/static/js/api.js b/system3-nonconformance/web/static/js/api.js index 89a1d9f..60afe72 100644 --- a/system3-nonconformance/web/static/js/api.js +++ b/system3-nonconformance/web/static/js/api.js @@ -273,6 +273,35 @@ const IssuesAPI = { getStats: () => apiRequest('/issues/stats/summary') }; +// Daily Work API +const DailyWorkAPI = { + create: (workData) => apiRequest('/daily-work/', { + method: 'POST', + body: JSON.stringify(workData) + }), + + getAll: (params = {}) => { + const queryString = new URLSearchParams(params).toString(); + return apiRequest(`/daily-work/${queryString ? '?' + queryString : ''}`); + }, + + get: (id) => apiRequest(`/daily-work/${id}`), + + update: (id, workData) => apiRequest(`/daily-work/${id}`, { + method: 'PUT', + body: JSON.stringify(workData) + }), + + delete: (id) => apiRequest(`/daily-work/${id}`, { + method: 'DELETE' + }), + + getStats: (params = {}) => { + const queryString = new URLSearchParams(params).toString(); + return apiRequest(`/daily-work/stats/summary${queryString ? '?' + queryString : ''}`); + } +}; + // Reports API const ReportsAPI = { getSummary: (startDate, endDate) => apiRequest('/reports/summary', { @@ -291,6 +320,13 @@ const ReportsAPI = { return apiRequest(`/reports/issues?${params}`); }, + getDailyWorks: (startDate, endDate) => { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate + }).toString(); + return apiRequest(`/reports/daily-works?${params}`); + } }; // 권한 체크 diff --git a/system3-nonconformance/web/static/js/app.js b/system3-nonconformance/web/static/js/app.js index b179ef9..f78f688 100644 --- a/system3-nonconformance/web/static/js/app.js +++ b/system3-nonconformance/web/static/js/app.js @@ -270,7 +270,10 @@ class App { const titles = { 'dashboard': '대시보드', 'issues': '부적합 사항', - 'reports': '보고서' + 'projects': '프로젝트', + 'daily_work': '일일 공수', + 'reports': '보고서', + 'users': '사용자 관리' }; const title = titles[module] || module; diff --git a/system3-nonconformance/web/static/js/components/common-header.js b/system3-nonconformance/web/static/js/components/common-header.js index 72d15fa..2dc199e 100644 --- a/system3-nonconformance/web/static/js/components/common-header.js +++ b/system3-nonconformance/web/static/js/components/common-header.js @@ -32,8 +32,8 @@ class CommonHeader { icon: 'fas fa-chart-line', url: '/issues-dashboard.html', pageName: 'issues_dashboard', - color: 'text-slate-300', - bgColor: 'text-slate-300 hover:bg-slate-700' + color: 'text-slate-600', + bgColor: 'text-slate-600 hover:bg-slate-100' }, { id: 'issues_inbox', @@ -41,8 +41,8 @@ class CommonHeader { icon: 'fas fa-inbox', url: '/issues-inbox.html', pageName: 'issues_inbox', - color: 'text-slate-300', - bgColor: 'text-slate-300 hover:bg-slate-700' + color: 'text-slate-600', + bgColor: 'text-slate-600 hover:bg-slate-100' }, { id: 'issues_management', @@ -50,8 +50,8 @@ class CommonHeader { icon: 'fas fa-cog', url: '/issues-management.html', pageName: 'issues_management', - color: 'text-slate-300', - bgColor: 'text-slate-300 hover:bg-slate-700' + color: 'text-slate-600', + bgColor: 'text-slate-600 hover:bg-slate-100' }, { id: 'issues_archive', @@ -59,8 +59,17 @@ class CommonHeader { icon: 'fas fa-archive', url: '/issues-archive.html', pageName: 'issues_archive', - color: 'text-slate-300', - bgColor: 'text-slate-300 hover:bg-slate-700' + color: 'text-slate-600', + bgColor: 'text-slate-600 hover:bg-slate-100' + }, + { + id: 'daily_work', + title: '일일 공수', + icon: 'fas fa-calendar-check', + url: '/daily-work.html', + pageName: 'daily_work', + color: 'text-slate-600', + bgColor: 'text-slate-600 hover:bg-slate-100' }, { id: 'reports', @@ -68,8 +77,8 @@ class CommonHeader { icon: 'fas fa-chart-bar', url: '/reports.html', pageName: 'reports', - color: 'text-slate-300', - bgColor: 'text-slate-300 hover:bg-slate-700', + color: 'text-slate-600', + bgColor: 'text-slate-600 hover:bg-slate-100', subMenus: [ { id: 'reports_daily', @@ -77,7 +86,7 @@ class CommonHeader { icon: 'fas fa-file-excel', url: '/reports-daily.html', pageName: 'reports_daily', - color: 'text-slate-300' + color: 'text-slate-600' }, { id: 'reports_weekly', @@ -85,7 +94,7 @@ class CommonHeader { icon: 'fas fa-calendar-week', url: '/reports-weekly.html', pageName: 'reports_weekly', - color: 'text-slate-300' + color: 'text-slate-600' }, { id: 'reports_monthly', @@ -93,10 +102,29 @@ class CommonHeader { icon: 'fas fa-calendar-alt', url: '/reports-monthly.html', pageName: 'reports_monthly', - color: 'text-slate-300' + color: 'text-slate-600' } ] }, + { + id: 'projects_manage', + title: '프로젝트 관리', + icon: 'fas fa-folder-open', + url: '/project-management.html', + pageName: 'projects_manage', + color: 'text-slate-600', + bgColor: 'text-slate-600 hover:bg-slate-100' + }, + { + id: 'users_manage', + title: '사용자 관리', + icon: 'fas fa-users-cog', + url: this._getUserManageUrl(), + pageName: 'users_manage', + color: 'text-slate-600', + bgColor: 'text-slate-600 hover:bg-slate-100', + external: true + } ]; } @@ -177,14 +205,14 @@ class CommonHeader { const userRole = this.getUserRoleDisplay(); return ` -
+
-
+
- -

부적합 관리

+ +

부적합 관리

@@ -198,8 +226,8 @@ class CommonHeader {
-
${userDisplayName}
-
${userRole}
+
${userDisplayName}
+
${userRole}
@@ -210,7 +238,7 @@ class CommonHeader {
- -
-