feat: tkuser 통합 관리 서비스 + 전체 시스템 SSO 쿠키 인증 통합
- tkuser 서비스 신규 추가 (API + Web) - 사용자/권한/프로젝트/부서/작업자/작업장/설비/작업/휴가 통합 관리 - 작업장 탭: 공장→작업장 드릴다운 네비게이션 + 구역지도 클릭 연동 - 작업 탭: 공정(work_types)→작업(tasks) 계층 관리 - 휴가 탭: 유형 관리 + 연차 배정(근로기준법 자동계산) - 전 시스템 SSO 쿠키 인증으로 통합 (.technicalkorea.net 공유) - System 2: 작업 이슈 리포트 기능 강화 - System 3: tkuser API 연동, 페이지 권한 체계 적용 - docker-compose에 tkuser-api, tkuser-web 서비스 추가 - ARCHITECTURE.md, DEPLOYMENT.md 문서 작성 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
136
ARCHITECTURE.md
Normal file
136
ARCHITECTURE.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 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
|
||||
```
|
||||
80
DEPLOYMENT.md
Normal file
80
DEPLOYMENT.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 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`으로 재연결
|
||||
@@ -24,11 +24,6 @@ services:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: "1.0"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -51,11 +46,6 @@ services:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mproject}"]
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1536M
|
||||
cpus: "0.5"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -69,11 +59,6 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -108,11 +93,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -153,11 +133,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "0.5"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -171,11 +146,6 @@ services:
|
||||
- "30080:80"
|
||||
depends_on:
|
||||
- system1-api
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -191,11 +161,6 @@ services:
|
||||
- API_BASE_URL=http://system1-api:3005
|
||||
depends_on:
|
||||
- system1-api
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -235,11 +200,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
cpus: "0.5"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -253,11 +213,6 @@ services:
|
||||
- "30180:80"
|
||||
depends_on:
|
||||
- system2-api
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -274,23 +229,22 @@ services:
|
||||
ports:
|
||||
- "30200:8000"
|
||||
environment:
|
||||
- DATABASE_URL=${SYSTEM3_DATABASE_URL:-postgresql://mproject:mproject2024@postgres:5432/mproject}
|
||||
- DB_HOST=mariadb
|
||||
- DB_PORT=3306
|
||||
- DB_USER=${MYSQL_USER:-hyungi_user}
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SECRET_KEY=${SSO_JWT_SECRET}
|
||||
- ALGORITHM=HS256
|
||||
- ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
- ADMIN_USERNAME=${SYSTEM3_ADMIN_USERNAME:-hyungi}
|
||||
- ADMIN_PASSWORD=${SYSTEM3_ADMIN_PASSWORD:-123456}
|
||||
- TZ=Asia/Seoul
|
||||
- TKUSER_API_URL=http://tkuser-api:3000
|
||||
volumes:
|
||||
- system3_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "0.5"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -302,13 +256,52 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30280:80"
|
||||
volumes:
|
||||
- system3_uploads:/usr/share/nginx/html/uploads
|
||||
depends_on:
|
||||
- system3-api
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# User Management (tkuser)
|
||||
# =================================================================
|
||||
|
||||
tkuser-api:
|
||||
build:
|
||||
context: ./user-management/api
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-tkuser-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30300:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DB_HOST=mariadb
|
||||
- DB_PORT=3306
|
||||
- DB_USER=${MYSQL_USER:-hyungi_user}
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
|
||||
volumes:
|
||||
- system1_uploads:/usr/src/app/uploads
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
tkuser-web:
|
||||
build:
|
||||
context: ./user-management/web
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-tkuser-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30380:80"
|
||||
depends_on:
|
||||
- tkuser-api
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -329,11 +322,6 @@ services:
|
||||
- system1-web
|
||||
- system2-web
|
||||
- system3-web
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -355,11 +343,6 @@ services:
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
@@ -378,23 +361,25 @@ services:
|
||||
- gateway
|
||||
- system2-web
|
||||
- system3-web
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: "0.25"
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
external: true
|
||||
name: tkfb-package_db_data
|
||||
postgres_data:
|
||||
external: true
|
||||
name: tkqc-package_postgres_data
|
||||
system1_uploads:
|
||||
external: true
|
||||
name: tkfb_api_uploads
|
||||
system1_logs:
|
||||
system2_uploads:
|
||||
system2_logs:
|
||||
system3_uploads:
|
||||
|
||||
external: true
|
||||
name: tkqc-package_uploads
|
||||
networks:
|
||||
tk-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM node:18-alpine
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ WORKDIR /usr/src/app
|
||||
COPY package*.json ./
|
||||
|
||||
# 프로덕션 의존성만 설치
|
||||
RUN npm ci --only=production
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# 앱 소스 복사
|
||||
COPY . .
|
||||
|
||||
@@ -13,10 +13,14 @@ const logger = require('../utils/logger');
|
||||
* 허용된 Origin 목록
|
||||
*/
|
||||
const allowedOrigins = [
|
||||
'http://localhost:20000', // 웹 UI
|
||||
'https://tkfb.technicalkorea.net', // Gateway (프로덕션)
|
||||
'https://tkreport.technicalkorea.net', // System 2
|
||||
'https://tkqc.technicalkorea.net', // System 3
|
||||
'http://localhost:20000', // 웹 UI (로컬)
|
||||
'http://localhost:30080', // 웹 UI (Docker)
|
||||
'http://localhost:3005', // API 서버
|
||||
'http://localhost:3000', // 개발 포트
|
||||
'http://127.0.0.1:20000', // 로컬호스트 대체
|
||||
'http://127.0.0.1:20000',
|
||||
'http://127.0.0.1:3005',
|
||||
'http://127.0.0.1:3000'
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import List
|
||||
class Settings:
|
||||
# 기본 설정
|
||||
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
|
||||
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://api:20005")
|
||||
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
||||
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
||||
|
||||
|
||||
@@ -6,25 +6,29 @@ function getApiBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
|
||||
|
||||
console.log('🌐 감지된 환경:', { hostname, protocol, port });
|
||||
|
||||
// 🔗 nginx 프록시를 통한 접근 (권장)
|
||||
// nginx가 /api/ 요청을 백엔드로 프록시하므로 포트 없이 접근
|
||||
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' ||
|
||||
hostname.includes('.local') || hostname.includes('hyungi')) {
|
||||
|
||||
// 현재 웹서버의 도메인/IP를 그대로 사용하되 API 포트(config.api.port)로 직접 연결
|
||||
const baseUrl = `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
|
||||
|
||||
console.log('✅ nginx 프록시 사용:', baseUrl);
|
||||
|
||||
// 🔗 외부 도메인 (Cloudflare Tunnel) - Gateway nginx가 /api/를 프록시
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
const baseUrl = `${protocol}//${hostname}${config.api.path}`;
|
||||
console.log('✅ Gateway 프록시 사용:', baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// 🚨 백업: 직접 접근 (nginx 프록시 실패시에만)
|
||||
console.warn('⚠️ 직접 API 접근 (백업 모드)');
|
||||
return `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
|
||||
|
||||
// 🔗 로컬/내부 네트워크 - API 포트 직접 접근
|
||||
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' ||
|
||||
hostname.includes('.local') || hostname.includes('hyungi')) {
|
||||
const baseUrl = `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
|
||||
console.log('✅ 로컬 직접 접근:', baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// 🚨 기타: 포트 없이 상대 경로
|
||||
const baseUrl = `${protocol}//${hostname}${config.api.path}`;
|
||||
console.log('✅ 기본 프록시 사용:', baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// API 설정
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ES6 모듈 의존성 제거 - 브라우저 호환성 개선
|
||||
|
||||
// API 설정 (window 객체에서 가져오기)
|
||||
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
|
||||
// 인증 관련 함수들 (직접 구현)
|
||||
function getToken() {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const config = {
|
||||
// API 관련 설정
|
||||
api: {
|
||||
// 로컬 개발 및 Docker 환경에서 사용하는 API 서버 포트
|
||||
port: 20005,
|
||||
port: 30005,
|
||||
// API의 기본 경로
|
||||
path: '/api',
|
||||
},
|
||||
|
||||
@@ -1115,7 +1115,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath, workplaces) {
|
||||
mapCtx = mapCanvas.getContext('2d');
|
||||
|
||||
// 이미지 URL 생성
|
||||
const baseUrl = window.API_BASE_URL || 'http://localhost:20005';
|
||||
const baseUrl = window.API_BASE_URL || 'http://localhost:30005';
|
||||
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
|
||||
const fullImageUrl = layoutImagePath.startsWith('http')
|
||||
? layoutImagePath
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* category_type=nonconformity 고정 필터
|
||||
*/
|
||||
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
const CATEGORY_TYPE = 'nonconformity';
|
||||
|
||||
// 상태 한글 변환
|
||||
@@ -110,7 +110,7 @@ function renderIssues(issues) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
|
||||
|
||||
issueList.innerHTML = issues.map(issue => {
|
||||
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* category_type=safety 고정 필터
|
||||
*/
|
||||
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
const CATEGORY_TYPE = 'safety';
|
||||
|
||||
// 상태 한글 변환
|
||||
@@ -110,7 +110,7 @@ function renderIssues(issues) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
|
||||
|
||||
issueList.innerHTML = issues.map(issue => {
|
||||
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
|
||||
|
||||
@@ -1672,7 +1672,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
|
||||
mapCtx = mapCanvas.getContext('2d');
|
||||
|
||||
// 이미지 URL 생성
|
||||
const baseUrl = window.API_BASE_URL || 'http://localhost:20005';
|
||||
const baseUrl = window.API_BASE_URL || 'http://localhost:30005';
|
||||
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
|
||||
const fullImageUrl = layoutImagePath.startsWith('http')
|
||||
? layoutImagePath
|
||||
|
||||
@@ -304,7 +304,7 @@ async function loadWorkplaceMap() {
|
||||
// 레이아웃 이미지가 있으면 표시
|
||||
if (selectedCategory && selectedCategory.layout_image) {
|
||||
// API_BASE_URL에서 /api 제거하고 이미지 경로 생성
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
class WorkAnalysisAPIClient {
|
||||
constructor() {
|
||||
this.baseURL = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
this.baseURL = window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -88,7 +88,7 @@ async function loadLayoutMapData() {
|
||||
// 이미지 경로를 전체 URL로 변환
|
||||
const fullImageUrl = category.layout_image.startsWith('http')
|
||||
? category.layout_image
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
|
||||
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${category.layout_image}`.replace('/api/', '/');
|
||||
|
||||
currentImageDiv.innerHTML = `
|
||||
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
|
||||
@@ -210,7 +210,7 @@ async function uploadLayoutImage() {
|
||||
formData.append('image', file);
|
||||
|
||||
// 업로드 요청
|
||||
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/categories/${currentCategoryId}/layout-image`, {
|
||||
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:30005/api'}/workplaces/categories/${currentCategoryId}/layout-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('sso_token')}`
|
||||
@@ -224,7 +224,7 @@ async function uploadLayoutImage() {
|
||||
window.showToast('이미지가 성공적으로 업로드되었습니다.', 'success');
|
||||
|
||||
// 이미지 경로를 전체 URL로 변환
|
||||
const fullImageUrl = `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
|
||||
const fullImageUrl = `${window.API_BASE_URL || 'http://localhost:30005/api'}${result.data.image_path}`.replace('/api/', '/');
|
||||
|
||||
// 이미지를 캔버스에 로드
|
||||
loadImageToCanvas(fullImageUrl);
|
||||
|
||||
@@ -147,7 +147,7 @@ async function updateLayoutPreview(category) {
|
||||
// 이미지 경로를 전체 URL로 변환
|
||||
const fullImageUrl = category.layout_image.startsWith('http')
|
||||
? category.layout_image
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
|
||||
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${category.layout_image}`.replace('/api/', '/');
|
||||
|
||||
// Canvas 컨테이너 생성
|
||||
previewDiv.innerHTML = `
|
||||
@@ -267,7 +267,7 @@ async function loadWorkplaceMapThumbnail(workplace) {
|
||||
if (workplace.layout_image) {
|
||||
const fullImageUrl = workplace.layout_image.startsWith('http')
|
||||
? workplace.layout_image
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
|
||||
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${workplace.layout_image}`.replace('/api/', '/');
|
||||
|
||||
// 설비 정보 로드
|
||||
let equipmentCount = 0;
|
||||
@@ -327,7 +327,7 @@ async function loadWorkplaceMapThumbnail(workplace) {
|
||||
|
||||
const fullImageUrl = category.layout_image.startsWith('http')
|
||||
? category.layout_image
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
|
||||
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${category.layout_image}`.replace('/api/', '/');
|
||||
|
||||
// 캔버스 생성
|
||||
const canvasId = `thumbnail-canvas-${workplace.workplace_id}`;
|
||||
@@ -1019,7 +1019,7 @@ async function openWorkplaceMapModal(workplaceId) {
|
||||
if (preview && workplace.layout_image) {
|
||||
const fullImageUrl = workplace.layout_image.startsWith('http')
|
||||
? workplace.layout_image
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
|
||||
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${workplace.layout_image}`.replace('/api/', '/');
|
||||
preview.innerHTML = `<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px;">`;
|
||||
|
||||
// 캔버스 초기화
|
||||
@@ -1126,7 +1126,7 @@ async function uploadWorkplaceLayout() {
|
||||
try {
|
||||
showToast('이미지 업로드 중...', 'info');
|
||||
|
||||
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/${window.currentWorkplaceMapId}/layout-image`, {
|
||||
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:30005/api'}/workplaces/${window.currentWorkplaceMapId}/layout-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('sso_token')}`
|
||||
@@ -1148,7 +1148,7 @@ async function uploadWorkplaceLayout() {
|
||||
if (preview && result.data.image_path) {
|
||||
const fullImageUrl = result.data.image_path.startsWith('http')
|
||||
? result.data.image_path
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
|
||||
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${result.data.image_path}`.replace('/api/', '/');
|
||||
preview.innerHTML = `<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px;">`;
|
||||
|
||||
// 캔버스 초기화 (설비 영역 편집용)
|
||||
@@ -1678,7 +1678,7 @@ async function openFullscreenEquipmentEditor() {
|
||||
// 이미지 URL 생성
|
||||
const fullImageUrl = workplace.layout_image.startsWith('http')
|
||||
? workplace.layout_image
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
|
||||
: `${window.API_BASE_URL || 'http://localhost:30005/api'}${workplace.layout_image}`.replace('/api/', '/');
|
||||
|
||||
// 캔버스 초기화
|
||||
initFullscreenCanvas(fullImageUrl);
|
||||
|
||||
@@ -25,7 +25,7 @@ class WorkplaceUtils {
|
||||
* API URL 생성
|
||||
*/
|
||||
getApiBaseUrl() {
|
||||
return window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
return window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -129,7 +129,7 @@ async function loadMapImage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
@@ -563,7 +563,7 @@ async function initDetailMap(workplace) {
|
||||
|
||||
// 작업장에 레이아웃 이미지가 있는지 확인
|
||||
if (workplace.layout_image) {
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
|
||||
const imageUrl = workplace.layout_image.startsWith('http')
|
||||
? workplace.layout_image
|
||||
: `${baseUrl}${workplace.layout_image}`;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>작업 현황판 | 테크니컬코리아</title>
|
||||
|
||||
<!-- 리소스 프리로딩 -->
|
||||
<link rel="preconnect" href="http://localhost:20005" crossorigin>
|
||||
<!-- preconnect는 Gateway 프록시 사용 시 불필요 -->
|
||||
<link rel="preload" href="/css/design-system.css" as="style">
|
||||
<link rel="preload" href="/js/api-base.js" as="script">
|
||||
<link rel="preload" href="/js/app-init.js?v=2" as="script">
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM node:18-alpine
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const workIssueModel = require('../models/workIssueModel');
|
||||
const imageUploadService = require('../services/imageUploadService');
|
||||
const mProjectService = require('../services/mProjectService');
|
||||
|
||||
// ==================== 신고 카테고리 관리 ====================
|
||||
|
||||
@@ -26,7 +27,7 @@ exports.getAllCategories = (req, res) => {
|
||||
exports.getCategoriesByType = (req, res) => {
|
||||
const { type } = req.params;
|
||||
|
||||
if (!['nonconformity', 'safety'].includes(type)) {
|
||||
if (!['nonconformity', 'safety', 'facility'].includes(type)) {
|
||||
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
|
||||
}
|
||||
|
||||
@@ -283,16 +284,53 @@ exports.createReport = async (req, res) => {
|
||||
...photoPaths
|
||||
};
|
||||
|
||||
workIssueModel.createReport(reportData, (err, reportId) => {
|
||||
workIssueModel.createReport(reportData, async (err, reportId) => {
|
||||
if (err) {
|
||||
console.error('신고 생성 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '신고 생성 실패' });
|
||||
}
|
||||
|
||||
// 응답 먼저 반환 (사용자 대기 X)
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '문제 신고가 등록되었습니다.',
|
||||
data: { report_id: reportId }
|
||||
});
|
||||
|
||||
// 부적합 유형이면 System 3(tkqc)으로 비동기 전달
|
||||
try {
|
||||
const categoryInfo = await new Promise((resolve, reject) => {
|
||||
workIssueModel.getCategoryById(issue_category_id, (catErr, data) => {
|
||||
if (catErr) reject(catErr); else resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
if (categoryInfo && categoryInfo.category_type === 'nonconformity') {
|
||||
// 사진은 System 2에만 저장, URL 참조만 전달
|
||||
const baseUrl = process.env.SYSTEM2_PUBLIC_URL || 'https://tkreport.technicalkorea.net';
|
||||
const photoUrls = Object.values(photoPaths).filter(Boolean)
|
||||
.map(p => `${baseUrl}/api/uploads/${p}`);
|
||||
|
||||
const descParts = [additional_description || categoryInfo.category_name];
|
||||
if (photoUrls.length > 0) {
|
||||
descParts.push('', '[첨부 사진]');
|
||||
photoUrls.forEach((url, i) => descParts.push(`${i + 1}. ${url}`));
|
||||
}
|
||||
|
||||
const result = await mProjectService.sendToMProject({
|
||||
category: categoryInfo.category_name,
|
||||
description: descParts.join('\n'),
|
||||
reporter_name: req.user.name || req.user.username,
|
||||
tk_issue_id: reportId,
|
||||
photos: [] // 사진 복사 안 함 (URL 참조만)
|
||||
});
|
||||
if (result.success && result.mProjectId) {
|
||||
workIssueModel.updateMProjectId(reportId, result.mProjectId, () => {});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('System3 연동 실패 (신고는 정상 저장됨):', e.message);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('신고 생성 에러:', error);
|
||||
|
||||
@@ -198,6 +198,24 @@ const deleteItem = async (itemId, callback) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 ID로 단건 조회
|
||||
*/
|
||||
const getCategoryById = async (categoryId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT category_id, category_type, category_name, description
|
||||
FROM issue_report_categories
|
||||
WHERE category_id = ?`,
|
||||
[categoryId]
|
||||
);
|
||||
callback(null, rows[0] || null);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 문제 신고 관리 ====================
|
||||
|
||||
// 한국 시간 유틸리티 import
|
||||
@@ -736,6 +754,22 @@ const getStatusLogs = async (reportId, callback) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* m_project_id 업데이트 (System 3 연동 후)
|
||||
*/
|
||||
const updateMProjectId = async (reportId, mProjectId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db.query(
|
||||
`UPDATE work_issue_reports SET m_project_id = ? WHERE report_id = ?`,
|
||||
[mProjectId, reportId]
|
||||
);
|
||||
callback(null);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 통계 ====================
|
||||
|
||||
/**
|
||||
@@ -854,6 +888,7 @@ module.exports = {
|
||||
// 카테고리
|
||||
getAllCategories,
|
||||
getCategoriesByType,
|
||||
getCategoryById,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
@@ -872,6 +907,9 @@ module.exports = {
|
||||
updateReport,
|
||||
deleteReport,
|
||||
|
||||
// System 3 연동
|
||||
updateMProjectId,
|
||||
|
||||
// 상태 관리
|
||||
receiveReport,
|
||||
assignReport,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"async-retry": "^1.3.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
300
system2-report/web/css/common.css
Normal file
300
system2-report/web/css/common.css
Normal file
@@ -0,0 +1,300 @@
|
||||
/* Common CSS - 공통 스타일 */
|
||||
|
||||
/* ========== 통일된 헤더 스타일 ========== */
|
||||
.work-report-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.work-report-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.work-report-header h1 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.3);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.work-report-header .subtitle {
|
||||
font-size: clamp(0.875rem, 2vw, 1.1rem);
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
font-weight: 300;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.work-report-main {
|
||||
background: #f8f9fa;
|
||||
min-height: calc(100vh - 12rem);
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
margin: 0 1.5rem 1.5rem 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: white;
|
||||
color: #007bff;
|
||||
transform: translateY(-0.0625rem);
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* 반응형 헤더 */
|
||||
@media (max-width: 768px) {
|
||||
.work-report-header {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.work-report-header h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
margin: 0 1rem 1rem 1rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.work-report-header {
|
||||
padding: 1.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.work-report-header .subtitle {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
margin: 0 0.75rem 0.75rem 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
h5 { font-size: 1rem; }
|
||||
h6 { font-size: 0.875rem; }
|
||||
|
||||
/* ========== 헤더 액션 버튼 ========== */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.dashboard-btn .btn-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-5 { margin-bottom: 1.25rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mt-5 { margin-top: 1.25rem; }
|
||||
.mt-6 { margin-top: 1.5rem; }
|
||||
|
||||
.p-1 { padding: 0.25rem; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-5 { padding: 1.25rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
h3 { font-size: 1.125rem; }
|
||||
}
|
||||
477
system2-report/web/css/design-system.css
Normal file
477
system2-report/web/css/design-system.css
Normal file
@@ -0,0 +1,477 @@
|
||||
/* ✅ design-system.css - 한글 기반 모던 디자인 시스템 */
|
||||
|
||||
/* ========== 색상 시스템 ========== */
|
||||
:root {
|
||||
/* 주요 브랜드 색상 (하늘색 계열) */
|
||||
--primary-50: #f0f9ff;
|
||||
--primary-100: #e0f2fe;
|
||||
--primary-200: #bae6fd;
|
||||
--primary-300: #7dd3fc;
|
||||
--primary-400: #38bdf8;
|
||||
--primary-500: #0ea5e9;
|
||||
--primary-600: #0284c7;
|
||||
--primary-700: #0369a1;
|
||||
--primary-800: #075985;
|
||||
--primary-900: #0c4a6e;
|
||||
|
||||
/* 헤더 그라디언트 */
|
||||
--header-gradient: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 50%, #7dd3fc 100%);
|
||||
|
||||
/* 보조 색상 */
|
||||
--secondary-50: #f3e5f5;
|
||||
--secondary-100: #e1bee7;
|
||||
--secondary-200: #ce93d8;
|
||||
--secondary-300: #ba68c8;
|
||||
--secondary-400: #ab47bc;
|
||||
--secondary-500: #9c27b0;
|
||||
--secondary-600: #8e24aa;
|
||||
--secondary-700: #7b1fa2;
|
||||
--secondary-800: #6a1b9a;
|
||||
--secondary-900: #4a148c;
|
||||
|
||||
/* 그레이 스케일 */
|
||||
--gray-50: #fafafa;
|
||||
--gray-100: #f5f5f5;
|
||||
--gray-200: #eeeeee;
|
||||
--gray-300: #e0e0e0;
|
||||
--gray-400: #bdbdbd;
|
||||
--gray-500: #9e9e9e;
|
||||
--gray-600: #757575;
|
||||
--gray-700: #616161;
|
||||
--gray-800: #424242;
|
||||
--gray-900: #212121;
|
||||
|
||||
/* 상태 색상 */
|
||||
--success-50: #e8f5e8;
|
||||
--success-500: #4caf50;
|
||||
--success-700: #388e3c;
|
||||
|
||||
--warning-50: #fff8e1;
|
||||
--warning-500: #ff9800;
|
||||
--warning-700: #f57c00;
|
||||
|
||||
--error-50: #ffebee;
|
||||
--error-500: #f44336;
|
||||
--error-700: #d32f2f;
|
||||
|
||||
--info-50: #e1f5fe;
|
||||
--info-500: #03a9f4;
|
||||
--info-700: #0288d1;
|
||||
|
||||
/* 따뜻한 중성 색상 (베이지/크림) */
|
||||
--warm-50: #fafaf9; /* 매우 밝은 크림 */
|
||||
--warm-100: #f5f5f4; /* 밝은 크림 */
|
||||
--warm-200: #e7e5e4; /* 베이지 */
|
||||
--warm-300: #d6d3d1; /* 중간 베이지 */
|
||||
--warm-400: #a8a29e; /* 진한 베이지 */
|
||||
--warm-500: #78716c; /* 그레이 베이지 */
|
||||
|
||||
/* 부드러운 작업 상태 색상 (눈이 편한 톤) */
|
||||
--status-success-bg: #dcfce7; /* 부드러운 초록 배경 */
|
||||
--status-success-text: #16a34a; /* 부드러운 초록 텍스트 */
|
||||
--status-info-bg: #e0f2fe; /* 부드러운 하늘색 배경 */
|
||||
--status-info-text: #0284c7; /* 부드러운 하늘색 텍스트 */
|
||||
--status-warning-bg: #fef3c7; /* 부드러운 노랑 배경 */
|
||||
--status-warning-text: #ca8a04; /* 부드러운 노랑 텍스트 */
|
||||
--status-error-bg: #fee2e2; /* 부드러운 빨강 배경 */
|
||||
--status-error-text: #dc2626; /* 부드러운 빨강 텍스트 */
|
||||
--status-critical-bg: #fecaca; /* 진한 빨강 배경 */
|
||||
--status-critical-text: #b91c1c; /* 진한 빨강 텍스트 */
|
||||
--status-vacation-bg: #fed7aa; /* 부드러운 주황 배경 */
|
||||
--status-vacation-text: #ea580c; /* 부드러운 주황 텍스트 */
|
||||
|
||||
/* 배경 색상 */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* 텍스트 색상 */
|
||||
--text-primary: #1a202c;
|
||||
--text-secondary: #4a5568;
|
||||
--text-tertiary: #718096;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* 경계선 */
|
||||
--border-light: #e2e8f0;
|
||||
--border-medium: #cbd5e0;
|
||||
--border-dark: #a0aec0;
|
||||
|
||||
/* 그림자 */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* 반경 */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* 간격 */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
--space-20: 80px;
|
||||
--space-24: 96px;
|
||||
|
||||
/* 폰트 크기 */
|
||||
--text-xs: 12px;
|
||||
--text-sm: 14px;
|
||||
--text-base: 16px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 24px;
|
||||
--text-3xl: 30px;
|
||||
--text-4xl: 36px;
|
||||
--text-5xl: 48px;
|
||||
|
||||
/* 폰트 두께 */
|
||||
--font-light: 300;
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
--font-extrabold: 800;
|
||||
|
||||
/* 애니메이션 */
|
||||
--transition-fast: 150ms ease-in-out;
|
||||
--transition-normal: 250ms ease-in-out;
|
||||
--transition-slow: 350ms ease-in-out;
|
||||
}
|
||||
|
||||
/* ========== 기본 리셋 ========== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Pretendard', 'Malgun Gothic', 'Apple SD Gothic Neo', system-ui, sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ========== 타이포그래피 ========== */
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-xl { font-size: var(--text-xl); }
|
||||
.text-2xl { font-size: var(--text-2xl); }
|
||||
.text-3xl { font-size: var(--text-3xl); }
|
||||
.text-4xl { font-size: var(--text-4xl); }
|
||||
.text-5xl { font-size: var(--text-5xl); }
|
||||
|
||||
.font-light { font-weight: var(--font-light); }
|
||||
.font-normal { font-weight: var(--font-normal); }
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
.font-extrabold { font-weight: var(--font-extrabold); }
|
||||
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-tertiary { color: var(--text-tertiary); }
|
||||
.text-inverse { color: var(--text-inverse); }
|
||||
|
||||
/* ========== 카드 컴포넌트 ========== */
|
||||
.card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: var(--space-6);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: var(--space-6);
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
|
||||
/* ========== 버튼 컴포넌트 ========== */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-500);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-600);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--gray-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-medium);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-500);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: var(--success-700);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning-500);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: var(--warning-700);
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
background: var(--error-500);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-error:hover:not(:disabled) {
|
||||
background: var(--error-700);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* ========== 배지 컴포넌트 ========== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--primary-100);
|
||||
color: var(--primary-800);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-50);
|
||||
color: var(--success-700);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--warning-50);
|
||||
color: var(--warning-700);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: var(--error-50);
|
||||
color: var(--error-700);
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* ========== 상태 표시기 ========== */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: var(--success-500);
|
||||
box-shadow: 0 0 0 2px var(--success-100);
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: var(--gray-400);
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: var(--warning-500);
|
||||
box-shadow: 0 0 0 2px var(--warning-100);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--error-500);
|
||||
box-shadow: 0 0 0 2px var(--error-100);
|
||||
}
|
||||
|
||||
/* ========== 그리드 시스템 ========== */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.grid-cols-1 { grid-template-columns: repeat(1, 1fr); }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-2,
|
||||
.grid-cols-3,
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 플렉스 유틸리티 ========== */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.items-end { align-items: flex-end; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-start { justify-content: flex-start; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.gap-1 { gap: var(--space-1); }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-3 { gap: var(--space-3); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
.gap-6 { gap: var(--space-6); }
|
||||
|
||||
/* ========== 간격 유틸리티 ========== */
|
||||
.p-1 { padding: var(--space-1); }
|
||||
.p-2 { padding: var(--space-2); }
|
||||
.p-3 { padding: var(--space-3); }
|
||||
.p-4 { padding: var(--space-4); }
|
||||
.p-6 { padding: var(--space-6); }
|
||||
.p-8 { padding: var(--space-8); }
|
||||
|
||||
.m-1 { margin: var(--space-1); }
|
||||
.m-2 { margin: var(--space-2); }
|
||||
.m-3 { margin: var(--space-3); }
|
||||
.m-4 { margin: var(--space-4); }
|
||||
.m-6 { margin: var(--space-6); }
|
||||
.m-8 { margin: var(--space-8); }
|
||||
|
||||
.mb-2 { margin-bottom: var(--space-2); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.mt-6 { margin-top: var(--space-6); }
|
||||
|
||||
/* ========== 반응형 유틸리티 ========== */
|
||||
@media (max-width: 640px) {
|
||||
.sm\:hidden { display: none; }
|
||||
.sm\:text-sm { font-size: var(--text-sm); }
|
||||
.sm\:p-4 { padding: var(--space-4); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.md\:hidden { display: none; }
|
||||
.md\:flex-col { flex-direction: column; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.lg\:hidden { display: none; }
|
||||
}
|
||||
|
||||
/* ========== 애니메이션 ========== */
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-normal) ease-in-out;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp var(--transition-normal) ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 로딩 스피너 ========== */
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-top: 2px solid var(--primary-500);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
1570
system2-report/web/css/project-management.css
Normal file
1570
system2-report/web/css/project-management.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
system2-report/web/img/favicon.png
Normal file
BIN
system2-report/web/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -2,7 +2,7 @@
|
||||
* 신고 상세 페이지 JavaScript
|
||||
*/
|
||||
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
|
||||
let reportId = null;
|
||||
let reportData = null;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
// API 설정
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
|
||||
// 상태 변수
|
||||
let selectedFactoryId = null;
|
||||
@@ -175,7 +175,7 @@ async function loadMapImage() {
|
||||
if (data.success && data.data) {
|
||||
const selectedCategory = data.data.find(c => c.category_id == selectedFactoryId);
|
||||
if (selectedCategory && selectedCategory.layout_image) {
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* category_type=safety 고정 필터
|
||||
*/
|
||||
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
const CATEGORY_TYPE = 'safety';
|
||||
|
||||
// 상태 한글 변환
|
||||
@@ -110,7 +110,7 @@ function renderIssues(issues) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
|
||||
|
||||
issueList.innerHTML = issues.map(issue => {
|
||||
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
*/
|
||||
|
||||
// API 설정
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
|
||||
|
||||
// 상태 변수
|
||||
let selectedFactoryId = null;
|
||||
let selectedWorkplaceId = null;
|
||||
let selectedWorkplaceName = null;
|
||||
let selectedType = null; // 'nonconformity' | 'safety'
|
||||
let selectedType = null; // 'nonconformity' | 'safety' | 'facility'
|
||||
let selectedCategoryId = null;
|
||||
let selectedCategoryName = null;
|
||||
let selectedItemId = null;
|
||||
let customItemName = null;
|
||||
let selectedTbmSessionId = null;
|
||||
let selectedVisitRequestId = null;
|
||||
let photos = [null, null, null, null, null];
|
||||
@@ -166,10 +167,9 @@ async function loadMapImage() {
|
||||
if (data.success && data.data) {
|
||||
const selectedCategory = data.data.find(c => c.category_id == selectedFactoryId);
|
||||
if (selectedCategory && selectedCategory.layout_image) {
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
: selectedCategory.layout_image;
|
||||
|
||||
canvasImage = new Image();
|
||||
canvasImage.onload = () => renderMap();
|
||||
@@ -251,7 +251,7 @@ function renderMap() {
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 배치도 이미지
|
||||
if (canvasImage && canvasImage.complete) {
|
||||
if (canvasImage && canvasImage.complete && canvasImage.naturalWidth > 0) {
|
||||
const scale = Math.min(canvas.width / canvasImage.width, canvas.height / canvasImage.height);
|
||||
const x = (canvas.width - canvasImage.width * scale) / 2;
|
||||
const y = (canvas.height - canvasImage.height * scale) / 2;
|
||||
@@ -587,6 +587,14 @@ function renderItems(items) {
|
||||
btn.onclick = () => onItemSelect(item, btn);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
// 직접 입력 버튼 추가
|
||||
const customBtn = document.createElement('button');
|
||||
customBtn.type = 'button';
|
||||
customBtn.className = 'item-btn custom-input-btn';
|
||||
customBtn.textContent = '+ 직접 입력';
|
||||
customBtn.onclick = () => showCustomItemInput(customBtn);
|
||||
grid.appendChild(customBtn);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,6 +606,21 @@ function onItemSelect(item, btn) {
|
||||
btn.classList.add('selected');
|
||||
|
||||
selectedItemId = item.item_id;
|
||||
customItemName = null;
|
||||
|
||||
// 직접 입력 영역 숨기기
|
||||
const customInput = document.getElementById('customItemInput');
|
||||
if (customInput) {
|
||||
customInput.style.display = 'none';
|
||||
document.getElementById('customItemName').value = '';
|
||||
}
|
||||
|
||||
// 직접 입력 버튼 텍스트 초기화
|
||||
const customBtn = document.querySelector('.item-btn.custom-input-btn');
|
||||
if (customBtn) {
|
||||
customBtn.textContent = '+ 직접 입력';
|
||||
}
|
||||
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
@@ -667,9 +690,9 @@ function updateStepStatus() {
|
||||
steps[2].classList.toggle('active', step2Complete);
|
||||
|
||||
// Step 3: 항목
|
||||
const step3Complete = selectedItemId;
|
||||
steps[2].classList.toggle('completed', step3Complete);
|
||||
steps[3].classList.toggle('active', step3Complete);
|
||||
const step3Complete = selectedItemId || (selectedItemId === 'custom' && customItemName);
|
||||
steps[2].classList.toggle('completed', !!step3Complete);
|
||||
steps[3].classList.toggle('active', !!step3Complete);
|
||||
|
||||
// 제출 버튼 활성화
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
@@ -697,7 +720,8 @@ async function submitReport() {
|
||||
tbm_session_id: selectedTbmSessionId,
|
||||
visit_request_id: selectedVisitRequestId,
|
||||
issue_category_id: selectedCategoryId,
|
||||
issue_item_id: selectedItemId,
|
||||
issue_item_id: selectedItemId === 'custom' ? null : selectedItemId,
|
||||
custom_item_name: customItemName || null,
|
||||
additional_description: additionalDescription || null,
|
||||
photos: photos.filter(p => p !== null)
|
||||
};
|
||||
@@ -728,6 +752,77 @@ async function submitReport() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 직접 입력 버튼 클릭
|
||||
*/
|
||||
function showCustomItemInput(btn) {
|
||||
// 기존 항목 선택 해제
|
||||
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
|
||||
btn.classList.add('selected');
|
||||
selectedItemId = null;
|
||||
customItemName = null;
|
||||
|
||||
const customInput = document.getElementById('customItemInput');
|
||||
if (customInput) {
|
||||
customInput.style.display = 'flex';
|
||||
document.getElementById('customItemName').focus();
|
||||
}
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 직접 입력 확인
|
||||
*/
|
||||
function confirmCustomItem() {
|
||||
const input = document.getElementById('customItemName');
|
||||
const name = input.value.trim();
|
||||
if (!name) {
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
customItemName = name;
|
||||
selectedItemId = 'custom';
|
||||
updateStepStatus();
|
||||
|
||||
// 직접 입력 UI 숨기되 값은 유지
|
||||
const customInput = document.getElementById('customItemInput');
|
||||
if (customInput) {
|
||||
customInput.style.display = 'none';
|
||||
}
|
||||
|
||||
// 직접 입력 버튼 텍스트 업데이트
|
||||
const customBtn = document.querySelector('.item-btn.custom-input-btn');
|
||||
if (customBtn) {
|
||||
customBtn.textContent = `✓ ${name}`;
|
||||
customBtn.classList.add('selected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 직접 입력 취소
|
||||
*/
|
||||
function cancelCustomItem() {
|
||||
const customInput = document.getElementById('customItemInput');
|
||||
if (customInput) {
|
||||
customInput.style.display = 'none';
|
||||
document.getElementById('customItemName').value = '';
|
||||
}
|
||||
|
||||
customItemName = null;
|
||||
if (selectedItemId === 'custom') {
|
||||
selectedItemId = null;
|
||||
}
|
||||
|
||||
// 직접 입력 버튼 상태 초기화
|
||||
const customBtn = document.querySelector('.item-btn.custom-input-btn');
|
||||
if (customBtn) {
|
||||
customBtn.textContent = '+ 직접 입력';
|
||||
customBtn.classList.remove('selected');
|
||||
}
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
// 기타 위치 입력 시 위치 정보 업데이트
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const customLocationInput = document.getElementById('customLocation');
|
||||
|
||||
@@ -17,7 +17,32 @@ server {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# API 프록시 (시스템 2 API)
|
||||
# System 1 API 프록시 (공장/작업장, TBM, 출입관리)
|
||||
location /api/workplaces/ {
|
||||
proxy_pass http://system1-api:3005;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/tbm/ {
|
||||
proxy_pass http://system1-api:3005;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/workplace-visits/ {
|
||||
proxy_pass http://system1-api:3005;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# System 2 API 프록시 (신고 관련)
|
||||
location /api/ {
|
||||
proxy_pass http://system2-api:3005;
|
||||
proxy_set_header Host $host;
|
||||
@@ -26,9 +51,24 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 업로드 파일
|
||||
location /uploads/ {
|
||||
# System 2 API uploads (신고 사진 등)
|
||||
# ^~ + 더 긴 prefix → /api/ 보다 우선 매칭
|
||||
location ^~ /api/uploads/ {
|
||||
proxy_pass http://system2-api:3005/uploads/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# System 1 uploads 프록시 (작업장 레이아웃 이미지 등)
|
||||
# ^~ : 정적파일 캐시 regex보다 우선 매칭
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass http://system1-api:3005/uploads/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,18 @@ from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
import os
|
||||
from typing import Generator
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://mproject:mproject2024@localhost:5432/mproject")
|
||||
# DB 설정 — 개별 환경변수에서 읽어서 URL 구성 (비밀번호 특수문자 처리)
|
||||
DB_HOST = os.getenv("DB_HOST", "mariadb")
|
||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||
DB_USER = os.getenv("DB_USER", "hyungi_user")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "password")
|
||||
DB_NAME = os.getenv("DB_NAME", "hyungi")
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_recycle=3600)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum, Index, JSON
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -15,8 +14,11 @@ def get_kst_now():
|
||||
Base = declarative_base()
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin" # 관리자
|
||||
user = "user" # 일반 사용자
|
||||
system = "system" # 시스템 관리자
|
||||
admin = "admin" # 관리자
|
||||
support_team = "support_team" # 지원팀
|
||||
leader = "leader" # 리더
|
||||
user = "user" # 일반 사용자
|
||||
|
||||
class IssueStatus(str, enum.Enum):
|
||||
new = "new"
|
||||
@@ -51,77 +53,76 @@ class DepartmentType(str, enum.Enum):
|
||||
sales = "sales" # 영업
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
full_name = Column(String)
|
||||
__tablename__ = "sso_users"
|
||||
|
||||
# Column mapping: Python attr → DB column
|
||||
id = Column("user_id", Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column("password_hash", String(255), nullable=False)
|
||||
full_name = Column("name", String(100))
|
||||
role = Column(Enum(UserRole), default=UserRole.user)
|
||||
department = Column(Enum(DepartmentType)) # 부서 정보 추가
|
||||
department = Column(String(50))
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
|
||||
# Relationships
|
||||
issues = relationship("Issue", back_populates="reporter", foreign_keys="Issue.reporter_id")
|
||||
reviewed_issues = relationship("Issue", foreign_keys="Issue.reviewed_by_id")
|
||||
daily_works = relationship("DailyWork", back_populates="created_by")
|
||||
projects = relationship("Project", back_populates="created_by")
|
||||
page_permissions = relationship("UserPagePermission", back_populates="user", foreign_keys="UserPagePermission.user_id")
|
||||
|
||||
class UserPagePermission(Base):
|
||||
__tablename__ = "user_page_permissions"
|
||||
|
||||
__tablename__ = "qc_user_page_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("sso_users.user_id", ondelete="CASCADE"), nullable=False)
|
||||
page_name = Column(String(50), nullable=False)
|
||||
can_access = Column(Boolean, default=False)
|
||||
granted_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
granted_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
granted_at = Column(DateTime, default=get_kst_now)
|
||||
notes = Column(Text)
|
||||
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="page_permissions", foreign_keys=[user_id])
|
||||
granted_by = relationship("User", foreign_keys=[granted_by_id], post_update=True)
|
||||
|
||||
# Unique constraint
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_user_page_permissions_user_id', 'user_id'),
|
||||
Index('idx_user_page_permissions_page_name', 'page_name'),
|
||||
Index('idx_qc_user_page_perm_user_id', 'user_id'),
|
||||
Index('idx_qc_user_page_perm_page_name', 'page_name'),
|
||||
)
|
||||
|
||||
class Issue(Base):
|
||||
__tablename__ = "issues"
|
||||
|
||||
__tablename__ = "qc_issues"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
photo_path = Column(String)
|
||||
photo_path2 = Column(String)
|
||||
photo_path3 = Column(String)
|
||||
photo_path4 = Column(String)
|
||||
photo_path5 = Column(String)
|
||||
photo_path = Column(String(500))
|
||||
photo_path2 = Column(String(500))
|
||||
photo_path3 = Column(String(500))
|
||||
photo_path4 = Column(String(500))
|
||||
photo_path5 = Column(String(500))
|
||||
category = Column(Enum(IssueCategory), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
status = Column(Enum(IssueStatus), default=IssueStatus.new)
|
||||
reporter_id = Column(Integer, ForeignKey("users.id"))
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id"))
|
||||
reporter_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
project_id = Column(Integer) # FK 제거 — projects는 tkuser에서 관리
|
||||
report_date = Column(DateTime, default=get_kst_now)
|
||||
work_hours = Column(Float, default=0)
|
||||
detail_notes = Column(Text)
|
||||
|
||||
|
||||
# 수신함 워크플로우 관련 컬럼들
|
||||
review_status = Column(Enum(ReviewStatus), default=ReviewStatus.pending_review)
|
||||
disposal_reason = Column(Enum(DisposalReasonType))
|
||||
custom_disposal_reason = Column(Text)
|
||||
disposed_at = Column(DateTime)
|
||||
reviewed_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
reviewed_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
reviewed_at = Column(DateTime)
|
||||
original_data = Column(JSONB) # 원본 데이터 보존
|
||||
modification_log = Column(JSONB, default=lambda: []) # 수정 이력
|
||||
|
||||
original_data = Column(JSON)
|
||||
modification_log = Column(JSON, default=lambda: [])
|
||||
|
||||
# 중복 신고 추적 시스템
|
||||
duplicate_of_issue_id = Column(Integer, ForeignKey("issues.id")) # 중복 대상 이슈 ID
|
||||
duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록
|
||||
|
||||
duplicate_of_issue_id = Column(Integer, ForeignKey("qc_issues.id"))
|
||||
duplicate_reporters = Column(JSON, default=lambda: [])
|
||||
|
||||
# 관리함에서 사용할 추가 필드들
|
||||
solution = Column(Text) # 해결방안 (관리함에서 입력)
|
||||
responsible_department = Column(Enum(DepartmentType)) # 담당부서
|
||||
@@ -133,16 +134,16 @@ class Issue(Base):
|
||||
project_sequence_no = Column(Integer) # 프로젝트별 순번 (No)
|
||||
final_description = Column(Text) # 최종 내용 (수정본 또는 원본)
|
||||
final_category = Column(Enum(IssueCategory)) # 최종 카테고리 (수정본 또는 원본)
|
||||
|
||||
|
||||
# 추가 정보 필드들 (관리함에서 기록용)
|
||||
responsible_person_detail = Column(String(200)) # 해당자 상세 정보
|
||||
cause_detail = Column(Text) # 원인 상세 정보
|
||||
additional_info_updated_at = Column(DateTime) # 추가 정보 입력 시간
|
||||
additional_info_updated_by_id = Column(Integer, ForeignKey("users.id")) # 추가 정보 입력자
|
||||
|
||||
additional_info_updated_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 추가 정보 입력자
|
||||
|
||||
# 완료 신청 관련 필드들
|
||||
completion_requested_at = Column(DateTime) # 완료 신청 시간
|
||||
completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자
|
||||
completion_requested_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 완료 신청자
|
||||
completion_photo_path = Column(String(500)) # 완료 사진 1
|
||||
completion_photo_path2 = Column(String(500)) # 완료 사진 2
|
||||
completion_photo_path3 = Column(String(500)) # 완료 사진 3
|
||||
@@ -152,7 +153,7 @@ class Issue(Base):
|
||||
|
||||
# 완료 반려 관련 필드들
|
||||
completion_rejected_at = Column(DateTime) # 완료 반려 시간
|
||||
completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자
|
||||
completion_rejected_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 완료 반려자
|
||||
completion_rejection_reason = Column(Text) # 완료 반려 사유
|
||||
|
||||
# 일일보고서 추출 이력
|
||||
@@ -162,26 +163,29 @@ class Issue(Base):
|
||||
# Relationships
|
||||
reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id])
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues")
|
||||
project = relationship("Project", back_populates="issues")
|
||||
project = relationship("Project", back_populates="issues",
|
||||
primaryjoin="Issue.project_id == Project.id",
|
||||
foreign_keys=[project_id])
|
||||
duplicate_of = relationship("Issue", remote_side=[id], foreign_keys=[duplicate_of_issue_id])
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
job_no = Column(String, unique=True, nullable=False, index=True)
|
||||
project_name = Column(String, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Column mapping: Python attr id → DB column project_id
|
||||
id = Column("project_id", Integer, primary_key=True, index=True)
|
||||
job_no = Column(String(50), unique=True, nullable=False, index=True)
|
||||
project_name = Column(String(255), nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="projects")
|
||||
issues = relationship("Issue", back_populates="project")
|
||||
issues = relationship("Issue", back_populates="project",
|
||||
primaryjoin="Project.id == Issue.project_id",
|
||||
foreign_keys="[Issue.project_id]")
|
||||
|
||||
class DailyWork(Base):
|
||||
__tablename__ = "daily_works"
|
||||
|
||||
__tablename__ = "qc_daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
worker_count = Column(Integer, nullable=False)
|
||||
@@ -190,20 +194,20 @@ class DailyWork(Base):
|
||||
overtime_hours = Column(Float, default=0)
|
||||
overtime_total = Column(Float, default=0)
|
||||
total_hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="daily_works")
|
||||
|
||||
class ProjectDailyWork(Base):
|
||||
__tablename__ = "project_daily_works"
|
||||
__tablename__ = "qc_project_daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id"), nullable=False)
|
||||
project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False)
|
||||
hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
@@ -211,13 +215,13 @@ class ProjectDailyWork(Base):
|
||||
created_by = relationship("User")
|
||||
|
||||
class DeletionLog(Base):
|
||||
__tablename__ = "deletion_logs"
|
||||
__tablename__ = "qc_deletion_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_type = Column(String(50), nullable=False) # 'issue', 'project', 'daily_work' 등
|
||||
entity_id = Column(Integer, nullable=False) # 삭제된 엔티티의 ID
|
||||
entity_data = Column(JSONB, nullable=False) # 삭제된 데이터 전체 (JSON)
|
||||
deleted_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
entity_data = Column(JSON, nullable=False) # 삭제된 데이터 전체 (JSON)
|
||||
deleted_by_id = Column(Integer, ForeignKey("sso_users.user_id"), nullable=False)
|
||||
deleted_at = Column(DateTime, default=get_kst_now, nullable=False)
|
||||
reason = Column(Text) # 삭제 사유 (선택사항)
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
class UserRole(str, Enum):
|
||||
system = "system"
|
||||
admin = "admin"
|
||||
support_team = "support_team"
|
||||
leader = "leader"
|
||||
user = "user"
|
||||
|
||||
class IssueStatus(str, Enum):
|
||||
@@ -285,12 +288,11 @@ class ProjectUpdate(BaseModel):
|
||||
|
||||
class Project(ProjectBase):
|
||||
id: int
|
||||
created_by_id: int
|
||||
created_by: User
|
||||
created_by_id: Optional[int] = None
|
||||
created_by: Optional[User] = None
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
# issues: Optional[List['Issue']] = None # 순환 참조 방지를 위해 제거
|
||||
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ from database.models import Base
|
||||
from routers import auth, issues, daily_work, reports, projects, page_permissions, inbox, management
|
||||
from services.auth_service import create_admin_user
|
||||
|
||||
# 데이터베이스 테이블 생성
|
||||
# 메타데이터 캐시 클리어
|
||||
Base.metadata.clear()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# 데이터베이스 테이블 생성 (sso_users, projects는 이미 존재하므로 제외)
|
||||
tables_to_create = [
|
||||
table for name, table in Base.metadata.tables.items()
|
||||
if name not in ("sso_users", "projects")
|
||||
]
|
||||
Base.metadata.create_all(bind=engine, tables=tables_to_create)
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
|
||||
@@ -5,7 +5,7 @@ python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.2
|
||||
sqlalchemy==2.0.23
|
||||
psycopg2-binary==2.9.9
|
||||
pymysql==1.1.0
|
||||
alembic==1.12.1
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
@@ -13,3 +13,4 @@ pillow==10.1.0
|
||||
pillow-heif==0.13.0
|
||||
reportlab==4.0.7
|
||||
openpyxl==3.1.2
|
||||
httpx==0.27.0
|
||||
|
||||
@@ -35,7 +35,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
||||
sso_role = payload.get("role", "user")
|
||||
role_map = {
|
||||
"Admin": UserRole.admin,
|
||||
"System Admin": UserRole.admin,
|
||||
"System Admin": UserRole.system,
|
||||
"system": UserRole.system,
|
||||
"admin": UserRole.admin,
|
||||
"support_team": UserRole.support_team,
|
||||
"leader": UserRole.leader,
|
||||
}
|
||||
mapped_role = role_map.get(sso_role, UserRole.user)
|
||||
|
||||
@@ -50,7 +54,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
||||
return sso_user
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role != UserRole.admin:
|
||||
if current_user.role not in [UserRole.admin, UserRole.system]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, User, Project, ReviewStatus, DisposalReasonType
|
||||
from database.models import Issue, User, ReviewStatus, DisposalReasonType
|
||||
from utils.tkuser_client import get_token_from_request
|
||||
import utils.tkuser_client as tkuser_client
|
||||
from database.schemas import (
|
||||
InboxIssue, IssueDisposalRequest, IssueReviewRequest,
|
||||
IssueStatusUpdateRequest, ModificationLogEntry, ManagementUpdateRequest
|
||||
@@ -123,6 +125,7 @@ async def dispose_issue(
|
||||
async def review_issue(
|
||||
issue_id: int,
|
||||
review_request: IssueReviewRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -157,9 +160,10 @@ async def review_issue(
|
||||
|
||||
# 프로젝트 변경
|
||||
if review_request.project_id is not None and review_request.project_id != issue.project_id:
|
||||
# 프로젝트 존재 확인
|
||||
# 프로젝트 존재 확인 (tkuser API)
|
||||
if review_request.project_id != 0: # 0은 프로젝트 없음을 의미
|
||||
project = db.query(Project).filter(Project.id == review_request.project_id).first()
|
||||
token = get_token_from_request(request)
|
||||
project = await tkuser_client.get_project(token, review_request.project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=400, detail="존재하지 않는 프로젝트입니다.")
|
||||
|
||||
@@ -264,12 +268,11 @@ async def update_issue_status(
|
||||
# 진행 중 또는 완료 상태로 변경 시 프로젝트별 순번 자동 할당
|
||||
if status_request.review_status in [ReviewStatus.in_progress, ReviewStatus.completed]:
|
||||
if not issue.project_sequence_no:
|
||||
from sqlalchemy import text
|
||||
result = db.execute(
|
||||
text("SELECT generate_project_sequence_no(:project_id)"),
|
||||
{"project_id": issue.project_id}
|
||||
)
|
||||
issue.project_sequence_no = result.scalar()
|
||||
from sqlalchemy import func
|
||||
max_seq = db.query(func.coalesce(func.max(Issue.project_sequence_no), 0)).filter(
|
||||
Issue.project_id == issue.project_id
|
||||
).scalar()
|
||||
issue.project_sequence_no = max_seq + 1
|
||||
|
||||
# 완료 사진 업로드 처리
|
||||
if status_request.completion_photo and status_request.review_status == ReviewStatus.completed:
|
||||
|
||||
@@ -259,9 +259,9 @@ async def get_issue_stats(
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
total = query.count()
|
||||
new = query.filter(Issue.status == IssueStatus.NEW).count()
|
||||
progress = query.filter(Issue.status == IssueStatus.PROGRESS).count()
|
||||
complete = query.filter(Issue.status == IssueStatus.COMPLETE).count()
|
||||
new = query.filter(Issue.status == IssueStatus.new).count()
|
||||
progress = query.filter(Issue.status == IssueStatus.progress).count()
|
||||
complete = query.filter(Issue.status == IssueStatus.complete).count()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from database.database import get_db
|
||||
from database.models import Project, User, UserRole
|
||||
from database.schemas import ProjectCreate, ProjectUpdate, Project as ProjectSchema
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from database.models import User, UserRole
|
||||
from database.schemas import ProjectCreate, ProjectUpdate
|
||||
from routers.auth import get_current_user
|
||||
from utils.tkuser_client import get_token_from_request
|
||||
import utils.tkuser_client as tkuser_client
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/projects",
|
||||
@@ -25,57 +24,36 @@ async def projects_options():
|
||||
"""OPTIONS preflight 요청 처리"""
|
||||
return {"message": "OK"}
|
||||
|
||||
@router.post("/", response_model=ProjectSchema)
|
||||
@router.post("/")
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
request: Request,
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 생성 (관리자만)"""
|
||||
# Job No. 중복 확인
|
||||
existing_project = db.query(Project).filter(Project.job_no == project.job_no).first()
|
||||
if existing_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 Job No.입니다."
|
||||
)
|
||||
|
||||
# 프로젝트 생성
|
||||
db_project = Project(
|
||||
job_no=project.job_no,
|
||||
project_name=project.project_name,
|
||||
created_by_id=current_user.id
|
||||
)
|
||||
|
||||
db.add(db_project)
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
|
||||
return db_project
|
||||
"""프로젝트 생성 (관리자만) - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
return await tkuser_client.create_project(token, project.dict())
|
||||
|
||||
@router.get("/", response_model=List[ProjectSchema])
|
||||
@router.get("/")
|
||||
async def get_projects(
|
||||
request: Request,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""프로젝트 목록 조회"""
|
||||
query = db.query(Project)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Project.is_active == True)
|
||||
|
||||
projects = query.offset(skip).limit(limit).all()
|
||||
return projects
|
||||
"""프로젝트 목록 조회 - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
projects = await tkuser_client.get_projects(token, active_only=active_only)
|
||||
return projects[skip:skip + limit]
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectSchema)
|
||||
@router.get("/{project_id}")
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
request: Request,
|
||||
):
|
||||
"""특정 프로젝트 조회"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
"""특정 프로젝트 조회 - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
project = await tkuser_client.get_project(token, project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -83,47 +61,26 @@ async def get_project(
|
||||
)
|
||||
return project
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectSchema)
|
||||
@router.put("/{project_id}")
|
||||
async def update_project(
|
||||
project_id: int,
|
||||
project_update: ProjectUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
request: Request,
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 수정 (관리자만)"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 업데이트할 필드만 수정
|
||||
update_data = project_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
return project
|
||||
"""프로젝트 수정 (관리자만) - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
return await tkuser_client.update_project(
|
||||
token, project_id, project_update.dict(exclude_unset=True)
|
||||
)
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
request: Request,
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 삭제 (비활성화) (관리자만)"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 실제 삭제 대신 비활성화
|
||||
project.is_active = False
|
||||
db.commit()
|
||||
|
||||
"""프로젝트 삭제 (비활성화) (관리자만) - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
await tkuser_client.delete_project(token, project_id)
|
||||
return {"message": "프로젝트가 삭제되었습니다."}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
@@ -13,9 +13,11 @@ from openpyxl.drawing.image import Image as XLImage
|
||||
import os
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, ReviewStatus
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
from utils.tkuser_client import get_token_from_request
|
||||
import utils.tkuser_client as tkuser_client
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@@ -140,6 +142,7 @@ async def get_report_daily_works(
|
||||
@router.get("/daily-preview")
|
||||
async def preview_daily_report(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -149,8 +152,9 @@ async def preview_daily_report(
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
# 프로젝트 확인 (tkuser API)
|
||||
token = get_token_from_request(request)
|
||||
project = await tkuser_client.get_project(token, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
@@ -178,7 +182,7 @@ async def preview_daily_report(
|
||||
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
|
||||
|
||||
return {
|
||||
"project": schemas.Project.from_orm(project),
|
||||
"project": project,
|
||||
"stats": stats,
|
||||
"issues": issues_data,
|
||||
"total_issues": len(issues)
|
||||
@@ -186,31 +190,33 @@ async def preview_daily_report(
|
||||
|
||||
@router.post("/daily-export")
|
||||
async def export_daily_report(
|
||||
request: schemas.DailyReportRequest,
|
||||
report_req: schemas.DailyReportRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""품질팀용 일일보고서 엑셀 내보내기"""
|
||||
|
||||
|
||||
# 권한 확인 (품질팀만 접근 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == request.project_id).first()
|
||||
|
||||
# 프로젝트 확인 (tkuser API)
|
||||
token = get_token_from_request(request)
|
||||
project = await tkuser_client.get_project(token, report_req.project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_only = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.project_id == report_req.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (모두 조회)
|
||||
all_completed = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.project_id == report_req.project_id,
|
||||
Issue.review_status == ReviewStatus.completed
|
||||
).all()
|
||||
|
||||
@@ -307,7 +313,7 @@ async def export_daily_report(
|
||||
for ws, sheet_issues, sheet_title in sheets_data:
|
||||
# 제목 및 기본 정보
|
||||
ws.merge_cells('A1:L1')
|
||||
ws['A1'] = f"{project.project_name} - {sheet_title}"
|
||||
ws['A1'] = f"{project['project_name']} - {sheet_title}"
|
||||
ws['A1'].font = Font(bold=True, size=16)
|
||||
ws['A1'].alignment = center_alignment
|
||||
|
||||
@@ -725,7 +731,7 @@ async def export_daily_report(
|
||||
|
||||
# 파일명 생성
|
||||
today = date.today().strftime('%Y%m%d')
|
||||
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
|
||||
filename = f"{project['project_name']}_일일보고서_{today}.xlsx"
|
||||
|
||||
# 한글 파일명을 위한 URL 인코딩
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -75,21 +75,11 @@ def authenticate_user(db: Session, username: str, password: str):
|
||||
return user
|
||||
|
||||
def create_admin_user(db: Session):
|
||||
"""초기 관리자 계정 생성"""
|
||||
"""관리자 계정 확인 (SSO에서 관리, 여기서는 조회만)"""
|
||||
admin_username = os.getenv("ADMIN_USERNAME", "hyungi")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "djg3-jj34-X3Q3")
|
||||
|
||||
existing_admin = db.query(User).filter(User.username == admin_username).first()
|
||||
if not existing_admin:
|
||||
admin_user = User(
|
||||
username=admin_username,
|
||||
hashed_password=get_password_hash(admin_password),
|
||||
full_name="관리자",
|
||||
role=UserRole.admin,
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
print(f"관리자 계정 생성됨: {admin_username}")
|
||||
if existing_admin:
|
||||
print(f"관리자 계정 확인됨: {admin_username} (role: {existing_admin.role.value})")
|
||||
else:
|
||||
print(f"관리자 계정이 이미 존재함: {admin_username}")
|
||||
print(f"경고: 관리자 계정이 sso_users에 없습니다: {admin_username}")
|
||||
|
||||
0
system3-nonconformance/api/utils/__init__.py
Normal file
0
system3-nonconformance/api/utils/__init__.py
Normal file
106
system3-nonconformance/api/utils/tkuser_client.py
Normal file
106
system3-nonconformance/api/utils/tkuser_client.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import httpx
|
||||
import os
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
TKUSER_API_URL = os.getenv("TKUSER_API_URL", "http://tkuser-api:3000")
|
||||
|
||||
|
||||
def get_token_from_request(request: Request) -> str:
|
||||
"""Request 헤더에서 Bearer 토큰 추출"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:]
|
||||
return request.cookies.get("sso_token", "")
|
||||
|
||||
|
||||
def _headers(token: str) -> dict:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="인증 토큰이 필요합니다.")
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _map_project(data: dict) -> dict:
|
||||
"""tkuser API 응답을 S3 프론트엔드 형식으로 매핑 (project_id → id)"""
|
||||
return {
|
||||
"id": data.get("project_id"),
|
||||
"job_no": data.get("job_no"),
|
||||
"project_name": data.get("project_name"),
|
||||
"is_active": data.get("is_active", True),
|
||||
"created_at": data.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
async def get_projects(token: str, active_only: bool = True) -> list:
|
||||
"""프로젝트 목록 조회"""
|
||||
endpoint = "/api/projects/active" if active_only else "/api/projects"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(f"{TKUSER_API_URL}{endpoint}", headers=_headers(token))
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 목록 조회 실패")
|
||||
body = resp.json()
|
||||
if not body.get("success"):
|
||||
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
|
||||
return [_map_project(p) for p in body.get("data", [])]
|
||||
|
||||
|
||||
async def get_project(token: str, project_id: int) -> dict | None:
|
||||
"""특정 프로젝트 조회. 없으면 None 반환"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{TKUSER_API_URL}/api/projects/{project_id}", headers=_headers(token)
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 조회 실패")
|
||||
body = resp.json()
|
||||
if not body.get("success"):
|
||||
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
|
||||
return _map_project(body.get("data"))
|
||||
|
||||
|
||||
async def create_project(token: str, data: dict) -> dict:
|
||||
"""프로젝트 생성"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TKUSER_API_URL}/api/projects", headers=_headers(token), json=data
|
||||
)
|
||||
if resp.status_code == 409:
|
||||
raise HTTPException(status_code=400, detail="이미 존재하는 Job No.입니다.")
|
||||
if resp.status_code not in (200, 201):
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 생성 실패")
|
||||
body = resp.json()
|
||||
if not body.get("success"):
|
||||
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
|
||||
return _map_project(body.get("data"))
|
||||
|
||||
|
||||
async def update_project(token: str, project_id: int, data: dict) -> dict:
|
||||
"""프로젝트 수정"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.put(
|
||||
f"{TKUSER_API_URL}/api/projects/{project_id}",
|
||||
headers=_headers(token),
|
||||
json=data,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 수정 실패")
|
||||
body = resp.json()
|
||||
if not body.get("success"):
|
||||
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
|
||||
return _map_project(body.get("data"))
|
||||
|
||||
|
||||
async def delete_project(token: str, project_id: int) -> dict:
|
||||
"""프로젝트 삭제 (비활성화)"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.delete(
|
||||
f"{TKUSER_API_URL}/api/projects/{project_id}", headers=_headers(token)
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 삭제 실패")
|
||||
return resp.json()
|
||||
@@ -339,7 +339,7 @@
|
||||
async function loadProjects() {
|
||||
try {
|
||||
// API에서 최신 프로젝트 데이터 가져오기
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>폐기함 - 작업보고서</title>
|
||||
<title>폐기함 - 부적합 관리</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -91,39 +91,39 @@
|
||||
|
||||
<!-- 아카이브 통계 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-green-600">완료</p>
|
||||
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
|
||||
<p class="text-sm text-slate-500">완료</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="completedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #475569;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-archive text-gray-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-archive text-slate-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">보관</p>
|
||||
<p class="text-2xl font-bold text-gray-700" id="archivedCount">0</p>
|
||||
<p class="text-sm text-slate-500">보관</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="archivedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #dc2626;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-times-circle text-red-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-times-circle text-red-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-red-600">취소</p>
|
||||
<p class="text-2xl font-bold text-red-700" id="cancelledCount">0</p>
|
||||
<p class="text-sm text-slate-500">취소</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="cancelledCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #7c3aed;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt text-purple-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-calendar-alt text-purple-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-purple-600">이번 달</p>
|
||||
<p class="text-2xl font-bold text-purple-700" id="thisMonthCount">0</p>
|
||||
<p class="text-sm text-slate-500">이번 달</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="thisMonthCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,7 +287,7 @@
|
||||
// 프로젝트 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
|
||||
@@ -18,29 +18,27 @@
|
||||
|
||||
/* 대시보드 카드 스타일 */
|
||||
.dashboard-card {
|
||||
transition: all 0.3s ease;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: all 0.2s ease;
|
||||
background: #ffffff;
|
||||
border-left: 4px solid #64748b;
|
||||
}
|
||||
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 이슈 카드 스타일 (세련된 모던 스타일) */
|
||||
/* 이슈 카드 스타일 */
|
||||
.issue-card {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.2s ease;
|
||||
border-left: 4px solid transparent;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
.issue-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
border-left-color: #3b82f6;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.15),
|
||||
0 0 0 1px rgba(59, 130, 246, 0.1),
|
||||
0 0 20px rgba(59, 130, 246, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-left-color: #475569;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.issue-card label {
|
||||
@@ -92,7 +90,7 @@
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
|
||||
background: #475569;
|
||||
transition: width 0.8s ease;
|
||||
}
|
||||
|
||||
@@ -155,55 +153,43 @@
|
||||
|
||||
<!-- 전체 통계 대시보드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="dashboard-card text-white p-6 rounded-xl">
|
||||
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #475569;">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-blue-100 text-sm flex items-center space-x-1">
|
||||
<span>전체 진행 중</span>
|
||||
<div class="w-1.5 h-1.5 bg-blue-200 rounded-full animate-pulse"></div>
|
||||
</p>
|
||||
<p class="text-3xl font-bold" id="totalInProgress">0</p>
|
||||
<p class="text-sm text-slate-500">전체 진행 중</p>
|
||||
<p class="text-3xl font-bold text-slate-800" id="totalInProgress">0</p>
|
||||
</div>
|
||||
<i class="fas fa-tasks text-4xl text-blue-200"></i>
|
||||
<i class="fas fa-tasks text-3xl text-slate-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-br from-green-400 to-green-600 text-white p-6 rounded-xl dashboard-card">
|
||||
|
||||
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #16a34a;">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-green-100 text-sm flex items-center space-x-1">
|
||||
<span>오늘 신규</span>
|
||||
<div class="w-1.5 h-1.5 bg-green-200 rounded-full animate-pulse"></div>
|
||||
</p>
|
||||
<p class="text-3xl font-bold" id="todayNew">0</p>
|
||||
<p class="text-sm text-slate-500">오늘 신규</p>
|
||||
<p class="text-3xl font-bold text-slate-800" id="todayNew">0</p>
|
||||
</div>
|
||||
<i class="fas fa-plus-circle text-4xl text-green-200"></i>
|
||||
<i class="fas fa-plus-circle text-3xl text-green-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-br from-purple-400 to-purple-600 text-white p-6 rounded-xl dashboard-card">
|
||||
|
||||
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #7c3aed;">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-purple-100 text-sm flex items-center space-x-1">
|
||||
<span>완료 대기</span>
|
||||
<div class="w-1.5 h-1.5 bg-purple-200 rounded-full animate-pulse"></div>
|
||||
</p>
|
||||
<p class="text-3xl font-bold" id="pendingCompletion">0</p>
|
||||
<p class="text-sm text-slate-500">완료 대기</p>
|
||||
<p class="text-3xl font-bold text-slate-800" id="pendingCompletion">0</p>
|
||||
</div>
|
||||
<i class="fas fa-hourglass-half text-4xl text-purple-200"></i>
|
||||
<i class="fas fa-hourglass-half text-3xl text-purple-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-br from-red-400 to-red-600 text-white p-6 rounded-xl dashboard-card">
|
||||
|
||||
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #dc2626;">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-red-100 text-sm flex items-center space-x-1">
|
||||
<span>지연 중</span>
|
||||
<div class="w-1.5 h-1.5 bg-red-200 rounded-full animate-pulse"></div>
|
||||
</p>
|
||||
<p class="text-3xl font-bold" id="overdue">0</p>
|
||||
<p class="text-sm text-slate-500">지연 중</p>
|
||||
<p class="text-3xl font-bold text-slate-800" id="overdue">0</p>
|
||||
</div>
|
||||
<i class="fas fa-clock text-4xl text-red-200"></i>
|
||||
<i class="fas fa-clock text-3xl text-red-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,7 +309,7 @@
|
||||
// 데이터 로드 함수들
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>수신함 - 작업보고서</title>
|
||||
<title>수신함 - 부적합 관리</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -200,30 +200,30 @@
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="bg-yellow-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #d97706;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-plus-circle text-yellow-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-plus-circle text-amber-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-yellow-600">금일 신규</p>
|
||||
<p class="text-2xl font-bold text-yellow-700" id="todayNewCount">0</p>
|
||||
<p class="text-sm text-slate-500">금일 신규</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="todayNewCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-green-600">금일 처리</p>
|
||||
<p class="text-2xl font-bold text-green-700" id="todayProcessedCount">0</p>
|
||||
<p class="text-sm text-slate-500">금일 처리</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="todayProcessedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #dc2626;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-exclamation-triangle text-red-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-red-600">미해결</p>
|
||||
<p class="text-2xl font-bold text-red-700" id="unresolvedCount">0</p>
|
||||
<p class="text-sm text-slate-500">미해결</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="unresolvedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -668,7 +668,7 @@
|
||||
async function loadProjects() {
|
||||
console.log('🔄 프로젝트 로드 시작');
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리함 - 작업보고서</title>
|
||||
<title>관리함 - 부적합 관리</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -273,39 +273,39 @@
|
||||
|
||||
<!-- 프로젝트별 통계 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #475569;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-chart-bar text-gray-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-chart-bar text-slate-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">총 부적합</p>
|
||||
<p class="text-2xl font-bold text-gray-700" id="totalCount">0</p>
|
||||
<p class="text-sm text-slate-500">총 부적합</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="totalCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #2563eb;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-cog text-blue-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-cog text-blue-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-blue-600">진행 중</p>
|
||||
<p class="text-2xl font-bold text-blue-700" id="inProgressCount">0</p>
|
||||
<p class="text-sm text-slate-500">진행 중</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="inProgressCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #7c3aed;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-hourglass-half text-purple-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-hourglass-half text-purple-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-purple-600">완료 대기</p>
|
||||
<p class="text-2xl font-bold text-purple-700" id="pendingCompletionCount">0</p>
|
||||
<p class="text-sm text-slate-500">완료 대기</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="pendingCompletionCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
|
||||
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm text-green-600">완료됨</p>
|
||||
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
|
||||
<p class="text-sm text-slate-500">완료됨</p>
|
||||
<p class="text-2xl font-bold text-slate-800" id="completedCount">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,7 +472,7 @@
|
||||
// 프로젝트 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
|
||||
@@ -5,7 +5,7 @@ server {
|
||||
client_max_body_size 10M;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
index issues-dashboard.html;
|
||||
|
||||
# HTML 캐시 비활성화
|
||||
location ~* \.html$ {
|
||||
@@ -46,6 +46,6 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri/ /issues-dashboard.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
// 프로젝트 목록 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
@@ -302,7 +302,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/reports/daily-preview?project_id=${selectedProjectId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
@@ -427,7 +427,7 @@
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>생성 중...';
|
||||
button.disabled = true;
|
||||
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/reports/daily-export`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -41,6 +41,11 @@ const API_BASE_URL = (() => {
|
||||
return protocol + '//' + hostname + ':16080/api';
|
||||
}
|
||||
|
||||
// 통합 Docker 환경에서 직접 접근 (포트 30280)
|
||||
if (port === '30280') {
|
||||
return protocol + '//' + hostname + ':30200/api';
|
||||
}
|
||||
|
||||
// 기타 환경
|
||||
return '/api';
|
||||
})();
|
||||
@@ -77,6 +82,10 @@ const TokenManager = {
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 노출 (permissions.js 등 다른 스크립트에서 접근)
|
||||
window.TokenManager = TokenManager;
|
||||
window.API_BASE_URL = API_BASE_URL;
|
||||
|
||||
// API 요청 헬퍼
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
@@ -10,81 +10,66 @@ class CommonHeader {
|
||||
this.menuItems = this.initMenuItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 관리 URL (tkuser 서브도메인 또는 로컬 포트)
|
||||
*/
|
||||
_getUserManageUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return window.location.protocol + '//tkuser.technicalkorea.net';
|
||||
}
|
||||
return window.location.protocol + '//' + hostname + ':30380';
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 아이템 정의
|
||||
*/
|
||||
initMenuItems() {
|
||||
return [
|
||||
{
|
||||
id: 'daily_work',
|
||||
title: '일일 공수',
|
||||
icon: 'fas fa-calendar-check',
|
||||
url: '/daily-work.html',
|
||||
pageName: 'daily_work',
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50 hover:bg-blue-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_create',
|
||||
title: '부적합 등록',
|
||||
icon: 'fas fa-plus-circle',
|
||||
url: '/index.html',
|
||||
pageName: 'issues_create',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50 hover:bg-green-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_view',
|
||||
title: '신고내용조회',
|
||||
icon: 'fas fa-search',
|
||||
url: '/issue-view.html',
|
||||
pageName: 'issues_view',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50 hover:bg-purple-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_manage',
|
||||
title: '목록 관리',
|
||||
icon: 'fas fa-tasks',
|
||||
url: '/index.html#list',
|
||||
pageName: 'issues_manage',
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50 hover:bg-orange-100',
|
||||
subMenus: [
|
||||
{
|
||||
id: 'issues_inbox',
|
||||
title: '수신함',
|
||||
icon: 'fas fa-inbox',
|
||||
url: '/issues-inbox.html',
|
||||
pageName: 'issues_inbox',
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 'issues_management',
|
||||
title: '관리함',
|
||||
icon: 'fas fa-cog',
|
||||
url: '/issues-management.html',
|
||||
pageName: 'issues_management',
|
||||
color: 'text-green-600'
|
||||
},
|
||||
{
|
||||
id: 'issues_archive',
|
||||
title: '폐기함',
|
||||
icon: 'fas fa-archive',
|
||||
url: '/issues-archive.html',
|
||||
pageName: 'issues_archive',
|
||||
color: 'text-gray-600'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'issues_dashboard',
|
||||
title: '현황판',
|
||||
icon: 'fas fa-chart-line',
|
||||
url: '/issues-dashboard.html',
|
||||
pageName: 'issues_dashboard',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50 hover:bg-purple-100'
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_inbox',
|
||||
title: '수신함',
|
||||
icon: 'fas fa-inbox',
|
||||
url: '/issues-inbox.html',
|
||||
pageName: 'issues_inbox',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_management',
|
||||
title: '관리함',
|
||||
icon: 'fas fa-cog',
|
||||
url: '/issues-management.html',
|
||||
pageName: 'issues_management',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100'
|
||||
},
|
||||
{
|
||||
id: 'issues_archive',
|
||||
title: '폐기함',
|
||||
icon: 'fas fa-archive',
|
||||
url: '/issues-archive.html',
|
||||
pageName: 'issues_archive',
|
||||
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',
|
||||
@@ -92,8 +77,8 @@ class CommonHeader {
|
||||
icon: 'fas fa-chart-bar',
|
||||
url: '/reports.html',
|
||||
pageName: 'reports',
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50 hover:bg-red-100',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100',
|
||||
subMenus: [
|
||||
{
|
||||
id: 'reports_daily',
|
||||
@@ -101,7 +86,7 @@ class CommonHeader {
|
||||
icon: 'fas fa-file-excel',
|
||||
url: '/reports-daily.html',
|
||||
pageName: 'reports_daily',
|
||||
color: 'text-green-600'
|
||||
color: 'text-slate-600'
|
||||
},
|
||||
{
|
||||
id: 'reports_weekly',
|
||||
@@ -109,7 +94,7 @@ class CommonHeader {
|
||||
icon: 'fas fa-calendar-week',
|
||||
url: '/reports-weekly.html',
|
||||
pageName: 'reports_weekly',
|
||||
color: 'text-blue-600'
|
||||
color: 'text-slate-600'
|
||||
},
|
||||
{
|
||||
id: 'reports_monthly',
|
||||
@@ -117,7 +102,7 @@ class CommonHeader {
|
||||
icon: 'fas fa-calendar-alt',
|
||||
url: '/reports-monthly.html',
|
||||
pageName: 'reports_monthly',
|
||||
color: 'text-purple-600'
|
||||
color: 'text-slate-600'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -127,17 +112,18 @@ class CommonHeader {
|
||||
icon: 'fas fa-folder-open',
|
||||
url: '/project-management.html',
|
||||
pageName: 'projects_manage',
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50 hover:bg-indigo-100'
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100'
|
||||
},
|
||||
{
|
||||
id: 'users_manage',
|
||||
title: '사용자 관리',
|
||||
icon: 'fas fa-users-cog',
|
||||
url: '/admin.html',
|
||||
url: this._getUserManageUrl(),
|
||||
pageName: 'users_manage',
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50 hover:bg-gray-100'
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'text-slate-600 hover:bg-slate-100',
|
||||
external: true
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -225,8 +211,8 @@ class CommonHeader {
|
||||
<!-- 로고 및 제목 -->
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<i class="fas fa-clipboard-check text-2xl text-blue-600 mr-3"></i>
|
||||
<h1 class="text-xl font-bold text-gray-900">작업보고서</h1>
|
||||
<i class="fas fa-shield-halved text-2xl text-slate-700 mr-3"></i>
|
||||
<h1 class="text-xl font-bold text-gray-900">부적합 관리</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -243,7 +229,7 @@ class CommonHeader {
|
||||
<div class="text-sm font-medium text-gray-900">${userDisplayName}</div>
|
||||
<div class="text-xs text-gray-500">${userRole}</div>
|
||||
</div>
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-slate-600 rounded-full flex items-center justify-center">
|
||||
<span class="text-white text-sm font-semibold">
|
||||
${userDisplayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
@@ -299,7 +285,7 @@ class CommonHeader {
|
||||
|
||||
// 권한 시스템이 로드되지 않았으면 기본 메뉴만
|
||||
if (!window.canAccessPage) {
|
||||
return ['issues_create', 'issues_view'].includes(menu.id);
|
||||
return ['issues_dashboard', 'issues_inbox'].includes(menu.id);
|
||||
}
|
||||
|
||||
// 메인 메뉴 권한 체크
|
||||
@@ -324,8 +310,8 @@ class CommonHeader {
|
||||
*/
|
||||
generateMenuItemHTML(menu) {
|
||||
const isActive = this.currentPage === menu.id;
|
||||
const activeClass = isActive ? 'bg-blue-100 text-blue-700' : `${menu.bgColor} ${menu.color}`;
|
||||
|
||||
const activeClass = isActive ? 'bg-slate-700 text-white' : `${menu.bgColor} ${menu.color}`;
|
||||
|
||||
// 하위 메뉴가 있는 경우 드롭다운 메뉴 생성
|
||||
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
|
||||
return `
|
||||
@@ -342,7 +328,7 @@ class CommonHeader {
|
||||
<div class="py-1">
|
||||
${menu.accessibleSubMenus.map(subMenu => `
|
||||
<a href="${subMenu.url}"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 ${this.currentPage === subMenu.id ? 'bg-blue-50 text-blue-700' : ''}"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100 ${this.currentPage === subMenu.id ? 'bg-slate-100 text-slate-800 font-medium' : ''}"
|
||||
data-page="${subMenu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
|
||||
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
|
||||
@@ -355,9 +341,21 @@ class CommonHeader {
|
||||
`;
|
||||
}
|
||||
|
||||
// 외부 링크 (tkuser 등)
|
||||
if (menu.external) {
|
||||
return `
|
||||
<a href="${menu.url}"
|
||||
class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
|
||||
data-page="${menu.id}">
|
||||
<i class="${menu.icon} mr-2"></i>
|
||||
${menu.title}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
// 일반 메뉴 아이템
|
||||
return `
|
||||
<a href="${menu.url}"
|
||||
<a href="${menu.url}"
|
||||
class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
|
||||
data-page="${menu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
|
||||
@@ -372,7 +370,7 @@ class CommonHeader {
|
||||
*/
|
||||
generateMobileMenuItemHTML(menu) {
|
||||
const isActive = this.currentPage === menu.id;
|
||||
const activeClass = isActive ? 'bg-blue-50 text-blue-700 border-blue-500' : 'text-gray-700 hover:bg-gray-50';
|
||||
const activeClass = isActive ? 'bg-slate-100 text-slate-800 border-slate-600' : 'text-gray-700 hover:bg-gray-50';
|
||||
|
||||
// 하위 메뉴가 있는 경우
|
||||
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
|
||||
@@ -392,7 +390,7 @@ class CommonHeader {
|
||||
<div class="hidden ml-6 mt-1 space-y-1">
|
||||
${menu.accessibleSubMenus.map(subMenu => `
|
||||
<a href="${subMenu.url}"
|
||||
class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 ${this.currentPage === subMenu.id ? 'bg-blue-50 text-blue-700' : ''}"
|
||||
class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-slate-100 ${this.currentPage === subMenu.id ? 'bg-slate-100 text-slate-800' : ''}"
|
||||
data-page="${subMenu.id}"
|
||||
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
|
||||
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
|
||||
@@ -684,10 +682,11 @@ class CommonHeader {
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
const itemPageId = item.getAttribute('data-page');
|
||||
if (itemPageId === pageId) {
|
||||
item.classList.add('bg-blue-100', 'text-blue-700');
|
||||
item.classList.remove('bg-blue-50', 'hover:bg-blue-100');
|
||||
item.classList.add('bg-slate-700', 'text-white');
|
||||
item.classList.remove('text-slate-600', 'hover:bg-slate-100');
|
||||
} else {
|
||||
item.classList.remove('bg-blue-100', 'text-blue-700');
|
||||
item.classList.remove('bg-slate-700', 'text-white');
|
||||
item.classList.add('text-slate-600');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class PageManager {
|
||||
async checkAuthentication() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
window.location.href = '/index.html';
|
||||
window.location.href = '/issues-dashboard.html';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class PageManager {
|
||||
console.error('인증 실패:', error);
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.href = '/index.html';
|
||||
window.location.href = '/issues-dashboard.html';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ class PageManager {
|
||||
|
||||
// 권한 시스템이 로드되지 않았으면 기본 페이지만 허용
|
||||
if (!window.canAccessPage) {
|
||||
return ['issues_create', 'issues_view'].includes(pageId);
|
||||
return ['issues_dashboard', 'issues_inbox'].includes(pageId);
|
||||
}
|
||||
|
||||
return window.canAccessPage(pageId);
|
||||
@@ -130,11 +130,7 @@ class PageManager {
|
||||
alert('이 페이지에 접근할 권한이 없습니다.');
|
||||
|
||||
// 기본적으로 접근 가능한 페이지로 이동
|
||||
if (window.canAccessPage && window.canAccessPage('issues_view')) {
|
||||
window.location.href = '/issue-view.html';
|
||||
} else {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
window.location.href = '/issues-dashboard.html';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,7 +246,7 @@ class PageManager {
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
다시 시도
|
||||
</button>
|
||||
<button onclick="window.location.href='/index.html'"
|
||||
<button onclick="window.location.href='/issues-dashboard.html'"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
|
||||
홈으로
|
||||
</button>
|
||||
|
||||
@@ -15,13 +15,11 @@ class PagePermissionManager {
|
||||
*/
|
||||
initDefaultPages() {
|
||||
return {
|
||||
'issues_create': { title: '부적합 등록', defaultAccess: true },
|
||||
'issues_view': { title: '부적합 조회', defaultAccess: true },
|
||||
'issues_dashboard': { title: '현황판', defaultAccess: true },
|
||||
'issues_manage': { title: '부적합 관리', defaultAccess: true },
|
||||
'issues_inbox': { title: '수신함', defaultAccess: true },
|
||||
'issues_management': { title: '관리함', defaultAccess: false },
|
||||
'issues_archive': { title: '폐기함', defaultAccess: false },
|
||||
'issues_dashboard': { title: '현황판', defaultAccess: true },
|
||||
'projects_manage': { title: '프로젝트 관리', defaultAccess: false },
|
||||
'daily_work': { title: '일일 공수', defaultAccess: false },
|
||||
'reports': { title: '보고서', defaultAccess: false },
|
||||
@@ -41,15 +39,32 @@ class PagePermissionManager {
|
||||
/**
|
||||
* 사용자별 페이지 권한 로드
|
||||
*/
|
||||
/**
|
||||
* SSO 토큰 직접 읽기 (api.js 로딩 전에도 동작)
|
||||
*/
|
||||
_getToken() {
|
||||
// 1) window.TokenManager (api.js 로딩 완료 시)
|
||||
if (window.TokenManager) return window.TokenManager.getToken();
|
||||
// 2) SSO 쿠키 직접 읽기
|
||||
const match = document.cookie.match(/(?:^|; )sso_token=([^;]*)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
// 3) localStorage 폴백
|
||||
return localStorage.getItem('sso_token') || localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
async loadPagePermissions() {
|
||||
if (!this.currentUser) return;
|
||||
|
||||
const userId = this.currentUser.id || this.currentUser.user_id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
// API에서 사용자별 페이지 권한 가져오기
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const response = await fetch(`${apiUrl}/users/${this.currentUser.id}/page-permissions`, {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const token = this._getToken();
|
||||
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
@@ -199,12 +214,12 @@ class PagePermissionManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/page-permissions/grant`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
'Authorization': `Bearer ${this._getToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
@@ -232,10 +247,10 @@ class PagePermissionManager {
|
||||
*/
|
||||
async getUserPagePermissions(userId) {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
'Authorization': `Bearer ${this._getToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
formData.append('username', 'hyungi');
|
||||
formData.append('password', '123456');
|
||||
|
||||
const response = await fetch('http://localhost:16080/api/auth/login', {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:16080/api/auth/users', {
|
||||
const response = await fetch('/api/auth/users', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
|
||||
18
user-management/api/Dockerfile
Normal file
18
user-management/api/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN chown -R node:node /usr/src/app
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
66
user-management/api/controllers/departmentController.js
Normal file
66
user-management/api/controllers/departmentController.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Department Controller
|
||||
*
|
||||
* 부서 CRUD
|
||||
*/
|
||||
|
||||
const departmentModel = require('../models/departmentModel');
|
||||
|
||||
async function getAll(req, res, next) {
|
||||
try {
|
||||
const departments = await departmentModel.getAll();
|
||||
res.json({ success: true, data: departments });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(req, res, next) {
|
||||
try {
|
||||
const dept = await departmentModel.getById(parseInt(req.params.id));
|
||||
if (!dept) {
|
||||
return res.status(404).json({ success: false, error: '부서를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: dept });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function create(req, res, next) {
|
||||
try {
|
||||
const { department_name } = req.body;
|
||||
if (!department_name) {
|
||||
return res.status(400).json({ success: false, error: '부서명은 필수입니다' });
|
||||
}
|
||||
const dept = await departmentModel.create(req.body);
|
||||
res.status(201).json({ success: true, data: dept });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const dept = await departmentModel.update(id, req.body);
|
||||
if (!dept) {
|
||||
return res.status(404).json({ success: false, error: '부서를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: dept });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await departmentModel.deactivate(id);
|
||||
res.json({ success: true, message: '부서가 비활성화되었습니다' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getAll, getById, create, update, remove };
|
||||
141
user-management/api/controllers/equipmentController.js
Normal file
141
user-management/api/controllers/equipmentController.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Equipment Controller
|
||||
*
|
||||
* 설비 CRUD + 지도위치 + 사진
|
||||
*/
|
||||
|
||||
const equipmentModel = require('../models/equipmentModel');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// ==================== 기본 CRUD ====================
|
||||
|
||||
async function getAll(req, res, next) {
|
||||
try {
|
||||
const filters = {};
|
||||
if (req.query.workplace_id) filters.workplace_id = parseInt(req.query.workplace_id);
|
||||
if (req.query.equipment_type) filters.equipment_type = req.query.equipment_type;
|
||||
if (req.query.status) filters.status = req.query.status;
|
||||
if (req.query.search) filters.search = req.query.search;
|
||||
const equipments = await equipmentModel.getAll(filters);
|
||||
res.json({ success: true, data: equipments });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function getById(req, res, next) {
|
||||
try {
|
||||
const eq = await equipmentModel.getById(parseInt(req.params.id));
|
||||
if (!eq) return res.status(404).json({ success: false, error: '설비를 찾을 수 없습니다' });
|
||||
res.json({ success: true, data: eq });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function getByWorkplace(req, res, next) {
|
||||
try {
|
||||
const equipments = await equipmentModel.getByWorkplace(parseInt(req.params.workplaceId));
|
||||
res.json({ success: true, data: equipments });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function create(req, res, next) {
|
||||
try {
|
||||
const { equipment_code, equipment_name } = req.body;
|
||||
if (!equipment_code || !equipment_name) return res.status(400).json({ success: false, error: '관리번호와 설비명은 필수입니다' });
|
||||
const dup = await equipmentModel.checkDuplicateCode(equipment_code);
|
||||
if (dup) return res.status(409).json({ success: false, error: '이미 존재하는 관리번호입니다' });
|
||||
const eq = await equipmentModel.create(req.body);
|
||||
res.status(201).json({ success: true, data: eq });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function update(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (req.body.equipment_code) {
|
||||
const dup = await equipmentModel.checkDuplicateCode(req.body.equipment_code, id);
|
||||
if (dup) return res.status(409).json({ success: false, error: '이미 존재하는 관리번호입니다' });
|
||||
}
|
||||
const eq = await equipmentModel.update(id, req.body);
|
||||
if (!eq) return res.status(404).json({ success: false, error: '설비를 찾을 수 없습니다' });
|
||||
res.json({ success: true, data: eq });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function remove(req, res, next) {
|
||||
try {
|
||||
await equipmentModel.remove(parseInt(req.params.id));
|
||||
res.json({ success: true, message: '설비가 삭제되었습니다' });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function getTypes(req, res, next) {
|
||||
try {
|
||||
const types = await equipmentModel.getEquipmentTypes();
|
||||
res.json({ success: true, data: types });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function getNextCode(req, res, next) {
|
||||
try {
|
||||
const code = await equipmentModel.getNextCode(req.query.prefix || 'TKP');
|
||||
res.json({ success: true, data: code });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
// ==================== 지도 위치 ====================
|
||||
|
||||
async function updateMapPosition(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const positionData = {
|
||||
map_x_percent: req.body.map_x_percent,
|
||||
map_y_percent: req.body.map_y_percent,
|
||||
map_width_percent: req.body.map_width_percent,
|
||||
map_height_percent: req.body.map_height_percent
|
||||
};
|
||||
if (req.body.workplace_id !== undefined) positionData.workplace_id = req.body.workplace_id;
|
||||
const eq = await equipmentModel.updateMapPosition(id, positionData);
|
||||
res.json({ success: true, data: eq });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
// ==================== 사진 ====================
|
||||
|
||||
async function addPhoto(req, res, next) {
|
||||
try {
|
||||
const equipmentId = parseInt(req.params.id);
|
||||
if (!req.file) return res.status(400).json({ success: false, error: '사진 파일이 필요합니다' });
|
||||
const photoData = {
|
||||
photo_path: `/uploads/${req.file.filename}`,
|
||||
description: req.body.description || null,
|
||||
display_order: parseInt(req.body.display_order) || 0,
|
||||
uploaded_by: req.user?.user_id || null
|
||||
};
|
||||
const result = await equipmentModel.addPhoto(equipmentId, photoData);
|
||||
res.status(201).json({ success: true, data: result });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function getPhotos(req, res, next) {
|
||||
try {
|
||||
const results = await equipmentModel.getPhotos(parseInt(req.params.id));
|
||||
res.json({ success: true, data: results });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function deletePhoto(req, res, next) {
|
||||
try {
|
||||
const result = await equipmentModel.deletePhoto(parseInt(req.params.photoId));
|
||||
if (result.photo_path) {
|
||||
const filePath = path.join(__dirname, '..', result.photo_path);
|
||||
fs.unlink(filePath, () => {});
|
||||
}
|
||||
res.json({ success: true, message: '사진이 삭제되었습니다' });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAll, getById, getByWorkplace, create, update, remove, getTypes, getNextCode,
|
||||
updateMapPosition,
|
||||
addPhoto, getPhotos, deletePhoto
|
||||
};
|
||||
159
user-management/api/controllers/permissionController.js
Normal file
159
user-management/api/controllers/permissionController.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Permission Controller
|
||||
*
|
||||
* 페이지 권한 관리 (system3 page_permissions.py 포팅)
|
||||
*/
|
||||
|
||||
const permissionModel = require('../models/permissionModel');
|
||||
const userModel = require('../models/userModel');
|
||||
|
||||
/**
|
||||
* GET /api/users/:id/page-permissions - 사용자 권한 조회
|
||||
*/
|
||||
async function getUserPermissions(req, res, next) {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
const requesterId = req.user.user_id || req.user.id;
|
||||
|
||||
// 관리자이거나 본인만 조회 가능
|
||||
if (req.user.role !== 'admin' && requesterId !== userId) {
|
||||
return res.status(403).json({ success: false, error: '권한이 없습니다' });
|
||||
}
|
||||
|
||||
const user = await userModel.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const permissions = await permissionModel.getUserPermissions(userId);
|
||||
res.json(permissions);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/permissions/grant - 단건 권한 부여
|
||||
*/
|
||||
async function grantPermission(req, res, next) {
|
||||
try {
|
||||
const { user_id, page_name, can_access, notes } = req.body;
|
||||
const grantedById = req.user.user_id || req.user.id;
|
||||
|
||||
// 대상 사용자 확인
|
||||
const targetUser = await userModel.findById(user_id);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
// 유효한 페이지명 확인
|
||||
if (!permissionModel.DEFAULT_PAGES[page_name]) {
|
||||
return res.status(400).json({ success: false, error: '유효하지 않은 페이지명입니다' });
|
||||
}
|
||||
|
||||
const result = await permissionModel.grantPermission({
|
||||
user_id,
|
||||
page_name,
|
||||
can_access,
|
||||
granted_by_id: grantedById,
|
||||
notes
|
||||
});
|
||||
|
||||
res.json({ success: true, message: '권한이 설정되었습니다', data: result });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/permissions/bulk-grant - 일괄 권한 부여
|
||||
*/
|
||||
async function bulkGrant(req, res, next) {
|
||||
try {
|
||||
const { user_id, permissions } = req.body;
|
||||
const grantedById = req.user.user_id || req.user.id;
|
||||
|
||||
// 대상 사용자 확인
|
||||
const targetUser = await userModel.findById(user_id);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const result = await permissionModel.bulkGrant({
|
||||
user_id,
|
||||
permissions,
|
||||
granted_by_id: grantedById
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${result.updated_count}개의 권한이 설정되었습니다`,
|
||||
updated_count: result.updated_count
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/permissions/check/:uid/:page - 접근 권한 확인
|
||||
*/
|
||||
async function checkAccess(req, res, next) {
|
||||
try {
|
||||
const userId = parseInt(req.params.uid);
|
||||
const pageName = req.params.page;
|
||||
|
||||
// 사용자 확인
|
||||
const user = await userModel.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
// admin은 모든 페이지 접근 가능
|
||||
if (user.role === 'admin') {
|
||||
return res.json({ can_access: true, reason: 'admin_role' });
|
||||
}
|
||||
|
||||
const result = await permissionModel.checkAccess(userId, pageName);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/permissions/available-pages - 설정 가능 페이지 목록
|
||||
*/
|
||||
async function getAvailablePages(req, res) {
|
||||
res.json({
|
||||
pages: permissionModel.DEFAULT_PAGES,
|
||||
total_count: Object.keys(permissionModel.DEFAULT_PAGES).length
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/permissions/:id - 권한 삭제
|
||||
*/
|
||||
async function deletePermission(req, res, next) {
|
||||
try {
|
||||
const permissionId = parseInt(req.params.id);
|
||||
const deleted = await permissionModel.deletePermission(permissionId);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ success: false, error: '권한을 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: '권한이 삭제되었습니다. 기본값이 적용됩니다.' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getUserPermissions,
|
||||
grantPermission,
|
||||
bulkGrant,
|
||||
checkAccess,
|
||||
getAvailablePages,
|
||||
deletePermission
|
||||
};
|
||||
83
user-management/api/controllers/projectController.js
Normal file
83
user-management/api/controllers/projectController.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Project Controller
|
||||
*
|
||||
* 프로젝트 CRUD
|
||||
*/
|
||||
|
||||
const projectModel = require('../models/projectModel');
|
||||
|
||||
async function getAll(req, res, next) {
|
||||
try {
|
||||
const projects = await projectModel.getAll();
|
||||
res.json({ success: true, data: projects });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getActive(req, res, next) {
|
||||
try {
|
||||
const projects = await projectModel.getActive();
|
||||
res.json({ success: true, data: projects });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(req, res, next) {
|
||||
try {
|
||||
const project = await projectModel.getById(parseInt(req.params.id));
|
||||
if (!project) {
|
||||
return res.status(404).json({ success: false, error: '프로젝트를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: project });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function create(req, res, next) {
|
||||
try {
|
||||
const { job_no, project_name } = req.body;
|
||||
|
||||
if (!job_no || !project_name) {
|
||||
return res.status(400).json({ success: false, error: 'Job No와 프로젝트명은 필수입니다' });
|
||||
}
|
||||
|
||||
const project = await projectModel.create(req.body);
|
||||
res.status(201).json({ success: true, data: project });
|
||||
} catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ success: false, error: '이미 존재하는 Job No입니다' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const project = await projectModel.update(id, req.body);
|
||||
if (!project) {
|
||||
return res.status(404).json({ success: false, error: '프로젝트를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: project });
|
||||
} catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ success: false, error: '이미 존재하는 Job No입니다' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await projectModel.deactivate(id);
|
||||
res.json({ success: true, message: '프로젝트가 비활성화되었습니다' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getAll, getActive, getById, create, update, remove };
|
||||
94
user-management/api/controllers/taskController.js
Normal file
94
user-management/api/controllers/taskController.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Task Controller
|
||||
*
|
||||
* 공정(work_types) + 작업(tasks) CRUD
|
||||
*/
|
||||
|
||||
const taskModel = require('../models/taskModel');
|
||||
|
||||
/* ===== Work Types ===== */
|
||||
|
||||
async function getWorkTypes(req, res, next) {
|
||||
try {
|
||||
const data = await taskModel.getWorkTypes();
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function createWorkType(req, res, next) {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
if (!name) return res.status(400).json({ success: false, error: '공정명은 필수입니다' });
|
||||
const data = await taskModel.createWorkType(req.body);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function updateWorkType(req, res, next) {
|
||||
try {
|
||||
const data = await taskModel.updateWorkType(parseInt(req.params.id), req.body);
|
||||
if (!data) return res.status(404).json({ success: false, error: '공정을 찾을 수 없습니다' });
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function deleteWorkType(req, res, next) {
|
||||
try {
|
||||
await taskModel.deleteWorkType(parseInt(req.params.id));
|
||||
res.json({ success: true, message: '공정이 삭제되었습니다' });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
/* ===== Tasks ===== */
|
||||
|
||||
async function getTasks(req, res, next) {
|
||||
try {
|
||||
const workTypeId = req.query.work_type_id ? parseInt(req.query.work_type_id) : null;
|
||||
const data = await taskModel.getTasks(workTypeId);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function getActiveTasks(req, res, next) {
|
||||
try {
|
||||
const data = await taskModel.getActiveTasks();
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function getTaskById(req, res, next) {
|
||||
try {
|
||||
const data = await taskModel.getTaskById(parseInt(req.params.id));
|
||||
if (!data) return res.status(404).json({ success: false, error: '작업을 찾을 수 없습니다' });
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function createTask(req, res, next) {
|
||||
try {
|
||||
const { task_name } = req.body;
|
||||
if (!task_name) return res.status(400).json({ success: false, error: '작업명은 필수입니다' });
|
||||
const data = await taskModel.createTask(req.body);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function updateTask(req, res, next) {
|
||||
try {
|
||||
const data = await taskModel.updateTask(parseInt(req.params.id), req.body);
|
||||
if (!data) return res.status(404).json({ success: false, error: '작업을 찾을 수 없습니다' });
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function deleteTask(req, res, next) {
|
||||
try {
|
||||
await taskModel.deleteTask(parseInt(req.params.id));
|
||||
res.json({ success: true, message: '작업이 삭제되었습니다' });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getWorkTypes, createWorkType, updateWorkType, deleteWorkType,
|
||||
getTasks, getActiveTasks, getTaskById, createTask, updateTask, deleteTask
|
||||
};
|
||||
146
user-management/api/controllers/userController.js
Normal file
146
user-management/api/controllers/userController.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* User Controller
|
||||
*
|
||||
* 사용자 CRUD + 비밀번호 관리
|
||||
*/
|
||||
|
||||
const userModel = require('../models/userModel');
|
||||
|
||||
/**
|
||||
* GET /api/users - 전체 사용자 목록
|
||||
*/
|
||||
async function getUsers(req, res, next) {
|
||||
try {
|
||||
const users = await userModel.findAll();
|
||||
res.json({ success: true, data: users });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/users - 사용자 생성
|
||||
*/
|
||||
async function createUser(req, res, next) {
|
||||
try {
|
||||
const { username, password, name, full_name, department, role } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ success: false, error: '사용자명과 비밀번호는 필수입니다' });
|
||||
}
|
||||
|
||||
const existing = await userModel.findByUsername(username);
|
||||
if (existing) {
|
||||
return res.status(409).json({ success: false, error: '이미 존재하는 사용자명입니다' });
|
||||
}
|
||||
|
||||
const user = await userModel.create({
|
||||
username,
|
||||
password,
|
||||
name: name || full_name,
|
||||
department,
|
||||
role
|
||||
});
|
||||
res.status(201).json({ success: true, data: user });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/users/:id - 사용자 수정
|
||||
*/
|
||||
async function updateUser(req, res, next) {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
const data = { ...req.body };
|
||||
|
||||
// full_name → name 매핑
|
||||
if (data.full_name !== undefined && data.name === undefined) {
|
||||
data.name = data.full_name;
|
||||
delete data.full_name;
|
||||
}
|
||||
|
||||
const user = await userModel.update(userId, data);
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: user });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/users/:id - 사용자 비활성화
|
||||
*/
|
||||
async function deleteUser(req, res, next) {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
await userModel.deleteUser(userId);
|
||||
res.json({ success: true, message: '사용자가 비활성화되었습니다' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/users/:id/reset-password - 비밀번호 초기화 (admin)
|
||||
*/
|
||||
async function resetPassword(req, res, next) {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
const { new_password } = req.body;
|
||||
const password = new_password || '000000';
|
||||
|
||||
const user = await userModel.update(userId, { password });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, message: '비밀번호가 초기화되었습니다' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/users/change-password - 본인 비밀번호 변경
|
||||
*/
|
||||
async function changePassword(req, res, next) {
|
||||
try {
|
||||
const { current_password, new_password } = req.body;
|
||||
const userId = req.user.user_id || req.user.id;
|
||||
|
||||
if (!current_password || !new_password) {
|
||||
return res.status(400).json({ success: false, error: '현재 비밀번호와 새 비밀번호를 입력하세요' });
|
||||
}
|
||||
|
||||
if (new_password.length < 6) {
|
||||
return res.status(400).json({ success: false, error: '새 비밀번호는 최소 6자 이상이어야 합니다' });
|
||||
}
|
||||
|
||||
const user = await userModel.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const valid = await userModel.verifyPassword(current_password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ success: false, error: '현재 비밀번호가 올바르지 않습니다' });
|
||||
}
|
||||
|
||||
await userModel.update(userId, { password: new_password });
|
||||
res.json({ success: true, message: '비밀번호가 변경되었습니다' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
resetPassword,
|
||||
changePassword
|
||||
};
|
||||
126
user-management/api/controllers/vacationController.js
Normal file
126
user-management/api/controllers/vacationController.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Vacation Controller
|
||||
*
|
||||
* 휴가 유형 + 연차 배정 관리
|
||||
*/
|
||||
|
||||
const vacationModel = require('../models/vacationModel');
|
||||
|
||||
/* ===== Vacation Types ===== */
|
||||
|
||||
async function getVacationTypes(req, res, next) {
|
||||
try {
|
||||
const all = req.query.all === 'true';
|
||||
const data = all ? await vacationModel.getAllVacationTypes() : await vacationModel.getVacationTypes();
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function createVacationType(req, res, next) {
|
||||
try {
|
||||
const { type_code, type_name } = req.body;
|
||||
if (!type_code || !type_name) return res.status(400).json({ success: false, error: '유형 코드와 이름은 필수입니다' });
|
||||
const data = await vacationModel.createVacationType(req.body);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ success: false, error: '이미 존재하는 유형 코드입니다' });
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVacationType(req, res, next) {
|
||||
try {
|
||||
const data = await vacationModel.updateVacationType(parseInt(req.params.id), req.body);
|
||||
if (!data) return res.status(404).json({ success: false, error: '휴가 유형을 찾을 수 없습니다' });
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ success: false, error: '이미 존재하는 유형 코드입니다' });
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVacationType(req, res, next) {
|
||||
try {
|
||||
await vacationModel.deleteVacationType(parseInt(req.params.id));
|
||||
res.json({ success: true, message: '휴가 유형이 비활성화되었습니다' });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function updatePriorities(req, res, next) {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
if (!items || !Array.isArray(items)) return res.status(400).json({ success: false, error: 'items 배열이 필요합니다' });
|
||||
await vacationModel.updatePriorities(items);
|
||||
res.json({ success: true, message: '우선순위가 업데이트되었습니다' });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
/* ===== Vacation Balances ===== */
|
||||
|
||||
async function getBalancesByYear(req, res, next) {
|
||||
try {
|
||||
const year = parseInt(req.params.year);
|
||||
const data = await vacationModel.getBalancesByYear(year);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function getBalancesByWorkerYear(req, res, next) {
|
||||
try {
|
||||
const workerId = parseInt(req.params.workerId);
|
||||
const year = parseInt(req.params.year);
|
||||
const data = await vacationModel.getBalancesByWorkerYear(workerId, year);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function createBalance(req, res, next) {
|
||||
try {
|
||||
const { worker_id, vacation_type_id, year } = req.body;
|
||||
if (!worker_id || !vacation_type_id || !year) {
|
||||
return res.status(400).json({ success: false, error: '작업자, 휴가유형, 연도는 필수입니다' });
|
||||
}
|
||||
const data = await vacationModel.createBalance({ ...req.body, created_by: req.user.user_id });
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function updateBalance(req, res, next) {
|
||||
try {
|
||||
const data = await vacationModel.updateBalance(parseInt(req.params.id), req.body);
|
||||
if (!data) return res.status(404).json({ success: false, error: '배정 정보를 찾을 수 없습니다' });
|
||||
res.json({ success: true, data });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function deleteBalance(req, res, next) {
|
||||
try {
|
||||
await vacationModel.deleteBalance(parseInt(req.params.id));
|
||||
res.json({ success: true, message: '삭제되었습니다' });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function bulkUpsertBalances(req, res, next) {
|
||||
try {
|
||||
const { balances } = req.body;
|
||||
if (!balances || !Array.isArray(balances)) return res.status(400).json({ success: false, error: 'balances 배열이 필요합니다' });
|
||||
const items = balances.map(b => ({ ...b, created_by: req.user.user_id }));
|
||||
const count = await vacationModel.bulkUpsertBalances(items);
|
||||
res.json({ success: true, data: { count }, message: `${count}건 처리되었습니다` });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
async function autoCalculate(req, res, next) {
|
||||
try {
|
||||
const { year } = req.body;
|
||||
if (!year) return res.status(400).json({ success: false, error: '연도는 필수입니다' });
|
||||
const result = await vacationModel.autoCalculateForAllWorkers(year, req.user.user_id);
|
||||
res.json({ success: true, data: result, message: `${result.count}명 자동 배정 완료` });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getVacationTypes, createVacationType, updateVacationType, deleteVacationType, updatePriorities,
|
||||
getBalancesByYear, getBalancesByWorkerYear, createBalance, updateBalance, deleteBalance,
|
||||
bulkUpsertBalances, autoCalculate
|
||||
};
|
||||
66
user-management/api/controllers/workerController.js
Normal file
66
user-management/api/controllers/workerController.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Worker Controller
|
||||
*
|
||||
* 작업자 CRUD
|
||||
*/
|
||||
|
||||
const workerModel = require('../models/workerModel');
|
||||
|
||||
async function getAll(req, res, next) {
|
||||
try {
|
||||
const workers = await workerModel.getAll();
|
||||
res.json({ success: true, data: workers });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(req, res, next) {
|
||||
try {
|
||||
const worker = await workerModel.getById(parseInt(req.params.id));
|
||||
if (!worker) {
|
||||
return res.status(404).json({ success: false, error: '작업자를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: worker });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function create(req, res, next) {
|
||||
try {
|
||||
const { worker_name } = req.body;
|
||||
if (!worker_name) {
|
||||
return res.status(400).json({ success: false, error: '작업자 이름은 필수입니다' });
|
||||
}
|
||||
const worker = await workerModel.create(req.body);
|
||||
res.status(201).json({ success: true, data: worker });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const worker = await workerModel.update(id, req.body);
|
||||
if (!worker) {
|
||||
return res.status(404).json({ success: false, error: '작업자를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: worker });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await workerModel.deactivate(id);
|
||||
res.json({ success: true, message: '작업자가 비활성화되었습니다' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getAll, getById, create, update, remove };
|
||||
155
user-management/api/controllers/workplaceController.js
Normal file
155
user-management/api/controllers/workplaceController.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Workplace Controller
|
||||
*
|
||||
* 작업장 CRUD + 카테고리 조회
|
||||
*/
|
||||
|
||||
const workplaceModel = require('../models/workplaceModel');
|
||||
|
||||
async function getAll(req, res, next) {
|
||||
try {
|
||||
const workplaces = await workplaceModel.getAll();
|
||||
res.json({ success: true, data: workplaces });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(req, res, next) {
|
||||
try {
|
||||
const wp = await workplaceModel.getById(parseInt(req.params.id));
|
||||
if (!wp) {
|
||||
return res.status(404).json({ success: false, error: '작업장을 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: wp });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCategories(req, res, next) {
|
||||
try {
|
||||
const categories = await workplaceModel.getCategories();
|
||||
res.json({ success: true, data: categories });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function create(req, res, next) {
|
||||
try {
|
||||
const { workplace_name } = req.body;
|
||||
if (!workplace_name) {
|
||||
return res.status(400).json({ success: false, error: '작업장명은 필수입니다' });
|
||||
}
|
||||
const wp = await workplaceModel.create(req.body);
|
||||
res.status(201).json({ success: true, data: wp });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const wp = await workplaceModel.update(id, req.body);
|
||||
if (!wp) {
|
||||
return res.status(404).json({ success: false, error: '작업장을 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: wp });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(req, res, next) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await workplaceModel.deactivate(id);
|
||||
res.json({ success: true, message: '작업장이 비활성화되었습니다' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 구역지도 ====================
|
||||
|
||||
async function uploadCategoryLayoutImage(req, res, next) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: '이미지 파일이 필요합니다' });
|
||||
}
|
||||
const id = parseInt(req.params.id);
|
||||
const imagePath = `/uploads/${req.file.filename}`;
|
||||
const category = await workplaceModel.updateCategoryLayoutImage(id, imagePath);
|
||||
if (!category) {
|
||||
return res.status(404).json({ success: false, error: '카테고리를 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: { image_path: imagePath, category } });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createMapRegion(req, res, next) {
|
||||
try {
|
||||
const { workplace_id, category_id } = req.body;
|
||||
if (!workplace_id || !category_id) {
|
||||
return res.status(400).json({ success: false, error: 'workplace_id와 category_id는 필수입니다' });
|
||||
}
|
||||
const region = await workplaceModel.createMapRegion(req.body);
|
||||
res.status(201).json({ success: true, data: region });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMapRegionsByCategory(req, res, next) {
|
||||
try {
|
||||
const categoryId = parseInt(req.params.categoryId);
|
||||
const regions = await workplaceModel.getMapRegionsByCategory(categoryId);
|
||||
res.json({ success: true, data: regions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMapRegion(req, res, next) {
|
||||
try {
|
||||
const regionId = parseInt(req.params.id);
|
||||
const region = await workplaceModel.updateMapRegion(regionId, req.body);
|
||||
if (!region) {
|
||||
return res.status(404).json({ success: false, error: '영역을 찾을 수 없습니다' });
|
||||
}
|
||||
res.json({ success: true, data: region });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMapRegion(req, res, next) {
|
||||
try {
|
||||
const regionId = parseInt(req.params.id);
|
||||
await workplaceModel.deleteMapRegion(regionId);
|
||||
res.json({ success: true, message: '영역이 삭제되었습니다' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadWorkplaceLayoutImage(req, res, next) {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: '이미지 파일이 필요합니다' });
|
||||
const id = parseInt(req.params.id);
|
||||
const imagePath = `/uploads/${req.file.filename}`;
|
||||
const wp = await workplaceModel.updateWorkplaceLayoutImage(id, imagePath);
|
||||
if (!wp) return res.status(404).json({ success: false, error: '작업장을 찾을 수 없습니다' });
|
||||
res.json({ success: true, data: { image_path: imagePath, workplace: wp } });
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAll, getById, getCategories, create, update, remove,
|
||||
uploadCategoryLayoutImage, uploadWorkplaceLayoutImage,
|
||||
createMapRegion, getMapRegionsByCategory, updateMapRegion, deleteMapRegion
|
||||
};
|
||||
65
user-management/api/index.js
Normal file
65
user-management/api/index.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* tkuser-api - 사용자 관리 서비스
|
||||
*
|
||||
* 사용자 CRUD + 페이지 권한 관리 통합 API
|
||||
* MariaDB (sso_users + user_page_permissions) 직접 연결
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const userRoutes = require('./routes/userRoutes');
|
||||
const permissionRoutes = require('./routes/permissionRoutes');
|
||||
const projectRoutes = require('./routes/projectRoutes');
|
||||
const workerRoutes = require('./routes/workerRoutes');
|
||||
const departmentRoutes = require('./routes/departmentRoutes');
|
||||
const workplaceRoutes = require('./routes/workplaceRoutes');
|
||||
const equipmentRoutes = require('./routes/equipmentRoutes');
|
||||
const taskRoutes = require('./routes/taskRoutes');
|
||||
const vacationRoutes = require('./routes/vacationRoutes');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'tkuser-api', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/permissions', permissionRoutes);
|
||||
app.use('/api/projects', projectRoutes);
|
||||
app.use('/api/workers', workerRoutes);
|
||||
app.use('/api/departments', departmentRoutes);
|
||||
app.use('/api/workplaces', workplaceRoutes);
|
||||
app.use('/api/equipments', equipmentRoutes);
|
||||
app.use('/api/tasks', taskRoutes);
|
||||
app.use('/api/vacations', vacationRoutes);
|
||||
|
||||
// 404
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ success: false, error: 'Not Found' });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('tkuser-api Error:', err.message);
|
||||
res.status(err.status || 500).json({
|
||||
success: false,
|
||||
error: err.message || 'Internal Server Error'
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`tkuser-api running on port ${PORT}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
61
user-management/api/middleware/auth.js
Normal file
61
user-management/api/middleware/auth.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 인증 미들웨어
|
||||
* JWT 검증 + admin 체크
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.SSO_JWT_SECRET;
|
||||
|
||||
/**
|
||||
* Bearer 토큰 또는 쿠키에서 토큰 추출
|
||||
*/
|
||||
function extractToken(req) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.split(' ')[1];
|
||||
}
|
||||
if (req.cookies && req.cookies.sso_token) {
|
||||
return req.cookies.sso_token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 필수 미들웨어
|
||||
*/
|
||||
function requireAuth(req, res, next) {
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, error: '인증이 필요합니다' });
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한 미들웨어
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, error: '인증이 필요합니다' });
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
if (!['admin', 'system'].includes(decoded.role)) {
|
||||
return res.status(403).json({ success: false, error: '관리자 권한이 필요합니다' });
|
||||
}
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { extractToken, requireAuth, requireAdmin };
|
||||
35
user-management/api/middleware/upload.js
Normal file
35
user-management/api/middleware/upload.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 파일 업로드 미들웨어 (multer)
|
||||
*/
|
||||
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, path.join(__dirname, '..', 'uploads'));
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const uniqueName = `workplace-layout-${Date.now()}-${crypto.randomInt(100000000, 999999999)}${ext}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('허용되지 않는 파일 형식입니다. (JPEG, PNG, GIF, WebP만 가능)'), false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }
|
||||
});
|
||||
|
||||
module.exports = upload;
|
||||
71
user-management/api/models/departmentModel.js
Normal file
71
user-management/api/models/departmentModel.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Department Model
|
||||
*
|
||||
* departments 테이블 CRUD (MariaDB)
|
||||
*/
|
||||
|
||||
const { getPool } = require('./userModel');
|
||||
|
||||
async function getAll() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT d.*, p.department_name AS parent_name
|
||||
FROM departments d
|
||||
LEFT JOIN departments p ON d.parent_id = p.department_id
|
||||
ORDER BY d.display_order ASC, d.department_id ASC`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT d.*, p.department_name AS parent_name
|
||||
FROM departments d
|
||||
LEFT JOIN departments p ON d.parent_id = p.department_id
|
||||
WHERE d.department_id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function create({ department_name, parent_id, description, display_order }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO departments (department_name, parent_id, description, display_order)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[department_name, parent_id || null, description || null, display_order || 0]
|
||||
);
|
||||
return getById(result.insertId);
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (data.department_name !== undefined) { fields.push('department_name = ?'); values.push(data.department_name); }
|
||||
if (data.parent_id !== undefined) { fields.push('parent_id = ?'); values.push(data.parent_id || null); }
|
||||
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
|
||||
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
|
||||
if (data.display_order !== undefined) { fields.push('display_order = ?'); values.push(data.display_order); }
|
||||
|
||||
if (fields.length === 0) return getById(id);
|
||||
|
||||
values.push(id);
|
||||
await db.query(
|
||||
`UPDATE departments SET ${fields.join(', ')} WHERE department_id = ?`,
|
||||
values
|
||||
);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
async function deactivate(id) {
|
||||
const db = getPool();
|
||||
await db.query(
|
||||
'UPDATE departments SET is_active = FALSE WHERE department_id = ?',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getAll, getById, create, update, deactivate };
|
||||
192
user-management/api/models/equipmentModel.js
Normal file
192
user-management/api/models/equipmentModel.js
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Equipment Model
|
||||
*
|
||||
* equipments + equipment_photos CRUD (MariaDB)
|
||||
*/
|
||||
|
||||
const { getPool } = require('./userModel');
|
||||
|
||||
// ==================== 기본 CRUD ====================
|
||||
|
||||
async function getAll(filters = {}) {
|
||||
const db = getPool();
|
||||
let sql = `SELECT e.*, w.workplace_name, c.category_name
|
||||
FROM equipments e
|
||||
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
|
||||
LEFT JOIN workplace_categories c ON w.category_id = c.category_id`;
|
||||
const conditions = [];
|
||||
const values = [];
|
||||
|
||||
if (filters.workplace_id) { conditions.push('e.workplace_id = ?'); values.push(filters.workplace_id); }
|
||||
if (filters.equipment_type) { conditions.push('e.equipment_type = ?'); values.push(filters.equipment_type); }
|
||||
if (filters.status) { conditions.push('e.status = ?'); values.push(filters.status); }
|
||||
if (filters.search) {
|
||||
conditions.push('(e.equipment_name LIKE ? OR e.equipment_code LIKE ?)');
|
||||
const term = `%${filters.search}%`;
|
||||
values.push(term, term);
|
||||
}
|
||||
|
||||
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
|
||||
sql += ' ORDER BY e.equipment_code ASC';
|
||||
|
||||
const [rows] = await db.query(sql, values);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT e.*, w.workplace_name, c.category_name
|
||||
FROM equipments e
|
||||
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
|
||||
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
|
||||
WHERE e.equipment_id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function getByWorkplace(workplaceId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT e.*, w.workplace_name
|
||||
FROM equipments e
|
||||
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
|
||||
WHERE e.workplace_id = ?
|
||||
ORDER BY e.equipment_code ASC`,
|
||||
[workplaceId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function create(data) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO equipments (equipment_code, equipment_name, equipment_type, model_name, manufacturer, supplier, purchase_price, installation_date, serial_number, specifications, status, notes, workplace_id, map_x_percent, map_y_percent, map_width_percent, map_height_percent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.equipment_code, data.equipment_name,
|
||||
data.equipment_type || null, data.model_name || null,
|
||||
data.manufacturer || null, data.supplier || null,
|
||||
data.purchase_price || null, data.installation_date || null,
|
||||
data.serial_number || null, data.specifications || null,
|
||||
data.status || 'active', data.notes || null,
|
||||
data.workplace_id || null,
|
||||
data.map_x_percent || null, data.map_y_percent || null,
|
||||
data.map_width_percent || null, data.map_height_percent || null
|
||||
]
|
||||
);
|
||||
return getById(result.insertId);
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (data.equipment_code !== undefined) { fields.push('equipment_code = ?'); values.push(data.equipment_code); }
|
||||
if (data.equipment_name !== undefined) { fields.push('equipment_name = ?'); values.push(data.equipment_name); }
|
||||
if (data.equipment_type !== undefined) { fields.push('equipment_type = ?'); values.push(data.equipment_type || null); }
|
||||
if (data.model_name !== undefined) { fields.push('model_name = ?'); values.push(data.model_name || null); }
|
||||
if (data.manufacturer !== undefined) { fields.push('manufacturer = ?'); values.push(data.manufacturer || null); }
|
||||
if (data.supplier !== undefined) { fields.push('supplier = ?'); values.push(data.supplier || null); }
|
||||
if (data.purchase_price !== undefined) { fields.push('purchase_price = ?'); values.push(data.purchase_price || null); }
|
||||
if (data.installation_date !== undefined) { fields.push('installation_date = ?'); values.push(data.installation_date || null); }
|
||||
if (data.serial_number !== undefined) { fields.push('serial_number = ?'); values.push(data.serial_number || null); }
|
||||
if (data.specifications !== undefined) { fields.push('specifications = ?'); values.push(data.specifications || null); }
|
||||
if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); }
|
||||
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
|
||||
if (data.workplace_id !== undefined) { fields.push('workplace_id = ?'); values.push(data.workplace_id || null); }
|
||||
if (data.map_x_percent !== undefined) { fields.push('map_x_percent = ?'); values.push(data.map_x_percent); }
|
||||
if (data.map_y_percent !== undefined) { fields.push('map_y_percent = ?'); values.push(data.map_y_percent); }
|
||||
if (data.map_width_percent !== undefined) { fields.push('map_width_percent = ?'); values.push(data.map_width_percent); }
|
||||
if (data.map_height_percent !== undefined) { fields.push('map_height_percent = ?'); values.push(data.map_height_percent); }
|
||||
|
||||
if (fields.length === 0) return getById(id);
|
||||
|
||||
values.push(id);
|
||||
await db.query(`UPDATE equipments SET ${fields.join(', ')} WHERE equipment_id = ?`, values);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const db = getPool();
|
||||
await db.query('DELETE FROM equipments WHERE equipment_id = ?', [id]);
|
||||
}
|
||||
|
||||
async function getEquipmentTypes() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT DISTINCT equipment_type FROM equipments WHERE equipment_type IS NOT NULL AND equipment_type != "" ORDER BY equipment_type ASC'
|
||||
);
|
||||
return rows.map(r => r.equipment_type);
|
||||
}
|
||||
|
||||
async function getNextCode(prefix = 'TKP') {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT equipment_code FROM equipments WHERE equipment_code LIKE ? ORDER BY equipment_code DESC LIMIT 1',
|
||||
[`${prefix}-%`]
|
||||
);
|
||||
if (!rows.length) return `${prefix}-001`;
|
||||
const lastNum = parseInt(rows[0].equipment_code.replace(`${prefix}-`, ''), 10) || 0;
|
||||
return `${prefix}-${String(lastNum + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
async function checkDuplicateCode(code, excludeId) {
|
||||
const db = getPool();
|
||||
let sql = 'SELECT equipment_id FROM equipments WHERE equipment_code = ?';
|
||||
const values = [code];
|
||||
if (excludeId) { sql += ' AND equipment_id != ?'; values.push(excludeId); }
|
||||
const [rows] = await db.query(sql, values);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// ==================== 지도 위치 ====================
|
||||
|
||||
async function updateMapPosition(id, positionData) {
|
||||
const db = getPool();
|
||||
const fields = ['map_x_percent = ?', 'map_y_percent = ?', 'map_width_percent = ?', 'map_height_percent = ?'];
|
||||
const values = [positionData.map_x_percent, positionData.map_y_percent, positionData.map_width_percent, positionData.map_height_percent];
|
||||
if (positionData.workplace_id !== undefined) {
|
||||
fields.push('workplace_id = ?');
|
||||
values.push(positionData.workplace_id);
|
||||
}
|
||||
values.push(id);
|
||||
await db.query(`UPDATE equipments SET ${fields.join(', ')} WHERE equipment_id = ?`, values);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
// ==================== 사진 ====================
|
||||
|
||||
async function addPhoto(equipmentId, photoData) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO equipment_photos (equipment_id, photo_path, description, display_order, uploaded_by) VALUES (?, ?, ?, ?, ?)`,
|
||||
[equipmentId, photoData.photo_path, photoData.description || null, photoData.display_order || 0, photoData.uploaded_by || null]
|
||||
);
|
||||
return { photo_id: result.insertId, equipment_id: equipmentId, ...photoData };
|
||||
}
|
||||
|
||||
async function getPhotos(equipmentId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM equipment_photos WHERE equipment_id = ? ORDER BY display_order ASC, created_at ASC',
|
||||
[equipmentId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function deletePhoto(photoId) {
|
||||
const db = getPool();
|
||||
const [photo] = await db.query('SELECT photo_path FROM equipment_photos WHERE photo_id = ?', [photoId]);
|
||||
await db.query('DELETE FROM equipment_photos WHERE photo_id = ?', [photoId]);
|
||||
return { photo_id: photoId, photo_path: photo[0]?.photo_path };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAll, getById, getByWorkplace, create, update, remove,
|
||||
getEquipmentTypes, getNextCode, checkDuplicateCode,
|
||||
updateMapPosition,
|
||||
addPhoto, getPhotos, deletePhoto
|
||||
};
|
||||
150
user-management/api/models/permissionModel.js
Normal file
150
user-management/api/models/permissionModel.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Permission Model
|
||||
*
|
||||
* MariaDB user_page_permissions 테이블 CRUD
|
||||
*/
|
||||
|
||||
const { getPool } = require('./userModel');
|
||||
|
||||
// 기본 페이지 목록 (시스템별 구분)
|
||||
const DEFAULT_PAGES = {
|
||||
// ===== System 1 - 공장관리 =====
|
||||
// 작업 관리
|
||||
's1.dashboard': { title: '대시보드', system: 'system1', group: '작업 관리', default_access: true },
|
||||
's1.work.tbm': { title: 'TBM 관리', system: 'system1', group: '작업 관리', default_access: true },
|
||||
's1.work.report_create': { title: '작업보고서 작성', system: 'system1', group: '작업 관리', default_access: true },
|
||||
's1.work.analysis': { title: '작업 분석', system: 'system1', group: '작업 관리', default_access: false },
|
||||
's1.work.nonconformity': { title: '부적합 현황', system: 'system1', group: '작업 관리', default_access: true },
|
||||
// 공장 관리
|
||||
's1.factory.repair_management':{ title: '시설설비 관리', system: 'system1', group: '공장 관리', default_access: false },
|
||||
's1.inspection.daily_patrol': { title: '일일순회점검', system: 'system1', group: '공장 관리', default_access: false },
|
||||
's1.inspection.checkin': { title: '출근 체크', system: 'system1', group: '공장 관리', default_access: true },
|
||||
's1.inspection.work_status': { title: '근무 현황', system: 'system1', group: '공장 관리', default_access: false },
|
||||
// 안전 관리
|
||||
's1.safety.visit_request': { title: '출입 신청', system: 'system1', group: '안전 관리', default_access: true },
|
||||
's1.safety.management': { title: '안전 관리', system: 'system1', group: '안전 관리', default_access: false },
|
||||
's1.safety.checklist_manage': { title: '체크리스트 관리', system: 'system1', group: '안전 관리', default_access: false },
|
||||
// 근태 관리
|
||||
's1.attendance.my_vacation_info': { title: '내 연차 정보', system: 'system1', group: '근태 관리', default_access: true },
|
||||
's1.attendance.monthly': { title: '월간 근태', system: 'system1', group: '근태 관리', default_access: true },
|
||||
's1.attendance.vacation_request': { title: '휴가 신청', system: 'system1', group: '근태 관리', default_access: true },
|
||||
's1.attendance.vacation_management': { title: '휴가 관리', system: 'system1', group: '근태 관리', default_access: false },
|
||||
's1.attendance.vacation_allocation': { title: '휴가 발생 입력', system: 'system1', group: '근태 관리', default_access: false },
|
||||
's1.attendance.annual_overview': { title: '연간 휴가 현황', system: 'system1', group: '근태 관리', default_access: false },
|
||||
// 시스템 관리
|
||||
's1.admin.workers': { title: '작업자 관리', system: 'system1', group: '시스템 관리', default_access: false },
|
||||
's1.admin.projects': { title: '프로젝트 관리', system: 'system1', group: '시스템 관리', default_access: false },
|
||||
's1.admin.tasks': { title: '작업 관리', system: 'system1', group: '시스템 관리', default_access: false },
|
||||
's1.admin.workplaces': { title: '작업장 관리', system: 'system1', group: '시스템 관리', default_access: false },
|
||||
's1.admin.equipments': { title: '설비 관리', system: 'system1', group: '시스템 관리', default_access: false },
|
||||
's1.admin.issue_categories': { title: '신고 카테고리 관리', system: 'system1', group: '시스템 관리', default_access: false },
|
||||
's1.admin.attendance_report': { title: '출퇴근-보고서 대조', system: 'system1', group: '시스템 관리', default_access: false },
|
||||
|
||||
// ===== System 3 - 부적합관리 =====
|
||||
// 메인
|
||||
'issues_dashboard': { title: '현황판', system: 'system3', group: '메인', default_access: true },
|
||||
'issues_inbox': { title: '수신함', system: 'system3', group: '메인', default_access: true },
|
||||
'issues_management': { title: '관리함', system: 'system3', group: '메인', default_access: false },
|
||||
'issues_archive': { title: '폐기함', system: 'system3', group: '메인', default_access: false },
|
||||
// 업무
|
||||
'daily_work': { title: '일일 공수', system: 'system3', group: '업무', default_access: false },
|
||||
'projects_manage': { title: '프로젝트 관리', system: 'system3', group: '업무', default_access: false },
|
||||
// 보고서
|
||||
'reports': { title: '보고서', system: 'system3', group: '보고서', default_access: false },
|
||||
'reports_daily': { title: '일일보고서', system: 'system3', group: '보고서', default_access: false },
|
||||
'reports_weekly': { title: '주간보고서', system: 'system3', group: '보고서', default_access: false },
|
||||
'reports_monthly': { title: '월간보고서', system: 'system3', group: '보고서', default_access: false }
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자의 페이지 권한 목록 조회
|
||||
*/
|
||||
async function getUserPermissions(userId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM user_page_permissions WHERE user_id = ? ORDER BY page_name',
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 권한 부여/업데이트 (UPSERT)
|
||||
*/
|
||||
async function grantPermission({ user_id, page_name, can_access, granted_by_id, notes }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), notes = VALUES(notes), granted_at = CURRENT_TIMESTAMP`,
|
||||
[user_id, page_name, can_access, granted_by_id, notes || null]
|
||||
);
|
||||
return { id: result.insertId, user_id, page_name, can_access };
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 권한 부여
|
||||
*/
|
||||
async function bulkGrant({ user_id, permissions, granted_by_id }) {
|
||||
const db = getPool();
|
||||
let count = 0;
|
||||
|
||||
for (const perm of permissions) {
|
||||
if (!DEFAULT_PAGES[perm.page_name]) continue;
|
||||
await db.query(
|
||||
`INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), granted_at = CURRENT_TIMESTAMP`,
|
||||
[user_id, perm.page_name, perm.can_access, granted_by_id]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
|
||||
return { updated_count: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 권한 확인
|
||||
*/
|
||||
async function checkAccess(userId, pageName) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
|
||||
[userId, pageName]
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return { can_access: rows[0].can_access, reason: 'explicit_permission' };
|
||||
}
|
||||
|
||||
// 기본 권한
|
||||
const pageConfig = DEFAULT_PAGES[pageName];
|
||||
if (!pageConfig) {
|
||||
return { can_access: false, reason: 'invalid_page' };
|
||||
}
|
||||
return { can_access: pageConfig.default_access, reason: 'default_permission' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 삭제 (기본값으로 되돌림)
|
||||
*/
|
||||
async function deletePermission(permissionId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM user_page_permissions WHERE id = ?',
|
||||
[permissionId]
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
await db.query('DELETE FROM user_page_permissions WHERE id = ?', [permissionId]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_PAGES,
|
||||
getUserPermissions,
|
||||
grantPermission,
|
||||
bulkGrant,
|
||||
checkAccess,
|
||||
deletePermission
|
||||
};
|
||||
79
user-management/api/models/projectModel.js
Normal file
79
user-management/api/models/projectModel.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Project Model
|
||||
*
|
||||
* projects 테이블 CRUD (MariaDB)
|
||||
* System 1과 같은 DB를 공유
|
||||
*/
|
||||
|
||||
const { getPool } = require('./userModel');
|
||||
|
||||
async function getAll() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM projects ORDER BY project_id DESC'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getActive() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM projects WHERE is_active = TRUE ORDER BY project_name ASC'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM projects WHERE project_id = ?',
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function create({ job_no, project_name, contract_date, due_date, delivery_method, site, pm }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO projects (job_no, project_name, contract_date, due_date, delivery_method, site, pm)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[job_no, project_name, contract_date || null, due_date || null, delivery_method || null, site || null, pm || null]
|
||||
);
|
||||
return getById(result.insertId);
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (data.job_no !== undefined) { fields.push('job_no = ?'); values.push(data.job_no); }
|
||||
if (data.project_name !== undefined) { fields.push('project_name = ?'); values.push(data.project_name); }
|
||||
if (data.contract_date !== undefined) { fields.push('contract_date = ?'); values.push(data.contract_date || null); }
|
||||
if (data.due_date !== undefined) { fields.push('due_date = ?'); values.push(data.due_date || null); }
|
||||
if (data.delivery_method !== undefined) { fields.push('delivery_method = ?'); values.push(data.delivery_method); }
|
||||
if (data.site !== undefined) { fields.push('site = ?'); values.push(data.site); }
|
||||
if (data.pm !== undefined) { fields.push('pm = ?'); values.push(data.pm); }
|
||||
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
|
||||
if (data.project_status !== undefined) { fields.push('project_status = ?'); values.push(data.project_status); }
|
||||
if (data.completed_date !== undefined) { fields.push('completed_date = ?'); values.push(data.completed_date || null); }
|
||||
|
||||
if (fields.length === 0) return getById(id);
|
||||
|
||||
values.push(id);
|
||||
await db.query(
|
||||
`UPDATE projects SET ${fields.join(', ')} WHERE project_id = ?`,
|
||||
values
|
||||
);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
async function deactivate(id) {
|
||||
const db = getPool();
|
||||
await db.query(
|
||||
'UPDATE projects SET is_active = FALSE, project_status = ? WHERE project_id = ?',
|
||||
['completed', id]
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getAll, getActive, getById, create, update, deactivate };
|
||||
127
user-management/api/models/taskModel.js
Normal file
127
user-management/api/models/taskModel.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Task Model
|
||||
*
|
||||
* work_types + tasks 테이블 CRUD (MariaDB)
|
||||
* System 1과 같은 DB를 공유
|
||||
*/
|
||||
|
||||
const { getPool } = require('./userModel');
|
||||
|
||||
/* ===== Work Types (공정) ===== */
|
||||
|
||||
async function getWorkTypes() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM work_types ORDER BY category ASC, name ASC'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getWorkTypeById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query('SELECT * FROM work_types WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function createWorkType({ name, category, description }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO work_types (name, category, description) VALUES (?, ?, ?)',
|
||||
[name, category || null, description || null]
|
||||
);
|
||||
return getWorkTypeById(result.insertId);
|
||||
}
|
||||
|
||||
async function updateWorkType(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
|
||||
if (data.category !== undefined) { fields.push('category = ?'); values.push(data.category || null); }
|
||||
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
|
||||
if (fields.length === 0) return getWorkTypeById(id);
|
||||
values.push(id);
|
||||
await db.query(`UPDATE work_types SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
return getWorkTypeById(id);
|
||||
}
|
||||
|
||||
async function deleteWorkType(id) {
|
||||
const db = getPool();
|
||||
// 연결된 tasks의 work_type_id를 NULL로 설정 (FK cascade가 처리하지만 명시적으로)
|
||||
await db.query('UPDATE tasks SET work_type_id = NULL WHERE work_type_id = ?', [id]);
|
||||
await db.query('DELETE FROM work_types WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ===== Tasks (작업) ===== */
|
||||
|
||||
async function getTasks(workTypeId) {
|
||||
const db = getPool();
|
||||
let sql = `SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
|
||||
FROM tasks t
|
||||
LEFT JOIN work_types wt ON t.work_type_id = wt.id`;
|
||||
const params = [];
|
||||
if (workTypeId) {
|
||||
sql += ' WHERE t.work_type_id = ?';
|
||||
params.push(workTypeId);
|
||||
}
|
||||
sql += ' ORDER BY wt.category ASC, t.task_id DESC';
|
||||
const [rows] = await db.query(sql, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getActiveTasks() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
|
||||
FROM tasks t
|
||||
LEFT JOIN work_types wt ON t.work_type_id = wt.id
|
||||
WHERE t.is_active = TRUE
|
||||
ORDER BY wt.category ASC, t.task_name ASC`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getTaskById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
|
||||
FROM tasks t
|
||||
LEFT JOIN work_types wt ON t.work_type_id = wt.id
|
||||
WHERE t.task_id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function createTask({ work_type_id, task_name, description, is_active }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO tasks (work_type_id, task_name, description, is_active) VALUES (?, ?, ?, ?)',
|
||||
[work_type_id || null, task_name, description || null, is_active !== undefined ? is_active : true]
|
||||
);
|
||||
return getTaskById(result.insertId);
|
||||
}
|
||||
|
||||
async function updateTask(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (data.work_type_id !== undefined) { fields.push('work_type_id = ?'); values.push(data.work_type_id || null); }
|
||||
if (data.task_name !== undefined) { fields.push('task_name = ?'); values.push(data.task_name); }
|
||||
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
|
||||
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
|
||||
if (fields.length === 0) return getTaskById(id);
|
||||
values.push(id);
|
||||
await db.query(`UPDATE tasks SET ${fields.join(', ')} WHERE task_id = ?`, values);
|
||||
return getTaskById(id);
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
const db = getPool();
|
||||
await db.query('DELETE FROM tasks WHERE task_id = ?', [id]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getWorkTypes, getWorkTypeById, createWorkType, updateWorkType, deleteWorkType,
|
||||
getTasks, getActiveTasks, getTaskById, createTask, updateTask, deleteTask
|
||||
};
|
||||
158
user-management/api/models/userModel.js
Normal file
158
user-management/api/models/userModel.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* User Model
|
||||
*
|
||||
* sso_users 테이블 CRUD 및 비밀번호 관리
|
||||
* sso-auth-service/models/userModel.js 기반
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
|
||||
let pool;
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'mariadb',
|
||||
port: parseInt(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'hyungi_user',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'hyungi',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* pbkdf2_sha256 해시 검증 (passlib 호환)
|
||||
*/
|
||||
function verifyPbkdf2(password, storedHash) {
|
||||
try {
|
||||
const parts = storedHash.split('$');
|
||||
if (parts.length < 5) return false;
|
||||
|
||||
const rounds = parseInt(parts[2]);
|
||||
const salt = parts[3].replace(/\./g, '+');
|
||||
const hash = parts[4].replace(/\./g, '+');
|
||||
|
||||
const padded = (s) => s + '='.repeat((4 - s.length % 4) % 4);
|
||||
|
||||
const saltBuffer = Buffer.from(padded(salt), 'base64');
|
||||
const expectedHash = Buffer.from(padded(hash), 'base64');
|
||||
|
||||
const derivedKey = crypto.pbkdf2Sync(password, saltBuffer, rounds, expectedHash.length, 'sha256');
|
||||
return crypto.timingSafeEqual(derivedKey, expectedHash);
|
||||
} catch (err) {
|
||||
console.error('pbkdf2 verify error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 검증 (bcrypt 또는 pbkdf2_sha256 자동 감지)
|
||||
*/
|
||||
async function verifyPassword(password, storedHash) {
|
||||
if (!password || !storedHash) return false;
|
||||
|
||||
if (storedHash.startsWith('$pbkdf2-sha256$')) {
|
||||
return verifyPbkdf2(password, storedHash);
|
||||
}
|
||||
|
||||
if (storedHash.startsWith('$2b$') || storedHash.startsWith('$2a$')) {
|
||||
return bcrypt.compare(password, storedHash);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* bcrypt로 비밀번호 해시 생성
|
||||
*/
|
||||
async function hashPassword(password) {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
async function findByUsername(username) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM sso_users WHERE username = ? AND is_active = TRUE',
|
||||
[username]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function findById(userId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM sso_users WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function findAll() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT user_id, username, name, department, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users ORDER BY user_id'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function create({ username, password, name, department, role }) {
|
||||
const db = getPool();
|
||||
const password_hash = await hashPassword(password);
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO sso_users (username, password_hash, name, department, role)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[username, password_hash, name || null, department || null, role || 'user']
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
async function update(userId, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
|
||||
if (data.department !== undefined) { fields.push('department = ?'); values.push(data.department); }
|
||||
if (data.role !== undefined) { fields.push('role = ?'); values.push(data.role); }
|
||||
if (data.system1_access !== undefined) { fields.push('system1_access = ?'); values.push(data.system1_access); }
|
||||
if (data.system2_access !== undefined) { fields.push('system2_access = ?'); values.push(data.system2_access); }
|
||||
if (data.system3_access !== undefined) { fields.push('system3_access = ?'); values.push(data.system3_access); }
|
||||
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
|
||||
if (data.password) {
|
||||
fields.push('password_hash = ?');
|
||||
values.push(await hashPassword(data.password));
|
||||
}
|
||||
|
||||
if (fields.length === 0) return findById(userId);
|
||||
|
||||
values.push(userId);
|
||||
await db.query(
|
||||
`UPDATE sso_users SET ${fields.join(', ')} WHERE user_id = ?`,
|
||||
values
|
||||
);
|
||||
return findById(userId);
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
const db = getPool();
|
||||
await db.query('UPDATE sso_users SET is_active = FALSE WHERE user_id = ?', [userId]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyPassword,
|
||||
hashPassword,
|
||||
findByUsername,
|
||||
findById,
|
||||
findAll,
|
||||
create,
|
||||
update,
|
||||
deleteUser,
|
||||
getPool
|
||||
};
|
||||
219
user-management/api/models/vacationModel.js
Normal file
219
user-management/api/models/vacationModel.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Vacation Model
|
||||
*
|
||||
* vacation_types + vacation_balance_details CRUD (MariaDB)
|
||||
* System 1과 같은 DB를 공유
|
||||
*/
|
||||
|
||||
const { getPool } = require('./userModel');
|
||||
|
||||
/* ===== Vacation Types (휴가 유형) ===== */
|
||||
|
||||
async function getVacationTypes() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM vacation_types WHERE is_active = TRUE ORDER BY priority ASC, id ASC'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getAllVacationTypes() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM vacation_types ORDER BY priority ASC, id ASC'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getVacationTypeById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query('SELECT * FROM vacation_types WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function createVacationType({ type_code, type_name, deduct_days, is_special, priority, description }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO vacation_types (type_code, type_name, deduct_days, is_special, priority, description, is_system)
|
||||
VALUES (?, ?, ?, ?, ?, ?, FALSE)`,
|
||||
[type_code, type_name, deduct_days ?? 1.0, is_special ? 1 : 0, priority ?? 99, description || null]
|
||||
);
|
||||
return getVacationTypeById(result.insertId);
|
||||
}
|
||||
|
||||
async function updateVacationType(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (data.type_code !== undefined) { fields.push('type_code = ?'); values.push(data.type_code); }
|
||||
if (data.type_name !== undefined) { fields.push('type_name = ?'); values.push(data.type_name); }
|
||||
if (data.deduct_days !== undefined) { fields.push('deduct_days = ?'); values.push(data.deduct_days); }
|
||||
if (data.is_special !== undefined) { fields.push('is_special = ?'); values.push(data.is_special ? 1 : 0); }
|
||||
if (data.priority !== undefined) { fields.push('priority = ?'); values.push(data.priority); }
|
||||
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
|
||||
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active ? 1 : 0); }
|
||||
if (fields.length === 0) return getVacationTypeById(id);
|
||||
values.push(id);
|
||||
await db.query(`UPDATE vacation_types SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
return getVacationTypeById(id);
|
||||
}
|
||||
|
||||
async function deleteVacationType(id) {
|
||||
const db = getPool();
|
||||
// 시스템 유형은 비활성화만
|
||||
const type = await getVacationTypeById(id);
|
||||
if (type && type.is_system) {
|
||||
await db.query('UPDATE vacation_types SET is_active = FALSE WHERE id = ?', [id]);
|
||||
} else {
|
||||
await db.query('UPDATE vacation_types SET is_active = FALSE WHERE id = ?', [id]);
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePriorities(items) {
|
||||
const db = getPool();
|
||||
for (const { id, priority } of items) {
|
||||
await db.query('UPDATE vacation_types SET priority = ? WHERE id = ?', [priority, id]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Vacation Balances (연차 배정) ===== */
|
||||
|
||||
async function getBalancesByYear(year) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority,
|
||||
w.worker_name, w.hire_date
|
||||
FROM vacation_balance_details vbd
|
||||
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
JOIN workers w ON vbd.worker_id = w.worker_id
|
||||
WHERE vbd.year = ?
|
||||
ORDER BY w.worker_name ASC, vt.priority ASC`,
|
||||
[year]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getBalancesByWorkerYear(workerId, year) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority
|
||||
FROM vacation_balance_details vbd
|
||||
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
WHERE vbd.worker_id = ? AND vbd.year = ?
|
||||
ORDER BY vt.priority ASC`,
|
||||
[workerId, year]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getBalanceById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT vbd.*, vt.type_code, vt.type_name
|
||||
FROM vacation_balance_details vbd
|
||||
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
WHERE vbd.id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function createBalance({ worker_id, vacation_type_id, year, total_days, used_days, notes, created_by }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), used_days = VALUES(used_days), notes = VALUES(notes)`,
|
||||
[worker_id, vacation_type_id, year, total_days ?? 0, used_days ?? 0, notes || null, created_by]
|
||||
);
|
||||
return result.insertId ? getBalanceById(result.insertId) : null;
|
||||
}
|
||||
|
||||
async function updateBalance(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (data.total_days !== undefined) { fields.push('total_days = ?'); values.push(data.total_days); }
|
||||
if (data.used_days !== undefined) { fields.push('used_days = ?'); values.push(data.used_days); }
|
||||
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
|
||||
if (fields.length === 0) return getBalanceById(id);
|
||||
values.push(id);
|
||||
await db.query(`UPDATE vacation_balance_details SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
return getBalanceById(id);
|
||||
}
|
||||
|
||||
async function deleteBalance(id) {
|
||||
const db = getPool();
|
||||
await db.query('DELETE FROM vacation_balance_details WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async function bulkUpsertBalances(balances) {
|
||||
const db = getPool();
|
||||
let count = 0;
|
||||
for (const b of balances) {
|
||||
await db.query(
|
||||
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
|
||||
[b.worker_id, b.vacation_type_id, b.year, b.total_days ?? 0, b.used_days ?? 0, b.notes || null, b.created_by]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* ===== 연차 자동 계산 (근로기준법) ===== */
|
||||
|
||||
function calculateAnnualDays(hireDate, targetYear) {
|
||||
if (!hireDate) return 0;
|
||||
const hire = new Date(hireDate);
|
||||
const yearStart = new Date(targetYear, 0, 1);
|
||||
const monthsDiff = (yearStart.getFullYear() - hire.getFullYear()) * 12 + (yearStart.getMonth() - hire.getMonth());
|
||||
|
||||
if (monthsDiff < 0) return 0;
|
||||
if (monthsDiff < 12) {
|
||||
// 1년 미만: 근무 개월 수
|
||||
return Math.max(0, Math.floor(monthsDiff));
|
||||
}
|
||||
// 1년 이상: 15일 + 2년마다 1일 추가 (최대 25일)
|
||||
const yearsWorked = Math.floor(monthsDiff / 12);
|
||||
const additional = Math.floor((yearsWorked - 1) / 2);
|
||||
return Math.min(15 + additional, 25);
|
||||
}
|
||||
|
||||
async function autoCalculateForAllWorkers(year, createdBy) {
|
||||
const db = getPool();
|
||||
const [workers] = await db.query(
|
||||
'SELECT worker_id, worker_name, hire_date FROM workers WHERE status != ? ORDER BY worker_name',
|
||||
['inactive']
|
||||
);
|
||||
// 연차 유형 (ANNUAL_FULL) 찾기
|
||||
const [types] = await db.query(
|
||||
"SELECT id FROM vacation_types WHERE type_code = 'ANNUAL_FULL' AND is_active = TRUE LIMIT 1"
|
||||
);
|
||||
if (!types.length) return { count: 0, results: [] };
|
||||
const annualTypeId = types[0].id;
|
||||
|
||||
const results = [];
|
||||
for (const w of workers) {
|
||||
const days = calculateAnnualDays(w.hire_date, year);
|
||||
if (days > 0) {
|
||||
await db.query(
|
||||
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
|
||||
[w.worker_id, annualTypeId, year, days, `자동계산 (입사: ${w.hire_date ? w.hire_date.toISOString().substring(0,10) : ''})`, createdBy]
|
||||
);
|
||||
results.push({ worker_id: w.worker_id, worker_name: w.worker_name, days, hire_date: w.hire_date });
|
||||
}
|
||||
}
|
||||
return { count: results.length, results };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getVacationTypes, getAllVacationTypes, getVacationTypeById,
|
||||
createVacationType, updateVacationType, deleteVacationType, updatePriorities,
|
||||
getBalancesByYear, getBalancesByWorkerYear, getBalanceById,
|
||||
createBalance, updateBalance, deleteBalance, bulkUpsertBalances,
|
||||
calculateAnnualDays, autoCalculateForAllWorkers
|
||||
};
|
||||
74
user-management/api/models/workerModel.js
Normal file
74
user-management/api/models/workerModel.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Worker Model
|
||||
*
|
||||
* workers 테이블 CRUD (MariaDB)
|
||||
*/
|
||||
|
||||
const { getPool } = require('./userModel');
|
||||
|
||||
async function getAll() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT w.*, d.department_name
|
||||
FROM workers w
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
ORDER BY w.worker_id DESC`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT w.*, d.department_name
|
||||
FROM workers w
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
WHERE w.worker_id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function create({ worker_name, job_type, department_id, phone_number, hire_date, notes }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO workers (worker_name, job_type, department_id, phone_number, hire_date, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[worker_name, job_type || null, department_id || null, phone_number || null, hire_date || null, notes || null]
|
||||
);
|
||||
return getById(result.insertId);
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (data.worker_name !== undefined) { fields.push('worker_name = ?'); values.push(data.worker_name); }
|
||||
if (data.job_type !== undefined) { fields.push('job_type = ?'); values.push(data.job_type); }
|
||||
if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); }
|
||||
if (data.department_id !== undefined) { fields.push('department_id = ?'); values.push(data.department_id || null); }
|
||||
if (data.employment_status !== undefined) { fields.push('employment_status = ?'); values.push(data.employment_status); }
|
||||
if (data.phone_number !== undefined) { fields.push('phone_number = ?'); values.push(data.phone_number || null); }
|
||||
if (data.hire_date !== undefined) { fields.push('hire_date = ?'); values.push(data.hire_date || null); }
|
||||
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
|
||||
|
||||
if (fields.length === 0) return getById(id);
|
||||
|
||||
values.push(id);
|
||||
await db.query(
|
||||
`UPDATE workers SET ${fields.join(', ')} WHERE worker_id = ?`,
|
||||
values
|
||||
);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
async function deactivate(id) {
|
||||
const db = getPool();
|
||||
await db.query(
|
||||
'UPDATE workers SET status = ? WHERE worker_id = ?',
|
||||
['inactive', id]
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getAll, getById, create, update, deactivate };
|
||||
158
user-management/api/models/workplaceModel.js
Normal file
158
user-management/api/models/workplaceModel.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Workplace Model
|
||||
*
|
||||
* workplaces + workplace_categories 테이블 CRUD (MariaDB)
|
||||
*/
|
||||
|
||||
const { getPool } = require('./userModel');
|
||||
|
||||
async function getAll() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT w.*, c.category_name
|
||||
FROM workplaces w
|
||||
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
|
||||
ORDER BY w.display_priority ASC, w.workplace_id DESC`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT w.*, c.category_name
|
||||
FROM workplaces w
|
||||
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
|
||||
WHERE w.workplace_id = ?`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function getCategories() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM workplace_categories WHERE is_active = TRUE ORDER BY display_order ASC'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function create({ workplace_name, category_id, workplace_purpose, description, display_priority }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO workplaces (workplace_name, category_id, workplace_purpose, description, display_priority)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[workplace_name, category_id || null, workplace_purpose || null, description || null, display_priority || 0]
|
||||
);
|
||||
return getById(result.insertId);
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (data.workplace_name !== undefined) { fields.push('workplace_name = ?'); values.push(data.workplace_name); }
|
||||
if (data.category_id !== undefined) { fields.push('category_id = ?'); values.push(data.category_id || null); }
|
||||
if (data.workplace_purpose !== undefined) { fields.push('workplace_purpose = ?'); values.push(data.workplace_purpose); }
|
||||
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
|
||||
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
|
||||
if (data.display_priority !== undefined) { fields.push('display_priority = ?'); values.push(data.display_priority); }
|
||||
|
||||
if (fields.length === 0) return getById(id);
|
||||
|
||||
values.push(id);
|
||||
await db.query(
|
||||
`UPDATE workplaces SET ${fields.join(', ')} WHERE workplace_id = ?`,
|
||||
values
|
||||
);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
async function deactivate(id) {
|
||||
const db = getPool();
|
||||
await db.query(
|
||||
'UPDATE workplaces SET is_active = FALSE WHERE workplace_id = ?',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 구역지도 ====================
|
||||
|
||||
async function updateCategoryLayoutImage(id, imagePath) {
|
||||
const db = getPool();
|
||||
await db.query(
|
||||
'UPDATE workplace_categories SET layout_image = ? WHERE category_id = ?',
|
||||
[imagePath, id]
|
||||
);
|
||||
const [rows] = await db.query('SELECT * FROM workplace_categories WHERE category_id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function createMapRegion({ workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO workplace_map_regions (workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[workplace_id, category_id, x_start, y_start, x_end, y_end, shape || 'rect', polygon_points || null]
|
||||
);
|
||||
const [rows] = await db.query('SELECT * FROM workplace_map_regions WHERE region_id = ?', [result.insertId]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function getMapRegionsByCategory(categoryId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT r.*, w.workplace_name
|
||||
FROM workplace_map_regions r
|
||||
LEFT JOIN workplaces w ON r.workplace_id = w.workplace_id
|
||||
WHERE r.category_id = ?
|
||||
ORDER BY r.region_id ASC`,
|
||||
[categoryId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function updateMapRegion(regionId, { x_start, y_start, x_end, y_end, workplace_id, shape, polygon_points }) {
|
||||
const db = getPool();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (x_start !== undefined) { fields.push('x_start = ?'); values.push(x_start); }
|
||||
if (y_start !== undefined) { fields.push('y_start = ?'); values.push(y_start); }
|
||||
if (x_end !== undefined) { fields.push('x_end = ?'); values.push(x_end); }
|
||||
if (y_end !== undefined) { fields.push('y_end = ?'); values.push(y_end); }
|
||||
if (workplace_id !== undefined) { fields.push('workplace_id = ?'); values.push(workplace_id); }
|
||||
if (shape !== undefined) { fields.push('shape = ?'); values.push(shape); }
|
||||
if (polygon_points !== undefined) { fields.push('polygon_points = ?'); values.push(polygon_points); }
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
values.push(regionId);
|
||||
await db.query(`UPDATE workplace_map_regions SET ${fields.join(', ')} WHERE region_id = ?`, values);
|
||||
const [rows] = await db.query(
|
||||
`SELECT r.*, w.workplace_name
|
||||
FROM workplace_map_regions r
|
||||
LEFT JOIN workplaces w ON r.workplace_id = w.workplace_id
|
||||
WHERE r.region_id = ?`,
|
||||
[regionId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function deleteMapRegion(regionId) {
|
||||
const db = getPool();
|
||||
await db.query('DELETE FROM workplace_map_regions WHERE region_id = ?', [regionId]);
|
||||
}
|
||||
|
||||
async function updateWorkplaceLayoutImage(id, imagePath) {
|
||||
const db = getPool();
|
||||
await db.query('UPDATE workplaces SET layout_image = ? WHERE workplace_id = ?', [imagePath, id]);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAll, getById, getCategories, create, update, deactivate,
|
||||
updateCategoryLayoutImage, createMapRegion, getMapRegionsByCategory, updateMapRegion, deleteMapRegion,
|
||||
updateWorkplaceLayoutImage
|
||||
};
|
||||
18
user-management/api/package.json
Normal file
18
user-management/api/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "tkuser-api",
|
||||
"version": "1.0.0",
|
||||
"description": "TK Factory Services - 사용자 관리 서비스",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.14.1"
|
||||
}
|
||||
}
|
||||
16
user-management/api/routes/departmentRoutes.js
Normal file
16
user-management/api/routes/departmentRoutes.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Department Routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const departmentController = require('../controllers/departmentController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
router.get('/', requireAuth, departmentController.getAll);
|
||||
router.get('/:id', requireAuth, departmentController.getById);
|
||||
router.post('/', requireAdmin, departmentController.create);
|
||||
router.put('/:id', requireAdmin, departmentController.update);
|
||||
router.delete('/:id', requireAdmin, departmentController.remove);
|
||||
|
||||
module.exports = router;
|
||||
32
user-management/api/routes/equipmentRoutes.js
Normal file
32
user-management/api/routes/equipmentRoutes.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Equipment Routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const equipmentController = require('../controllers/equipmentController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
const upload = require('../middleware/upload');
|
||||
|
||||
// 고정 경로를 /:id 보다 먼저 등록
|
||||
router.get('/types', requireAuth, equipmentController.getTypes);
|
||||
router.get('/next-code', requireAuth, equipmentController.getNextCode);
|
||||
router.get('/workplace/:workplaceId', requireAuth, equipmentController.getByWorkplace);
|
||||
// 사진 삭제 (photo_id만으로)
|
||||
router.delete('/photos/:photoId', requireAdmin, equipmentController.deletePhoto);
|
||||
|
||||
// 기본 CRUD
|
||||
router.get('/', requireAuth, equipmentController.getAll);
|
||||
router.get('/:id', requireAuth, equipmentController.getById);
|
||||
router.post('/', requireAdmin, equipmentController.create);
|
||||
router.put('/:id', requireAdmin, equipmentController.update);
|
||||
router.delete('/:id', requireAdmin, equipmentController.remove);
|
||||
|
||||
// 지도 위치
|
||||
router.patch('/:id/map-position', requireAdmin, equipmentController.updateMapPosition);
|
||||
|
||||
// 사진
|
||||
router.post('/:id/photos', requireAdmin, upload.single('photo'), equipmentController.addPhoto);
|
||||
router.get('/:id/photos', requireAuth, equipmentController.getPhotos);
|
||||
|
||||
module.exports = router;
|
||||
23
user-management/api/routes/permissionRoutes.js
Normal file
23
user-management/api/routes/permissionRoutes.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Permission Routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const permissionController = require('../controllers/permissionController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
// 권한 부여 (admin)
|
||||
router.post('/grant', requireAdmin, permissionController.grantPermission);
|
||||
router.post('/bulk-grant', requireAdmin, permissionController.bulkGrant);
|
||||
|
||||
// 접근 권한 확인 (auth)
|
||||
router.get('/check/:uid/:page', requireAuth, permissionController.checkAccess);
|
||||
|
||||
// 설정 가능 페이지 목록 (auth)
|
||||
router.get('/available-pages', requireAuth, permissionController.getAvailablePages);
|
||||
|
||||
// 권한 삭제 (admin)
|
||||
router.delete('/:id', requireAdmin, permissionController.deletePermission);
|
||||
|
||||
module.exports = router;
|
||||
17
user-management/api/routes/projectRoutes.js
Normal file
17
user-management/api/routes/projectRoutes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Project Routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const projectController = require('../controllers/projectController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
router.get('/', requireAuth, projectController.getAll);
|
||||
router.get('/active', requireAuth, projectController.getActive);
|
||||
router.get('/:id', requireAuth, projectController.getById);
|
||||
router.post('/', requireAdmin, projectController.create);
|
||||
router.put('/:id', requireAdmin, projectController.update);
|
||||
router.delete('/:id', requireAdmin, projectController.remove);
|
||||
|
||||
module.exports = router;
|
||||
26
user-management/api/routes/taskRoutes.js
Normal file
26
user-management/api/routes/taskRoutes.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Task Routes
|
||||
*
|
||||
* 공정(work-types) + 작업(tasks) 라우팅
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const taskController = require('../controllers/taskController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
// Work Types (공정)
|
||||
router.get('/work-types', requireAuth, taskController.getWorkTypes);
|
||||
router.post('/work-types', requireAdmin, taskController.createWorkType);
|
||||
router.put('/work-types/:id', requireAdmin, taskController.updateWorkType);
|
||||
router.delete('/work-types/:id', requireAdmin, taskController.deleteWorkType);
|
||||
|
||||
// Tasks (작업)
|
||||
router.get('/', requireAuth, taskController.getTasks);
|
||||
router.get('/active', requireAuth, taskController.getActiveTasks);
|
||||
router.get('/:id', requireAuth, taskController.getTaskById);
|
||||
router.post('/', requireAdmin, taskController.createTask);
|
||||
router.put('/:id', requireAdmin, taskController.updateTask);
|
||||
router.delete('/:id', requireAdmin, taskController.deleteTask);
|
||||
|
||||
module.exports = router;
|
||||
24
user-management/api/routes/userRoutes.js
Normal file
24
user-management/api/routes/userRoutes.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* User Routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const userController = require('../controllers/userController');
|
||||
const permissionController = require('../controllers/permissionController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
// 사용자 CRUD (admin)
|
||||
router.get('/', requireAdmin, userController.getUsers);
|
||||
router.post('/', requireAdmin, userController.createUser);
|
||||
router.put('/:id', requireAdmin, userController.updateUser);
|
||||
router.delete('/:id', requireAdmin, userController.deleteUser);
|
||||
|
||||
// 비밀번호 관리
|
||||
router.post('/:id/reset-password', requireAdmin, userController.resetPassword);
|
||||
router.post('/change-password', requireAuth, userController.changePassword);
|
||||
|
||||
// 사용자별 페이지 권한 조회 (auth - /api/users/:id/page-permissions)
|
||||
router.get('/:id/page-permissions', requireAuth, permissionController.getUserPermissions);
|
||||
|
||||
module.exports = router;
|
||||
28
user-management/api/routes/vacationRoutes.js
Normal file
28
user-management/api/routes/vacationRoutes.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Vacation Routes
|
||||
*
|
||||
* 휴가 유형 + 연차 배정 라우팅
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const vc = require('../controllers/vacationController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
// Vacation Types (휴가 유형)
|
||||
router.get('/types', requireAuth, vc.getVacationTypes);
|
||||
router.post('/types', requireAdmin, vc.createVacationType);
|
||||
router.put('/types/priorities', requireAdmin, vc.updatePriorities);
|
||||
router.put('/types/:id', requireAdmin, vc.updateVacationType);
|
||||
router.delete('/types/:id', requireAdmin, vc.deleteVacationType);
|
||||
|
||||
// Vacation Balances (연차 배정)
|
||||
router.get('/balances/year/:year', requireAdmin, vc.getBalancesByYear);
|
||||
router.get('/balances/worker/:workerId/year/:year', requireAuth, vc.getBalancesByWorkerYear);
|
||||
router.post('/balances', requireAdmin, vc.createBalance);
|
||||
router.post('/balances/bulk-upsert', requireAdmin, vc.bulkUpsertBalances);
|
||||
router.post('/balances/auto-calculate', requireAdmin, vc.autoCalculate);
|
||||
router.put('/balances/:id', requireAdmin, vc.updateBalance);
|
||||
router.delete('/balances/:id', requireAdmin, vc.deleteBalance);
|
||||
|
||||
module.exports = router;
|
||||
16
user-management/api/routes/workerRoutes.js
Normal file
16
user-management/api/routes/workerRoutes.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Worker Routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const workerController = require('../controllers/workerController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
router.get('/', requireAuth, workerController.getAll);
|
||||
router.get('/:id', requireAuth, workerController.getById);
|
||||
router.post('/', requireAdmin, workerController.create);
|
||||
router.put('/:id', requireAdmin, workerController.update);
|
||||
router.delete('/:id', requireAdmin, workerController.remove);
|
||||
|
||||
module.exports = router;
|
||||
28
user-management/api/routes/workplaceRoutes.js
Normal file
28
user-management/api/routes/workplaceRoutes.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Workplace Routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const workplaceController = require('../controllers/workplaceController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
const upload = require('../middleware/upload');
|
||||
|
||||
router.get('/categories', requireAuth, workplaceController.getCategories);
|
||||
|
||||
// 구역지도 (/:id 보다 먼저 등록)
|
||||
router.post('/categories/:id/layout-image', requireAdmin, upload.single('image'), workplaceController.uploadCategoryLayoutImage);
|
||||
router.get('/categories/:categoryId/map-regions', requireAuth, workplaceController.getMapRegionsByCategory);
|
||||
router.post('/map-regions', requireAdmin, workplaceController.createMapRegion);
|
||||
router.put('/map-regions/:id', requireAdmin, workplaceController.updateMapRegion);
|
||||
router.delete('/map-regions/:id', requireAdmin, workplaceController.deleteMapRegion);
|
||||
|
||||
router.post('/:id/layout-image', requireAdmin, upload.single('image'), workplaceController.uploadWorkplaceLayoutImage);
|
||||
|
||||
router.get('/', requireAuth, workplaceController.getAll);
|
||||
router.get('/:id', requireAuth, workplaceController.getById);
|
||||
router.post('/', requireAdmin, workplaceController.create);
|
||||
router.put('/:id', requireAdmin, workplaceController.update);
|
||||
router.delete('/:id', requireAdmin, workplaceController.remove);
|
||||
|
||||
module.exports = router;
|
||||
14
user-management/migrations/001_page_permissions.sql
Normal file
14
user-management/migrations/001_page_permissions.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- user_page_permissions 테이블 (MariaDB)
|
||||
-- 사용자별 페이지 접근 권한 관리
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_page_permissions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
page_name VARCHAR(50) NOT NULL,
|
||||
can_access BOOLEAN DEFAULT FALSE,
|
||||
granted_by_id INT NULL,
|
||||
granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
notes TEXT NULL,
|
||||
UNIQUE KEY unique_user_page (user_id, page_name),
|
||||
FOREIGN KEY (user_id) REFERENCES sso_users(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
8
user-management/web/Dockerfile
Normal file
8
user-management/web/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
3140
user-management/web/index.html
Normal file
3140
user-management/web/index.html
Normal file
File diff suppressed because it is too large
Load Diff
40
user-management/web/nginx.conf
Normal file
40
user-management/web/nginx.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 정적 파일
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 업로드 파일 프록시
|
||||
location /uploads/ {
|
||||
proxy_pass http://tkuser-api:3000/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# API 프록시 → tkuser-api
|
||||
location /api/ {
|
||||
proxy_pass http://tkuser-api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user