diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..8857677
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -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
+```
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 0000000..3c02cdf
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -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`으로 재연결
diff --git a/docker-compose.yml b/docker-compose.yml
index df8c64b..80ae705 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/sso-auth-service/Dockerfile b/sso-auth-service/Dockerfile
index b27b305..7f8276e 100644
--- a/sso-auth-service/Dockerfile
+++ b/sso-auth-service/Dockerfile
@@ -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 . .
diff --git a/system1-factory/api/Dockerfile b/system1-factory/api/Dockerfile
index 5ce8317..7aa384c 100644
--- a/system1-factory/api/Dockerfile
+++ b/system1-factory/api/Dockerfile
@@ -8,7 +8,7 @@ WORKDIR /usr/src/app
COPY package*.json ./
# 프로덕션 의존성만 설치
-RUN npm ci --only=production
+RUN npm install --omit=dev
# 앱 소스 복사
COPY . .
diff --git a/system1-factory/api/config/cors.js b/system1-factory/api/config/cors.js
index a6ba58f..723664d 100644
--- a/system1-factory/api/config/cors.js
+++ b/system1-factory/api/config/cors.js
@@ -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'
];
diff --git a/system1-factory/fastapi-bridge/config.py b/system1-factory/fastapi-bridge/config.py
index 65696b3..f8ac982 100644
--- a/system1-factory/fastapi-bridge/config.py
+++ b/system1-factory/fastapi-bridge/config.py
@@ -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")
diff --git a/system1-factory/web/js/api-config.js b/system1-factory/web/js/api-config.js
index 206eba5..6106538 100644
--- a/system1-factory/web/js/api-config.js
+++ b/system1-factory/web/js/api-config.js
@@ -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 설정
diff --git a/system1-factory/web/js/api-helper.js b/system1-factory/web/js/api-helper.js
index 6bc5c05..ce77e84 100644
--- a/system1-factory/web/js/api-helper.js
+++ b/system1-factory/web/js/api-helper.js
@@ -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() {
diff --git a/system1-factory/web/js/config.js b/system1-factory/web/js/config.js
index 0b551a5..598e1cd 100644
--- a/system1-factory/web/js/config.js
+++ b/system1-factory/web/js/config.js
@@ -7,7 +7,7 @@ export const config = {
// API 관련 설정
api: {
// 로컬 개발 및 Docker 환경에서 사용하는 API 서버 포트
- port: 20005,
+ port: 30005,
// API의 기본 경로
path: '/api',
},
diff --git a/system1-factory/web/js/daily-work-report.js b/system1-factory/web/js/daily-work-report.js
index 1fa7f1b..1abac42 100644
--- a/system1-factory/web/js/daily-work-report.js
+++ b/system1-factory/web/js/daily-work-report.js
@@ -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
diff --git a/system1-factory/web/js/nonconformity-list.js b/system1-factory/web/js/nonconformity-list.js
index 315b2ad..af119cc 100644
--- a/system1-factory/web/js/nonconformity-list.js
+++ b/system1-factory/web/js/nonconformity-list.js
@@ -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', {
diff --git a/system1-factory/web/js/safety-report-list.js b/system1-factory/web/js/safety-report-list.js
index e721dd8..313a777 100644
--- a/system1-factory/web/js/safety-report-list.js
+++ b/system1-factory/web/js/safety-report-list.js
@@ -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', {
diff --git a/system1-factory/web/js/tbm.js b/system1-factory/web/js/tbm.js
index c2fb7e3..9297dd2 100644
--- a/system1-factory/web/js/tbm.js
+++ b/system1-factory/web/js/tbm.js
@@ -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
diff --git a/system1-factory/web/js/visit-request.js b/system1-factory/web/js/visit-request.js
index 22eadef..7a26e18 100644
--- a/system1-factory/web/js/visit-request.js
+++ b/system1-factory/web/js/visit-request.js
@@ -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}`;
diff --git a/system1-factory/web/js/work-analysis/api-client.js b/system1-factory/web/js/work-analysis/api-client.js
index 9ede593..92ae821 100644
--- a/system1-factory/web/js/work-analysis/api-client.js
+++ b/system1-factory/web/js/work-analysis/api-client.js
@@ -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';
}
/**
diff --git a/system1-factory/web/js/workplace-layout-map.js b/system1-factory/web/js/workplace-layout-map.js
index 049ff3c..c7c8252 100644
--- a/system1-factory/web/js/workplace-layout-map.js
+++ b/system1-factory/web/js/workplace-layout-map.js
@@ -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 = `
@@ -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);
diff --git a/system1-factory/web/js/workplace-management.js b/system1-factory/web/js/workplace-management.js
index 9aff3c5..223c8a7 100644
--- a/system1-factory/web/js/workplace-management.js
+++ b/system1-factory/web/js/workplace-management.js
@@ -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 = `
`;
// 캔버스 초기화
@@ -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 = `
`;
// 캔버스 초기화 (설비 영역 편집용)
@@ -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);
diff --git a/system1-factory/web/js/workplace-management/utils.js b/system1-factory/web/js/workplace-management/utils.js
index 04de92e..49fa1be 100644
--- a/system1-factory/web/js/workplace-management/utils.js
+++ b/system1-factory/web/js/workplace-management/utils.js
@@ -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';
}
/**
diff --git a/system1-factory/web/js/workplace-status.js b/system1-factory/web/js/workplace-status.js
index 6e72dd7..d08cf75 100644
--- a/system1-factory/web/js/workplace-status.js
+++ b/system1-factory/web/js/workplace-status.js
@@ -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}`;
diff --git a/system1-factory/web/pages/dashboard.html b/system1-factory/web/pages/dashboard.html
index 598c987..d59b3da 100644
--- a/system1-factory/web/pages/dashboard.html
+++ b/system1-factory/web/pages/dashboard.html
@@ -7,7 +7,7 @@
작업 현황판 | 테크니컬코리아
-
+
diff --git a/system2-report/api/Dockerfile b/system2-report/api/Dockerfile
index b4927c9..cfe530e 100644
--- a/system2-report/api/Dockerfile
+++ b/system2-report/api/Dockerfile
@@ -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 . .
diff --git a/system2-report/api/controllers/workIssueController.js b/system2-report/api/controllers/workIssueController.js
index 0811be6..c090a11 100644
--- a/system2-report/api/controllers/workIssueController.js
+++ b/system2-report/api/controllers/workIssueController.js
@@ -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);
diff --git a/system2-report/api/models/workIssueModel.js b/system2-report/api/models/workIssueModel.js
index 34ec5ef..4351f0f 100644
--- a/system2-report/api/models/workIssueModel.js
+++ b/system2-report/api/models/workIssueModel.js
@@ -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,
diff --git a/system2-report/api/package.json b/system2-report/api/package.json
index c354333..98a77ca 100644
--- a/system2-report/api/package.json
+++ b/system2-report/api/package.json
@@ -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",
diff --git a/system2-report/web/css/common.css b/system2-report/web/css/common.css
new file mode 100644
index 0000000..eb1a24c
--- /dev/null
+++ b/system2-report/web/css/common.css
@@ -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; }
+}
diff --git a/system2-report/web/css/design-system.css b/system2-report/web/css/design-system.css
new file mode 100644
index 0000000..ee1b15c
--- /dev/null
+++ b/system2-report/web/css/design-system.css
@@ -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); }
+}
diff --git a/system2-report/web/css/project-management.css b/system2-report/web/css/project-management.css
new file mode 100644
index 0000000..a0ee6a5
--- /dev/null
+++ b/system2-report/web/css/project-management.css
@@ -0,0 +1,1570 @@
+/* 프로젝트 관리 페이지 스타일 */
+
+/* 기본 레이아웃 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+}
+
+/* 헤더 스타일은 navbar 컴포넌트가 관리 */
+/* 필요한 경우 navbar.html에서 수정하세요 */
+
+.logo {
+ height: 40px;
+ width: auto;
+}
+
+.company-info {
+ display: flex;
+ flex-direction: column;
+}
+
+.company-name {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #1f2937;
+ margin: 0;
+ line-height: 1.2;
+}
+
+.company-subtitle {
+ font-size: 0.875rem;
+ color: #6b7280;
+ font-weight: 500;
+}
+
+.header-center {
+ display: flex;
+ align-items: center;
+}
+
+.current-time {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ background: rgba(59, 130, 246, 0.1);
+ border-radius: 0.5rem;
+ border: 1px solid rgba(59, 130, 246, 0.2);
+}
+
+.time-label {
+ font-size: 0.75rem;
+ color: #6b7280;
+ margin-bottom: 0.125rem;
+}
+
+.time-value {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #1f2937;
+ font-family: 'Courier New', monospace;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.back-btn, .dashboard-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: rgba(255, 255, 255, 0.15);
+ color: #374151;
+ text-decoration: none;
+ border-radius: 1.25rem;
+ font-size: 0.85rem;
+ font-weight: 500;
+ transition: all 0.3s ease;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ backdrop-filter: blur(10px);
+}
+
+.back-btn:hover, .dashboard-btn:hover {
+ background: rgba(255, 255, 255, 0.25);
+ transform: translateY(-1px);
+ text-decoration: none;
+ color: #1f2937;
+}
+
+.user-profile {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 1rem;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 2rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ position: relative;
+}
+
+.user-profile:hover {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.user-avatar {
+ width: 2.5rem;
+ height: 2.5rem;
+ background: linear-gradient(135deg, #3b82f6, #1d4ed8);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: 600;
+ font-size: 1rem;
+}
+
+.user-info {
+ display: flex;
+ flex-direction: column;
+}
+
+.user-name {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #1f2937;
+ line-height: 1.2;
+}
+
+.user-role {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+/* 메인 콘텐츠 */
+.dashboard-main {
+ flex: 1;
+ padding: 2rem;
+ min-height: calc(100vh - 80px);
+ max-width: 1600px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 2rem;
+}
+
+.page-title-section {
+ flex: 1;
+}
+
+.page-title {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: white;
+ margin: 0 0 0.5rem 0;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.title-icon {
+ font-size: 2.5rem;
+}
+
+.page-description {
+ font-size: 1.125rem;
+ color: rgba(255, 255, 255, 0.9);
+ margin: 0;
+ font-weight: 400;
+}
+
+.page-actions {
+ display: flex;
+ gap: 1rem;
+}
+
+.btn {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 0.75rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ text-decoration: none;
+}
+
+.btn-primary {
+ background: #3b82f6;
+ color: white;
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
+}
+
+.btn-primary:hover {
+ background: #2563eb;
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
+}
+
+.btn-secondary {
+ background: rgba(255, 255, 255, 0.9);
+ color: #374151;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+}
+
+.btn-secondary:hover {
+ background: white;
+ transform: translateY(-2px);
+}
+
+.btn-danger {
+ background: #ef4444;
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #dc2626;
+ transform: translateY(-2px);
+}
+
+/* 검색 및 필터 섹션 */
+.search-section {
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(20px);
+ border-radius: 1rem;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+}
+
+.search-bar {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.search-input {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ transition: all 0.3s ease;
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.search-btn {
+ padding: 0.75rem 1rem;
+ background: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.search-btn:hover {
+ background: #2563eb;
+}
+
+.filter-options {
+ display: flex;
+ gap: 1rem;
+}
+
+.filter-select {
+ padding: 0.5rem 1rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ background: white;
+ cursor: pointer;
+}
+
+/* 프로젝트 섹션 */
+.projects-section {
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(20px);
+ border-radius: 1rem;
+ padding: 1.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.section-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #1f2937;
+ margin: 0;
+}
+
+.project-stats {
+ display: flex;
+ gap: 1.5rem;
+ font-size: 0.875rem;
+ align-items: center;
+}
+
+.stat-item {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.5rem;
+ font-weight: 500;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ position: relative;
+}
+
+.stat-item:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.stat-item.active {
+ transform: scale(1.05);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
+ z-index: 10;
+}
+
+.stat-item .stat-icon {
+ font-size: 1rem;
+}
+
+.stat-item span:not(.stat-icon) {
+ font-weight: 600;
+}
+
+/* 활성 프로젝트 통계 */
+.active-stat {
+ background: rgba(16, 185, 129, 0.1);
+ color: #065f46;
+ border: 1px solid rgba(16, 185, 129, 0.2);
+}
+
+.active-stat span:not(.stat-icon) {
+ color: #10b981;
+}
+
+.active-stat.active {
+ background: rgba(16, 185, 129, 0.2);
+ border: 2px solid #10b981;
+}
+
+.active-stat:hover {
+ background: rgba(16, 185, 129, 0.15);
+}
+
+/* 비활성 프로젝트 통계 */
+.inactive-stat {
+ background: rgba(239, 68, 68, 0.1);
+ color: #7f1d1d;
+ border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+.inactive-stat span:not(.stat-icon) {
+ color: #ef4444;
+}
+
+.inactive-stat.active {
+ background: rgba(239, 68, 68, 0.2);
+ border: 2px solid #ef4444;
+}
+
+.inactive-stat:hover {
+ background: rgba(239, 68, 68, 0.15);
+}
+
+/* 전체 프로젝트 통계 */
+.total-stat {
+ background: rgba(59, 130, 246, 0.1);
+ color: #1e3a8a;
+ border: 1px solid rgba(59, 130, 246, 0.2);
+}
+
+.total-stat span:not(.stat-icon) {
+ color: #3b82f6;
+}
+
+.total-stat.active {
+ background: rgba(59, 130, 246, 0.2);
+ border: 2px solid #3b82f6;
+}
+
+.total-stat:hover {
+ background: rgba(59, 130, 246, 0.15);
+}
+
+/* 프로젝트 그리드 */
+.projects-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+ gap: 1.5rem;
+ justify-content: center;
+}
+
+/* 작업자 카드 전용 스타일 */
+.worker-card .project-info {
+ display: flex;
+ align-items: flex-start;
+ gap: 1rem;
+}
+
+.worker-avatar {
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #3b82f6, #1d4ed8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
+}
+
+.avatar-initial {
+ color: white;
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+
+.worker-card .project-name {
+ margin-top: 0.25rem;
+}
+
+.worker-card .project-meta {
+ margin-top: 0.5rem;
+}
+
+.worker-card.inactive .worker-avatar {
+ background: linear-gradient(135deg, #9ca3af, #6b7280);
+ box-shadow: 0 4px 12px rgba(156, 163, 175, 0.3);
+}
+
+/* 작업 유형 트리 뷰 스타일 */
+.task-tree-container {
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(20px);
+ border-radius: 1rem;
+ padding: 1.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+}
+
+.tree-header {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.btn-outline {
+ background: transparent;
+ border: 1px solid #d1d5db;
+ color: #374151;
+ padding: 0.5rem 1rem;
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.btn-outline:hover {
+ background: #f3f4f6;
+ border-color: #9ca3af;
+}
+
+.task-tree {
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+/* 카테고리 (대분류) 스타일 */
+.tree-category {
+ margin-bottom: 1rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.75rem;
+ overflow: hidden;
+}
+
+.category-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ background: linear-gradient(135deg, #f8fafc, #e2e8f0);
+ cursor: pointer;
+ transition: all 0.3s ease;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.category-header:hover {
+ background: linear-gradient(135deg, #f1f5f9, #cbd5e1);
+}
+
+.category-toggle {
+ font-size: 0.875rem;
+ color: #6b7280;
+ transition: transform 0.3s ease;
+}
+
+.category-icon {
+ font-size: 1.25rem;
+}
+
+.category-name {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #1f2937;
+ flex: 1;
+}
+
+.category-count {
+ font-size: 0.875rem;
+ color: #6b7280;
+ background: rgba(107, 114, 128, 0.1);
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.375rem;
+}
+
+.category-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.category-content {
+ background: #ffffff;
+}
+
+/* 서브카테고리 (중분류) 스타일 */
+.tree-subcategory {
+ border-bottom: 1px solid #f3f4f6;
+}
+
+.tree-subcategory:last-child {
+ border-bottom: none;
+}
+
+.subcategory-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.875rem 1.25rem;
+ background: #f8fafc;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.subcategory-header:hover {
+ background: #f1f5f9;
+}
+
+.subcategory-toggle {
+ font-size: 0.75rem;
+ color: #6b7280;
+ margin-left: 1rem;
+}
+
+.subcategory-icon {
+ font-size: 1rem;
+}
+
+.subcategory-name {
+ font-size: 1rem;
+ font-weight: 500;
+ color: #374151;
+ flex: 1;
+}
+
+.subcategory-count {
+ font-size: 0.75rem;
+ color: #6b7280;
+ background: rgba(107, 114, 128, 0.1);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+}
+
+.subcategory-actions {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.subcategory-content {
+ background: #ffffff;
+}
+
+/* 작업 (상세) 스타일 */
+.tree-task {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1.25rem 0.75rem 2.5rem;
+ border-bottom: 1px solid #f9fafb;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.tree-task:hover {
+ background: #f9fafb;
+}
+
+.tree-task:last-child {
+ border-bottom: none;
+}
+
+.task-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex: 1;
+}
+
+.task-icon {
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+.task-name {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #1f2937;
+}
+
+.task-description {
+ font-size: 0.75rem;
+ color: #6b7280;
+ margin-left: 0.5rem;
+ font-style: italic;
+}
+
+.task-actions {
+ display: flex;
+ gap: 0.25rem;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+
+.tree-task:hover .task-actions {
+ opacity: 1;
+}
+
+/* 작은 버튼 스타일 */
+.btn-small {
+ padding: 0.25rem 0.5rem;
+ border: none;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.btn-small.btn-primary {
+ background: #3b82f6;
+ color: white;
+}
+
+.btn-small.btn-primary:hover {
+ background: #2563eb;
+}
+
+.btn-small.btn-secondary {
+ background: #6b7280;
+ color: white;
+}
+
+.btn-small.btn-secondary:hover {
+ background: #4b5563;
+}
+
+.btn-small.btn-edit {
+ background: #f59e0b;
+ color: white;
+}
+
+.btn-small.btn-edit:hover {
+ background: #d97706;
+}
+
+.btn-small.btn-delete {
+ background: #ef4444;
+ color: white;
+}
+
+.btn-small.btn-delete:hover {
+ background: #dc2626;
+}
+
+/* 코드 관리 전용 스타일 */
+.code-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+ border-bottom: 3px solid #d1d5db;
+ padding-bottom: 0;
+ background: rgba(255, 255, 255, 0.9);
+ border-radius: 1rem 1rem 0 0;
+ padding: 0.5rem 0.5rem 0 0.5rem;
+}
+
+.tab-btn {
+ background: rgba(255, 255, 255, 0.7);
+ border: 2px solid #e5e7eb;
+ padding: 1rem 1.5rem;
+ border-radius: 0.75rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #4b5563;
+ border-bottom: 3px solid transparent;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.tab-btn:hover {
+ background: rgba(59, 130, 246, 0.1);
+ color: #1e40af;
+ border-color: #3b82f6;
+ transform: translateY(-1px);
+}
+
+.tab-btn.active {
+ background: linear-gradient(135deg, #3b82f6, #1d4ed8);
+ color: #ffffff;
+ border-color: #1d4ed8;
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
+ transform: translateY(-2px);
+}
+
+.tab-icon {
+ font-size: 1rem;
+}
+
+.code-tab-content {
+ display: none;
+}
+
+.code-tab-content.active {
+ display: block;
+}
+
+.code-section {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95));
+ backdrop-filter: blur(20px);
+ border-radius: 1rem;
+ padding: 2rem;
+ border: 2px solid rgba(59, 130, 246, 0.2);
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.section-title {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #1f2937;
+ margin: 0;
+}
+
+.section-icon {
+ font-size: 1.5rem;
+}
+
+.section-actions {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.code-stats {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.code-stats .stat-item {
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(59, 130, 246, 0.05));
+ border: 2px solid rgba(59, 130, 246, 0.3);
+ color: #1e40af;
+ padding: 0.75rem 1.25rem;
+ border-radius: 0.75rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
+}
+
+.code-stats .critical-stat {
+ background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(239, 68, 68, 0.1));
+ border-color: rgba(239, 68, 68, 0.4);
+ color: #dc2626;
+ box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
+}
+
+.code-stats .high-stat {
+ background: linear-gradient(135deg, rgba(249, 115, 22, 0.2), rgba(249, 115, 22, 0.1));
+ border-color: rgba(249, 115, 22, 0.4);
+ color: #ea580c;
+ box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
+}
+
+.code-stats .medium-stat {
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(245, 158, 11, 0.1));
+ border-color: rgba(245, 158, 11, 0.4);
+ color: #d97706;
+ box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
+}
+
+.code-stats .low-stat {
+ background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1));
+ border-color: rgba(16, 185, 129, 0.4);
+ color: #059669;
+ box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
+}
+
+.code-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 1.5rem;
+}
+
+.code-card {
+ background: linear-gradient(135deg, #ffffff, #f8fafc);
+ border: 2px solid #e5e7eb;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ position: relative;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.code-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 12px 35px rgba(0, 0, 0, 0.2);
+ border-color: #3b82f6;
+ background: linear-gradient(135deg, #ffffff, #f0f9ff);
+}
+
+.code-card.normal-status {
+ border-left: 6px solid #10b981;
+ background: linear-gradient(135deg, #ffffff, #f0fdf4);
+}
+
+.code-card.normal-status:hover {
+ background: linear-gradient(135deg, #f0fdf4, #dcfce7);
+ box-shadow: 0 12px 35px rgba(16, 185, 129, 0.3);
+}
+
+.code-card.error-status {
+ border-left: 6px solid #ef4444;
+ background: linear-gradient(135deg, #ffffff, #fef2f2);
+}
+
+.code-card.error-status:hover {
+ background: linear-gradient(135deg, #fef2f2, #fee2e2);
+ box-shadow: 0 12px 35px rgba(239, 68, 68, 0.3);
+}
+
+.code-card.error-type-card.severity-low {
+ border-left: 6px solid #10b981;
+ background: linear-gradient(135deg, #ffffff, #f0fdf4);
+}
+
+.code-card.error-type-card.severity-low:hover {
+ background: linear-gradient(135deg, #f0fdf4, #dcfce7);
+ box-shadow: 0 12px 35px rgba(16, 185, 129, 0.3);
+}
+
+.code-card.error-type-card.severity-medium {
+ border-left: 6px solid #f59e0b;
+ background: linear-gradient(135deg, #ffffff, #fffbeb);
+}
+
+.code-card.error-type-card.severity-medium:hover {
+ background: linear-gradient(135deg, #fffbeb, #fef3c7);
+ box-shadow: 0 12px 35px rgba(245, 158, 11, 0.3);
+}
+
+.code-card.error-type-card.severity-high {
+ border-left: 6px solid #f97316;
+ background: linear-gradient(135deg, #ffffff, #fff7ed);
+}
+
+.code-card.error-type-card.severity-high:hover {
+ background: linear-gradient(135deg, #fff7ed, #fed7aa);
+ box-shadow: 0 12px 35px rgba(249, 115, 22, 0.3);
+}
+
+.code-card.error-type-card.severity-critical {
+ border-left: 6px solid #ef4444;
+ background: linear-gradient(135deg, #ffffff, #fef2f2);
+}
+
+.code-card.error-type-card.severity-critical:hover {
+ background: linear-gradient(135deg, #fef2f2, #fee2e2);
+ box-shadow: 0 12px 35px rgba(239, 68, 68, 0.3);
+}
+
+.code-card.work-type-card {
+ border-left: 6px solid #6366f1;
+ background: linear-gradient(135deg, #ffffff, #faf5ff);
+}
+
+.code-card.work-type-card:hover {
+ background: linear-gradient(135deg, #faf5ff, #f3e8ff);
+ box-shadow: 0 12px 35px rgba(99, 102, 241, 0.3);
+}
+
+.code-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+}
+
+.code-icon {
+ font-size: 1.5rem;
+ margin-right: 0.75rem;
+}
+
+.code-info {
+ flex: 1;
+}
+
+.code-name {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #111827;
+ margin: 0 0 0.5rem 0;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.code-label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: #374151;
+ background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
+ padding: 0.25rem 0.75rem;
+ border-radius: 0.5rem;
+ border: 1px solid #d1d5db;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.code-description {
+ color: #6b7280;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ margin: 0 0 1rem 0;
+}
+
+.solution-guide {
+ background: #f0f9ff;
+ border: 1px solid #bae6fd;
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ margin: 1rem 0;
+ font-size: 0.875rem;
+ color: #0c4a6e;
+}
+
+.code-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.75rem;
+ color: #9ca3af;
+ margin-top: 1rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid #f3f4f6;
+}
+
+.code-date {
+ font-size: 0.75rem;
+ color: #9ca3af;
+}
+
+.code-actions {
+ display: flex;
+ gap: 0.25rem;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+
+.code-card:hover .code-actions {
+ opacity: 1;
+}
+
+.form-checkbox {
+ margin-right: 0.5rem;
+}
+
+.form-help {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+/* 프로필 드롭다운 스타일 개선 */
+.profile-dropdown {
+ position: relative;
+}
+
+.profile-dropdown .dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ color: #374151;
+ text-decoration: none;
+ transition: all 0.3s ease;
+ border: none;
+ background: transparent;
+ width: 100%;
+ text-align: left;
+ font-size: 0.875rem;
+ cursor: pointer;
+ font-family: inherit;
+ border-radius: 0.5rem;
+ margin: 0.25rem;
+}
+
+.profile-dropdown .dropdown-item:hover {
+ background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
+ color: #1f2937;
+ transform: translateX(2px);
+}
+
+.profile-dropdown .dropdown-item.logout-btn {
+ color: #dc2626;
+ border-top: 1px solid #e5e7eb;
+ margin-top: 0.5rem;
+ padding-top: 0.75rem;
+}
+
+.profile-dropdown .dropdown-item.logout-btn:hover {
+ background: linear-gradient(135deg, #fef2f2, #fee2e2);
+ color: #b91c1c;
+}
+
+.profile-dropdown .dropdown-icon {
+ font-size: 1.1rem;
+ width: 1.5rem;
+ text-align: center;
+ opacity: 0.8;
+}
+
+.profile-dropdown .dropdown-item:hover .dropdown-icon {
+ opacity: 1;
+}
+
+/* 헤더 사용자 프로필 스타일 개선 */
+.user-profile {
+ position: relative;
+ cursor: pointer;
+}
+
+.user-profile .profile-dropdown {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ right: 0;
+ background: linear-gradient(135deg, #ffffff, #f8fafc);
+ border: 2px solid rgba(59, 130, 246, 0.2);
+ border-radius: 1rem;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
+ min-width: 200px;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-10px);
+ transition: all 0.3s ease;
+ overflow: hidden;
+ z-index: 1000;
+ backdrop-filter: blur(20px);
+}
+
+.user-profile .profile-dropdown[style*="block"] {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+/* 반응형 디자인 */
+@media (max-width: 768px) {
+ .code-tabs {
+ flex-direction: column;
+ gap: 0;
+ }
+
+ .tab-btn {
+ border-radius: 0;
+ border-bottom: 1px solid #e5e7eb;
+ }
+
+ .tab-btn.active {
+ border-bottom-color: #3b82f6;
+ }
+
+ .section-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .code-stats {
+ justify-content: center;
+ }
+
+ .code-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .user-profile .profile-dropdown {
+ right: -1rem;
+ min-width: 180px;
+ }
+}
+
+.project-card {
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.75rem;
+ padding: 1.5rem;
+ transition: all 0.3s ease;
+ cursor: pointer;
+}
+
+.project-card:hover {
+ background: #f1f5f9;
+ border-color: #cbd5e1;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.project-card.inactive {
+ opacity: 0.8;
+ background: #f8f9fa;
+ border-color: #e9ecef;
+ border-left: 4px solid #ef4444;
+ position: relative;
+}
+
+.project-card.inactive:hover {
+ background: #f1f3f4;
+ border-color: #dee2e6;
+}
+
+/* 비활성화 오버레이 */
+.inactive-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 10;
+}
+
+.inactive-badge {
+ background: linear-gradient(135deg, #ef4444, #dc2626);
+ color: white;
+ padding: 0.25rem 0.75rem;
+ border-radius: 0 0.75rem 0 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
+}
+
+/* 비활성 라벨 */
+.inactive-label {
+ color: #ef4444;
+ font-size: 0.8rem;
+ font-weight: 600;
+ margin-left: 0.5rem;
+ background: rgba(239, 68, 68, 0.1);
+ padding: 0.125rem 0.5rem;
+ border-radius: 0.25rem;
+}
+
+/* 비활성 안내 */
+.inactive-notice {
+ color: #f59e0b;
+ font-size: 0.75rem;
+ font-weight: 500;
+ background: rgba(245, 158, 11, 0.1);
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ border: 1px solid rgba(245, 158, 11, 0.2);
+ display: inline-block;
+ margin-top: 0.25rem;
+}
+
+.project-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+}
+
+.project-info {
+ flex: 1;
+}
+
+.project-job-no {
+ font-size: 0.75rem;
+ color: #6b7280;
+ font-weight: 500;
+ margin-bottom: 0.25rem;
+}
+
+.project-name {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #1f2937;
+ margin: 0 0 0.5rem 0;
+ line-height: 1.4;
+}
+
+.project-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.project-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.btn-edit, .btn-delete {
+ padding: 0.375rem 0.75rem;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.btn-edit {
+ background: #3b82f6;
+ color: white;
+}
+
+.btn-edit:hover {
+ background: #2563eb;
+}
+
+.btn-delete {
+ background: #ef4444;
+ color: white;
+}
+
+.btn-delete:hover {
+ background: #dc2626;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 3rem 2rem;
+ color: #6b7280;
+}
+
+.empty-state .empty-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+ opacity: 0.5;
+}
+
+.empty-state h3 {
+ margin: 0 0 0.5rem 0;
+ color: #374151;
+ font-size: 1.25rem;
+}
+
+.empty-state p {
+ margin: 0 0 1.5rem 0;
+ font-size: 0.875rem;
+}
+
+/* 모달 스타일 */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.modal-container {
+ background: white;
+ border-radius: 1rem;
+ max-width: 600px;
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 20px 25px rgba(0, 0, 0, 0.1);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.modal-header h2 {
+ margin: 0;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #1f2937;
+}
+
+.modal-close-btn {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ color: #6b7280;
+ cursor: pointer;
+ padding: 0.25rem;
+ border-radius: 0.25rem;
+ transition: all 0.3s ease;
+}
+
+.modal-close-btn:hover {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+.modal-body {
+ padding: 1.5rem;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem;
+ border-top: 1px solid #e5e7eb;
+ gap: 1rem;
+}
+
+/* 폼 스타일 */
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.form-label {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+}
+
+.form-control {
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ transition: all 0.3s ease;
+}
+
+.form-control:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+/* 반응형 디자인 */
+@media (max-width: 1024px) {
+ .dashboard-header {
+ padding: 1rem;
+ }
+
+ .dashboard-main {
+ padding: 1.5rem;
+ }
+
+ .page-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .projects-grid {
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ }
+}
+
+@media (max-width: 768px) {
+ .header-center {
+ display: none;
+ }
+
+ .company-info {
+ display: none;
+ }
+
+ .page-title {
+ font-size: 2rem;
+ }
+
+ .page-actions {
+ flex-direction: column;
+ }
+
+ .search-bar {
+ flex-direction: column;
+ }
+
+ .filter-options {
+ flex-direction: column;
+ }
+
+ .projects-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .project-stats {
+ flex-direction: column;
+ gap: 0.75rem;
+ align-items: stretch;
+ }
+
+ .stat-item {
+ justify-content: center;
+ }
+}
+
+@media (max-width: 480px) {
+ .dashboard-header {
+ padding: 0.75rem;
+ }
+
+ .dashboard-main {
+ padding: 1rem;
+ }
+
+ .page-title {
+ font-size: 1.75rem;
+ }
+
+ .search-section,
+ .projects-section {
+ padding: 1rem;
+ }
+
+ .modal-container {
+ margin: 0.5rem;
+ max-height: 95vh;
+ }
+}
+
+/* 작업자 상태 토글 버튼 스타일 */
+.btn-toggle {
+ background: none;
+ border: 2px solid;
+ border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.2s ease;
+ margin-right: 0.25rem;
+}
+
+.btn-deactivate {
+ border-color: #ef4444;
+ color: #ef4444;
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.btn-deactivate:hover {
+ background: #ef4444;
+ color: white;
+ transform: scale(1.05);
+}
+
+.btn-activate {
+ border-color: #10b981;
+ color: #10b981;
+ background: rgba(16, 185, 129, 0.1);
+}
+
+.btn-activate:hover {
+ background: #10b981;
+ color: white;
+ transform: scale(1.05);
+}
diff --git a/system2-report/web/img/favicon.png b/system2-report/web/img/favicon.png
new file mode 100644
index 0000000..5bbd962
Binary files /dev/null and b/system2-report/web/img/favicon.png differ
diff --git a/system2-report/web/js/issue-detail.js b/system2-report/web/js/issue-detail.js
index 611fce2..4692a94 100644
--- a/system2-report/web/js/issue-detail.js
+++ b/system2-report/web/js/issue-detail.js
@@ -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;
diff --git a/system2-report/web/js/issue-report.js b/system2-report/web/js/issue-report.js
index 31820be..383edc1 100644
--- a/system2-report/web/js/issue-report.js
+++ b/system2-report/web/js/issue-report.js
@@ -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}`;
diff --git a/system2-report/web/js/safety-report-list.js b/system2-report/web/js/safety-report-list.js
index f1c1a27..2093707 100644
--- a/system2-report/web/js/safety-report-list.js
+++ b/system2-report/web/js/safety-report-list.js
@@ -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', {
diff --git a/system2-report/web/js/work-issue-report.js b/system2-report/web/js/work-issue-report.js
index 2349d46..3f3d8f7 100644
--- a/system2-report/web/js/work-issue-report.js
+++ b/system2-report/web/js/work-issue-report.js
@@ -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');
diff --git a/system2-report/web/nginx.conf b/system2-report/web/nginx.conf
index 6866849..bdd14a9 100644
--- a/system2-report/web/nginx.conf
+++ b/system2-report/web/nginx.conf
@@ -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 / {
diff --git a/system2-report/web/pages/safety/issue-report.html b/system2-report/web/pages/safety/issue-report.html
index ebe4725..1a52e74 100644
--- a/system2-report/web/pages/safety/issue-report.html
+++ b/system2-report/web/pages/safety/issue-report.html
@@ -3,128 +3,167 @@
- 문제 신고 | (주)테크니컬코리아
+ 신고 | (주)테크니컬코리아
-
-
-
-
-
-
-