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:
Hyungi Ahn
2026-02-12 13:45:52 +09:00
parent 6495b8af32
commit 733bb0cb35
96 changed files with 9721 additions and 825 deletions

136
ARCHITECTURE.md Normal file
View 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
View 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`으로 재연결

View File

@@ -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

View File

@@ -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 . .

View File

@@ -8,7 +8,7 @@ WORKDIR /usr/src/app
COPY package*.json ./
# 프로덕션 의존성만 설치
RUN npm ci --only=production
RUN npm install --omit=dev
# 앱 소스 복사
COPY . .

View File

@@ -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'
];

View File

@@ -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")

View File

@@ -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 설정

View File

@@ -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() {

View File

@@ -7,7 +7,7 @@ export const config = {
// API 관련 설정
api: {
// 로컬 개발 및 Docker 환경에서 사용하는 API 서버 포트
port: 20005,
port: 30005,
// API의 기본 경로
path: '/api',
},

View File

@@ -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

View File

@@ -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', {

View File

@@ -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', {

View File

@@ -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

View File

@@ -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}`;

View File

@@ -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';
}
/**

View File

@@ -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);

View File

@@ -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);

View File

@@ -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';
}
/**

View File

@@ -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}`;

View File

@@ -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">

View File

@@ -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 . .

View File

@@ -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);

View File

@@ -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,

View File

@@ -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",

View 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; }
}

View 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); }
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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;

View File

@@ -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}`;

View File

@@ -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', {

View File

@@ -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');

View File

@@ -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

View File

@@ -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()

View File

@@ -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) # 삭제 사유 (선택사항)

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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"

View File

@@ -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:

View File

@@ -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,

View File

@@ -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": "프로젝트가 삭제되었습니다."}

View File

@@ -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

View File

@@ -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}")

View 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()

View File

@@ -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()}`,

View File

@@ -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()}`,

View File

@@ -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()}`,

View File

@@ -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()}`,

View File

@@ -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()}`,

View File

@@ -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;
}
}

View File

@@ -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: {

View File

@@ -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();

View File

@@ -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');
}
});
}

View File

@@ -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>

View File

@@ -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()}`
}
});

View File

@@ -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}`
}

View 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"]

View 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 };

View 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
};

View 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
};

View 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 };

View 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
};

View 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
};

View 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
};

View 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 };

View 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
};

View 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;

View 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 };

View 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;

View 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 };

View 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
};

View 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
};

View 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 };

View 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
};

View 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
};

View 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
};

View 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 };

View 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
};

View 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"
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;"]

File diff suppressed because it is too large Load Diff

View 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;
}
}