feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시 - 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시 - 외부링/필러/내부링/추가구성 모든 정보 포함 - 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
# TK-MP-Project Backend 개선/확장/운영 권장사항
|
||||
|
||||
## 1. 코드 구조/품질
|
||||
- ResponseModel(Pydantic) 적용: API 반환값의 타입 안정성 및 문서화 강화
|
||||
- 로깅/에러 처리: print → logging 모듈, 운영 환경에 맞는 에러/이벤트 기록
|
||||
- 환경변수/설정 분리: CORS, DB, 포트 등 환경별 관리 용이하게 분리
|
||||
- 라우터 자동 등록/동적 관리: 라우터가 많아질 경우 코드 중복 최소화
|
||||
|
||||
## 2. 보안/운영
|
||||
- CORS 제한: 운영 환경에서는 허용 origin을 제한
|
||||
- 업로드 파일 검증 강화: 경로, 파일명, 크기 등 보안 검증 추가
|
||||
|
||||
## 3. 성능/확장성
|
||||
- 대용량 파일/데이터 처리: 비동기/청크 처리, 인덱스 튜닝 등
|
||||
- DB 트랜잭션 명확화: 파일/자재 저장 등에서 트랜잭션 관리 강화
|
||||
|
||||
## 4. 테스트/CI
|
||||
- 자동화 테스트(assert 기반): print 위주 → assert 기반 자동화로 CI/CD 연동
|
||||
- 테스트 커버리지 확대: 다양한 예외/경계 케이스 추가
|
||||
|
||||
## 5. 기타
|
||||
- 코드/유틸 함수 분리: 중복 유틸 함수는 별도 모듈로 분리
|
||||
- 상태/활성화 관리 enum화: status 등은 enum으로 관리
|
||||
- 삭제/수정 API 추가: Job 등 주요 엔티티의 논리적 삭제/수정 지원
|
||||
|
||||
---
|
||||
|
||||
*2024-07-15 기준, backend 코드 리뷰 기반 개선/확장/운영 권장사항 정리*
|
||||
123
README.md
123
README.md
@@ -1,123 +0,0 @@
|
||||
아! 이해했습니다! 😅
|
||||
cat > README.md << 'EOF' 명령어에서 EOF까지의 모든 내용을 한 번에 입력하라는 뜻이에요.
|
||||
즉, 이 전체 부분을 한 번에 복사해서 터미널에 붙여넣기하면 됩니다:
|
||||
bashcat > README.md << 'EOF'
|
||||
# 🚀 TK-MP-Project: BOM 시스템 개발 프로젝트
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
BOM (Bill of Materials) 시스템의 기능 이상을 해결하고, 도면 완성 후 자재 관리의 모든 프로세스를 자동화하는 종합 시스템 개발 프로젝트입니다.
|
||||
|
||||
## 🎯 프로젝트 목표
|
||||
|
||||
### 핵심 미션
|
||||
**"도면 완성 후 자재 관리의 모든 번거로움을 해결"**
|
||||
|
||||
### 주요 해결 과제
|
||||
- 📄 **파일 분석 자동화**: 엑셀/CSV 자재 목록의 자동 분류 및 정제
|
||||
- 🔍 **정확한 분류 체계**: 파이프/피팅/볼트/밸브/계기류의 4단계 자동 분류
|
||||
- 💾 **체계적 데이터 관리**: 프로젝트별 버전 관리 및 이력 추적
|
||||
- 📊 **업무별 맞춤 출력**: 구매/생산/품질 각 팀의 필요에 맞는 자료 생성
|
||||
- 🔄 **리비전 변화 추적**: 도면 변경 시 자재 변경사항 자동 비교
|
||||
|
||||
## 💻 기술 스택
|
||||
|
||||
### Backend
|
||||
- **Language**: Python 3.9+
|
||||
- **Framework**: FastAPI (고성능 API 서버)
|
||||
- **Database**: PostgreSQL 15 (복잡한 관계형 데이터 처리)
|
||||
- **ORM**: SQLAlchemy (데이터베이스 모델링)
|
||||
- **Data Processing**: Pandas, openpyxl (파일 처리)
|
||||
|
||||
### DevOps & Tools
|
||||
- **Containerization**: Docker & Docker Compose
|
||||
- **Version Control**: Git (Gitea 호스팅)
|
||||
- **Development**: VS Code + Python 확장
|
||||
|
||||
## 🌐 개발 환경 설정
|
||||
|
||||
### Git 저장소 접속
|
||||
```bash
|
||||
# VPN 연결 필요: vpn.hyungi.net:21194
|
||||
git clone http://192.168.1.227:10300/hyungi/TK-MP-Project.git
|
||||
cd TK-MP-Project
|
||||
데이터베이스 실행
|
||||
bash# PostgreSQL 및 pgAdmin 실행
|
||||
docker-compose up -d postgres pgadmin redis
|
||||
|
||||
# 접속 확인
|
||||
# pgAdmin: http://localhost:5050 (admin@tkmp.local / admin2025)
|
||||
Python 개발 환경
|
||||
bash# 가상환경 생성
|
||||
python -m venv venv
|
||||
source venv/bin/activate # macOS/Linux
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# 개발 서버 실행
|
||||
cd backend
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
📁 프로젝트 구조
|
||||
TK-MP-Project/
|
||||
├── README.md
|
||||
├── docker-compose.yml
|
||||
├── backend/ # FastAPI 백엔드
|
||||
│ ├── app/
|
||||
│ │ ├── models/ # SQLAlchemy 모델
|
||||
│ │ ├── schemas/ # Pydantic 스키마
|
||||
│ │ ├── api/ # API 라우터
|
||||
│ │ ├── core/ # 설정 및 유틸리티
|
||||
│ │ ├── services/ # 비즈니스 로직
|
||||
│ │ └── database/ # DB 연결 설정
|
||||
│ └── requirements.txt
|
||||
├── database/ # DB 스키마 및 초기 데이터
|
||||
│ └── init/
|
||||
│ └── 01_schema.sql
|
||||
└── docs/ # 프로젝트 문서
|
||||
🚀 개발 로드맵
|
||||
Phase 1: 기반 시스템 구축 (진행중)
|
||||
|
||||
Git 환경 구축 ✅
|
||||
데이터베이스 스키마 설계 ✅
|
||||
Docker 개발 환경 설정 ✅
|
||||
FastAPI 기본 구조 구현
|
||||
파일 업로드 및 파싱 기능
|
||||
|
||||
Phase 2: 핵심 기능 개발
|
||||
|
||||
자재 분류 알고리즘 구현
|
||||
웹 인터페이스 구축
|
||||
구매 BOM 생성 기능
|
||||
|
||||
Phase 3: 고도화
|
||||
|
||||
리비전 비교 기능
|
||||
파이프 cutting 자료 생성
|
||||
사용자 테스트 및 최적화
|
||||
|
||||
🗄️ 데이터베이스 스키마
|
||||
핵심 테이블
|
||||
|
||||
projects: 프로젝트 관리 (코드 매칭, 버전 관리)
|
||||
files: 업로드된 자재 목록 파일들
|
||||
materials: 개별 자재 상세 정보 (분류 결과 포함)
|
||||
|
||||
주요 기능
|
||||
|
||||
프로젝트별 파일 버전 관리 (Rev.0, Rev.1, Rev.2)
|
||||
자재 자동 분류 시스템 (카테고리, 재질, 사이즈)
|
||||
분류 신뢰도 및 사용자 검증 시스템
|
||||
|
||||
📞 개발팀
|
||||
|
||||
Lead Developer: hyungi
|
||||
Gitea Repository: http://192.168.1.227:10300/hyungi/TK-MP-Project
|
||||
|
||||
🎯 다음 단계
|
||||
|
||||
데이터베이스 실행: docker-compose up -d postgres pgadmin
|
||||
Python 환경 구축: 가상환경 생성 및 패키지 설치
|
||||
FastAPI 구조 구현: 기본 API 서버 및 모델 생성
|
||||
|
||||
|
||||
Last Updated: 2025.07.14
|
||||
33
REVIEW.md
33
REVIEW.md
@@ -1,33 +0,0 @@
|
||||
# TK-MP-Project Backend 코드 리뷰 요약
|
||||
|
||||
## 1. 전체 구조
|
||||
- FastAPI + SQLAlchemy 기반 백엔드
|
||||
- models, schemas, routers, services, api, uploads 등 역할별 디렉토리 분리
|
||||
- 자재/BOM/스풀/계장 등 플랜트/조선/기계 실무에 특화된 구조
|
||||
|
||||
## 2. 주요 코드 검토
|
||||
- **main.py**: 앱 진입점, CORS, 라우터 등록, 헬스체크 등
|
||||
- **routers/**: 파일, 작업(Job) 등 API 엔드포인트 구현
|
||||
- **services/**: 품목별 분류기(볼트, 밸브, 플랜지, 피팅, 가스켓, 파이프, 계장 등), 스풀 관리, 테스트 코드
|
||||
- **material_classifier.py**: 재질 분류 공통 모듈, 규격/패턴/키워드 기반 robust 분류
|
||||
- **spool_manager.py/v2**: 도면-에어리어-스풀 넘버링, 유효성 검증, 자동 추천 등
|
||||
- **api/**: 과거 버전/백업/보조 코드(실제 서비스는 routers/가 메인)
|
||||
- **테스트 코드**: 다양한 실무 케이스를 print 기반으로 커버(자동화는 미흡)
|
||||
- **materials_schema.py**: 분류기에서 사용하는 규격/패턴/키워드/등급 등 데이터 정의
|
||||
|
||||
## 3. 품목별 분류기 구조
|
||||
- 볼트/밸브/플랜지/피팅/가스켓/파이프/계장 등 각 품목별로 dict 기반 패턴/키워드/규격 관리
|
||||
- material_classifier와 연동, 신뢰도/구매정보 등 실무적 정보 제공
|
||||
- 구조/로직은 유사하나, 각 품목별 실무 특성에 맞는 분류 포인트 반영
|
||||
|
||||
## 4. 테스트 코드
|
||||
- 다양한 실무 케이스를 print 기반으로 커버
|
||||
- 자동화(assert) 기반 테스트는 미흡(추후 개선 필요)
|
||||
|
||||
## 5. materials_schema.py
|
||||
- 분류기에서 사용하는 규격/패턴/키워드/등급 등 실무적 데이터가 체계적으로 구조화
|
||||
- 신규 규격/등급/패턴 추가/수정이 용이
|
||||
|
||||
---
|
||||
|
||||
*2024-07-15 기준, 전체 backend 코드 리뷰 및 구조 요약*
|
||||
25
backend/.dockerignore
Normal file
25
backend/.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env
|
||||
venv
|
||||
.venv
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.tox
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.log
|
||||
.git
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.hypothesis
|
||||
.DS_Store
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
@@ -9,6 +9,8 @@ RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
libmagic1 \
|
||||
libmagic-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# requirements.txt 복사 및 의존성 설치
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# API 라우터 패키지
|
||||
"""
|
||||
API 모듈
|
||||
분리된 API 엔드포인트들
|
||||
"""
|
||||
56
backend/app/api/file_management.py
Normal file
56
backend/app/api/file_management.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
파일 관리 API
|
||||
main.py에서 분리된 파일 관련 엔드포인트들
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..utils.logger import get_logger
|
||||
from ..schemas import FileListResponse, FileDeleteResponse, FileInfo
|
||||
from ..services.file_service import get_file_service
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/files", response_model=FileListResponse)
|
||||
async def get_files(
|
||||
job_no: Optional[str] = None,
|
||||
show_history: bool = False,
|
||||
use_cache: bool = True,
|
||||
db: Session = Depends(get_db)
|
||||
) -> FileListResponse:
|
||||
"""파일 목록 조회 (BOM별 그룹화)"""
|
||||
file_service = get_file_service(db)
|
||||
|
||||
# 서비스 레이어 호출
|
||||
files, cache_hit = await file_service.get_files(job_no, show_history, use_cache)
|
||||
|
||||
return FileListResponse(
|
||||
success=True,
|
||||
message="파일 목록 조회 성공" + (" (캐시)" if cache_hit else ""),
|
||||
data=files,
|
||||
total_count=len(files),
|
||||
cache_hit=cache_hit
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}", response_model=FileDeleteResponse)
|
||||
async def delete_file(
|
||||
file_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
) -> FileDeleteResponse:
|
||||
"""파일 삭제"""
|
||||
file_service = get_file_service(db)
|
||||
|
||||
# 서비스 레이어 호출
|
||||
result = await file_service.delete_file(file_id)
|
||||
|
||||
return FileDeleteResponse(
|
||||
success=result["success"],
|
||||
message=result["message"],
|
||||
deleted_file_id=result["deleted_file_id"]
|
||||
)
|
||||
63
backend/app/auth/__init__.py
Normal file
63
backend/app/auth/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
인증 모듈 초기화
|
||||
TK-MP-Project 인증 시스템의 모든 컴포넌트를 노출
|
||||
"""
|
||||
|
||||
from .jwt_service import jwt_service, JWTService
|
||||
from .auth_service import AuthService, get_auth_service
|
||||
from .auth_controller import router as auth_router
|
||||
from .middleware import (
|
||||
auth_middleware,
|
||||
get_current_user,
|
||||
get_current_active_user,
|
||||
require_admin,
|
||||
require_leader_or_admin,
|
||||
require_roles,
|
||||
require_permissions,
|
||||
get_user_from_token,
|
||||
check_user_permission,
|
||||
get_user_permissions_by_role,
|
||||
get_current_user_optional
|
||||
)
|
||||
from .models import (
|
||||
User,
|
||||
LoginLog,
|
||||
UserSession,
|
||||
Permission,
|
||||
RolePermission,
|
||||
UserRepository
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# JWT 서비스
|
||||
'jwt_service',
|
||||
'JWTService',
|
||||
|
||||
# 인증 서비스
|
||||
'AuthService',
|
||||
'get_auth_service',
|
||||
|
||||
# 라우터
|
||||
'auth_router',
|
||||
|
||||
# 미들웨어 및 의존성
|
||||
'auth_middleware',
|
||||
'get_current_user',
|
||||
'get_current_active_user',
|
||||
'require_admin',
|
||||
'require_leader_or_admin',
|
||||
'require_roles',
|
||||
'require_permissions',
|
||||
'get_user_from_token',
|
||||
'check_user_permission',
|
||||
'get_user_permissions_by_role',
|
||||
'get_current_user_optional',
|
||||
|
||||
# 모델
|
||||
'User',
|
||||
'LoginLog',
|
||||
'UserSession',
|
||||
'Permission',
|
||||
'RolePermission',
|
||||
'UserRepository'
|
||||
]
|
||||
393
backend/app/auth/auth_controller.py
Normal file
393
backend/app/auth/auth_controller.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
인증 컨트롤러
|
||||
TK-FB-Project의 authController.js를 참고하여 FastAPI용으로 구현
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..database import get_db
|
||||
from .auth_service import get_auth_service
|
||||
from .jwt_service import jwt_service
|
||||
from .models import UserRepository
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
name: str
|
||||
email: Optional[EmailStr] = None
|
||||
access_level: str = 'worker'
|
||||
department: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
success: bool
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
user: Dict[str, Any]
|
||||
redirect_url: str
|
||||
permissions: List[str]
|
||||
|
||||
|
||||
class RefreshTokenResponse(BaseModel):
|
||||
success: bool
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
user: Dict[str, Any]
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
user_id: int
|
||||
username: str
|
||||
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
success: bool
|
||||
user: Dict[str, Any]
|
||||
permissions: List[str]
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 로그인
|
||||
|
||||
Args:
|
||||
login_data: 로그인 정보 (사용자명, 비밀번호)
|
||||
request: FastAPI Request 객체
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
LoginResponse: 로그인 결과 (토큰, 사용자 정보 등)
|
||||
"""
|
||||
try:
|
||||
auth_service = get_auth_service(db)
|
||||
result = await auth_service.login(
|
||||
username=login_data.username,
|
||||
password=login_data.password,
|
||||
request=request
|
||||
)
|
||||
|
||||
return LoginResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login endpoint error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register(
|
||||
register_data: RegisterRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 등록
|
||||
|
||||
Args:
|
||||
register_data: 등록 정보
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
RegisterResponse: 등록 결과
|
||||
"""
|
||||
try:
|
||||
auth_service = get_auth_service(db)
|
||||
result = await auth_service.register(register_data.dict())
|
||||
|
||||
return RegisterResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Register endpoint error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshTokenResponse)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshTokenRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_data: 리프레시 토큰 정보
|
||||
request: FastAPI Request 객체
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
RefreshTokenResponse: 새로운 토큰 정보
|
||||
"""
|
||||
try:
|
||||
auth_service = get_auth_service(db)
|
||||
result = await auth_service.refresh_token(
|
||||
refresh_token=refresh_data.refresh_token,
|
||||
request=request
|
||||
)
|
||||
|
||||
return RefreshTokenResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Refresh token endpoint error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/logout", response_model=LogoutResponse)
|
||||
async def logout(
|
||||
refresh_data: RefreshTokenRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
로그아웃
|
||||
|
||||
Args:
|
||||
refresh_data: 리프레시 토큰 정보
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
LogoutResponse: 로그아웃 결과
|
||||
"""
|
||||
try:
|
||||
auth_service = get_auth_service(db)
|
||||
result = await auth_service.logout(refresh_data.refresh_token)
|
||||
|
||||
return LogoutResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logout endpoint error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfoResponse)
|
||||
async def get_current_user_info(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
현재 사용자 정보 조회
|
||||
|
||||
Args:
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
UserInfoResponse: 사용자 정보 및 권한
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
user_id = payload['user_id']
|
||||
|
||||
# 사용자 정보 조회
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="사용자를 찾을 수 없거나 비활성화된 계정입니다"
|
||||
)
|
||||
|
||||
# 권한 정보 조회
|
||||
permissions = user_repo.get_user_permissions(user.role)
|
||||
|
||||
return UserInfoResponse(
|
||||
success=True,
|
||||
user=user.to_dict(),
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Get current user info error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="사용자 정보 조회 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_token(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
):
|
||||
"""
|
||||
토큰 검증
|
||||
|
||||
Args:
|
||||
credentials: JWT 토큰
|
||||
|
||||
Returns:
|
||||
Dict: 토큰 검증 결과
|
||||
"""
|
||||
try:
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'valid': True,
|
||||
'user_id': payload['user_id'],
|
||||
'username': payload['username'],
|
||||
'role': payload['role'],
|
||||
'expires_at': payload.get('exp')
|
||||
}
|
||||
|
||||
except HTTPException as e:
|
||||
return {
|
||||
'success': False,
|
||||
'valid': False,
|
||||
'error': e.detail
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification error: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'valid': False,
|
||||
'error': '토큰 검증 중 오류가 발생했습니다'
|
||||
}
|
||||
|
||||
|
||||
# 관리자 전용 엔드포인트들
|
||||
@router.get("/users")
|
||||
async def get_all_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
모든 사용자 목록 조회 (관리자 전용)
|
||||
|
||||
Args:
|
||||
skip: 건너뛸 레코드 수
|
||||
limit: 조회할 레코드 수
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 사용자 목록
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
if payload['role'] not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자 권한이 필요합니다"
|
||||
)
|
||||
|
||||
# 사용자 목록 조회
|
||||
user_repo = UserRepository(db)
|
||||
users = user_repo.get_all_users(skip=skip, limit=limit)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'users': [user.to_dict() for user in users],
|
||||
'total_count': len(users)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Get all users error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="사용자 목록 조회 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 삭제 (관리자 전용)
|
||||
|
||||
Args:
|
||||
user_id: 삭제할 사용자 ID
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 삭제 결과
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
if payload['role'] not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자 권한이 필요합니다"
|
||||
)
|
||||
|
||||
# 자기 자신 삭제 방지
|
||||
if payload['user_id'] == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="자기 자신은 삭제할 수 없습니다"
|
||||
)
|
||||
|
||||
# 사용자 조회 및 삭제
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="해당 사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
user_repo.delete_user(user)
|
||||
|
||||
logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '사용자가 삭제되었습니다',
|
||||
'deleted_user_id': user_id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Delete user error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="사용자 삭제 중 오류가 발생했습니다"
|
||||
)
|
||||
372
backend/app/auth/auth_service.py
Normal file
372
backend/app/auth/auth_service.py
Normal file
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
인증 서비스
|
||||
TK-FB-Project의 auth.service.js를 참고하여 FastAPI용으로 구현
|
||||
"""
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import User, UserRepository
|
||||
from .jwt_service import jwt_service
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.error_handlers import TKMPException
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""인증 서비스 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.user_repo = UserRepository(db)
|
||||
|
||||
async def login(self, username: str, password: str, request: Request) -> Dict[str, Any]:
|
||||
"""
|
||||
사용자 로그인
|
||||
|
||||
Args:
|
||||
username: 사용자명
|
||||
password: 비밀번호
|
||||
request: FastAPI Request 객체
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 로그인 결과 (토큰, 사용자 정보 등)
|
||||
|
||||
Raises:
|
||||
TKMPException: 로그인 실패 시
|
||||
"""
|
||||
try:
|
||||
# 클라이언트 정보 추출
|
||||
ip_address = self._get_client_ip(request)
|
||||
user_agent = request.headers.get('user-agent', 'unknown')
|
||||
|
||||
logger.info(f"Login attempt for username: {username} from IP: {ip_address}")
|
||||
|
||||
# 입력 검증
|
||||
if not username or not password:
|
||||
await self._record_login_failure(None, ip_address, user_agent, 'missing_credentials')
|
||||
raise TKMPException(
|
||||
message="사용자명과 비밀번호를 입력해주세요",
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 사용자 조회
|
||||
user = self.user_repo.find_by_username(username)
|
||||
if not user:
|
||||
await self._record_login_failure(None, ip_address, user_agent, 'user_not_found')
|
||||
logger.warning(f"Login failed - user not found: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
message="아이디 또는 비밀번호가 올바르지 않습니다"
|
||||
)
|
||||
|
||||
# 계정 활성화 상태 확인
|
||||
if not user.is_active:
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||
logger.warning(f"Login failed - account disabled: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
||||
)
|
||||
|
||||
# 계정 잠금 상태 확인
|
||||
if user.is_locked():
|
||||
remaining_time = int((user.locked_until - datetime.utcnow()).total_seconds() / 60)
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_locked')
|
||||
logger.warning(f"Login failed - account locked: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
message=f"계정이 잠겨있습니다. {remaining_time}분 후에 다시 시도하세요"
|
||||
)
|
||||
|
||||
# 비밀번호 확인
|
||||
if not user.check_password(password):
|
||||
# 로그인 실패 처리
|
||||
user.increment_failed_attempts()
|
||||
self.user_repo.update_user(user)
|
||||
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'invalid_password')
|
||||
logger.warning(f"Login failed - invalid password: {username}")
|
||||
|
||||
# 계정 잠금 확인
|
||||
if user.failed_login_attempts >= 5:
|
||||
logger.warning(f"Account locked due to failed attempts: {username}")
|
||||
|
||||
raise TKMPException(
|
||||
message="아이디 또는 비밀번호가 올바르지 않습니다",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# 로그인 성공 처리
|
||||
user.reset_failed_attempts()
|
||||
user.update_last_login()
|
||||
self.user_repo.update_user(user)
|
||||
|
||||
# 토큰 생성
|
||||
user_data = user.to_dict()
|
||||
access_token = jwt_service.create_access_token(user_data)
|
||||
refresh_token = jwt_service.create_refresh_token(user.user_id)
|
||||
|
||||
# 세션 생성
|
||||
expires_at = datetime.utcnow() + timedelta(days=7)
|
||||
session = self.user_repo.create_session(
|
||||
user_id=user.user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
# 로그인 성공 기록
|
||||
self.user_repo.record_login_log(
|
||||
user_id=user.user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status='success'
|
||||
)
|
||||
|
||||
# 리디렉션 URL 결정
|
||||
redirect_url = self._get_redirect_url(user.role)
|
||||
|
||||
logger.info(f"Login successful for user: {username} (role: {user.role})")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'token_type': 'bearer',
|
||||
'expires_in': 24 * 3600, # 24시간 (초)
|
||||
'user': user_data,
|
||||
'redirect_url': redirect_url,
|
||||
'permissions': self.user_repo.get_user_permissions(user.role)
|
||||
}
|
||||
|
||||
except TKMPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Login service error for {username}: {str(e)}")
|
||||
raise TKMPException(
|
||||
message="로그인 처리 중 서버 오류가 발생했습니다",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
async def refresh_token(self, refresh_token: str, request: Request) -> Dict[str, Any]:
|
||||
"""
|
||||
토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
request: FastAPI Request 객체
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 새로운 토큰 정보
|
||||
"""
|
||||
try:
|
||||
# 리프레시 토큰 검증
|
||||
payload = jwt_service.verify_refresh_token(refresh_token)
|
||||
user_id = payload['user_id']
|
||||
|
||||
# 세션 확인
|
||||
session = self.user_repo.find_session_by_token(refresh_token)
|
||||
if not session or session.is_expired() or not session.is_active:
|
||||
logger.warning(f"Invalid or expired refresh token for user_id: {user_id}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
message="유효하지 않거나 만료된 리프레시 토큰입니다"
|
||||
)
|
||||
|
||||
# 사용자 조회
|
||||
user = self.user_repo.find_by_id(user_id)
|
||||
if not user or not user.is_active:
|
||||
logger.warning(f"User not found or inactive for token refresh: {user_id}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
message="사용자를 찾을 수 없거나 비활성화된 계정입니다"
|
||||
)
|
||||
|
||||
# 새 액세스 토큰 생성
|
||||
user_data = user.to_dict()
|
||||
new_access_token = jwt_service.create_access_token(user_data)
|
||||
|
||||
logger.info(f"Token refreshed for user: {user.username}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'access_token': new_access_token,
|
||||
'token_type': 'bearer',
|
||||
'expires_in': 24 * 3600,
|
||||
'user': user_data
|
||||
}
|
||||
|
||||
except TKMPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh error: {str(e)}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
message="토큰 갱신 중 서버 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
async def logout(self, refresh_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
로그아웃
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 로그아웃 결과
|
||||
"""
|
||||
try:
|
||||
# 세션 찾기 및 비활성화
|
||||
session = self.user_repo.find_session_by_token(refresh_token)
|
||||
if session:
|
||||
session.deactivate()
|
||||
self.user_repo.update_user(session.user)
|
||||
logger.info(f"User logged out: user_id {session.user_id}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '로그아웃되었습니다'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {str(e)}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
message="로그아웃 처리 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
async def register(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
사용자 등록
|
||||
|
||||
Args:
|
||||
user_data: 사용자 정보
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 등록 결과
|
||||
"""
|
||||
try:
|
||||
# 필수 필드 검증
|
||||
required_fields = ['username', 'password', 'name']
|
||||
for field in required_fields:
|
||||
if not user_data.get(field):
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
message=f"{field}는 필수 입력 항목입니다"
|
||||
)
|
||||
|
||||
# 중복 사용자명 확인
|
||||
existing_user = self.user_repo.find_by_username(user_data['username'])
|
||||
if existing_user:
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
message="이미 존재하는 사용자명입니다"
|
||||
)
|
||||
|
||||
# 이메일 중복 확인 (이메일이 제공된 경우)
|
||||
if user_data.get('email'):
|
||||
existing_email = self.user_repo.find_by_email(user_data['email'])
|
||||
if existing_email:
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
message="이미 사용 중인 이메일입니다"
|
||||
)
|
||||
|
||||
# 역할 매핑
|
||||
role_map = {
|
||||
'admin': 'admin',
|
||||
'system': 'system',
|
||||
'group_leader': 'leader',
|
||||
'support_team': 'support',
|
||||
'worker': 'user'
|
||||
}
|
||||
access_level = user_data.get('access_level', 'worker')
|
||||
role = role_map.get(access_level, 'user')
|
||||
|
||||
# 사용자 생성
|
||||
new_user_data = {
|
||||
'username': user_data['username'],
|
||||
'name': user_data['name'],
|
||||
'email': user_data.get('email'),
|
||||
'role': role,
|
||||
'access_level': access_level,
|
||||
'department': user_data.get('department'),
|
||||
'position': user_data.get('position'),
|
||||
'phone': user_data.get('phone')
|
||||
}
|
||||
|
||||
user = User(**new_user_data)
|
||||
user.set_password(user_data['password'])
|
||||
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
|
||||
logger.info(f"User registered successfully: {user.username}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '사용자 등록이 완료되었습니다',
|
||||
'user_id': user.user_id,
|
||||
'username': user.username
|
||||
}
|
||||
|
||||
except TKMPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"User registration error: {str(e)}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
message="사용자 등록 중 서버 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""클라이언트 IP 주소 추출"""
|
||||
# X-Forwarded-For 헤더 확인 (프록시 환경)
|
||||
forwarded_for = request.headers.get('x-forwarded-for')
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(',')[0].strip()
|
||||
|
||||
# X-Real-IP 헤더 확인
|
||||
real_ip = request.headers.get('x-real-ip')
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# 직접 연결된 클라이언트 IP
|
||||
return request.client.host if request.client else 'unknown'
|
||||
|
||||
def _get_redirect_url(self, role: str) -> str:
|
||||
"""역할에 따른 리디렉션 URL 결정"""
|
||||
redirect_urls = {
|
||||
'system': '/admin/system',
|
||||
'admin': '/admin/dashboard',
|
||||
'leader': '/dashboard/leader',
|
||||
'support': '/dashboard/support',
|
||||
'user': '/dashboard'
|
||||
}
|
||||
return redirect_urls.get(role, '/dashboard')
|
||||
|
||||
async def _record_login_failure(self, user_id: Optional[int], ip_address: str,
|
||||
user_agent: str, failure_reason: str):
|
||||
"""로그인 실패 기록"""
|
||||
try:
|
||||
if user_id:
|
||||
self.user_repo.record_login_log(
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status='failed',
|
||||
failure_reason=failure_reason
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record login failure: {str(e)}")
|
||||
|
||||
|
||||
def get_auth_service(db: Session) -> AuthService:
|
||||
"""인증 서비스 팩토리 함수"""
|
||||
return AuthService(db)
|
||||
251
backend/app/auth/jwt_service.py
Normal file
251
backend/app/auth/jwt_service.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
JWT 토큰 관리 서비스
|
||||
TK-FB-Project의 JWT 구현을 참고하여 FastAPI용으로 구현
|
||||
"""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import HTTPException, status
|
||||
import os
|
||||
from ..config import get_settings
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# JWT 설정
|
||||
JWT_SECRET = os.getenv('JWT_SECRET', 'tkmp-secret-key-2025')
|
||||
JWT_REFRESH_SECRET = os.getenv('JWT_REFRESH_SECRET', 'tkmp-refresh-secret-2025')
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv('JWT_EXPIRES_IN_HOURS', '24'))
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv('JWT_REFRESH_EXPIRES_IN_DAYS', '7'))
|
||||
|
||||
|
||||
class JWTService:
|
||||
"""JWT 토큰 관리 서비스"""
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(user_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Access Token 생성
|
||||
|
||||
Args:
|
||||
user_data: 사용자 정보 딕셔너리
|
||||
|
||||
Returns:
|
||||
str: JWT Access Token
|
||||
"""
|
||||
try:
|
||||
# 토큰 만료 시간 설정
|
||||
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||
|
||||
# 토큰 페이로드 구성
|
||||
payload = {
|
||||
'user_id': user_data['user_id'],
|
||||
'username': user_data['username'],
|
||||
'name': user_data['name'],
|
||||
'role': user_data['role'],
|
||||
'access_level': user_data['access_level'],
|
||||
'exp': expire,
|
||||
'iat': datetime.utcnow(),
|
||||
'type': 'access'
|
||||
}
|
||||
|
||||
# JWT 토큰 생성
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
logger.debug(f"Access token created for user: {user_data['username']}")
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Access token creation failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="토큰 생성에 실패했습니다"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
"""
|
||||
Refresh Token 생성
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
str: JWT Refresh Token
|
||||
"""
|
||||
try:
|
||||
# 토큰 만료 시간 설정
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
# 토큰 페이로드 구성
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'exp': expire,
|
||||
'iat': datetime.utcnow(),
|
||||
'type': 'refresh'
|
||||
}
|
||||
|
||||
# JWT 토큰 생성
|
||||
token = jwt.encode(payload, JWT_REFRESH_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
logger.debug(f"Refresh token created for user_id: {user_id}")
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Refresh token creation failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="리프레시 토큰 생성에 실패했습니다"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_access_token(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Access Token 검증
|
||||
|
||||
Args:
|
||||
token: JWT Access Token
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 토큰 페이로드
|
||||
|
||||
Raises:
|
||||
HTTPException: 토큰 검증 실패 시
|
||||
"""
|
||||
try:
|
||||
# JWT 토큰 디코딩
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
# 토큰 타입 확인
|
||||
if payload.get('type') != 'access':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="잘못된 토큰 타입입니다"
|
||||
)
|
||||
|
||||
# 필수 필드 확인
|
||||
required_fields = ['user_id', 'username', 'role']
|
||||
for field in required_fields:
|
||||
if field not in payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"토큰에 {field} 정보가 없습니다"
|
||||
)
|
||||
|
||||
logger.debug(f"Access token verified for user: {payload['username']}")
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Access token expired")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="토큰이 만료되었습니다"
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid access token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 토큰입니다"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Access token verification failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="토큰 검증에 실패했습니다"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_refresh_token(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Refresh Token 검증
|
||||
|
||||
Args:
|
||||
token: JWT Refresh Token
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 토큰 페이로드
|
||||
|
||||
Raises:
|
||||
HTTPException: 토큰 검증 실패 시
|
||||
"""
|
||||
try:
|
||||
# JWT 토큰 디코딩
|
||||
payload = jwt.decode(token, JWT_REFRESH_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
# 토큰 타입 확인
|
||||
if payload.get('type') != 'refresh':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="잘못된 리프레시 토큰 타입입니다"
|
||||
)
|
||||
|
||||
# 필수 필드 확인
|
||||
if 'user_id' not in payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="토큰에 사용자 정보가 없습니다"
|
||||
)
|
||||
|
||||
logger.debug(f"Refresh token verified for user_id: {payload['user_id']}")
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Refresh token expired")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="리프레시 토큰이 만료되었습니다"
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid refresh token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 리프레시 토큰입니다"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Refresh token verification failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="리프레시 토큰 검증에 실패했습니다"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_token_expiry_info(token: str, token_type: str = 'access') -> Dict[str, Any]:
|
||||
"""
|
||||
토큰 만료 정보 조회
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
token_type: 토큰 타입 ('access' 또는 'refresh')
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 토큰 만료 정보
|
||||
"""
|
||||
try:
|
||||
secret = JWT_SECRET if token_type == 'access' else JWT_REFRESH_SECRET
|
||||
payload = jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
exp_timestamp = payload.get('exp')
|
||||
iat_timestamp = payload.get('iat')
|
||||
|
||||
if exp_timestamp:
|
||||
exp_datetime = datetime.fromtimestamp(exp_timestamp)
|
||||
remaining_time = exp_datetime - datetime.utcnow()
|
||||
|
||||
return {
|
||||
'expires_at': exp_datetime,
|
||||
'issued_at': datetime.fromtimestamp(iat_timestamp) if iat_timestamp else None,
|
||||
'remaining_seconds': int(remaining_time.total_seconds()),
|
||||
'is_expired': remaining_time.total_seconds() <= 0
|
||||
}
|
||||
|
||||
return {'error': '토큰에 만료 시간 정보가 없습니다'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token expiry info retrieval failed: {str(e)}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
# JWT 서비스 인스턴스
|
||||
jwt_service = JWTService()
|
||||
305
backend/app/auth/middleware.py
Normal file
305
backend/app/auth/middleware.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
인증 및 권한 미들웨어
|
||||
JWT 토큰 기반 인증과 역할 기반 접근 제어(RBAC) 구현
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Callable, Any
|
||||
from functools import wraps
|
||||
import inspect
|
||||
|
||||
from ..database import get_db
|
||||
from .jwt_service import jwt_service
|
||||
from .models import UserRepository
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""인증 미들웨어 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.security = HTTPBearer()
|
||||
|
||||
async def get_current_user(
|
||||
self,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""
|
||||
현재 사용자 정보 조회
|
||||
|
||||
Args:
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
dict: 사용자 정보
|
||||
|
||||
Raises:
|
||||
HTTPException: 인증 실패 시
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
user_id = payload['user_id']
|
||||
|
||||
# 사용자 정보 조회
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
logger.warning(f"User not found for token: user_id {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(f"Inactive user attempted access: {user.username}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="비활성화된 계정입니다"
|
||||
)
|
||||
|
||||
if user.is_locked():
|
||||
logger.warning(f"Locked user attempted access: {user.username}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="잠긴 계정입니다"
|
||||
)
|
||||
|
||||
# 사용자 정보와 토큰 페이로드 결합
|
||||
user_info = user.to_dict()
|
||||
user_info.update({
|
||||
'token_user_id': payload['user_id'],
|
||||
'token_username': payload['username'],
|
||||
'token_role': payload['role']
|
||||
})
|
||||
|
||||
return user_info
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Get current user error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="인증 처리 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
async def get_current_active_user(
|
||||
self,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
현재 활성 사용자 정보 조회 (별칭)
|
||||
|
||||
Args:
|
||||
current_user: 현재 사용자 정보
|
||||
|
||||
Returns:
|
||||
dict: 사용자 정보
|
||||
"""
|
||||
return current_user
|
||||
|
||||
def require_roles(self, allowed_roles: List[str]):
|
||||
"""
|
||||
특정 역할을 요구하는 의존성 함수 생성
|
||||
|
||||
Args:
|
||||
allowed_roles: 허용된 역할 목록
|
||||
|
||||
Returns:
|
||||
Callable: 의존성 함수
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: dict = Depends(self.get_current_user)
|
||||
) -> dict:
|
||||
user_role = current_user.get('role')
|
||||
|
||||
if user_role not in allowed_roles:
|
||||
logger.warning(
|
||||
f"Access denied for user {current_user.get('username')} "
|
||||
f"with role {user_role}. Required roles: {allowed_roles}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(allowed_roles)}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
def require_permissions(self, required_permissions: List[str]):
|
||||
"""
|
||||
특정 권한을 요구하는 의존성 함수 생성
|
||||
|
||||
Args:
|
||||
required_permissions: 필요한 권한 목록
|
||||
|
||||
Returns:
|
||||
Callable: 의존성 함수
|
||||
"""
|
||||
async def permission_checker(
|
||||
current_user: dict = Depends(self.get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
user_role = current_user.get('role')
|
||||
|
||||
# 사용자 권한 조회
|
||||
user_repo = UserRepository(db)
|
||||
user_permissions = user_repo.get_user_permissions(user_role)
|
||||
|
||||
# 필요한 권한 확인
|
||||
missing_permissions = []
|
||||
for permission in required_permissions:
|
||||
if permission not in user_permissions:
|
||||
missing_permissions.append(permission)
|
||||
|
||||
if missing_permissions:
|
||||
logger.warning(
|
||||
f"Permission denied for user {current_user.get('username')} "
|
||||
f"with role {user_role}. Missing permissions: {missing_permissions}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(missing_permissions)}"
|
||||
)
|
||||
|
||||
# 사용자 정보에 권한 정보 추가
|
||||
current_user['permissions'] = user_permissions
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
def require_admin(self):
|
||||
"""관리자 권한을 요구하는 의존성 함수"""
|
||||
return self.require_roles(['admin', 'system'])
|
||||
|
||||
def require_leader_or_admin(self):
|
||||
"""팀장 이상 권한을 요구하는 의존성 함수"""
|
||||
return self.require_roles(['admin', 'system', 'leader'])
|
||||
|
||||
|
||||
# 전역 인증 미들웨어 인스턴스
|
||||
auth_middleware = AuthMiddleware()
|
||||
|
||||
# 편의를 위한 의존성 함수들
|
||||
get_current_user = auth_middleware.get_current_user
|
||||
get_current_active_user = auth_middleware.get_current_active_user
|
||||
require_admin = auth_middleware.require_admin
|
||||
require_leader_or_admin = auth_middleware.require_leader_or_admin
|
||||
|
||||
|
||||
def require_roles(allowed_roles: List[str]):
|
||||
"""역할 기반 접근 제어 데코레이터"""
|
||||
return auth_middleware.require_roles(allowed_roles)
|
||||
|
||||
|
||||
def require_permissions(required_permissions: List[str]):
|
||||
"""권한 기반 접근 제어 데코레이터"""
|
||||
return auth_middleware.require_permissions(required_permissions)
|
||||
|
||||
|
||||
# 추가 유틸리티 함수들
|
||||
async def get_user_from_token(token: str, db: Session) -> Optional[dict]:
|
||||
"""
|
||||
토큰에서 사용자 정보 추출 (미들웨어 없이 직접 사용)
|
||||
|
||||
Args:
|
||||
token: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 사용자 정보 또는 None
|
||||
"""
|
||||
try:
|
||||
payload = jwt_service.verify_access_token(token)
|
||||
user_id = payload['user_id']
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if user and user.is_active and not user.is_locked():
|
||||
return user.to_dict()
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get user from token error: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def check_user_permission(user_role: str, required_permission: str, db: Session) -> bool:
|
||||
"""
|
||||
사용자 권한 확인
|
||||
|
||||
Args:
|
||||
user_role: 사용자 역할
|
||||
required_permission: 필요한 권한
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
bool: 권한 보유 여부
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
user_permissions = user_repo.get_user_permissions(user_role)
|
||||
return required_permission in user_permissions
|
||||
except Exception as e:
|
||||
logger.error(f"Check user permission error: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_user_permissions_by_role(role: str, db: Session) -> List[str]:
|
||||
"""
|
||||
역할별 권한 목록 조회
|
||||
|
||||
Args:
|
||||
role: 사용자 역할
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
List[str]: 권한 목록
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
return user_repo.get_user_permissions(role)
|
||||
except Exception as e:
|
||||
logger.error(f"Get user permissions by role error: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
# 선택적 인증 (토큰이 있으면 검증, 없으면 None 반환)
|
||||
async def get_current_user_optional(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
선택적 사용자 인증 (토큰이 있으면 검증, 없으면 None)
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 객체
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 사용자 정보 또는 None
|
||||
"""
|
||||
try:
|
||||
# Authorization 헤더 확인
|
||||
authorization = request.headers.get('authorization')
|
||||
if not authorization or not authorization.startswith('Bearer '):
|
||||
return None
|
||||
|
||||
token = authorization.split(' ')[1]
|
||||
return await get_user_from_token(token, db)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Optional auth failed: {str(e)}")
|
||||
return None
|
||||
354
backend/app/auth/models.py
Normal file
354
backend/app/auth/models.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
인증 시스템 모델
|
||||
TK-FB-Project의 사용자 모델을 참고하여 SQLAlchemy 기반으로 구현
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
import bcrypt
|
||||
|
||||
from ..database import Base
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""사용자 모델"""
|
||||
__tablename__ = "users"
|
||||
|
||||
user_id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
password = Column(String(255), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
email = Column(String(100), index=True)
|
||||
|
||||
# 권한 관리
|
||||
role = Column(String(20), default='user', nullable=False)
|
||||
access_level = Column(String(20), default='worker', nullable=False)
|
||||
|
||||
# 계정 상태 관리
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
failed_login_attempts = Column(Integer, default=0)
|
||||
locked_until = Column(DateTime, nullable=True)
|
||||
|
||||
# 추가 정보
|
||||
department = Column(String(50))
|
||||
position = Column(String(50))
|
||||
phone = Column(String(20))
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
|
||||
# 관계 설정
|
||||
login_logs = relationship("LoginLog", back_populates="user", cascade="all, delete-orphan")
|
||||
sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(username='{self.username}', name='{self.name}', role='{self.role}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""사용자 정보를 딕셔너리로 변환 (비밀번호 제외)"""
|
||||
return {
|
||||
'user_id': self.user_id,
|
||||
'username': self.username,
|
||||
'name': self.name,
|
||||
'email': self.email,
|
||||
'role': self.role,
|
||||
'access_level': self.access_level,
|
||||
'is_active': self.is_active,
|
||||
'department': self.department,
|
||||
'position': self.position,
|
||||
'phone': self.phone,
|
||||
'created_at': self.created_at,
|
||||
'last_login_at': self.last_login_at
|
||||
}
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""비밀번호 확인"""
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8'))
|
||||
except Exception as e:
|
||||
logger.error(f"Password check failed for user {self.username}: {str(e)}")
|
||||
return False
|
||||
|
||||
def set_password(self, password: str):
|
||||
"""비밀번호 설정 (해싱)"""
|
||||
try:
|
||||
salt = bcrypt.gensalt()
|
||||
self.password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"Password hashing failed for user {self.username}: {str(e)}")
|
||||
raise
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""계정 잠금 상태 확인"""
|
||||
if self.locked_until is None:
|
||||
return False
|
||||
return datetime.utcnow() < self.locked_until
|
||||
|
||||
def lock_account(self, minutes: int = 15):
|
||||
"""계정 잠금"""
|
||||
self.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
logger.warning(f"User account locked: {self.username} for {minutes} minutes")
|
||||
|
||||
def unlock_account(self):
|
||||
"""계정 잠금 해제"""
|
||||
self.locked_until = None
|
||||
self.failed_login_attempts = 0
|
||||
logger.info(f"User account unlocked: {self.username}")
|
||||
|
||||
def increment_failed_attempts(self):
|
||||
"""로그인 실패 횟수 증가"""
|
||||
self.failed_login_attempts += 1
|
||||
if self.failed_login_attempts >= 5:
|
||||
self.lock_account()
|
||||
|
||||
def reset_failed_attempts(self):
|
||||
"""로그인 실패 횟수 초기화"""
|
||||
self.failed_login_attempts = 0
|
||||
self.locked_until = None
|
||||
|
||||
def update_last_login(self):
|
||||
"""마지막 로그인 시간 업데이트"""
|
||||
self.last_login_at = datetime.utcnow()
|
||||
|
||||
|
||||
class LoginLog(Base):
|
||||
"""로그인 이력 모델"""
|
||||
__tablename__ = "login_logs"
|
||||
|
||||
log_id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
|
||||
login_time = Column(DateTime, default=func.now())
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(Text)
|
||||
login_status = Column(String(20), nullable=False) # 'success' or 'failed'
|
||||
failure_reason = Column(String(100))
|
||||
session_duration = Column(Integer) # 세션 지속 시간 (초)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 관계 설정
|
||||
user = relationship("User", back_populates="login_logs")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LoginLog(user_id={self.user_id}, status='{self.login_status}', time='{self.login_time}')>"
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
"""사용자 세션 모델 (Refresh Token 관리)"""
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
session_id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
|
||||
refresh_token = Column(String(500), nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(Text)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# 관계 설정
|
||||
user = relationship("User", back_populates="sessions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserSession(user_id={self.user_id}, expires_at='{self.expires_at}')>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""세션 만료 여부 확인"""
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
def deactivate(self):
|
||||
"""세션 비활성화"""
|
||||
self.is_active = False
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""권한 모델"""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
permission_id = Column(Integer, primary_key=True, index=True)
|
||||
permission_name = Column(String(50), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
module = Column(String(30), index=True) # 모듈별 권한 관리
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission(name='{self.permission_name}', module='{self.module}')>"
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""역할-권한 매핑 모델"""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
role_permission_id = Column(Integer, primary_key=True, index=True)
|
||||
role = Column(String(20), nullable=False, index=True)
|
||||
permission_id = Column(Integer, ForeignKey("permissions.permission_id", ondelete="CASCADE"))
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 관계 설정
|
||||
permission = relationship("Permission")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RolePermission(role='{self.role}', permission_id={self.permission_id})>"
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""사용자 데이터 접근 계층"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def find_by_username(self, username: str) -> Optional[User]:
|
||||
"""사용자명으로 사용자 조회"""
|
||||
try:
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find user by username {username}: {str(e)}")
|
||||
return None
|
||||
|
||||
def find_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""사용자 ID로 사용자 조회"""
|
||||
try:
|
||||
return self.db.query(User).filter(User.user_id == user_id).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find user by id {user_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def find_by_email(self, email: str) -> Optional[User]:
|
||||
"""이메일로 사용자 조회"""
|
||||
try:
|
||||
return self.db.query(User).filter(User.email == email).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find user by email {email}: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_user(self, user_data: Dict[str, Any]) -> User:
|
||||
"""새 사용자 생성"""
|
||||
try:
|
||||
user = User(**user_data)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
logger.info(f"User created: {user.username}")
|
||||
return user
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to create user: {str(e)}")
|
||||
raise
|
||||
|
||||
def update_user(self, user: User) -> User:
|
||||
"""사용자 정보 업데이트"""
|
||||
try:
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
logger.info(f"User updated: {user.username}")
|
||||
return user
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to update user {user.username}: {str(e)}")
|
||||
raise
|
||||
|
||||
def delete_user(self, user: User):
|
||||
"""사용자 삭제"""
|
||||
try:
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
logger.info(f"User deleted: {user.username}")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to delete user {user.username}: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
||||
"""모든 사용자 조회"""
|
||||
try:
|
||||
return self.db.query(User).offset(skip).limit(limit).all()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all users: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_user_permissions(self, role: str) -> List[str]:
|
||||
"""사용자 역할에 따른 권한 목록 조회"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT p.permission_name
|
||||
FROM permissions p
|
||||
JOIN role_permissions rp ON p.permission_id = rp.permission_id
|
||||
WHERE rp.role = :role
|
||||
""")
|
||||
result = self.db.execute(query, {"role": role})
|
||||
return [row[0] for row in result.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get permissions for role {role}: {str(e)}")
|
||||
return []
|
||||
|
||||
def record_login_log(self, user_id: int, ip_address: str, user_agent: str,
|
||||
status: str, failure_reason: str = None):
|
||||
"""로그인 이력 기록"""
|
||||
try:
|
||||
log = LoginLog(
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
login_status=status,
|
||||
failure_reason=failure_reason
|
||||
)
|
||||
self.db.add(log)
|
||||
self.db.commit()
|
||||
logger.debug(f"Login log recorded for user_id {user_id}: {status}")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to record login log: {str(e)}")
|
||||
|
||||
def create_session(self, user_id: int, refresh_token: str, expires_at: datetime,
|
||||
ip_address: str, user_agent: str) -> UserSession:
|
||||
"""사용자 세션 생성"""
|
||||
try:
|
||||
session = UserSession(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
logger.debug(f"Session created for user_id {user_id}")
|
||||
return session
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to create session: {str(e)}")
|
||||
raise
|
||||
|
||||
def find_session_by_token(self, refresh_token: str) -> Optional[UserSession]:
|
||||
"""리프레시 토큰으로 세션 조회"""
|
||||
try:
|
||||
return self.db.query(UserSession).filter(
|
||||
UserSession.refresh_token == refresh_token,
|
||||
UserSession.is_active == True
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find session by token: {str(e)}")
|
||||
return None
|
||||
|
||||
def deactivate_user_sessions(self, user_id: int):
|
||||
"""사용자의 모든 세션 비활성화"""
|
||||
try:
|
||||
self.db.query(UserSession).filter(
|
||||
UserSession.user_id == user_id
|
||||
).update({"is_active": False})
|
||||
self.db.commit()
|
||||
logger.info(f"All sessions deactivated for user_id {user_id}")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}")
|
||||
raise
|
||||
284
backend/app/config.py
Normal file
284
backend/app/config.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
TK-MP-Project 설정 관리
|
||||
환경별 설정을 중앙화하여 관리
|
||||
"""
|
||||
import os
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field, validator
|
||||
import json
|
||||
|
||||
|
||||
class DatabaseSettings(BaseSettings):
|
||||
"""데이터베이스 설정"""
|
||||
url: str = Field(
|
||||
default="postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom",
|
||||
description="데이터베이스 연결 URL"
|
||||
)
|
||||
pool_size: int = Field(default=10, description="연결 풀 크기")
|
||||
max_overflow: int = Field(default=20, description="최대 오버플로우")
|
||||
pool_timeout: int = Field(default=30, description="연결 타임아웃 (초)")
|
||||
pool_recycle: int = Field(default=3600, description="연결 재활용 시간 (초)")
|
||||
echo: bool = Field(default=False, description="SQL 로그 출력 여부")
|
||||
|
||||
class Config:
|
||||
env_prefix = "DB_"
|
||||
|
||||
|
||||
class RedisSettings(BaseSettings):
|
||||
"""Redis 설정"""
|
||||
url: str = Field(default="redis://redis:6379", description="Redis 연결 URL")
|
||||
max_connections: int = Field(default=20, description="최대 연결 수")
|
||||
socket_timeout: int = Field(default=5, description="소켓 타임아웃 (초)")
|
||||
socket_connect_timeout: int = Field(default=5, description="연결 타임아웃 (초)")
|
||||
retry_on_timeout: bool = Field(default=True, description="타임아웃 시 재시도")
|
||||
decode_responses: bool = Field(default=False, description="응답 디코딩 여부")
|
||||
|
||||
class Config:
|
||||
env_prefix = "REDIS_"
|
||||
|
||||
|
||||
class SecuritySettings(BaseSettings):
|
||||
"""보안 설정"""
|
||||
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
|
||||
cors_methods: List[str] = Field(
|
||||
default=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
description="CORS 허용 메서드"
|
||||
)
|
||||
cors_headers: List[str] = Field(
|
||||
default=["*"],
|
||||
description="CORS 허용 헤더"
|
||||
)
|
||||
cors_credentials: bool = Field(default=True, description="CORS 자격증명 허용")
|
||||
|
||||
# 파일 업로드 보안
|
||||
max_file_size: int = Field(default=50 * 1024 * 1024, description="최대 파일 크기 (bytes)")
|
||||
allowed_file_extensions: List[str] = Field(
|
||||
default=['.xlsx', '.xls', '.csv'],
|
||||
description="허용된 파일 확장자"
|
||||
)
|
||||
upload_path: str = Field(default="uploads", description="업로드 경로")
|
||||
|
||||
# API 보안
|
||||
api_key_header: str = Field(default="X-API-Key", description="API 키 헤더명")
|
||||
rate_limit_per_minute: int = Field(default=100, description="분당 요청 제한")
|
||||
|
||||
class Config:
|
||||
env_prefix = "SECURITY_"
|
||||
|
||||
|
||||
class LoggingSettings(BaseSettings):
|
||||
"""로깅 설정"""
|
||||
level: str = Field(default="INFO", description="로그 레벨")
|
||||
file_path: str = Field(default="logs/app.log", description="로그 파일 경로")
|
||||
max_file_size: int = Field(default=10 * 1024 * 1024, description="로그 파일 최대 크기")
|
||||
backup_count: int = Field(default=5, description="백업 파일 수")
|
||||
format: str = Field(
|
||||
default="%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s",
|
||||
description="로그 포맷"
|
||||
)
|
||||
date_format: str = Field(default="%Y-%m-%d %H:%M:%S", description="날짜 포맷")
|
||||
|
||||
# 환경별 로그 레벨
|
||||
development_level: str = Field(default="DEBUG", description="개발 환경 로그 레벨")
|
||||
production_level: str = Field(default="INFO", description="운영 환경 로그 레벨")
|
||||
test_level: str = Field(default="WARNING", description="테스트 환경 로그 레벨")
|
||||
|
||||
class Config:
|
||||
env_prefix = "LOG_"
|
||||
|
||||
|
||||
class PerformanceSettings(BaseSettings):
|
||||
"""성능 설정"""
|
||||
# 캐시 설정
|
||||
cache_ttl_default: int = Field(default=3600, description="기본 캐시 TTL (초)")
|
||||
cache_ttl_files: int = Field(default=300, description="파일 목록 캐시 TTL")
|
||||
cache_ttl_materials: int = Field(default=600, description="자재 목록 캐시 TTL")
|
||||
cache_ttl_jobs: int = Field(default=1800, description="작업 목록 캐시 TTL")
|
||||
cache_ttl_classification: int = Field(default=3600, description="분류 결과 캐시 TTL")
|
||||
cache_ttl_statistics: int = Field(default=900, description="통계 데이터 캐시 TTL")
|
||||
|
||||
# 파일 처리 설정
|
||||
chunk_size: int = Field(default=1000, description="파일 처리 청크 크기")
|
||||
max_workers: int = Field(default=4, description="최대 워커 수")
|
||||
memory_limit_mb: int = Field(default=512, description="메모리 제한 (MB)")
|
||||
|
||||
class Config:
|
||||
env_prefix = "PERF_"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""메인 애플리케이션 설정"""
|
||||
|
||||
# 기본 설정
|
||||
app_name: str = Field(default="TK-MP BOM Management API", description="애플리케이션 이름")
|
||||
app_version: str = Field(default="1.0.0", description="애플리케이션 버전")
|
||||
app_description: str = Field(
|
||||
default="자재 분류 및 프로젝트 관리 시스템",
|
||||
description="애플리케이션 설명"
|
||||
)
|
||||
debug: bool = Field(default=False, description="디버그 모드")
|
||||
|
||||
# 환경 설정
|
||||
environment: str = Field(
|
||||
default="development",
|
||||
description="실행 환경 (development, production, test, synology)"
|
||||
)
|
||||
|
||||
# 서버 설정
|
||||
host: str = Field(default="0.0.0.0", description="서버 호스트")
|
||||
port: int = Field(default=8000, description="서버 포트")
|
||||
reload: bool = Field(default=False, description="자동 재로드")
|
||||
workers: int = Field(default=1, description="워커 프로세스 수")
|
||||
|
||||
# 하위 설정들
|
||||
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||
redis: RedisSettings = Field(default_factory=RedisSettings)
|
||||
security: SecuritySettings = Field(default_factory=SecuritySettings)
|
||||
logging: LoggingSettings = Field(default_factory=LoggingSettings)
|
||||
performance: PerformanceSettings = Field(default_factory=PerformanceSettings)
|
||||
|
||||
# 추가 설정
|
||||
timezone: str = Field(default="Asia/Seoul", description="시간대")
|
||||
language: str = Field(default="ko", description="기본 언어")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._setup_environment_specific_settings()
|
||||
self._setup_cors_origins()
|
||||
self._validate_settings()
|
||||
|
||||
@validator('environment')
|
||||
def validate_environment(cls, v):
|
||||
"""환경 값 검증"""
|
||||
allowed_environments = ['development', 'production', 'test', 'synology']
|
||||
if v not in allowed_environments:
|
||||
raise ValueError(f'Environment must be one of: {allowed_environments}')
|
||||
return v
|
||||
|
||||
@validator('port')
|
||||
def validate_port(cls, v):
|
||||
"""포트 번호 검증"""
|
||||
if not 1 <= v <= 65535:
|
||||
raise ValueError('Port must be between 1 and 65535')
|
||||
return v
|
||||
|
||||
def _setup_environment_specific_settings(self):
|
||||
"""환경별 특정 설정 적용"""
|
||||
if self.environment == "development":
|
||||
self.debug = True
|
||||
self.reload = True
|
||||
self.database.echo = True
|
||||
self.logging.level = self.logging.development_level
|
||||
|
||||
elif self.environment == "production":
|
||||
self.debug = False
|
||||
self.reload = False
|
||||
self.database.echo = False
|
||||
self.logging.level = self.logging.production_level
|
||||
self.workers = max(2, os.cpu_count() or 1)
|
||||
|
||||
elif self.environment == "test":
|
||||
self.debug = False
|
||||
self.reload = False
|
||||
self.database.echo = False
|
||||
self.logging.level = self.logging.test_level
|
||||
# 테스트용 인메모리 데이터베이스
|
||||
self.database.url = "sqlite:///:memory:"
|
||||
|
||||
elif self.environment == "synology":
|
||||
self.debug = False
|
||||
self.reload = False
|
||||
self.host = "0.0.0.0"
|
||||
self.port = 10080
|
||||
|
||||
def _setup_cors_origins(self):
|
||||
"""환경별 CORS origins 설정"""
|
||||
if not self.security.cors_origins:
|
||||
cors_config = {
|
||||
"development": [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173"
|
||||
],
|
||||
"production": [
|
||||
"https://your-domain.com",
|
||||
"https://api.your-domain.com"
|
||||
],
|
||||
"synology": [
|
||||
"http://192.168.0.3:10173",
|
||||
"http://localhost:10173"
|
||||
],
|
||||
"test": [
|
||||
"http://testserver"
|
||||
]
|
||||
}
|
||||
|
||||
self.security.cors_origins = cors_config.get(
|
||||
self.environment,
|
||||
cors_config["development"]
|
||||
)
|
||||
|
||||
def _validate_settings(self):
|
||||
"""설정 검증"""
|
||||
# 로그 디렉토리 생성
|
||||
log_dir = Path(self.logging.file_path).parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
upload_dir = Path(self.security.upload_path)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_database_url(self) -> str:
|
||||
"""데이터베이스 URL 반환"""
|
||||
return self.database.url
|
||||
|
||||
def get_redis_url(self) -> str:
|
||||
"""Redis URL 반환"""
|
||||
return self.redis.url
|
||||
|
||||
def is_development(self) -> bool:
|
||||
"""개발 환경 여부"""
|
||||
return self.environment == "development"
|
||||
|
||||
def is_production(self) -> bool:
|
||||
"""운영 환경 여부"""
|
||||
return self.environment == "production"
|
||||
|
||||
def is_test(self) -> bool:
|
||||
"""테스트 환경 여부"""
|
||||
return self.environment == "test"
|
||||
|
||||
def get_cors_config(self) -> Dict[str, Any]:
|
||||
"""CORS 설정 반환"""
|
||||
return {
|
||||
"allow_origins": self.security.cors_origins,
|
||||
"allow_methods": self.security.cors_methods,
|
||||
"allow_headers": self.security.cors_headers,
|
||||
"allow_credentials": self.security.cors_credentials
|
||||
}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""설정을 딕셔너리로 변환"""
|
||||
return self.dict()
|
||||
|
||||
def save_to_file(self, file_path: str):
|
||||
"""설정을 파일로 저장"""
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
# 전역 설정 인스턴스
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""설정 인스턴스 반환"""
|
||||
return settings
|
||||
@@ -7,162 +7,83 @@ from fastapi import Depends
|
||||
from typing import Optional, List, Dict
|
||||
import os
|
||||
import shutil
|
||||
# 설정 및 로깅 import
|
||||
from .config import get_settings
|
||||
from .utils.logger import get_logger
|
||||
from .utils.error_handlers import setup_error_handlers
|
||||
|
||||
# 설정 로드
|
||||
settings = get_settings()
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
title="TK-MP BOM Management API",
|
||||
title=settings.app_name,
|
||||
description="자재 분류 및 프로젝트 관리 시스템",
|
||||
version="1.0.0"
|
||||
version=settings.app_version,
|
||||
debug=settings.debug
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
# 에러 핸들러 설정
|
||||
setup_error_handlers(app)
|
||||
|
||||
# CORS 설정 (환경별 분리)
|
||||
cors_config = settings.get_cors_config()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
**cors_config
|
||||
)
|
||||
|
||||
logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}")
|
||||
|
||||
# 라우터들 import 및 등록
|
||||
try:
|
||||
from .routers import files
|
||||
app.include_router(files.router, prefix="/files", tags=["files"])
|
||||
except ImportError:
|
||||
print("files 라우터를 찾을 수 없습니다")
|
||||
logger.warning("files 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import jobs
|
||||
app.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
|
||||
except ImportError:
|
||||
print("jobs 라우터를 찾을 수 없습니다")
|
||||
logger.warning("jobs 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import purchase
|
||||
app.include_router(purchase.router, tags=["purchase"])
|
||||
except ImportError:
|
||||
print("purchase 라우터를 찾을 수 없습니다")
|
||||
logger.warning("purchase 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import material_comparison
|
||||
app.include_router(material_comparison.router, tags=["material-comparison"])
|
||||
except ImportError:
|
||||
print("material_comparison 라우터를 찾을 수 없습니다")
|
||||
logger.warning("material_comparison 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 파일 목록 조회 API
|
||||
@app.get("/files")
|
||||
async def get_files(
|
||||
job_no: Optional[str] = None, # project_id 대신 job_no 사용
|
||||
show_history: bool = False, # 이력 표시 여부
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""파일 목록 조회 (BOM별 그룹화)"""
|
||||
try:
|
||||
if show_history:
|
||||
# 전체 이력 표시
|
||||
query = "SELECT * FROM files"
|
||||
params = {}
|
||||
try:
|
||||
from .routers import tubing
|
||||
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
|
||||
except ImportError:
|
||||
logger.warning("tubing 라우터를 찾을 수 없습니다")
|
||||
|
||||
if job_no:
|
||||
query += " WHERE job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
# 파일 관리 API 라우터 등록
|
||||
try:
|
||||
from .api import file_management
|
||||
app.include_router(file_management.router, tags=["file-management"])
|
||||
logger.info("파일 관리 API 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"파일 관리 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
query += " ORDER BY original_filename, revision DESC"
|
||||
else:
|
||||
# 최신 리비전만 표시
|
||||
if job_no:
|
||||
query = """
|
||||
SELECT f1.* FROM files f1
|
||||
INNER JOIN (
|
||||
SELECT original_filename, MAX(revision) as max_revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no
|
||||
GROUP BY original_filename
|
||||
) f2 ON f1.original_filename = f2.original_filename
|
||||
AND f1.revision = f2.max_revision
|
||||
WHERE f1.job_no = :job_no
|
||||
ORDER BY f1.upload_date DESC
|
||||
"""
|
||||
params = {"job_no": job_no}
|
||||
else:
|
||||
# job_no가 없으면 전체 파일 조회
|
||||
query = "SELECT * FROM files ORDER BY upload_date DESC"
|
||||
params = {}
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
files = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
"filename": f.original_filename,
|
||||
"original_filename": f.original_filename,
|
||||
"name": f.original_filename,
|
||||
"job_no": f.job_no, # job_no 사용
|
||||
"bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명
|
||||
"revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
|
||||
"parsed_count": f.parsed_count or 0, # 파싱된 자재 수
|
||||
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
|
||||
"status": "active" if f.is_active else "inactive", # is_active 상태
|
||||
"file_size": f.file_size,
|
||||
"created_at": f.upload_date,
|
||||
"upload_date": f.upload_date,
|
||||
"description": f"파일: {f.original_filename}"
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"파일 목록 조회 에러: {str(e)}")
|
||||
return {"error": f"파일 목록 조회 실패: {str(e)}"}
|
||||
|
||||
# 파일 삭제 API
|
||||
@app.delete("/files/{file_id}")
|
||||
async def delete_file(
|
||||
file_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""파일 삭제"""
|
||||
try:
|
||||
# 먼저 파일 정보 조회
|
||||
file_query = text("SELECT * FROM files WHERE id = :file_id")
|
||||
file_result = db.execute(file_query, {"file_id": file_id})
|
||||
file = file_result.fetchone()
|
||||
|
||||
if not file:
|
||||
return {"error": "파일을 찾을 수 없습니다"}
|
||||
|
||||
# 먼저 상세 테이블의 데이터 삭제 (외래 키 제약 조건 때문)
|
||||
# 각 자재 타입별 상세 테이블 데이터 삭제
|
||||
detail_tables = [
|
||||
'pipe_details', 'fitting_details', 'valve_details',
|
||||
'flange_details', 'bolt_details', 'gasket_details',
|
||||
'instrument_details'
|
||||
]
|
||||
|
||||
# 해당 파일의 materials ID 조회
|
||||
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
|
||||
material_ids_result = db.execute(material_ids_query, {"file_id": file_id})
|
||||
material_ids = [row[0] for row in material_ids_result]
|
||||
|
||||
if material_ids:
|
||||
# 각 상세 테이블에서 관련 데이터 삭제
|
||||
for table in detail_tables:
|
||||
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
|
||||
db.execute(delete_detail_query, {"material_ids": material_ids})
|
||||
|
||||
# materials 테이블 데이터 삭제
|
||||
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
|
||||
db.execute(materials_query, {"file_id": file_id})
|
||||
|
||||
# 파일 삭제
|
||||
delete_query = text("DELETE FROM files WHERE id = :file_id")
|
||||
db.execute(delete_query, {"file_id": file_id})
|
||||
|
||||
db.commit()
|
||||
return {"success": True, "message": "파일과 관련 데이터가 삭제되었습니다"}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {"error": f"파일 삭제 실패: {str(e)}"}
|
||||
# 인증 API 라우터 등록
|
||||
try:
|
||||
from .auth import auth_router
|
||||
app.include_router(auth_router, prefix="/auth", tags=["authentication"])
|
||||
logger.info("인증 API 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 프로젝트 관리 API (비활성화 - jobs 테이블 사용)
|
||||
# projects 테이블은 더 이상 사용하지 않음
|
||||
|
||||
@@ -276,8 +276,7 @@ class RequirementType(Base):
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
requirements = relationship("UserRequirement", back_populates="requirement_type")
|
||||
# 관계 설정은 문자열 기반이므로 제거
|
||||
|
||||
class UserRequirement(Base):
|
||||
"""사용자 추가 요구사항"""
|
||||
@@ -308,4 +307,145 @@ class UserRequirement(Base):
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File", backref="user_requirements")
|
||||
requirement_type_rel = relationship("RequirementType", back_populates="requirements")
|
||||
|
||||
# ========== Tubing 시스템 모델들 ==========
|
||||
|
||||
class TubingCategory(Base):
|
||||
"""Tubing 카테고리 (일반, VCR, 위생용 등)"""
|
||||
__tablename__ = "tubing_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_code = Column(String(20), unique=True, nullable=False)
|
||||
category_name = Column(String(100), nullable=False)
|
||||
description = Column(Text)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
specifications = relationship("TubingSpecification", back_populates="category")
|
||||
|
||||
class TubingSpecification(Base):
|
||||
"""Tubing 규격 마스터"""
|
||||
__tablename__ = "tubing_specifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_id = Column(Integer, ForeignKey("tubing_categories.id"))
|
||||
spec_code = Column(String(50), unique=True, nullable=False)
|
||||
spec_name = Column(String(200), nullable=False)
|
||||
|
||||
# 물리적 규격
|
||||
outer_diameter_mm = Column(Numeric(8, 3))
|
||||
wall_thickness_mm = Column(Numeric(6, 3))
|
||||
inner_diameter_mm = Column(Numeric(8, 3))
|
||||
|
||||
# 재질 정보
|
||||
material_grade = Column(String(100))
|
||||
material_standard = Column(String(100))
|
||||
|
||||
# 압력/온도 등급
|
||||
max_pressure_bar = Column(Numeric(8, 2))
|
||||
max_temperature_c = Column(Numeric(6, 2))
|
||||
min_temperature_c = Column(Numeric(6, 2))
|
||||
|
||||
# 표준 규격
|
||||
standard_length_m = Column(Numeric(8, 3))
|
||||
bend_radius_min_mm = Column(Numeric(8, 2))
|
||||
|
||||
# 기타 정보
|
||||
surface_finish = Column(String(100))
|
||||
hardness = Column(String(50))
|
||||
notes = Column(Text)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
category = relationship("TubingCategory", back_populates="specifications")
|
||||
products = relationship("TubingProduct", back_populates="specification")
|
||||
|
||||
class TubingManufacturer(Base):
|
||||
"""Tubing 제조사"""
|
||||
__tablename__ = "tubing_manufacturers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
manufacturer_code = Column(String(20), unique=True, nullable=False)
|
||||
manufacturer_name = Column(String(200), nullable=False)
|
||||
country = Column(String(100))
|
||||
website = Column(String(500))
|
||||
contact_info = Column(JSON) # JSONB 타입
|
||||
quality_certs = Column(JSON) # JSONB 타입
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
products = relationship("TubingProduct", back_populates="manufacturer")
|
||||
|
||||
class TubingProduct(Base):
|
||||
"""제조사별 Tubing 제품 (품목번호 매핑)"""
|
||||
__tablename__ = "tubing_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
specification_id = Column(Integer, ForeignKey("tubing_specifications.id"))
|
||||
manufacturer_id = Column(Integer, ForeignKey("tubing_manufacturers.id"))
|
||||
|
||||
# 제조사 품목번호 정보
|
||||
manufacturer_part_number = Column(String(200), nullable=False)
|
||||
manufacturer_product_name = Column(String(300))
|
||||
|
||||
# 가격/공급 정보
|
||||
list_price = Column(Numeric(12, 2))
|
||||
currency = Column(String(10), default='KRW')
|
||||
lead_time_days = Column(Integer)
|
||||
minimum_order_qty = Column(Numeric(10, 3))
|
||||
standard_packaging_qty = Column(Numeric(10, 3))
|
||||
|
||||
# 가용성 정보
|
||||
availability_status = Column(String(50))
|
||||
last_price_update = Column(DateTime)
|
||||
|
||||
# 추가 정보
|
||||
datasheet_url = Column(String(500))
|
||||
catalog_page = Column(String(100))
|
||||
notes = Column(Text)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
specification = relationship("TubingSpecification", back_populates="products")
|
||||
manufacturer = relationship("TubingManufacturer", back_populates="products")
|
||||
material_mappings = relationship("MaterialTubingMapping", back_populates="tubing_product")
|
||||
|
||||
class MaterialTubingMapping(Base):
|
||||
"""BOM 자재와 Tubing 제품 매핑"""
|
||||
__tablename__ = "material_tubing_mapping"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id", ondelete="CASCADE"))
|
||||
tubing_product_id = Column(Integer, ForeignKey("tubing_products.id"))
|
||||
|
||||
# 매핑 정보
|
||||
confidence_score = Column(Numeric(3, 2))
|
||||
mapping_method = Column(String(50))
|
||||
mapped_by = Column(String(100))
|
||||
mapped_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 수량 정보
|
||||
required_length_m = Column(Numeric(10, 3))
|
||||
calculated_quantity = Column(Numeric(10, 3))
|
||||
|
||||
# 검증 정보
|
||||
is_verified = Column(Boolean, default=False)
|
||||
verified_by = Column(String(100))
|
||||
verified_at = Column(DateTime)
|
||||
|
||||
notes = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material", backref="tubing_mappings")
|
||||
tubing_product = relationship("TubingProduct", back_populates="material_mappings")
|
||||
|
||||
@@ -12,7 +12,11 @@ from pathlib import Path
|
||||
import json
|
||||
|
||||
from ..database import get_db
|
||||
from ..utils.logger import get_logger
|
||||
from app.services.material_classifier import classify_material
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger(__name__)
|
||||
from app.services.integrated_classifier import classify_material_integrated, should_exclude_material
|
||||
from app.services.bolt_classifier import classify_bolt
|
||||
from app.services.flange_classifier import classify_flange
|
||||
@@ -664,9 +668,14 @@ async def upload_file(
|
||||
else:
|
||||
gasket_type = str(gasket_type_info) if gasket_type_info else "UNKNOWN"
|
||||
|
||||
# 가스켓 소재 (GRAPHITE, PTFE 등)
|
||||
# 가스켓 소재 - SWG의 경우 메탈 부분을 우선으로
|
||||
material_type = ""
|
||||
if isinstance(gasket_material_info, dict):
|
||||
# SWG 상세 정보가 있으면 메탈 부분을 material_type으로 사용
|
||||
swg_details = gasket_material_info.get("swg_details", {})
|
||||
if swg_details and swg_details.get("outer_ring"):
|
||||
material_type = swg_details.get("outer_ring", "UNKNOWN") # SS304
|
||||
else:
|
||||
material_type = gasket_material_info.get("material", "UNKNOWN")
|
||||
else:
|
||||
material_type = str(gasket_material_info) if gasket_material_info else "UNKNOWN"
|
||||
@@ -978,7 +987,7 @@ async def get_files(
|
||||
try:
|
||||
query = """
|
||||
SELECT id, filename, original_filename, job_no, revision,
|
||||
description, file_size, parsed_count, created_at, is_active
|
||||
description, file_size, parsed_count, upload_date, is_active
|
||||
FROM files
|
||||
WHERE is_active = TRUE
|
||||
"""
|
||||
@@ -988,7 +997,7 @@ async def get_files(
|
||||
query += " AND job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
query += " ORDER BY upload_date DESC"
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
files = result.fetchall()
|
||||
@@ -1003,7 +1012,7 @@ async def get_files(
|
||||
"description": file.description,
|
||||
"file_size": file.file_size,
|
||||
"parsed_count": file.parsed_count,
|
||||
"created_at": file.created_at,
|
||||
"created_at": file.upload_date,
|
||||
"is_active": file.is_active
|
||||
}
|
||||
for file in files
|
||||
@@ -1012,6 +1021,47 @@ async def get_files(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_files_stats(db: Session = Depends(get_db)):
|
||||
"""파일 및 자재 통계 조회"""
|
||||
try:
|
||||
# 총 파일 수
|
||||
files_query = text("SELECT COUNT(*) FROM files WHERE is_active = true")
|
||||
total_files = db.execute(files_query).fetchone()[0]
|
||||
|
||||
# 총 자재 수
|
||||
materials_query = text("SELECT COUNT(*) FROM materials")
|
||||
total_materials = db.execute(materials_query).fetchone()[0]
|
||||
|
||||
# 최근 업로드 (최근 5개)
|
||||
recent_query = text("""
|
||||
SELECT f.original_filename, f.upload_date, f.parsed_count, j.job_name
|
||||
FROM files f
|
||||
LEFT JOIN jobs j ON f.job_no = j.job_no
|
||||
WHERE f.is_active = true
|
||||
ORDER BY f.upload_date DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
recent_uploads = db.execute(recent_query).fetchall()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"totalFiles": total_files,
|
||||
"totalMaterials": total_materials,
|
||||
"recentUploads": [
|
||||
{
|
||||
"filename": upload.original_filename,
|
||||
"created_at": upload.upload_date,
|
||||
"parsed_count": upload.parsed_count or 0,
|
||||
"project_name": upload.job_name or "Unknown"
|
||||
}
|
||||
for upload in recent_uploads
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}")
|
||||
|
||||
@router.delete("/files/{file_id}")
|
||||
async def delete_file(file_id: int, db: Session = Depends(get_db)):
|
||||
"""파일 삭제"""
|
||||
|
||||
@@ -20,6 +20,7 @@ class JobCreate(BaseModel):
|
||||
contract_date: Optional[date] = None
|
||||
delivery_date: Optional[date] = None
|
||||
delivery_terms: Optional[str] = None
|
||||
project_type: Optional[str] = "냉동기"
|
||||
description: Optional[str] = None
|
||||
|
||||
@router.get("/")
|
||||
@@ -34,7 +35,7 @@ async def get_jobs(
|
||||
query = """
|
||||
SELECT job_no, job_name, client_name, end_user, epc_company,
|
||||
project_site, contract_date, delivery_date, delivery_terms,
|
||||
status, description, created_by, created_at, updated_at, is_active
|
||||
project_type, status, description, created_by, created_at, updated_at, is_active
|
||||
FROM jobs
|
||||
WHERE is_active = true
|
||||
"""
|
||||
@@ -66,6 +67,7 @@ async def get_jobs(
|
||||
"contract_date": job.contract_date,
|
||||
"delivery_date": job.delivery_date,
|
||||
"delivery_terms": job.delivery_terms,
|
||||
"project_type": job.project_type,
|
||||
"status": job.status,
|
||||
"description": job.description,
|
||||
"created_at": job.created_at,
|
||||
@@ -85,7 +87,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)):
|
||||
query = text("""
|
||||
SELECT job_no, job_name, client_name, end_user, epc_company,
|
||||
project_site, contract_date, delivery_date, delivery_terms,
|
||||
status, description, created_by, created_at, updated_at, is_active
|
||||
project_type, status, description, created_by, created_at, updated_at, is_active
|
||||
FROM jobs
|
||||
WHERE job_no = :job_no AND is_active = true
|
||||
""")
|
||||
@@ -108,6 +110,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)):
|
||||
"contract_date": job.contract_date,
|
||||
"delivery_date": job.delivery_date,
|
||||
"delivery_terms": job.delivery_terms,
|
||||
"project_type": job.project_type,
|
||||
"status": job.status,
|
||||
"description": job.description,
|
||||
"created_by": job.created_by,
|
||||
@@ -139,14 +142,14 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)):
|
||||
INSERT INTO jobs (
|
||||
job_no, job_name, client_name, end_user, epc_company,
|
||||
project_site, contract_date, delivery_date, delivery_terms,
|
||||
description, created_by, status, is_active
|
||||
project_type, description, created_by, status, is_active
|
||||
)
|
||||
VALUES (
|
||||
:job_no, :job_name, :client_name, :end_user, :epc_company,
|
||||
:project_site, :contract_date, :delivery_date, :delivery_terms,
|
||||
:description, :created_by, :status, :is_active
|
||||
:project_type, :description, :created_by, :status, :is_active
|
||||
)
|
||||
RETURNING job_no, job_name, client_name
|
||||
RETURNING job_no, job_name, client_name, project_type
|
||||
""")
|
||||
|
||||
result = db.execute(insert_query, {
|
||||
@@ -165,7 +168,8 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)):
|
||||
"job": {
|
||||
"job_no": new_job.job_no,
|
||||
"job_name": new_job.job_name,
|
||||
"client_name": new_job.client_name
|
||||
"client_name": new_job.client_name,
|
||||
"project_type": new_job.project_type
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
538
backend/app/routers/tubing.py
Normal file
538
backend/app/routers/tubing.py
Normal file
@@ -0,0 +1,538 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import text
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
TubingCategory, TubingSpecification, TubingManufacturer,
|
||||
TubingProduct, MaterialTubingMapping, Material
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ================================
|
||||
# Pydantic 모델들
|
||||
# ================================
|
||||
|
||||
class TubingCategoryResponse(BaseModel):
|
||||
id: int
|
||||
category_code: str
|
||||
category_name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TubingManufacturerResponse(BaseModel):
|
||||
id: int
|
||||
manufacturer_code: str
|
||||
manufacturer_name: str
|
||||
country: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TubingSpecificationResponse(BaseModel):
|
||||
id: int
|
||||
spec_code: str
|
||||
spec_name: str
|
||||
category_name: Optional[str] = None
|
||||
outer_diameter_mm: Optional[float] = None
|
||||
wall_thickness_mm: Optional[float] = None
|
||||
inner_diameter_mm: Optional[float] = None
|
||||
material_grade: Optional[str] = None
|
||||
material_standard: Optional[str] = None
|
||||
max_pressure_bar: Optional[float] = None
|
||||
max_temperature_c: Optional[float] = None
|
||||
min_temperature_c: Optional[float] = None
|
||||
standard_length_m: Optional[float] = None
|
||||
surface_finish: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TubingProductResponse(BaseModel):
|
||||
id: int
|
||||
specification_id: int
|
||||
manufacturer_id: int
|
||||
manufacturer_part_number: str
|
||||
manufacturer_product_name: Optional[str] = None
|
||||
spec_name: Optional[str] = None
|
||||
manufacturer_name: Optional[str] = None
|
||||
list_price: Optional[float] = None
|
||||
currency: Optional[str] = 'KRW'
|
||||
lead_time_days: Optional[int] = None
|
||||
availability_status: Optional[str] = None
|
||||
datasheet_url: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TubingProductCreate(BaseModel):
|
||||
specification_id: int
|
||||
manufacturer_id: int
|
||||
manufacturer_part_number: str
|
||||
manufacturer_product_name: Optional[str] = None
|
||||
list_price: Optional[float] = None
|
||||
currency: str = 'KRW'
|
||||
lead_time_days: Optional[int] = None
|
||||
minimum_order_qty: Optional[float] = None
|
||||
standard_packaging_qty: Optional[float] = None
|
||||
availability_status: Optional[str] = None
|
||||
datasheet_url: Optional[str] = None
|
||||
catalog_page: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class MaterialTubingMappingCreate(BaseModel):
|
||||
material_id: int
|
||||
tubing_product_id: int
|
||||
confidence_score: Optional[float] = None
|
||||
mapping_method: str = 'manual'
|
||||
required_length_m: Optional[float] = None
|
||||
calculated_quantity: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
# ================================
|
||||
# API 엔드포인트들
|
||||
# ================================
|
||||
|
||||
@router.get("/categories", response_model=List[TubingCategoryResponse])
|
||||
async def get_tubing_categories(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Tubing 카테고리 목록 조회"""
|
||||
try:
|
||||
categories = db.query(TubingCategory)\
|
||||
.filter(TubingCategory.is_active == True)\
|
||||
.offset(skip)\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
return categories
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/manufacturers", response_model=List[TubingManufacturerResponse])
|
||||
async def get_tubing_manufacturers(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: Optional[str] = Query(None),
|
||||
country: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Tubing 제조사 목록 조회"""
|
||||
try:
|
||||
query = db.query(TubingManufacturer)\
|
||||
.filter(TubingManufacturer.is_active == True)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
TubingManufacturer.manufacturer_name.ilike(f"%{search}%") |
|
||||
TubingManufacturer.manufacturer_code.ilike(f"%{search}%")
|
||||
)
|
||||
|
||||
if country:
|
||||
query = query.filter(TubingManufacturer.country.ilike(f"%{country}%"))
|
||||
|
||||
manufacturers = query.offset(skip).limit(limit).all()
|
||||
return manufacturers
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"제조사 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/specifications", response_model=List[TubingSpecificationResponse])
|
||||
async def get_tubing_specifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
category_id: Optional[int] = Query(None),
|
||||
material_grade: Optional[str] = Query(None),
|
||||
outer_diameter_min: Optional[float] = Query(None),
|
||||
outer_diameter_max: Optional[float] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Tubing 규격 목록 조회"""
|
||||
try:
|
||||
query = db.query(TubingSpecification)\
|
||||
.options(joinedload(TubingSpecification.category))\
|
||||
.filter(TubingSpecification.is_active == True)
|
||||
|
||||
if category_id:
|
||||
query = query.filter(TubingSpecification.category_id == category_id)
|
||||
|
||||
if material_grade:
|
||||
query = query.filter(TubingSpecification.material_grade.ilike(f"%{material_grade}%"))
|
||||
|
||||
if outer_diameter_min:
|
||||
query = query.filter(TubingSpecification.outer_diameter_mm >= outer_diameter_min)
|
||||
|
||||
if outer_diameter_max:
|
||||
query = query.filter(TubingSpecification.outer_diameter_mm <= outer_diameter_max)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
TubingSpecification.spec_name.ilike(f"%{search}%") |
|
||||
TubingSpecification.spec_code.ilike(f"%{search}%") |
|
||||
TubingSpecification.material_grade.ilike(f"%{search}%")
|
||||
)
|
||||
|
||||
specifications = query.offset(skip).limit(limit).all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
result = []
|
||||
for spec in specifications:
|
||||
spec_dict = {
|
||||
"id": spec.id,
|
||||
"spec_code": spec.spec_code,
|
||||
"spec_name": spec.spec_name,
|
||||
"category_name": spec.category.category_name if spec.category else None,
|
||||
"outer_diameter_mm": float(spec.outer_diameter_mm) if spec.outer_diameter_mm else None,
|
||||
"wall_thickness_mm": float(spec.wall_thickness_mm) if spec.wall_thickness_mm else None,
|
||||
"inner_diameter_mm": float(spec.inner_diameter_mm) if spec.inner_diameter_mm else None,
|
||||
"material_grade": spec.material_grade,
|
||||
"material_standard": spec.material_standard,
|
||||
"max_pressure_bar": float(spec.max_pressure_bar) if spec.max_pressure_bar else None,
|
||||
"max_temperature_c": float(spec.max_temperature_c) if spec.max_temperature_c else None,
|
||||
"min_temperature_c": float(spec.min_temperature_c) if spec.min_temperature_c else None,
|
||||
"standard_length_m": float(spec.standard_length_m) if spec.standard_length_m else None,
|
||||
"surface_finish": spec.surface_finish,
|
||||
"is_active": spec.is_active
|
||||
}
|
||||
result.append(spec_dict)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"규격 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/products", response_model=List[TubingProductResponse])
|
||||
async def get_tubing_products(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
specification_id: Optional[int] = Query(None),
|
||||
manufacturer_id: Optional[int] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Tubing 제품 목록 조회"""
|
||||
try:
|
||||
query = db.query(TubingProduct)\
|
||||
.options(
|
||||
joinedload(TubingProduct.specification),
|
||||
joinedload(TubingProduct.manufacturer)
|
||||
)\
|
||||
.filter(TubingProduct.is_active == True)
|
||||
|
||||
if specification_id:
|
||||
query = query.filter(TubingProduct.specification_id == specification_id)
|
||||
|
||||
if manufacturer_id:
|
||||
query = query.filter(TubingProduct.manufacturer_id == manufacturer_id)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
TubingProduct.manufacturer_part_number.ilike(f"%{search}%") |
|
||||
TubingProduct.manufacturer_product_name.ilike(f"%{search}%")
|
||||
)
|
||||
|
||||
products = query.offset(skip).limit(limit).all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
result = []
|
||||
for product in products:
|
||||
product_dict = {
|
||||
"id": product.id,
|
||||
"specification_id": product.specification_id,
|
||||
"manufacturer_id": product.manufacturer_id,
|
||||
"manufacturer_part_number": product.manufacturer_part_number,
|
||||
"manufacturer_product_name": product.manufacturer_product_name,
|
||||
"spec_name": product.specification.spec_name if product.specification else None,
|
||||
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
|
||||
"list_price": float(product.list_price) if product.list_price else None,
|
||||
"currency": product.currency,
|
||||
"lead_time_days": product.lead_time_days,
|
||||
"availability_status": product.availability_status,
|
||||
"datasheet_url": product.datasheet_url,
|
||||
"notes": product.notes,
|
||||
"is_active": product.is_active
|
||||
}
|
||||
result.append(product_dict)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"제품 조회 실패: {str(e)}")
|
||||
|
||||
@router.post("/products", response_model=TubingProductResponse)
|
||||
async def create_tubing_product(
|
||||
product_data: TubingProductCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""새 Tubing 제품 등록"""
|
||||
try:
|
||||
# 중복 확인
|
||||
existing = db.query(TubingProduct)\
|
||||
.filter(
|
||||
TubingProduct.specification_id == product_data.specification_id,
|
||||
TubingProduct.manufacturer_id == product_data.manufacturer_id,
|
||||
TubingProduct.manufacturer_part_number == product_data.manufacturer_part_number
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"동일한 제품이 이미 등록되어 있습니다: {product_data.manufacturer_part_number}"
|
||||
)
|
||||
|
||||
# 새 제품 생성
|
||||
new_product = TubingProduct(**product_data.dict())
|
||||
db.add(new_product)
|
||||
db.commit()
|
||||
db.refresh(new_product)
|
||||
|
||||
# 관련 정보와 함께 조회
|
||||
product_with_relations = db.query(TubingProduct)\
|
||||
.options(
|
||||
joinedload(TubingProduct.specification),
|
||||
joinedload(TubingProduct.manufacturer)
|
||||
)\
|
||||
.filter(TubingProduct.id == new_product.id)\
|
||||
.first()
|
||||
|
||||
return {
|
||||
"id": product_with_relations.id,
|
||||
"specification_id": product_with_relations.specification_id,
|
||||
"manufacturer_id": product_with_relations.manufacturer_id,
|
||||
"manufacturer_part_number": product_with_relations.manufacturer_part_number,
|
||||
"manufacturer_product_name": product_with_relations.manufacturer_product_name,
|
||||
"spec_name": product_with_relations.specification.spec_name if product_with_relations.specification else None,
|
||||
"manufacturer_name": product_with_relations.manufacturer.manufacturer_name if product_with_relations.manufacturer else None,
|
||||
"list_price": float(product_with_relations.list_price) if product_with_relations.list_price else None,
|
||||
"currency": product_with_relations.currency,
|
||||
"lead_time_days": product_with_relations.lead_time_days,
|
||||
"availability_status": product_with_relations.availability_status,
|
||||
"datasheet_url": product_with_relations.datasheet_url,
|
||||
"notes": product_with_relations.notes,
|
||||
"is_active": product_with_relations.is_active
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"제품 등록 실패: {str(e)}")
|
||||
|
||||
@router.post("/material-mapping")
|
||||
async def create_material_tubing_mapping(
|
||||
mapping_data: MaterialTubingMappingCreate,
|
||||
mapped_by: str = "admin",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""BOM 자재와 Tubing 제품 매핑 생성"""
|
||||
try:
|
||||
# 기존 매핑 확인
|
||||
existing = db.query(MaterialTubingMapping)\
|
||||
.filter(
|
||||
MaterialTubingMapping.material_id == mapping_data.material_id,
|
||||
MaterialTubingMapping.tubing_product_id == mapping_data.tubing_product_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="이미 매핑된 자재와 제품입니다"
|
||||
)
|
||||
|
||||
# 새 매핑 생성
|
||||
new_mapping = MaterialTubingMapping(
|
||||
**mapping_data.dict(),
|
||||
mapped_by=mapped_by
|
||||
)
|
||||
db.add(new_mapping)
|
||||
db.commit()
|
||||
db.refresh(new_mapping)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "매핑이 성공적으로 생성되었습니다",
|
||||
"mapping_id": new_mapping.id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"매핑 생성 실패: {str(e)}")
|
||||
|
||||
@router.get("/material-mappings/{material_id}")
|
||||
async def get_material_tubing_mappings(
|
||||
material_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""특정 자재의 Tubing 매핑 조회"""
|
||||
try:
|
||||
mappings = db.query(MaterialTubingMapping)\
|
||||
.options(
|
||||
joinedload(MaterialTubingMapping.tubing_product)
|
||||
.joinedload(TubingProduct.specification),
|
||||
joinedload(MaterialTubingMapping.tubing_product)
|
||||
.joinedload(TubingProduct.manufacturer)
|
||||
)\
|
||||
.filter(MaterialTubingMapping.material_id == material_id)\
|
||||
.all()
|
||||
|
||||
result = []
|
||||
for mapping in mappings:
|
||||
product = mapping.tubing_product
|
||||
mapping_dict = {
|
||||
"mapping_id": mapping.id,
|
||||
"confidence_score": float(mapping.confidence_score) if mapping.confidence_score else None,
|
||||
"mapping_method": mapping.mapping_method,
|
||||
"mapped_by": mapping.mapped_by,
|
||||
"mapped_at": mapping.mapped_at,
|
||||
"required_length_m": float(mapping.required_length_m) if mapping.required_length_m else None,
|
||||
"calculated_quantity": float(mapping.calculated_quantity) if mapping.calculated_quantity else None,
|
||||
"is_verified": mapping.is_verified,
|
||||
"tubing_product": {
|
||||
"id": product.id,
|
||||
"manufacturer_part_number": product.manufacturer_part_number,
|
||||
"manufacturer_product_name": product.manufacturer_product_name,
|
||||
"spec_name": product.specification.spec_name if product.specification else None,
|
||||
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
|
||||
"list_price": float(product.list_price) if product.list_price else None,
|
||||
"currency": product.currency,
|
||||
"availability_status": product.availability_status
|
||||
}
|
||||
}
|
||||
result.append(mapping_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"material_id": material_id,
|
||||
"mappings": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"매핑 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/search")
|
||||
async def search_tubing_products(
|
||||
query: str = Query(..., min_length=2),
|
||||
category: Optional[str] = Query(None),
|
||||
manufacturer: Optional[str] = Query(None),
|
||||
min_diameter: Optional[float] = Query(None),
|
||||
max_diameter: Optional[float] = Query(None),
|
||||
material_grade: Optional[str] = Query(None),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""통합 Tubing 검색 (규격, 제품, 제조사)"""
|
||||
try:
|
||||
# SQL 쿼리로 복합 검색
|
||||
sql_query = """
|
||||
SELECT DISTINCT
|
||||
tp.id as product_id,
|
||||
tp.manufacturer_part_number,
|
||||
tp.manufacturer_product_name,
|
||||
tp.list_price,
|
||||
tp.currency,
|
||||
tp.availability_status,
|
||||
ts.spec_code,
|
||||
ts.spec_name,
|
||||
ts.outer_diameter_mm,
|
||||
ts.wall_thickness_mm,
|
||||
ts.material_grade,
|
||||
tc.category_name,
|
||||
tm.manufacturer_name,
|
||||
tm.country
|
||||
FROM tubing_products tp
|
||||
JOIN tubing_specifications ts ON tp.specification_id = ts.id
|
||||
JOIN tubing_categories tc ON ts.category_id = tc.id
|
||||
JOIN tubing_manufacturers tm ON tp.manufacturer_id = tm.id
|
||||
WHERE tp.is_active = true
|
||||
AND ts.is_active = true
|
||||
AND tc.is_active = true
|
||||
AND tm.is_active = true
|
||||
AND (
|
||||
tp.manufacturer_part_number ILIKE :query OR
|
||||
tp.manufacturer_product_name ILIKE :query OR
|
||||
ts.spec_name ILIKE :query OR
|
||||
ts.spec_code ILIKE :query OR
|
||||
ts.material_grade ILIKE :query OR
|
||||
tm.manufacturer_name ILIKE :query
|
||||
)
|
||||
"""
|
||||
|
||||
params = {"query": f"%{query}%"}
|
||||
|
||||
# 필터 조건 추가
|
||||
if category:
|
||||
sql_query += " AND tc.category_code = :category"
|
||||
params["category"] = category
|
||||
|
||||
if manufacturer:
|
||||
sql_query += " AND tm.manufacturer_code = :manufacturer"
|
||||
params["manufacturer"] = manufacturer
|
||||
|
||||
if min_diameter:
|
||||
sql_query += " AND ts.outer_diameter_mm >= :min_diameter"
|
||||
params["min_diameter"] = min_diameter
|
||||
|
||||
if max_diameter:
|
||||
sql_query += " AND ts.outer_diameter_mm <= :max_diameter"
|
||||
params["max_diameter"] = max_diameter
|
||||
|
||||
if material_grade:
|
||||
sql_query += " AND ts.material_grade ILIKE :material_grade"
|
||||
params["material_grade"] = f"%{material_grade}%"
|
||||
|
||||
sql_query += " ORDER BY tp.manufacturer_part_number LIMIT :limit"
|
||||
params["limit"] = limit
|
||||
|
||||
result = db.execute(text(sql_query), params)
|
||||
products = result.fetchall()
|
||||
|
||||
search_results = []
|
||||
for product in products:
|
||||
product_dict = {
|
||||
"product_id": product.product_id,
|
||||
"manufacturer_part_number": product.manufacturer_part_number,
|
||||
"manufacturer_product_name": product.manufacturer_product_name,
|
||||
"list_price": float(product.list_price) if product.list_price else None,
|
||||
"currency": product.currency,
|
||||
"availability_status": product.availability_status,
|
||||
"spec_code": product.spec_code,
|
||||
"spec_name": product.spec_name,
|
||||
"outer_diameter_mm": float(product.outer_diameter_mm) if product.outer_diameter_mm else None,
|
||||
"wall_thickness_mm": float(product.wall_thickness_mm) if product.wall_thickness_mm else None,
|
||||
"material_grade": product.material_grade,
|
||||
"category_name": product.category_name,
|
||||
"manufacturer_name": product.manufacturer_name,
|
||||
"country": product.country
|
||||
}
|
||||
search_results.append(product_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"total_results": len(search_results),
|
||||
"results": search_results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"검색 실패: {str(e)}")
|
||||
69
backend/app/schemas/__init__.py
Normal file
69
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
스키마 모듈
|
||||
API 요청/응답 모델 정의
|
||||
"""
|
||||
from .response_models import (
|
||||
BaseResponse,
|
||||
ErrorResponse,
|
||||
SuccessResponse,
|
||||
FileInfo,
|
||||
FileListResponse,
|
||||
FileDeleteResponse,
|
||||
MaterialInfo,
|
||||
MaterialListResponse,
|
||||
JobInfo,
|
||||
JobListResponse,
|
||||
ClassificationResult,
|
||||
ClassificationResponse,
|
||||
MaterialStatistics,
|
||||
ProjectStatistics,
|
||||
StatisticsResponse,
|
||||
CacheInfo,
|
||||
SystemHealthResponse,
|
||||
APIResponse,
|
||||
# 열거형
|
||||
FileStatus,
|
||||
MaterialCategory,
|
||||
JobStatus
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 기본 응답 모델
|
||||
"BaseResponse",
|
||||
"ErrorResponse",
|
||||
"SuccessResponse",
|
||||
|
||||
# 파일 관련
|
||||
"FileInfo",
|
||||
"FileListResponse",
|
||||
"FileDeleteResponse",
|
||||
|
||||
# 자재 관련
|
||||
"MaterialInfo",
|
||||
"MaterialListResponse",
|
||||
|
||||
# 작업 관련
|
||||
"JobInfo",
|
||||
"JobListResponse",
|
||||
|
||||
# 분류 관련
|
||||
"ClassificationResult",
|
||||
"ClassificationResponse",
|
||||
|
||||
# 통계 관련
|
||||
"MaterialStatistics",
|
||||
"ProjectStatistics",
|
||||
"StatisticsResponse",
|
||||
|
||||
# 시스템 관련
|
||||
"CacheInfo",
|
||||
"SystemHealthResponse",
|
||||
|
||||
# 유니온 타입
|
||||
"APIResponse",
|
||||
|
||||
# 열거형
|
||||
"FileStatus",
|
||||
"MaterialCategory",
|
||||
"JobStatus"
|
||||
]
|
||||
354
backend/app/schemas/response_models.py
Normal file
354
backend/app/schemas/response_models.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
API 응답 모델 정의
|
||||
타입 안정성 및 API 문서화를 위한 Pydantic 모델들
|
||||
"""
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ================================
|
||||
# 기본 응답 모델
|
||||
# ================================
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
"""기본 응답 모델"""
|
||||
success: bool = Field(description="요청 성공 여부")
|
||||
message: Optional[str] = Field(None, description="응답 메시지")
|
||||
timestamp: datetime = Field(default_factory=datetime.now, description="응답 시간")
|
||||
|
||||
|
||||
class ErrorResponse(BaseResponse):
|
||||
"""에러 응답 모델"""
|
||||
success: bool = Field(False, description="요청 성공 여부")
|
||||
error: Dict[str, Any] = Field(description="에러 정보")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": False,
|
||||
"message": "요청 처리 중 오류가 발생했습니다",
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"details": "입력 데이터가 올바르지 않습니다"
|
||||
},
|
||||
"timestamp": "2025-01-01T12:00:00"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SuccessResponse(BaseResponse):
|
||||
"""성공 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: Optional[Any] = Field(None, description="응답 데이터")
|
||||
|
||||
|
||||
# ================================
|
||||
# 열거형 정의
|
||||
# ================================
|
||||
|
||||
class FileStatus(str, Enum):
|
||||
"""파일 상태"""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
PROCESSING = "processing"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class MaterialCategory(str, Enum):
|
||||
"""자재 카테고리"""
|
||||
PIPE = "PIPE"
|
||||
FITTING = "FITTING"
|
||||
VALVE = "VALVE"
|
||||
FLANGE = "FLANGE"
|
||||
BOLT = "BOLT"
|
||||
GASKET = "GASKET"
|
||||
INSTRUMENT = "INSTRUMENT"
|
||||
EXCLUDE = "EXCLUDE"
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""작업 상태"""
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
ON_HOLD = "on_hold"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
# ================================
|
||||
# 파일 관련 모델
|
||||
# ================================
|
||||
|
||||
class FileInfo(BaseModel):
|
||||
"""파일 정보 모델"""
|
||||
id: int = Field(description="파일 ID")
|
||||
filename: str = Field(description="파일명")
|
||||
original_filename: str = Field(description="원본 파일명")
|
||||
job_no: Optional[str] = Field(None, description="작업 번호")
|
||||
bom_name: Optional[str] = Field(None, description="BOM 이름")
|
||||
revision: str = Field(default="Rev.0", description="리비전")
|
||||
parsed_count: int = Field(default=0, description="파싱된 자재 수")
|
||||
bom_type: str = Field(default="unknown", description="BOM 타입")
|
||||
status: FileStatus = Field(description="파일 상태")
|
||||
file_size: Optional[int] = Field(None, description="파일 크기 (bytes)")
|
||||
upload_date: datetime = Field(description="업로드 일시")
|
||||
description: Optional[str] = Field(None, description="파일 설명")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"filename": "BOM_Rev1.xlsx",
|
||||
"original_filename": "BOM_Rev1.xlsx",
|
||||
"job_no": "TK-2025-001",
|
||||
"bom_name": "메인 BOM",
|
||||
"revision": "Rev.1",
|
||||
"parsed_count": 150,
|
||||
"bom_type": "excel",
|
||||
"status": "active",
|
||||
"file_size": 2048576,
|
||||
"upload_date": "2025-01-01T12:00:00",
|
||||
"description": "파일: BOM_Rev1.xlsx"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FileListResponse(BaseResponse):
|
||||
"""파일 목록 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: List[FileInfo] = Field(description="파일 목록")
|
||||
total_count: int = Field(description="전체 파일 수")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseResponse):
|
||||
"""파일 삭제 응답 모델"""
|
||||
success: bool = Field(True, description="삭제 성공 여부")
|
||||
message: str = Field(description="삭제 결과 메시지")
|
||||
deleted_file_id: int = Field(description="삭제된 파일 ID")
|
||||
|
||||
|
||||
# ================================
|
||||
# 자재 관련 모델
|
||||
# ================================
|
||||
|
||||
class MaterialInfo(BaseModel):
|
||||
"""자재 정보 모델"""
|
||||
id: int = Field(description="자재 ID")
|
||||
file_id: int = Field(description="파일 ID")
|
||||
line_number: Optional[int] = Field(None, description="엑셀 행 번호")
|
||||
original_description: str = Field(description="원본 품명")
|
||||
classified_category: Optional[MaterialCategory] = Field(None, description="분류된 카테고리")
|
||||
classified_subcategory: Optional[str] = Field(None, description="세부 분류")
|
||||
material_grade: Optional[str] = Field(None, description="재질 등급")
|
||||
schedule: Optional[str] = Field(None, description="스케줄")
|
||||
size_spec: Optional[str] = Field(None, description="사이즈 규격")
|
||||
quantity: float = Field(description="수량")
|
||||
unit: str = Field(description="단위")
|
||||
classification_confidence: Optional[float] = Field(None, description="분류 신뢰도")
|
||||
is_verified: bool = Field(default=False, description="검증 여부")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"file_id": 1,
|
||||
"line_number": 5,
|
||||
"original_description": "PIPE, SEAMLESS, A333-6, 6\", SCH40",
|
||||
"classified_category": "PIPE",
|
||||
"classified_subcategory": "SEAMLESS",
|
||||
"material_grade": "A333-6",
|
||||
"schedule": "SCH40",
|
||||
"size_spec": "6\"",
|
||||
"quantity": 12.5,
|
||||
"unit": "EA",
|
||||
"classification_confidence": 0.95,
|
||||
"is_verified": False
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MaterialListResponse(BaseResponse):
|
||||
"""자재 목록 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: List[MaterialInfo] = Field(description="자재 목록")
|
||||
total_count: int = Field(description="전체 자재 수")
|
||||
file_info: Optional[FileInfo] = Field(None, description="파일 정보")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
# ================================
|
||||
# 작업 관련 모델
|
||||
# ================================
|
||||
|
||||
class JobInfo(BaseModel):
|
||||
"""작업 정보 모델"""
|
||||
job_no: str = Field(description="작업 번호")
|
||||
job_name: str = Field(description="작업명")
|
||||
client_name: Optional[str] = Field(None, description="고객사명")
|
||||
end_user: Optional[str] = Field(None, description="최종 사용자")
|
||||
epc_company: Optional[str] = Field(None, description="EPC 회사")
|
||||
status: JobStatus = Field(description="작업 상태")
|
||||
created_at: datetime = Field(description="생성 일시")
|
||||
file_count: int = Field(default=0, description="파일 수")
|
||||
material_count: int = Field(default=0, description="자재 수")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"job_no": "TK-2025-001",
|
||||
"job_name": "석유화학 플랜트 배관 프로젝트",
|
||||
"client_name": "한국석유화학",
|
||||
"end_user": "울산공장",
|
||||
"epc_company": "현대엔지니어링",
|
||||
"status": "active",
|
||||
"created_at": "2025-01-01T09:00:00",
|
||||
"file_count": 3,
|
||||
"material_count": 450
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class JobListResponse(BaseResponse):
|
||||
"""작업 목록 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: List[JobInfo] = Field(description="작업 목록")
|
||||
total_count: int = Field(description="전체 작업 수")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
# ================================
|
||||
# 분류 관련 모델
|
||||
# ================================
|
||||
|
||||
class ClassificationResult(BaseModel):
|
||||
"""분류 결과 모델"""
|
||||
category: MaterialCategory = Field(description="분류된 카테고리")
|
||||
subcategory: Optional[str] = Field(None, description="세부 분류")
|
||||
confidence: float = Field(description="분류 신뢰도 (0.0-1.0)")
|
||||
material_grade: Optional[str] = Field(None, description="재질 등급")
|
||||
size_spec: Optional[str] = Field(None, description="사이즈 규격")
|
||||
schedule: Optional[str] = Field(None, description="스케줄")
|
||||
details: Optional[Dict[str, Any]] = Field(None, description="분류 상세 정보")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"category": "PIPE",
|
||||
"subcategory": "SEAMLESS",
|
||||
"confidence": 0.95,
|
||||
"material_grade": "A333-6",
|
||||
"size_spec": "6\"",
|
||||
"schedule": "SCH40",
|
||||
"details": {
|
||||
"matched_keywords": ["PIPE", "SEAMLESS", "A333-6"],
|
||||
"size_detected": True,
|
||||
"material_detected": True
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ClassificationResponse(BaseResponse):
|
||||
"""분류 응답 모델"""
|
||||
success: bool = Field(True, description="분류 성공 여부")
|
||||
data: ClassificationResult = Field(description="분류 결과")
|
||||
processing_time: float = Field(description="처리 시간 (초)")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
# ================================
|
||||
# 통계 관련 모델
|
||||
# ================================
|
||||
|
||||
class MaterialStatistics(BaseModel):
|
||||
"""자재 통계 모델"""
|
||||
category: MaterialCategory = Field(description="자재 카테고리")
|
||||
count: int = Field(description="개수")
|
||||
percentage: float = Field(description="비율 (%)")
|
||||
total_quantity: float = Field(description="총 수량")
|
||||
unique_items: int = Field(description="고유 항목 수")
|
||||
|
||||
|
||||
class ProjectStatistics(BaseModel):
|
||||
"""프로젝트 통계 모델"""
|
||||
job_no: str = Field(description="작업 번호")
|
||||
total_materials: int = Field(description="총 자재 수")
|
||||
total_files: int = Field(description="총 파일 수")
|
||||
category_breakdown: List[MaterialStatistics] = Field(description="카테고리별 분석")
|
||||
classification_accuracy: float = Field(description="분류 정확도")
|
||||
verified_percentage: float = Field(description="검증 완료율")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"job_no": "TK-2025-001",
|
||||
"total_materials": 450,
|
||||
"total_files": 3,
|
||||
"category_breakdown": [
|
||||
{
|
||||
"category": "PIPE",
|
||||
"count": 180,
|
||||
"percentage": 40.0,
|
||||
"total_quantity": 1250.5,
|
||||
"unique_items": 45
|
||||
}
|
||||
],
|
||||
"classification_accuracy": 0.92,
|
||||
"verified_percentage": 0.75
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StatisticsResponse(BaseResponse):
|
||||
"""통계 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: ProjectStatistics = Field(description="통계 데이터")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
# ================================
|
||||
# 시스템 관련 모델
|
||||
# ================================
|
||||
|
||||
class CacheInfo(BaseModel):
|
||||
"""캐시 정보 모델"""
|
||||
status: str = Field(description="캐시 상태")
|
||||
used_memory: str = Field(description="사용 메모리")
|
||||
connected_clients: int = Field(description="연결된 클라이언트 수")
|
||||
hit_rate: float = Field(description="캐시 히트율 (%)")
|
||||
total_commands: int = Field(description="총 명령 수")
|
||||
|
||||
|
||||
class SystemHealthResponse(BaseResponse):
|
||||
"""시스템 상태 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: Dict[str, Any] = Field(description="시스템 상태 정보")
|
||||
cache_info: Optional[CacheInfo] = Field(None, description="캐시 정보")
|
||||
database_status: str = Field(description="데이터베이스 상태")
|
||||
api_version: str = Field(description="API 버전")
|
||||
|
||||
|
||||
# ================================
|
||||
# 유니온 타입 (여러 응답 타입)
|
||||
# ================================
|
||||
|
||||
# API 응답으로 사용할 수 있는 모든 타입
|
||||
APIResponse = Union[
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
FileListResponse,
|
||||
FileDeleteResponse,
|
||||
MaterialListResponse,
|
||||
JobListResponse,
|
||||
ClassificationResponse,
|
||||
StatisticsResponse,
|
||||
SystemHealthResponse
|
||||
]
|
||||
333
backend/app/services/file_service.py
Normal file
333
backend/app/services/file_service.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
파일 관리 비즈니스 로직
|
||||
API 레이어에서 분리된 핵심 비즈니스 로직
|
||||
"""
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.cache_manager import tkmp_cache
|
||||
from ..utils.transaction_manager import TransactionManager, async_transactional
|
||||
from ..schemas.response_models import FileInfo
|
||||
from ..config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class FileService:
|
||||
"""파일 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.transaction_manager = TransactionManager(db)
|
||||
|
||||
async def get_files(
|
||||
self,
|
||||
job_no: Optional[str] = None,
|
||||
show_history: bool = False,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[List[Dict], bool]:
|
||||
"""
|
||||
파일 목록 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
show_history: 이력 표시 여부
|
||||
use_cache: 캐시 사용 여부
|
||||
|
||||
Returns:
|
||||
Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부)
|
||||
"""
|
||||
try:
|
||||
logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}")
|
||||
|
||||
# 캐시 확인
|
||||
if use_cache:
|
||||
cached_files = tkmp_cache.get_file_list(job_no, show_history)
|
||||
if cached_files:
|
||||
logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일")
|
||||
return cached_files, True
|
||||
|
||||
# 데이터베이스에서 조회
|
||||
query, params = self._build_file_query(job_no, show_history)
|
||||
result = self.db.execute(text(query), params)
|
||||
files = result.fetchall()
|
||||
|
||||
# 결과 변환
|
||||
file_list = self._convert_files_to_dict(files)
|
||||
|
||||
# 캐시에 저장
|
||||
if use_cache:
|
||||
tkmp_cache.set_file_list(file_list, job_no, show_history)
|
||||
logger.debug("파일 목록 캐시 저장 완료")
|
||||
|
||||
logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환")
|
||||
return file_list, False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||||
|
||||
def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]:
|
||||
"""파일 조회 쿼리 생성"""
|
||||
if show_history:
|
||||
# 전체 이력 표시
|
||||
query = "SELECT * FROM files"
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " WHERE job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
query += " ORDER BY original_filename, revision DESC"
|
||||
else:
|
||||
# 최신 리비전만 표시
|
||||
if job_no:
|
||||
query = """
|
||||
SELECT f1.* FROM files f1
|
||||
INNER JOIN (
|
||||
SELECT original_filename, MAX(revision) as max_revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no
|
||||
GROUP BY original_filename
|
||||
) f2 ON f1.original_filename = f2.original_filename
|
||||
AND f1.revision = f2.max_revision
|
||||
WHERE f1.job_no = :job_no
|
||||
ORDER BY f1.upload_date DESC
|
||||
"""
|
||||
params = {"job_no": job_no}
|
||||
else:
|
||||
query = "SELECT * FROM files ORDER BY upload_date DESC"
|
||||
params = {}
|
||||
|
||||
return query, params
|
||||
|
||||
def _convert_files_to_dict(self, files) -> List[Dict]:
|
||||
"""파일 결과를 딕셔너리로 변환"""
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
"filename": f.original_filename,
|
||||
"original_filename": f.original_filename,
|
||||
"name": f.original_filename,
|
||||
"job_no": f.job_no,
|
||||
"bom_name": f.bom_name or f.original_filename,
|
||||
"revision": f.revision or "Rev.0",
|
||||
"parsed_count": f.parsed_count or 0,
|
||||
"bom_type": f.file_type or "unknown",
|
||||
"status": "active" if f.is_active else "inactive",
|
||||
"file_size": f.file_size,
|
||||
"created_at": f.upload_date,
|
||||
"upload_date": f.upload_date,
|
||||
"description": f"파일: {f.original_filename}"
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
async def delete_file(self, file_id: int) -> Dict:
|
||||
"""
|
||||
파일 삭제 (트랜잭션 관리 적용)
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
|
||||
Returns:
|
||||
Dict: 삭제 결과
|
||||
"""
|
||||
try:
|
||||
logger.info(f"파일 삭제 요청 - file_id: {file_id}")
|
||||
|
||||
# 트랜잭션 내에서 삭제 작업 수행
|
||||
with self.transaction_manager.transaction():
|
||||
# 파일 정보 조회
|
||||
file_info = self._get_file_info(file_id)
|
||||
if not file_info:
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
|
||||
# 관련 데이터 삭제 (세이브포인트 사용)
|
||||
with self.transaction_manager.savepoint("delete_related_data"):
|
||||
self._delete_related_data(file_id)
|
||||
|
||||
# 파일 삭제
|
||||
with self.transaction_manager.savepoint("delete_file_record"):
|
||||
self._delete_file_record(file_id)
|
||||
|
||||
# 트랜잭션이 성공적으로 완료되면 캐시 무효화
|
||||
self._invalidate_file_cache(file_id, file_info)
|
||||
|
||||
logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "파일과 관련 데이터가 삭제되었습니다",
|
||||
"deleted_file_id": file_id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
|
||||
|
||||
def _get_file_info(self, file_id: int):
|
||||
"""파일 정보 조회"""
|
||||
file_query = text("SELECT * FROM files WHERE id = :file_id")
|
||||
file_result = self.db.execute(file_query, {"file_id": file_id})
|
||||
return file_result.fetchone()
|
||||
|
||||
def _delete_related_data(self, file_id: int):
|
||||
"""관련 데이터 삭제"""
|
||||
# 상세 테이블 목록
|
||||
detail_tables = [
|
||||
'pipe_details', 'fitting_details', 'valve_details',
|
||||
'flange_details', 'bolt_details', 'gasket_details',
|
||||
'instrument_details'
|
||||
]
|
||||
|
||||
# 해당 파일의 materials ID 조회
|
||||
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
|
||||
material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id})
|
||||
material_ids = [row[0] for row in material_ids_result]
|
||||
|
||||
if material_ids:
|
||||
logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재")
|
||||
# 각 상세 테이블에서 관련 데이터 삭제
|
||||
for table in detail_tables:
|
||||
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
|
||||
self.db.execute(delete_detail_query, {"material_ids": material_ids})
|
||||
|
||||
# materials 테이블 데이터 삭제
|
||||
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
|
||||
self.db.execute(materials_query, {"file_id": file_id})
|
||||
|
||||
def _delete_file_record(self, file_id: int):
|
||||
"""파일 레코드 삭제"""
|
||||
delete_query = text("DELETE FROM files WHERE id = :file_id")
|
||||
self.db.execute(delete_query, {"file_id": file_id})
|
||||
|
||||
def _invalidate_file_cache(self, file_id: int, file_info):
|
||||
"""파일 관련 캐시 무효화"""
|
||||
tkmp_cache.invalidate_file_cache(file_id)
|
||||
if hasattr(file_info, 'job_no') and file_info.job_no:
|
||||
tkmp_cache.invalidate_job_cache(file_info.job_no)
|
||||
|
||||
async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
파일 통계 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
|
||||
Returns:
|
||||
Dict: 파일 통계
|
||||
"""
|
||||
try:
|
||||
# 캐시 확인
|
||||
if job_no:
|
||||
cached_stats = tkmp_cache.get_statistics(job_no, "file_stats")
|
||||
if cached_stats:
|
||||
return cached_stats
|
||||
|
||||
# 통계 쿼리 실행
|
||||
stats_query = self._build_statistics_query(job_no)
|
||||
result = self.db.execute(text(stats_query["query"]), stats_query["params"])
|
||||
stats_data = result.fetchall()
|
||||
|
||||
# 통계 데이터 변환
|
||||
statistics = self._convert_statistics_data(stats_data)
|
||||
|
||||
# 캐시에 저장
|
||||
if job_no:
|
||||
tkmp_cache.set_statistics(statistics, job_no, "file_stats")
|
||||
|
||||
return statistics
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}")
|
||||
|
||||
def _build_statistics_query(self, job_no: Optional[str]) -> Dict:
|
||||
"""통계 쿼리 생성"""
|
||||
base_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_files,
|
||||
COUNT(DISTINCT job_no) as total_jobs,
|
||||
SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files,
|
||||
SUM(file_size) as total_size,
|
||||
AVG(file_size) as avg_size,
|
||||
MAX(upload_date) as latest_upload,
|
||||
MIN(upload_date) as earliest_upload
|
||||
FROM files
|
||||
"""
|
||||
|
||||
params = {}
|
||||
if job_no:
|
||||
base_query += " WHERE job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
return {"query": base_query, "params": params}
|
||||
|
||||
def _convert_statistics_data(self, stats_data) -> Dict:
|
||||
"""통계 데이터 변환"""
|
||||
if not stats_data:
|
||||
return {
|
||||
"total_files": 0,
|
||||
"total_jobs": 0,
|
||||
"active_files": 0,
|
||||
"total_size": 0,
|
||||
"avg_size": 0,
|
||||
"latest_upload": None,
|
||||
"earliest_upload": None
|
||||
}
|
||||
|
||||
stats = stats_data[0]
|
||||
return {
|
||||
"total_files": stats.total_files or 0,
|
||||
"total_jobs": stats.total_jobs or 0,
|
||||
"active_files": stats.active_files or 0,
|
||||
"total_size": stats.total_size or 0,
|
||||
"total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2),
|
||||
"avg_size": stats.avg_size or 0,
|
||||
"avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2),
|
||||
"latest_upload": stats.latest_upload,
|
||||
"earliest_upload": stats.earliest_upload
|
||||
}
|
||||
|
||||
async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
파일 접근 권한 검증
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
bool: 접근 권한 여부
|
||||
"""
|
||||
try:
|
||||
# 파일 존재 여부 확인
|
||||
file_info = self._get_file_info(file_id)
|
||||
if not file_info:
|
||||
return False
|
||||
|
||||
# 파일이 활성 상태인지 확인
|
||||
if not file_info.is_active:
|
||||
logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}")
|
||||
return False
|
||||
|
||||
# 추가 권한 검증 로직 (필요시 구현)
|
||||
# 예: 사용자별 프로젝트 접근 권한 등
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def get_file_service(db: Session) -> FileService:
|
||||
"""파일 서비스 팩토리 함수"""
|
||||
return FileService(db)
|
||||
@@ -10,26 +10,26 @@ from typing import Dict, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
# 자재별 기본 여유율
|
||||
# 자재별 기본 여유율 (올바른 규칙으로 수정)
|
||||
SAFETY_FACTORS = {
|
||||
'PIPE': 1.15, # 15% 추가 (절단 손실)
|
||||
'FITTING': 1.10, # 10% 추가 (연결 오차)
|
||||
'VALVE': 1.50, # 50% 추가 (예비품)
|
||||
'FLANGE': 1.10, # 10% 추가
|
||||
'BOLT': 1.20, # 20% 추가 (분실율)
|
||||
'GASKET': 1.25, # 25% 추가 (교체주기)
|
||||
'INSTRUMENT': 1.00, # 0% 추가 (정확한 수량)
|
||||
'DEFAULT': 1.10 # 기본 10% 추가
|
||||
'PIPE': 1.00, # 0% 추가 (절단 손실은 별도 계산)
|
||||
'FITTING': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'VALVE': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'FLANGE': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'BOLT': 1.05, # 5% 추가 (분실율)
|
||||
'GASKET': 1.00, # 0% 추가 (5의 배수 올림으로 처리)
|
||||
'INSTRUMENT': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'DEFAULT': 1.00 # 기본 0% 추가
|
||||
}
|
||||
|
||||
# 최소 주문 수량 (자재별)
|
||||
# 최소 주문 수량 (자재별) - 올바른 규칙으로 수정
|
||||
MINIMUM_ORDER_QTY = {
|
||||
'PIPE': 6000, # 6M 단위
|
||||
'FITTING': 1, # 개별 주문 가능
|
||||
'VALVE': 1, # 개별 주문 가능
|
||||
'FLANGE': 1, # 개별 주문 가능
|
||||
'BOLT': 50, # 박스 단위 (50개)
|
||||
'GASKET': 10, # 세트 단위
|
||||
'BOLT': 4, # 4의 배수 단위
|
||||
'GASKET': 5, # 5의 배수 단위
|
||||
'INSTRUMENT': 1, # 개별 주문 가능
|
||||
'DEFAULT': 1
|
||||
}
|
||||
@@ -37,7 +37,7 @@ MINIMUM_ORDER_QTY = {
|
||||
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
PIPE 구매 수량 계산
|
||||
- 각 절단마다 3mm 손실
|
||||
- 각 절단마다 2mm 손실 (올바른 규칙)
|
||||
- 6,000mm (6M) 단위로 올림
|
||||
"""
|
||||
total_bom_length = 0
|
||||
@@ -45,19 +45,23 @@ def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
|
||||
pipe_details = []
|
||||
|
||||
for material in materials:
|
||||
# 길이 정보 추출
|
||||
# 길이 정보 추출 (Decimal 타입 처리)
|
||||
length_mm = float(material.get('length_mm', 0) or 0)
|
||||
quantity = float(material.get('quantity', 1) or 1)
|
||||
|
||||
if length_mm > 0:
|
||||
total_bom_length += length_mm
|
||||
cutting_count += 1
|
||||
total_length = length_mm * quantity # 총 길이 = 단위길이 × 수량
|
||||
total_bom_length += total_length
|
||||
cutting_count += quantity # 절단 횟수 = 수량
|
||||
pipe_details.append({
|
||||
'description': material.get('original_description', ''),
|
||||
'length_mm': length_mm,
|
||||
'quantity': material.get('quantity', 1)
|
||||
'quantity': quantity,
|
||||
'total_length': total_length
|
||||
})
|
||||
|
||||
# 절단 손실 계산 (각 절단마다 3mm)
|
||||
cutting_loss = cutting_count * 3
|
||||
# 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙)
|
||||
cutting_loss = cutting_count * 2
|
||||
|
||||
# 총 필요 길이 = BOM 길이 + 절단 손실
|
||||
required_length = total_bom_length + cutting_loss
|
||||
@@ -92,7 +96,8 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
|
||||
if safety_factor is None:
|
||||
safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT'])
|
||||
|
||||
# 1단계: 여유율 적용
|
||||
# 1단계: 여유율 적용 (Decimal 타입 처리)
|
||||
bom_quantity = float(bom_quantity) if bom_quantity else 0.0
|
||||
safety_qty = bom_quantity * safety_factor
|
||||
|
||||
# 2단계: 최소 주문 수량 확인
|
||||
@@ -101,9 +106,13 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
|
||||
# 3단계: 최소 주문 수량과 비교하여 큰 값 선택
|
||||
calculated_qty = max(safety_qty, min_order_qty)
|
||||
|
||||
# 4단계: 특별 처리 (BOLT는 박스 단위로 올림)
|
||||
if category == 'BOLT' and calculated_qty > min_order_qty:
|
||||
calculated_qty = math.ceil(calculated_qty / min_order_qty) * min_order_qty
|
||||
# 4단계: 특별 처리 (올바른 규칙 적용)
|
||||
if category == 'BOLT':
|
||||
# BOLT: 5% 여유율 후 4의 배수로 올림
|
||||
calculated_qty = math.ceil(safety_qty / min_order_qty) * min_order_qty
|
||||
elif category == 'GASKET':
|
||||
# GASKET: 5의 배수로 올림 (여유율 없음)
|
||||
calculated_qty = math.ceil(bom_quantity / min_order_qty) * min_order_qty
|
||||
|
||||
return {
|
||||
'bom_quantity': bom_quantity,
|
||||
@@ -120,16 +129,19 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
|
||||
"""
|
||||
자재 데이터로부터 구매 품목 생성
|
||||
"""
|
||||
# 1. 파일의 모든 자재 조회
|
||||
# 1. 파일의 모든 자재 조회 (상세 테이블 정보 포함)
|
||||
materials_query = text("""
|
||||
SELECT m.*,
|
||||
pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec,
|
||||
fd.fitting_type, fd.connection_method as fitting_connection,
|
||||
fd.fitting_type, fd.connection_method as fitting_connection, fd.main_size as fitting_main_size,
|
||||
fd.reduced_size as fitting_reduced_size, fd.material_grade as fitting_material_grade,
|
||||
vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure,
|
||||
fl.flange_type, fl.pressure_rating as flange_pressure,
|
||||
gd.gasket_type, gd.material_type as gasket_material,
|
||||
bd.bolt_type, bd.material_standard, bd.diameter,
|
||||
id.instrument_type
|
||||
vd.size_inches as valve_size,
|
||||
fl.flange_type, fl.pressure_rating as flange_pressure, fl.size_inches as flange_size,
|
||||
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, gd.filler_material,
|
||||
gd.size_inches as gasket_size, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
|
||||
bd.bolt_type, bd.material_standard, bd.diameter as bolt_diameter, bd.length as bolt_length,
|
||||
id.instrument_type, id.connection_size as instrument_size
|
||||
FROM materials m
|
||||
LEFT JOIN pipe_details pd ON m.id = pd.material_id
|
||||
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||
@@ -144,13 +156,65 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
|
||||
|
||||
materials = db.execute(materials_query, {"file_id": file_id}).fetchall()
|
||||
|
||||
|
||||
|
||||
# 2. 카테고리별로 그룹핑
|
||||
grouped_materials = {}
|
||||
for material in materials:
|
||||
category = material.classified_category or 'OTHER'
|
||||
if category not in grouped_materials:
|
||||
grouped_materials[category] = []
|
||||
grouped_materials[category].append(dict(material))
|
||||
|
||||
# Row 객체를 딕셔너리로 안전하게 변환
|
||||
material_dict = {
|
||||
'id': material.id,
|
||||
'file_id': material.file_id,
|
||||
'original_description': material.original_description,
|
||||
'quantity': material.quantity,
|
||||
'unit': material.unit,
|
||||
'size_spec': material.size_spec,
|
||||
'material_grade': material.material_grade,
|
||||
'classified_category': material.classified_category,
|
||||
'line_number': material.line_number,
|
||||
# PIPE 상세 정보
|
||||
'length_mm': getattr(material, 'length_mm', None),
|
||||
'outer_diameter': getattr(material, 'outer_diameter', None),
|
||||
'schedule': getattr(material, 'schedule', None),
|
||||
'pipe_material_spec': getattr(material, 'pipe_material_spec', None),
|
||||
# FITTING 상세 정보
|
||||
'fitting_type': getattr(material, 'fitting_type', None),
|
||||
'fitting_connection': getattr(material, 'fitting_connection', None),
|
||||
'fitting_main_size': getattr(material, 'fitting_main_size', None),
|
||||
'fitting_reduced_size': getattr(material, 'fitting_reduced_size', None),
|
||||
'fitting_material_grade': getattr(material, 'fitting_material_grade', None),
|
||||
# VALVE 상세 정보
|
||||
'valve_type': getattr(material, 'valve_type', None),
|
||||
'valve_connection': getattr(material, 'valve_connection', None),
|
||||
'valve_pressure': getattr(material, 'valve_pressure', None),
|
||||
'valve_size': getattr(material, 'valve_size', None),
|
||||
# FLANGE 상세 정보
|
||||
'flange_type': getattr(material, 'flange_type', None),
|
||||
'flange_pressure': getattr(material, 'flange_pressure', None),
|
||||
'flange_size': getattr(material, 'flange_size', None),
|
||||
# GASKET 상세 정보
|
||||
'gasket_type': getattr(material, 'gasket_type', None),
|
||||
'gasket_subtype': getattr(material, 'gasket_subtype', None),
|
||||
'gasket_material': getattr(material, 'gasket_material', None),
|
||||
'filler_material': getattr(material, 'filler_material', None),
|
||||
'gasket_size': getattr(material, 'gasket_size', None),
|
||||
'gasket_pressure': getattr(material, 'gasket_pressure', None),
|
||||
'gasket_thickness': getattr(material, 'gasket_thickness', None),
|
||||
# BOLT 상세 정보
|
||||
'bolt_type': getattr(material, 'bolt_type', None),
|
||||
'material_standard': getattr(material, 'material_standard', None),
|
||||
'bolt_diameter': getattr(material, 'bolt_diameter', None),
|
||||
'bolt_length': getattr(material, 'bolt_length', None),
|
||||
# INSTRUMENT 상세 정보
|
||||
'instrument_type': getattr(material, 'instrument_type', None),
|
||||
'instrument_size': getattr(material, 'instrument_size', None)
|
||||
}
|
||||
|
||||
grouped_materials[category].append(material_dict)
|
||||
|
||||
# 3. 각 카테고리별로 구매 품목 생성
|
||||
purchase_items = []
|
||||
@@ -249,13 +313,41 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
|
||||
if category == 'FITTING':
|
||||
fitting_type = material.get('fitting_type', 'FITTING')
|
||||
connection_method = material.get('fitting_connection', '')
|
||||
material_spec = material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
red_nom = material.get('red_nom', '')
|
||||
size_display = f"{main_nom} x {red_nom}" if red_nom else main_nom
|
||||
# 상세 테이블의 재질 정보 우선 사용
|
||||
material_spec = material.get('fitting_material_grade') or material.get('material_grade', '')
|
||||
# 상세 테이블의 사이즈 정보 사용
|
||||
main_size = material.get('fitting_main_size', '')
|
||||
reduced_size = material.get('fitting_reduced_size', '')
|
||||
|
||||
# 사이즈 표시 생성 (축소형인 경우 main x reduced 형태)
|
||||
if main_size and reduced_size and main_size != reduced_size:
|
||||
size_display = f"{main_size} x {reduced_size}"
|
||||
else:
|
||||
size_display = main_size or material.get('size_spec', '')
|
||||
|
||||
# 기존 분류기 방식: 피팅 타입 + 연결방식 + 압력등급
|
||||
# 예: "ELBOW, SOCKET WELD, 3000LB"
|
||||
fitting_display = fitting_type.replace('_', ' ') if fitting_type else 'FITTING'
|
||||
|
||||
spec_parts = [fitting_display]
|
||||
|
||||
# 연결방식 추가
|
||||
if connection_method and connection_method != 'UNKNOWN':
|
||||
connection_display = connection_method.replace('_', ' ')
|
||||
spec_parts.append(connection_display)
|
||||
|
||||
# 압력등급 추출 (description에서)
|
||||
description = material.get('original_description', '').upper()
|
||||
import re
|
||||
pressure_match = re.search(r'(\d+)LB', description)
|
||||
if pressure_match:
|
||||
spec_parts.append(f"{pressure_match.group(1)}LB")
|
||||
|
||||
# 스케줄 정보 추출 (니플 등에 중요)
|
||||
schedule_match = re.search(r'SCH\s*(\d+)', description)
|
||||
if schedule_match:
|
||||
spec_parts.append(f"SCH {schedule_match.group(1)}")
|
||||
|
||||
spec_parts = [fitting_type]
|
||||
if connection_method: spec_parts.append(connection_method)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}"
|
||||
@@ -272,19 +364,20 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
|
||||
connection_method = material.get('valve_connection', '')
|
||||
pressure_rating = material.get('valve_pressure', '')
|
||||
material_spec = material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('valve_size') or material.get('size_spec', '')
|
||||
|
||||
spec_parts = [valve_type.replace('_', ' ')]
|
||||
if connection_method: spec_parts.append(connection_method.replace('_', ' '))
|
||||
if pressure_rating: spec_parts.append(pressure_rating)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"VALVE|{full_spec}|{material_spec}|{main_nom}"
|
||||
spec_key = f"VALVE|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'VALVE',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': main_nom,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
@@ -292,102 +385,198 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
|
||||
flange_type = material.get('flange_type', 'FLANGE')
|
||||
pressure_rating = material.get('flange_pressure', '')
|
||||
material_spec = material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('flange_size') or material.get('size_spec', '')
|
||||
|
||||
spec_parts = [flange_type]
|
||||
spec_parts = [flange_type.replace('_', ' ')]
|
||||
if pressure_rating: spec_parts.append(pressure_rating)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"FLANGE|{full_spec}|{material_spec}|{main_nom}"
|
||||
spec_key = f"FLANGE|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'FLANGE',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': main_nom,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'BOLT':
|
||||
bolt_type = material.get('bolt_type', 'BOLT')
|
||||
material_standard = material.get('material_standard', '')
|
||||
diameter = material.get('diameter', material.get('main_nom', ''))
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
diameter = material.get('bolt_diameter') or material.get('size_spec', '')
|
||||
length = material.get('bolt_length', '')
|
||||
material_spec = material_standard or material.get('material_grade', '')
|
||||
|
||||
# 분수 사이즈 정보 추출 (새로 추가된 분류기 정보)
|
||||
size_fraction = material.get('size_fraction', diameter)
|
||||
surface_treatment = material.get('surface_treatment', '')
|
||||
|
||||
# 특수 용도 정보 추출 (PSV, LT, CK)
|
||||
special_applications = {
|
||||
'PSV': 0,
|
||||
'LT': 0,
|
||||
'CK': 0
|
||||
# 기존 분류기 방식에 따른 사이즈 표시 (분수 형태)
|
||||
# 소수점을 분수로 변환 (예: 0.625 -> 5/8)
|
||||
size_display = diameter
|
||||
if diameter and '.' in diameter:
|
||||
try:
|
||||
decimal_val = float(diameter)
|
||||
# 일반적인 볼트 사이즈 분수 변환
|
||||
fraction_map = {
|
||||
0.25: "1/4\"", 0.3125: "5/16\"", 0.375: "3/8\"",
|
||||
0.4375: "7/16\"", 0.5: "1/2\"", 0.5625: "9/16\"",
|
||||
0.625: "5/8\"", 0.6875: "11/16\"", 0.75: "3/4\"",
|
||||
0.8125: "13/16\"", 0.875: "7/8\"", 1.0: "1\""
|
||||
}
|
||||
if decimal_val in fraction_map:
|
||||
size_display = fraction_map[decimal_val]
|
||||
except:
|
||||
pass
|
||||
|
||||
# 설명에서 특수 용도 키워드 확인 (간단한 방법)
|
||||
description = material.get('original_description', '').upper()
|
||||
if 'PSV' in description or 'PRESSURE SAFETY VALVE' in description:
|
||||
special_applications['PSV'] = material.get('quantity', 0)
|
||||
if any(keyword in description for keyword in ['LT', 'LOW TEMP', '저온용']):
|
||||
special_applications['LT'] = material.get('quantity', 0)
|
||||
if 'CK' in description or 'CHECK VALVE' in description:
|
||||
special_applications['CK'] = material.get('quantity', 0)
|
||||
# 길이 정보 포함한 사이즈 표시 (예: 5/8" x 165L)
|
||||
if length:
|
||||
# 길이에서 숫자만 추출
|
||||
import re
|
||||
length_match = re.search(r'(\d+(?:\.\d+)?)', str(length))
|
||||
if length_match:
|
||||
length_num = length_match.group(1)
|
||||
size_display_with_length = f"{size_display} x {length_num}L"
|
||||
else:
|
||||
size_display_with_length = f"{size_display} x {length}"
|
||||
else:
|
||||
size_display_with_length = size_display
|
||||
|
||||
spec_parts = [bolt_type.replace('_', ' ')]
|
||||
if material_standard: spec_parts.append(material_standard)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
# 특수 용도와 관계없이 사이즈+길이로 합산 (구매는 동일하므로)
|
||||
# 길이 정보가 있으면 포함
|
||||
length_info = material.get('length', '')
|
||||
if length_info:
|
||||
diameter_key = f"{diameter}L{length_info}"
|
||||
else:
|
||||
diameter_key = diameter
|
||||
|
||||
spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter_key}"
|
||||
# 사이즈+길이로 그룹핑
|
||||
spec_key = f"BOLT|{full_spec}|{material_spec}|{size_display_with_length}"
|
||||
spec_data = {
|
||||
'category': 'BOLT',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': diameter,
|
||||
'size_fraction': size_fraction,
|
||||
'surface_treatment': surface_treatment,
|
||||
'size_display': size_display_with_length,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'GASKET':
|
||||
# 상세 테이블 정보 우선 사용
|
||||
gasket_type = material.get('gasket_type', 'GASKET')
|
||||
gasket_subtype = material.get('gasket_subtype', '')
|
||||
gasket_material = material.get('gasket_material', '')
|
||||
material_spec = gasket_material or material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
filler_material = material.get('filler_material', '')
|
||||
gasket_pressure = material.get('gasket_pressure', '')
|
||||
gasket_thickness = material.get('gasket_thickness', '')
|
||||
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('gasket_size') or material.get('size_spec', '')
|
||||
|
||||
# 기존 분류기 방식: 가스켓 타입 + 압력등급 + 재질
|
||||
# 예: "SPIRAL WOUND, 150LB, 304SS + GRAPHITE"
|
||||
spec_parts = [gasket_type.replace('_', ' ')]
|
||||
|
||||
# 서브타입 추가 (있는 경우)
|
||||
if gasket_subtype and gasket_subtype != gasket_type:
|
||||
spec_parts.append(gasket_subtype.replace('_', ' '))
|
||||
|
||||
# 상세 테이블의 압력등급 우선 사용, 없으면 description에서 추출
|
||||
if gasket_pressure:
|
||||
spec_parts.append(gasket_pressure)
|
||||
else:
|
||||
description = material.get('original_description', '').upper()
|
||||
import re
|
||||
pressure_match = re.search(r'(\d+)LB', description)
|
||||
if pressure_match:
|
||||
spec_parts.append(f"{pressure_match.group(1)}LB")
|
||||
|
||||
# 재질 정보 구성 (상세 테이블 정보 활용)
|
||||
material_spec_parts = []
|
||||
|
||||
# SWG의 경우 메탈 + 필러 형태로 구성
|
||||
if gasket_type == 'SPIRAL_WOUND':
|
||||
# 기존 저장된 데이터가 부정확한 경우, 원본 description에서 직접 파싱
|
||||
description = material.get('original_description', '').upper()
|
||||
|
||||
# SS304/GRAPHITE/CS/CS 패턴 파싱 (H/F/I/O 다음에 오는 재질 정보)
|
||||
import re
|
||||
material_spec = None
|
||||
|
||||
# H/F/I/O SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
|
||||
hfio_material_match = re.search(r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
|
||||
if hfio_material_match:
|
||||
part1 = hfio_material_match.group(1) # SS304
|
||||
part2 = hfio_material_match.group(2) # GRAPHITE
|
||||
part3 = hfio_material_match.group(3) # CS
|
||||
part4 = hfio_material_match.group(4) # CS
|
||||
material_spec = f"{part1}/{part2}/{part3}/{part4}"
|
||||
else:
|
||||
# 단순 SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
|
||||
simple_material_match = re.search(r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
|
||||
if simple_material_match:
|
||||
part1 = simple_material_match.group(1) # SS304
|
||||
part2 = simple_material_match.group(2) # GRAPHITE
|
||||
part3 = simple_material_match.group(3) # CS
|
||||
part4 = simple_material_match.group(4) # CS
|
||||
material_spec = f"{part1}/{part2}/{part3}/{part4}"
|
||||
|
||||
if not material_spec:
|
||||
# 상세 테이블 정보 사용
|
||||
if gasket_material and gasket_material != 'GRAPHITE': # 메탈 부분
|
||||
material_spec_parts.append(gasket_material)
|
||||
elif gasket_material == 'GRAPHITE':
|
||||
# GRAPHITE만 있는 경우 description에서 메탈 부분 찾기
|
||||
metal_match = re.search(r'(SS\d+|CS|INCONEL\d*)', description)
|
||||
if metal_match:
|
||||
material_spec_parts.append(metal_match.group(1))
|
||||
|
||||
if filler_material and filler_material != gasket_material: # 필러 부분
|
||||
material_spec_parts.append(filler_material)
|
||||
elif 'GRAPHITE' in description and 'GRAPHITE' not in material_spec_parts:
|
||||
material_spec_parts.append('GRAPHITE')
|
||||
|
||||
if material_spec_parts:
|
||||
material_spec = ' + '.join(material_spec_parts) # SS304 + GRAPHITE
|
||||
else:
|
||||
material_spec = material.get('material_grade', '')
|
||||
else:
|
||||
# 일반 가스켓의 경우
|
||||
if gasket_material:
|
||||
material_spec_parts.append(gasket_material)
|
||||
if filler_material and filler_material != gasket_material:
|
||||
material_spec_parts.append(filler_material)
|
||||
|
||||
if material_spec_parts:
|
||||
material_spec = ', '.join(material_spec_parts)
|
||||
else:
|
||||
material_spec = material.get('material_grade', '')
|
||||
|
||||
if material_spec:
|
||||
spec_parts.append(material_spec)
|
||||
|
||||
# 두께 정보 추가 (있는 경우)
|
||||
if gasket_thickness:
|
||||
spec_parts.append(f"THK {gasket_thickness}")
|
||||
|
||||
spec_parts = [gasket_type]
|
||||
if gasket_material: spec_parts.append(gasket_material)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"GASKET|{full_spec}|{material_spec}|{main_nom}"
|
||||
spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'GASKET',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': main_nom,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'INSTRUMENT':
|
||||
instrument_type = material.get('instrument_type', 'INSTRUMENT')
|
||||
material_spec = material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('instrument_size') or material.get('size_spec', '')
|
||||
|
||||
full_spec = instrument_type.replace('_', ' ')
|
||||
|
||||
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{main_nom}"
|
||||
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'INSTRUMENT',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': main_nom,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
|
||||
12
backend/app/utils/__init__.py
Normal file
12
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
유틸리티 모듈
|
||||
"""
|
||||
from .logger import get_logger, setup_logger, app_logger
|
||||
from .file_validator import file_validator, validate_uploaded_file
|
||||
from .error_handlers import ErrorResponse, TKMPException, setup_error_handlers
|
||||
|
||||
__all__ = [
|
||||
"get_logger", "setup_logger", "app_logger",
|
||||
"file_validator", "validate_uploaded_file",
|
||||
"ErrorResponse", "TKMPException", "setup_error_handlers"
|
||||
]
|
||||
266
backend/app/utils/cache_manager.py
Normal file
266
backend/app/utils/cache_manager.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Redis 캐시 관리 유틸리티
|
||||
성능 향상을 위한 캐싱 전략 구현
|
||||
"""
|
||||
import json
|
||||
import redis
|
||||
from typing import Any, Optional, Dict, List
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
import pickle
|
||||
|
||||
from ..config import get_settings
|
||||
from .logger import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Redis 캐시 관리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
# Redis 연결 설정
|
||||
self.redis_client = redis.from_url(
|
||||
settings.redis.url,
|
||||
decode_responses=False, # 바이너리 데이터 지원
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
retry_on_timeout=True
|
||||
)
|
||||
|
||||
# 연결 테스트
|
||||
self.redis_client.ping()
|
||||
logger.info("Redis 연결 성공")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 연결 실패: {e}")
|
||||
self.redis_client = None
|
||||
|
||||
def _generate_key(self, prefix: str, *args, **kwargs) -> str:
|
||||
"""캐시 키 생성"""
|
||||
# 인자들을 문자열로 변환하여 해시 생성
|
||||
key_parts = [str(arg) for arg in args]
|
||||
key_parts.extend([f"{k}:{v}" for k, v in sorted(kwargs.items())])
|
||||
|
||||
if key_parts:
|
||||
key_hash = hashlib.md5("|".join(key_parts).encode()).hexdigest()[:8]
|
||||
return f"tkmp:{prefix}:{key_hash}"
|
||||
else:
|
||||
return f"tkmp:{prefix}"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""캐시에서 데이터 조회"""
|
||||
if not self.redis_client:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = self.redis_client.get(key)
|
||||
if data:
|
||||
return pickle.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 조회 실패 - key: {key}, error: {e}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, expire: int = 3600) -> bool:
|
||||
"""캐시에 데이터 저장"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized_data = pickle.dumps(value)
|
||||
result = self.redis_client.setex(key, expire, serialized_data)
|
||||
logger.debug(f"캐시 저장 - key: {key}, expire: {expire}s")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 저장 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""캐시에서 데이터 삭제"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = self.redis_client.delete(key)
|
||||
logger.debug(f"캐시 삭제 - key: {key}")
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 삭제 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def delete_pattern(self, pattern: str) -> int:
|
||||
"""패턴에 맞는 캐시 키들 삭제"""
|
||||
if not self.redis_client:
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = self.redis_client.keys(pattern)
|
||||
if keys:
|
||||
deleted = self.redis_client.delete(*keys)
|
||||
logger.info(f"패턴 캐시 삭제 - pattern: {pattern}, deleted: {deleted}")
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(f"패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}")
|
||||
return 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""캐시 키 존재 여부 확인"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(self.redis_client.exists(key))
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 존재 확인 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def get_ttl(self, key: str) -> int:
|
||||
"""캐시 TTL 조회"""
|
||||
if not self.redis_client:
|
||||
return -1
|
||||
|
||||
try:
|
||||
return self.redis_client.ttl(key)
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 TTL 조회 실패 - key: {key}, error: {e}")
|
||||
return -1
|
||||
|
||||
|
||||
class TKMPCache:
|
||||
"""TK-MP 프로젝트 전용 캐시 래퍼"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = CacheManager()
|
||||
|
||||
# 캐시 TTL 설정 (초 단위)
|
||||
self.ttl_config = {
|
||||
"file_list": 300, # 5분 - 파일 목록
|
||||
"material_list": 600, # 10분 - 자재 목록
|
||||
"job_list": 1800, # 30분 - 작업 목록
|
||||
"classification": 3600, # 1시간 - 분류 결과
|
||||
"statistics": 900, # 15분 - 통계 데이터
|
||||
"comparison": 1800, # 30분 - 리비전 비교
|
||||
}
|
||||
|
||||
def get_file_list(self, job_no: Optional[str] = None, show_history: bool = False) -> Optional[List[Dict]]:
|
||||
"""파일 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_file_list(self, files: List[Dict], job_no: Optional[str] = None, show_history: bool = False) -> bool:
|
||||
"""파일 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
|
||||
return self.cache.set(key, files, self.ttl_config["file_list"])
|
||||
|
||||
def get_material_list(self, file_id: int) -> Optional[List[Dict]]:
|
||||
"""자재 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("materials", file_id=file_id)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_material_list(self, materials: List[Dict], file_id: int) -> bool:
|
||||
"""자재 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("materials", file_id=file_id)
|
||||
return self.cache.set(key, materials, self.ttl_config["material_list"])
|
||||
|
||||
def get_job_list(self) -> Optional[List[Dict]]:
|
||||
"""작업 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("jobs")
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_job_list(self, jobs: List[Dict]) -> bool:
|
||||
"""작업 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("jobs")
|
||||
return self.cache.set(key, jobs, self.ttl_config["job_list"])
|
||||
|
||||
def get_classification_result(self, description: str, category: str) -> Optional[Dict]:
|
||||
"""분류 결과 캐시 조회"""
|
||||
key = self.cache._generate_key("classification", desc=description, cat=category)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_classification_result(self, result: Dict, description: str, category: str) -> bool:
|
||||
"""분류 결과 캐시 저장"""
|
||||
key = self.cache._generate_key("classification", desc=description, cat=category)
|
||||
return self.cache.set(key, result, self.ttl_config["classification"])
|
||||
|
||||
def get_statistics(self, job_no: str, stat_type: str) -> Optional[Dict]:
|
||||
"""통계 데이터 캐시 조회"""
|
||||
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_statistics(self, stats: Dict, job_no: str, stat_type: str) -> bool:
|
||||
"""통계 데이터 캐시 저장"""
|
||||
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
|
||||
return self.cache.set(key, stats, self.ttl_config["statistics"])
|
||||
|
||||
def get_revision_comparison(self, job_no: str, rev1: str, rev2: str) -> Optional[Dict]:
|
||||
"""리비전 비교 결과 캐시 조회"""
|
||||
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_revision_comparison(self, comparison: Dict, job_no: str, rev1: str, rev2: str) -> bool:
|
||||
"""리비전 비교 결과 캐시 저장"""
|
||||
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
|
||||
return self.cache.set(key, comparison, self.ttl_config["comparison"])
|
||||
|
||||
def invalidate_job_cache(self, job_no: str):
|
||||
"""특정 작업의 모든 캐시 무효화"""
|
||||
patterns = [
|
||||
f"tkmp:files:*job_no:{job_no}*",
|
||||
f"tkmp:materials:*job_no:{job_no}*",
|
||||
f"tkmp:stats:*job_no:{job_no}*",
|
||||
f"tkmp:comparison:*job_no:{job_no}*"
|
||||
]
|
||||
|
||||
total_deleted = 0
|
||||
for pattern in patterns:
|
||||
deleted = self.cache.delete_pattern(pattern)
|
||||
total_deleted += deleted
|
||||
|
||||
logger.info(f"작업 캐시 무효화 완료 - job_no: {job_no}, deleted: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
def invalidate_file_cache(self, file_id: int):
|
||||
"""특정 파일의 모든 캐시 무효화"""
|
||||
patterns = [
|
||||
f"tkmp:materials:*file_id:{file_id}*",
|
||||
f"tkmp:files:*" # 파일 목록도 갱신 필요
|
||||
]
|
||||
|
||||
total_deleted = 0
|
||||
for pattern in patterns:
|
||||
deleted = self.cache.delete_pattern(pattern)
|
||||
total_deleted += deleted
|
||||
|
||||
logger.info(f"파일 캐시 무효화 완료 - file_id: {file_id}, deleted: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
def get_cache_info(self) -> Dict[str, Any]:
|
||||
"""캐시 상태 정보 조회"""
|
||||
if not self.cache.redis_client:
|
||||
return {"status": "disconnected"}
|
||||
|
||||
try:
|
||||
info = self.cache.redis_client.info()
|
||||
return {
|
||||
"status": "connected",
|
||||
"used_memory": info.get("used_memory_human", "N/A"),
|
||||
"connected_clients": info.get("connected_clients", 0),
|
||||
"total_commands_processed": info.get("total_commands_processed", 0),
|
||||
"keyspace_hits": info.get("keyspace_hits", 0),
|
||||
"keyspace_misses": info.get("keyspace_misses", 0),
|
||||
"hit_rate": round(
|
||||
info.get("keyspace_hits", 0) /
|
||||
max(info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0), 1) * 100, 2
|
||||
)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"캐시 정보 조회 실패: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
# 전역 캐시 인스턴스
|
||||
tkmp_cache = TKMPCache()
|
||||
139
backend/app/utils/error_handlers.py
Normal file
139
backend/app/utils/error_handlers.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
에러 처리 유틸리티
|
||||
표준화된 에러 응답 및 예외 처리
|
||||
"""
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from typing import Dict, Any
|
||||
import traceback
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TKMPException(Exception):
|
||||
"""TK-MP 프로젝트 커스텀 예외"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = "TKMP_ERROR", status_code: int = 500):
|
||||
self.message = message
|
||||
self.error_code = error_code
|
||||
self.status_code = status_code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ErrorResponse:
|
||||
"""표준화된 에러 응답 생성기"""
|
||||
|
||||
@staticmethod
|
||||
def create_error_response(
|
||||
message: str,
|
||||
error_code: str = "INTERNAL_ERROR",
|
||||
status_code: int = 500,
|
||||
details: Dict[str, Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""표준화된 에러 응답 생성"""
|
||||
response = {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": error_code,
|
||||
"message": message,
|
||||
"timestamp": "2025-01-01T00:00:00Z" # 실제로는 datetime.utcnow().isoformat()
|
||||
}
|
||||
}
|
||||
|
||||
if details:
|
||||
response["error"]["details"] = details
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def validation_error_response(errors: list) -> Dict[str, Any]:
|
||||
"""검증 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="입력 데이터 검증에 실패했습니다.",
|
||||
error_code="VALIDATION_ERROR",
|
||||
status_code=422,
|
||||
details={"validation_errors": errors}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def database_error_response(error: str) -> Dict[str, Any]:
|
||||
"""데이터베이스 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="데이터베이스 작업 중 오류가 발생했습니다.",
|
||||
error_code="DATABASE_ERROR",
|
||||
status_code=500,
|
||||
details={"db_error": error}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def file_error_response(error: str) -> Dict[str, Any]:
|
||||
"""파일 처리 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="파일 처리 중 오류가 발생했습니다.",
|
||||
error_code="FILE_ERROR",
|
||||
status_code=400,
|
||||
details={"file_error": error}
|
||||
)
|
||||
|
||||
|
||||
async def tkmp_exception_handler(request: Request, exc: TKMPException):
|
||||
"""TK-MP 커스텀 예외 핸들러"""
|
||||
logger.error(f"TK-MP 예외 발생: {exc.message} (코드: {exc.error_code})")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse.create_error_response(
|
||||
message=exc.message,
|
||||
error_code=exc.error_code,
|
||||
status_code=exc.status_code
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""검증 예외 핸들러"""
|
||||
logger.warning(f"검증 오류: {exc.errors()}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ErrorResponse.validation_error_response(exc.errors())
|
||||
)
|
||||
|
||||
|
||||
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
|
||||
"""SQLAlchemy 예외 핸들러"""
|
||||
logger.error(f"데이터베이스 오류: {str(exc)}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse.database_error_response(str(exc))
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""일반 예외 핸들러"""
|
||||
logger.error(f"예상치 못한 오류: {str(exc)}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse.create_error_response(
|
||||
message="서버 내부 오류가 발생했습니다.",
|
||||
error_code="INTERNAL_SERVER_ERROR",
|
||||
status_code=500,
|
||||
details={"error": str(exc)} if logger.level <= 10 else None # DEBUG 레벨일 때만 상세 에러 표시
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def setup_error_handlers(app):
|
||||
"""FastAPI 앱에 에러 핸들러 등록"""
|
||||
app.add_exception_handler(TKMPException, tkmp_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
logger.info("에러 핸들러 등록 완료")
|
||||
335
backend/app/utils/file_processor.py
Normal file
335
backend/app/utils/file_processor.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
대용량 파일 처리 최적화 유틸리티
|
||||
메모리 효율적인 파일 처리 및 청크 기반 처리
|
||||
"""
|
||||
import pandas as pd
|
||||
import asyncio
|
||||
from typing import Iterator, List, Dict, Any, Optional, Callable
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import gc
|
||||
|
||||
from .logger import get_logger
|
||||
from ..config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class FileProcessor:
|
||||
"""대용량 파일 처리 최적화 클래스"""
|
||||
|
||||
def __init__(self, chunk_size: int = 1000, max_workers: int = 4):
|
||||
self.chunk_size = chunk_size
|
||||
self.max_workers = max_workers
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
|
||||
def read_excel_chunks(self, file_path: str, sheet_name: str = None) -> Iterator[pd.DataFrame]:
|
||||
"""
|
||||
엑셀 파일을 청크 단위로 읽기
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
sheet_name: 시트명 (None이면 첫 번째 시트)
|
||||
|
||||
Yields:
|
||||
DataFrame: 청크 단위 데이터
|
||||
"""
|
||||
try:
|
||||
# 파일 크기 확인
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"엑셀 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
|
||||
|
||||
# 전체 행 수 확인 (메모리 효율적으로)
|
||||
with pd.ExcelFile(file_path) as xls:
|
||||
if sheet_name is None:
|
||||
sheet_name = xls.sheet_names[0]
|
||||
|
||||
# 첫 번째 청크로 컬럼 정보 확인
|
||||
first_chunk = pd.read_excel(xls, sheet_name=sheet_name, nrows=self.chunk_size)
|
||||
total_rows = len(first_chunk)
|
||||
|
||||
# 전체 데이터를 청크로 나누어 처리
|
||||
processed_rows = 0
|
||||
chunk_num = 0
|
||||
|
||||
while processed_rows < total_rows:
|
||||
try:
|
||||
# 청크 읽기
|
||||
chunk = pd.read_excel(
|
||||
xls,
|
||||
sheet_name=sheet_name,
|
||||
skiprows=processed_rows + 1 if processed_rows > 0 else 0,
|
||||
nrows=self.chunk_size,
|
||||
header=0 if processed_rows == 0 else None
|
||||
)
|
||||
|
||||
if chunk.empty:
|
||||
break
|
||||
|
||||
# 첫 번째 청크가 아닌 경우 컬럼명 설정
|
||||
if processed_rows > 0:
|
||||
chunk.columns = first_chunk.columns
|
||||
|
||||
chunk_num += 1
|
||||
processed_rows += len(chunk)
|
||||
|
||||
logger.debug(f"청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {processed_rows}")
|
||||
|
||||
yield chunk
|
||||
|
||||
# 메모리 정리
|
||||
del chunk
|
||||
gc.collect()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"청크 {chunk_num} 처리 중 오류: {e}")
|
||||
break
|
||||
|
||||
logger.info(f"엑셀 파일 처리 완료 - 총 {chunk_num}개 청크, {processed_rows}행 처리")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"엑셀 파일 읽기 실패: {e}")
|
||||
raise
|
||||
|
||||
def read_csv_chunks(self, file_path: str, encoding: str = 'utf-8') -> Iterator[pd.DataFrame]:
|
||||
"""
|
||||
CSV 파일을 청크 단위로 읽기
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
encoding: 인코딩 (기본: utf-8)
|
||||
|
||||
Yields:
|
||||
DataFrame: 청크 단위 데이터
|
||||
"""
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"CSV 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
|
||||
|
||||
chunk_num = 0
|
||||
total_rows = 0
|
||||
|
||||
# pandas의 chunksize 옵션 사용
|
||||
for chunk in pd.read_csv(file_path, chunksize=self.chunk_size, encoding=encoding):
|
||||
chunk_num += 1
|
||||
total_rows += len(chunk)
|
||||
|
||||
logger.debug(f"CSV 청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {total_rows}")
|
||||
|
||||
yield chunk
|
||||
|
||||
# 메모리 정리
|
||||
gc.collect()
|
||||
|
||||
logger.info(f"CSV 파일 처리 완료 - 총 {chunk_num}개 청크, {total_rows}행 처리")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CSV 파일 읽기 실패: {e}")
|
||||
raise
|
||||
|
||||
async def process_file_async(
|
||||
self,
|
||||
file_path: str,
|
||||
processor_func: Callable[[pd.DataFrame], List[Dict]],
|
||||
file_type: str = "excel"
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
파일을 비동기적으로 처리
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
processor_func: 각 청크를 처리할 함수
|
||||
file_type: 파일 타입 ("excel" 또는 "csv")
|
||||
|
||||
Returns:
|
||||
List[Dict]: 처리된 결과 리스트
|
||||
"""
|
||||
try:
|
||||
logger.info(f"비동기 파일 처리 시작 - {file_path}")
|
||||
|
||||
results = []
|
||||
chunk_futures = []
|
||||
|
||||
# 파일 타입에 따른 청크 리더 선택
|
||||
if file_type.lower() == "csv":
|
||||
chunk_reader = self.read_csv_chunks(file_path)
|
||||
else:
|
||||
chunk_reader = self.read_excel_chunks(file_path)
|
||||
|
||||
# 청크별 비동기 처리
|
||||
for chunk in chunk_reader:
|
||||
# 스레드 풀에서 청크 처리
|
||||
future = asyncio.get_event_loop().run_in_executor(
|
||||
self.executor,
|
||||
processor_func,
|
||||
chunk
|
||||
)
|
||||
chunk_futures.append(future)
|
||||
|
||||
# 너무 많은 청크가 동시에 처리되지 않도록 제한
|
||||
if len(chunk_futures) >= self.max_workers:
|
||||
# 완료된 작업들 수집
|
||||
completed_results = await asyncio.gather(*chunk_futures)
|
||||
for result in completed_results:
|
||||
if result:
|
||||
results.extend(result)
|
||||
|
||||
chunk_futures = []
|
||||
gc.collect()
|
||||
|
||||
# 남은 청크들 처리
|
||||
if chunk_futures:
|
||||
completed_results = await asyncio.gather(*chunk_futures)
|
||||
for result in completed_results:
|
||||
if result:
|
||||
results.extend(result)
|
||||
|
||||
logger.info(f"비동기 파일 처리 완료 - 총 {len(results)}개 항목 처리")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"비동기 파일 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def optimize_dataframe_memory(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
DataFrame 메모리 사용량 최적화
|
||||
|
||||
Args:
|
||||
df: 최적화할 DataFrame
|
||||
|
||||
Returns:
|
||||
DataFrame: 최적화된 DataFrame
|
||||
"""
|
||||
try:
|
||||
original_memory = df.memory_usage(deep=True).sum()
|
||||
|
||||
# 수치형 컬럼 최적화
|
||||
for col in df.select_dtypes(include=['int64']).columns:
|
||||
col_min = df[col].min()
|
||||
col_max = df[col].max()
|
||||
|
||||
if col_min >= -128 and col_max <= 127:
|
||||
df[col] = df[col].astype('int8')
|
||||
elif col_min >= -32768 and col_max <= 32767:
|
||||
df[col] = df[col].astype('int16')
|
||||
elif col_min >= -2147483648 and col_max <= 2147483647:
|
||||
df[col] = df[col].astype('int32')
|
||||
|
||||
# 실수형 컬럼 최적화
|
||||
for col in df.select_dtypes(include=['float64']).columns:
|
||||
df[col] = pd.to_numeric(df[col], downcast='float')
|
||||
|
||||
# 문자열 컬럼 최적화 (카테고리형으로 변환)
|
||||
for col in df.select_dtypes(include=['object']).columns:
|
||||
if df[col].nunique() / len(df) < 0.5: # 고유값이 50% 미만인 경우
|
||||
df[col] = df[col].astype('category')
|
||||
|
||||
optimized_memory = df.memory_usage(deep=True).sum()
|
||||
memory_reduction = (original_memory - optimized_memory) / original_memory * 100
|
||||
|
||||
logger.debug(f"DataFrame 메모리 최적화 완료 - 감소율: {memory_reduction:.1f}%")
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DataFrame 메모리 최적화 실패: {e}")
|
||||
return df
|
||||
|
||||
def create_temp_file(self, suffix: str = '.tmp') -> str:
|
||||
"""
|
||||
임시 파일 생성
|
||||
|
||||
Args:
|
||||
suffix: 파일 확장자
|
||||
|
||||
Returns:
|
||||
str: 임시 파일 경로
|
||||
"""
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
temp_file.close()
|
||||
logger.debug(f"임시 파일 생성: {temp_file.name}")
|
||||
return temp_file.name
|
||||
|
||||
def cleanup_temp_file(self, file_path: str):
|
||||
"""
|
||||
임시 파일 정리
|
||||
|
||||
Args:
|
||||
file_path: 삭제할 파일 경로
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.unlink(file_path)
|
||||
logger.debug(f"임시 파일 삭제: {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"임시 파일 삭제 실패: {file_path}, error: {e}")
|
||||
|
||||
def get_file_info(self, file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
파일 정보 조회
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
|
||||
Returns:
|
||||
Dict: 파일 정보
|
||||
"""
|
||||
try:
|
||||
file_stat = os.stat(file_path)
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
|
||||
info = {
|
||||
"file_path": file_path,
|
||||
"file_size": file_stat.st_size,
|
||||
"file_size_mb": round(file_stat.st_size / (1024 * 1024), 2),
|
||||
"file_extension": file_ext,
|
||||
"is_large_file": file_stat.st_size > 10 * 1024 * 1024, # 10MB 이상
|
||||
"recommended_chunk_size": self._calculate_optimal_chunk_size(file_stat.st_size)
|
||||
}
|
||||
|
||||
# 파일 타입별 추가 정보
|
||||
if file_ext in ['.xlsx', '.xls']:
|
||||
info["file_type"] = "excel"
|
||||
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
|
||||
elif file_ext == '.csv':
|
||||
info["file_type"] = "csv"
|
||||
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 정보 조회 실패: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _calculate_optimal_chunk_size(self, file_size: int) -> int:
|
||||
"""
|
||||
파일 크기에 따른 최적 청크 크기 계산
|
||||
|
||||
Args:
|
||||
file_size: 파일 크기 (bytes)
|
||||
|
||||
Returns:
|
||||
int: 최적 청크 크기
|
||||
"""
|
||||
# 파일 크기에 따른 청크 크기 조정
|
||||
if file_size < 1024 * 1024: # 1MB 미만
|
||||
return 500
|
||||
elif file_size < 10 * 1024 * 1024: # 10MB 미만
|
||||
return 1000
|
||||
elif file_size < 50 * 1024 * 1024: # 50MB 미만
|
||||
return 2000
|
||||
else: # 50MB 이상
|
||||
return 5000
|
||||
|
||||
def __del__(self):
|
||||
"""소멸자 - 스레드 풀 정리"""
|
||||
if hasattr(self, 'executor'):
|
||||
self.executor.shutdown(wait=True)
|
||||
|
||||
|
||||
# 전역 파일 프로세서 인스턴스
|
||||
file_processor = FileProcessor()
|
||||
169
backend/app/utils/file_validator.py
Normal file
169
backend/app/utils/file_validator.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
파일 업로드 검증 유틸리티
|
||||
보안 강화를 위한 파일 검증 로직
|
||||
"""
|
||||
import os
|
||||
import magic
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from fastapi import UploadFile, HTTPException
|
||||
|
||||
from ..config import get_settings
|
||||
from .logger import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FileValidator:
|
||||
"""파일 업로드 검증 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.max_file_size = settings.security.max_file_size
|
||||
self.allowed_extensions = settings.security.allowed_file_extensions
|
||||
|
||||
# MIME 타입 매핑
|
||||
self.mime_type_mapping = {
|
||||
'.xlsx': [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/octet-stream' # 일부 브라우저에서 xlsx를 이렇게 인식
|
||||
],
|
||||
'.xls': [
|
||||
'application/vnd.ms-excel',
|
||||
'application/octet-stream'
|
||||
],
|
||||
'.csv': [
|
||||
'text/csv',
|
||||
'text/plain',
|
||||
'application/csv'
|
||||
]
|
||||
}
|
||||
|
||||
def validate_file_extension(self, filename: str) -> bool:
|
||||
"""파일 확장자 검증"""
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
is_valid = file_ext in self.allowed_extensions
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"허용되지 않은 파일 확장자: {file_ext}, 파일: {filename}")
|
||||
|
||||
return is_valid
|
||||
|
||||
def validate_file_size(self, file_size: int) -> bool:
|
||||
"""파일 크기 검증"""
|
||||
is_valid = file_size <= self.max_file_size
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"파일 크기 초과: {file_size} bytes (최대: {self.max_file_size} bytes)")
|
||||
|
||||
return is_valid
|
||||
|
||||
def validate_filename(self, filename: str) -> bool:
|
||||
"""파일명 검증 (보안 위험 문자 체크)"""
|
||||
# 위험한 문자들
|
||||
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
|
||||
for char in dangerous_chars:
|
||||
if char in filename:
|
||||
logger.warning(f"위험한 문자 포함된 파일명: {filename}")
|
||||
return False
|
||||
|
||||
# 파일명 길이 체크 (255자 제한)
|
||||
if len(filename) > 255:
|
||||
logger.warning(f"파일명이 너무 긺: {len(filename)} 문자")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def validate_mime_type(self, file_content: bytes, filename: str) -> bool:
|
||||
"""MIME 타입 검증 (파일 내용 기반)"""
|
||||
try:
|
||||
# python-magic을 사용한 MIME 타입 검증
|
||||
detected_mime = magic.from_buffer(file_content, mime=True)
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
|
||||
expected_mimes = self.mime_type_mapping.get(file_ext, [])
|
||||
|
||||
if detected_mime in expected_mimes:
|
||||
return True
|
||||
|
||||
logger.warning(f"MIME 타입 불일치 - 파일: {filename}, 감지된 타입: {detected_mime}, 예상 타입: {expected_mimes}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MIME 타입 검증 실패: {e}")
|
||||
# magic 라이브러리 오류 시 확장자 검증으로 대체
|
||||
return self.validate_file_extension(filename)
|
||||
|
||||
def sanitize_filename(self, filename: str) -> str:
|
||||
"""파일명 정화 (안전한 파일명으로 변환)"""
|
||||
# 위험한 문자들을 언더스코어로 대체
|
||||
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
sanitized = filename
|
||||
|
||||
for char in dangerous_chars:
|
||||
sanitized = sanitized.replace(char, '_')
|
||||
|
||||
# 연속된 언더스코어 제거
|
||||
while '__' in sanitized:
|
||||
sanitized = sanitized.replace('__', '_')
|
||||
|
||||
# 앞뒤 공백 및 점 제거
|
||||
sanitized = sanitized.strip(' .')
|
||||
|
||||
return sanitized
|
||||
|
||||
async def validate_upload_file(self, file: UploadFile) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
업로드 파일 종합 검증
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (검증 성공 여부, 에러 메시지)
|
||||
"""
|
||||
try:
|
||||
# 1. 파일명 검증
|
||||
if not self.validate_filename(file.filename):
|
||||
return False, f"유효하지 않은 파일명: {file.filename}"
|
||||
|
||||
# 2. 확장자 검증
|
||||
if not self.validate_file_extension(file.filename):
|
||||
return False, f"허용되지 않은 파일 형식입니다. 허용 형식: {', '.join(self.allowed_extensions)}"
|
||||
|
||||
# 3. 파일 내용 읽기
|
||||
file_content = await file.read()
|
||||
await file.seek(0) # 파일 포인터 리셋
|
||||
|
||||
# 4. 파일 크기 검증
|
||||
if not self.validate_file_size(len(file_content)):
|
||||
return False, f"파일 크기가 너무 큽니다. 최대 크기: {self.max_file_size // (1024*1024)}MB"
|
||||
|
||||
# 5. MIME 타입 검증
|
||||
if not self.validate_mime_type(file_content, file.filename):
|
||||
return False, "파일 형식이 올바르지 않습니다."
|
||||
|
||||
logger.info(f"파일 검증 성공: {file.filename} ({len(file_content)} bytes)")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 검증 중 오류 발생: {e}", exc_info=True)
|
||||
return False, f"파일 검증 중 오류가 발생했습니다: {str(e)}"
|
||||
|
||||
|
||||
# 전역 파일 검증기 인스턴스
|
||||
file_validator = FileValidator()
|
||||
|
||||
|
||||
async def validate_uploaded_file(file: UploadFile) -> None:
|
||||
"""
|
||||
파일 검증 헬퍼 함수 (HTTPException 발생)
|
||||
|
||||
Args:
|
||||
file: 업로드된 파일
|
||||
|
||||
Raises:
|
||||
HTTPException: 검증 실패 시
|
||||
"""
|
||||
is_valid, error_message = await file_validator.validate_upload_file(file)
|
||||
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error_message)
|
||||
87
backend/app/utils/logger.py
Normal file
87
backend/app/utils/logger.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
로깅 유틸리티 모듈
|
||||
중앙화된 로깅 설정 및 관리
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup_logger(
|
||||
name: str,
|
||||
log_file: Optional[str] = None,
|
||||
level: str = None
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
로거 설정 및 반환
|
||||
|
||||
Args:
|
||||
name: 로거 이름
|
||||
log_file: 로그 파일 경로 (선택사항)
|
||||
level: 로그 레벨 (선택사항)
|
||||
|
||||
Returns:
|
||||
설정된 로거 인스턴스
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# 이미 핸들러가 설정된 경우 중복 방지
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
# 로그 레벨 설정
|
||||
log_level = level or settings.logging.level
|
||||
logger.setLevel(getattr(logging, log_level.upper()))
|
||||
|
||||
# 포맷터 설정
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
|
||||
# 콘솔 핸들러
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 파일 핸들러 (선택사항)
|
||||
if log_file or settings.logging.file_path:
|
||||
file_path = log_file or settings.logging.file_path
|
||||
|
||||
# 로그 디렉토리 생성
|
||||
log_dir = os.path.dirname(file_path)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# 로테이팅 파일 핸들러 (10MB, 5개 파일 유지)
|
||||
file_handler = RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
로거 인스턴스 반환 (간편 함수)
|
||||
|
||||
Args:
|
||||
name: 로거 이름
|
||||
|
||||
Returns:
|
||||
로거 인스턴스
|
||||
"""
|
||||
return setup_logger(name)
|
||||
|
||||
|
||||
# 애플리케이션 전역 로거
|
||||
app_logger = setup_logger("tk_mp_app", settings.logging.file_path)
|
||||
355
backend/app/utils/transaction_manager.py
Normal file
355
backend/app/utils/transaction_manager.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
트랜잭션 관리 유틸리티
|
||||
데이터 일관성을 위한 트랜잭션 관리 및 데코레이터
|
||||
"""
|
||||
import functools
|
||||
from typing import Any, Callable, Optional, TypeVar, Generic
|
||||
from contextlib import contextmanager
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import asyncio
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class TransactionManager:
|
||||
"""트랜잭션 관리 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, rollback_on_exception: bool = True):
|
||||
"""
|
||||
트랜잭션 컨텍스트 매니저
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
try:
|
||||
logger.debug("트랜잭션 시작")
|
||||
yield self.db
|
||||
self.db.commit()
|
||||
logger.debug("트랜잭션 커밋 완료")
|
||||
|
||||
except Exception as e:
|
||||
if rollback_on_exception:
|
||||
self.db.rollback()
|
||||
logger.warning(f"트랜잭션 롤백 - 에러: {str(e)}")
|
||||
else:
|
||||
logger.error(f"트랜잭션 에러 (롤백 안함) - 에러: {str(e)}")
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def savepoint(self, name: Optional[str] = None):
|
||||
"""
|
||||
세이브포인트 컨텍스트 매니저
|
||||
|
||||
Args:
|
||||
name: 세이브포인트 이름
|
||||
"""
|
||||
savepoint_name = name or f"sp_{id(self)}"
|
||||
|
||||
try:
|
||||
# 세이브포인트 생성
|
||||
savepoint = self.db.begin_nested()
|
||||
logger.debug(f"세이브포인트 생성: {savepoint_name}")
|
||||
|
||||
yield self.db
|
||||
|
||||
# 세이브포인트 커밋
|
||||
savepoint.commit()
|
||||
logger.debug(f"세이브포인트 커밋: {savepoint_name}")
|
||||
|
||||
except Exception as e:
|
||||
# 세이브포인트 롤백
|
||||
savepoint.rollback()
|
||||
logger.warning(f"세이브포인트 롤백: {savepoint_name} - 에러: {str(e)}")
|
||||
raise
|
||||
|
||||
def execute_in_transaction(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||
"""
|
||||
함수를 트랜잭션 내에서 실행
|
||||
|
||||
Args:
|
||||
func: 실행할 함수
|
||||
*args: 함수 인자
|
||||
**kwargs: 함수 키워드 인자
|
||||
|
||||
Returns:
|
||||
함수 실행 결과
|
||||
"""
|
||||
with self.transaction():
|
||||
return func(*args, **kwargs)
|
||||
|
||||
async def execute_in_transaction_async(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||
"""
|
||||
비동기 함수를 트랜잭션 내에서 실행
|
||||
|
||||
Args:
|
||||
func: 실행할 비동기 함수
|
||||
*args: 함수 인자
|
||||
**kwargs: 함수 키워드 인자
|
||||
|
||||
Returns:
|
||||
함수 실행 결과
|
||||
"""
|
||||
with self.transaction():
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
def transactional(rollback_on_exception: bool = True):
|
||||
"""
|
||||
트랜잭션 데코레이터
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 첫 번째 인자가 Session인지 확인
|
||||
if args and isinstance(args[0], Session):
|
||||
db = args[0]
|
||||
transaction_manager = TransactionManager(db)
|
||||
|
||||
try:
|
||||
with transaction_manager.transaction(rollback_on_exception):
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
|
||||
raise
|
||||
else:
|
||||
# Session이 없으면 일반 함수로 실행
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def async_transactional(rollback_on_exception: bool = True):
|
||||
"""
|
||||
비동기 트랜잭션 데코레이터
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 첫 번째 인자가 Session인지 확인
|
||||
if args and isinstance(args[0], Session):
|
||||
db = args[0]
|
||||
transaction_manager = TransactionManager(db)
|
||||
|
||||
try:
|
||||
with transaction_manager.transaction(rollback_on_exception):
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"비동기 트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
|
||||
raise
|
||||
else:
|
||||
# Session이 없으면 일반 함수로 실행
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class BatchProcessor:
|
||||
"""배치 처리를 위한 트랜잭션 관리"""
|
||||
|
||||
def __init__(self, db: Session, batch_size: int = 1000):
|
||||
self.db = db
|
||||
self.batch_size = batch_size
|
||||
self.transaction_manager = TransactionManager(db)
|
||||
|
||||
def process_in_batches(
|
||||
self,
|
||||
items: list,
|
||||
process_func: Callable,
|
||||
commit_per_batch: bool = True
|
||||
):
|
||||
"""
|
||||
아이템들을 배치 단위로 처리
|
||||
|
||||
Args:
|
||||
items: 처리할 아이템 리스트
|
||||
process_func: 각 아이템을 처리할 함수
|
||||
commit_per_batch: 배치마다 커밋 여부
|
||||
"""
|
||||
total_items = len(items)
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
logger.info(f"배치 처리 시작 - 총 {total_items}개 아이템, 배치 크기: {self.batch_size}")
|
||||
|
||||
for i in range(0, total_items, self.batch_size):
|
||||
batch = items[i:i + self.batch_size]
|
||||
batch_num = (i // self.batch_size) + 1
|
||||
|
||||
try:
|
||||
if commit_per_batch:
|
||||
with self.transaction_manager.transaction():
|
||||
self._process_batch(batch, process_func)
|
||||
else:
|
||||
self._process_batch(batch, process_func)
|
||||
|
||||
processed_count += len(batch)
|
||||
logger.debug(f"배치 {batch_num} 처리 완료 - {len(batch)}개 아이템")
|
||||
|
||||
except Exception as e:
|
||||
failed_count += len(batch)
|
||||
logger.error(f"배치 {batch_num} 처리 실패 - {str(e)}")
|
||||
|
||||
# 개별 아이템 처리 시도
|
||||
if commit_per_batch:
|
||||
self._process_batch_individually(batch, process_func)
|
||||
|
||||
# 전체 커밋 (배치마다 커밋하지 않은 경우)
|
||||
if not commit_per_batch:
|
||||
try:
|
||||
self.db.commit()
|
||||
logger.info("전체 배치 처리 커밋 완료")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"전체 배치 처리 커밋 실패: {str(e)}")
|
||||
raise
|
||||
|
||||
logger.info(f"배치 처리 완료 - 성공: {processed_count}, 실패: {failed_count}")
|
||||
|
||||
return {
|
||||
"total_items": total_items,
|
||||
"processed_count": processed_count,
|
||||
"failed_count": failed_count,
|
||||
"success_rate": (processed_count / total_items) * 100 if total_items > 0 else 0
|
||||
}
|
||||
|
||||
def _process_batch(self, batch: list, process_func: Callable):
|
||||
"""배치 처리"""
|
||||
for item in batch:
|
||||
process_func(item)
|
||||
|
||||
def _process_batch_individually(self, batch: list, process_func: Callable):
|
||||
"""배치 내 아이템을 개별적으로 처리 (에러 복구용)"""
|
||||
for item in batch:
|
||||
try:
|
||||
with self.transaction_manager.savepoint():
|
||||
process_func(item)
|
||||
except Exception as e:
|
||||
logger.warning(f"개별 아이템 처리 실패: {str(e)}")
|
||||
|
||||
|
||||
class DatabaseLock:
|
||||
"""데이터베이스 레벨 락 관리"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@contextmanager
|
||||
def advisory_lock(self, lock_id: int):
|
||||
"""
|
||||
PostgreSQL Advisory Lock
|
||||
|
||||
Args:
|
||||
lock_id: 락 ID
|
||||
"""
|
||||
try:
|
||||
# Advisory Lock 획득
|
||||
result = self.db.execute(f"SELECT pg_advisory_lock({lock_id})")
|
||||
logger.debug(f"Advisory Lock 획득: {lock_id}")
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
# Advisory Lock 해제
|
||||
self.db.execute(f"SELECT pg_advisory_unlock({lock_id})")
|
||||
logger.debug(f"Advisory Lock 해제: {lock_id}")
|
||||
|
||||
@contextmanager
|
||||
def table_lock(self, table_name: str, lock_mode: str = "ACCESS EXCLUSIVE"):
|
||||
"""
|
||||
테이블 레벨 락
|
||||
|
||||
Args:
|
||||
table_name: 테이블명
|
||||
lock_mode: 락 모드
|
||||
"""
|
||||
try:
|
||||
# 테이블 락 획득
|
||||
self.db.execute(f"LOCK TABLE {table_name} IN {lock_mode} MODE")
|
||||
logger.debug(f"테이블 락 획득: {table_name} ({lock_mode})")
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"테이블 락 실패: {table_name} - {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
class TransactionStats:
|
||||
"""트랜잭션 통계 수집"""
|
||||
|
||||
def __init__(self):
|
||||
self.stats = {
|
||||
"total_transactions": 0,
|
||||
"successful_transactions": 0,
|
||||
"failed_transactions": 0,
|
||||
"rollback_count": 0,
|
||||
"savepoint_count": 0
|
||||
}
|
||||
|
||||
def record_transaction_start(self):
|
||||
"""트랜잭션 시작 기록"""
|
||||
self.stats["total_transactions"] += 1
|
||||
|
||||
def record_transaction_success(self):
|
||||
"""트랜잭션 성공 기록"""
|
||||
self.stats["successful_transactions"] += 1
|
||||
|
||||
def record_transaction_failure(self):
|
||||
"""트랜잭션 실패 기록"""
|
||||
self.stats["failed_transactions"] += 1
|
||||
|
||||
def record_rollback(self):
|
||||
"""롤백 기록"""
|
||||
self.stats["rollback_count"] += 1
|
||||
|
||||
def record_savepoint(self):
|
||||
"""세이브포인트 기록"""
|
||||
self.stats["savepoint_count"] += 1
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""통계 반환"""
|
||||
total = self.stats["total_transactions"]
|
||||
if total > 0:
|
||||
self.stats["success_rate"] = (self.stats["successful_transactions"] / total) * 100
|
||||
self.stats["failure_rate"] = (self.stats["failed_transactions"] / total) * 100
|
||||
else:
|
||||
self.stats["success_rate"] = 0
|
||||
self.stats["failure_rate"] = 0
|
||||
|
||||
return self.stats.copy()
|
||||
|
||||
def reset_stats(self):
|
||||
"""통계 초기화"""
|
||||
for key in self.stats:
|
||||
if key not in ["success_rate", "failure_rate"]:
|
||||
self.stats[key] = 0
|
||||
|
||||
|
||||
# 전역 트랜잭션 통계 인스턴스
|
||||
transaction_stats = TransactionStats()
|
||||
25
backend/env.example
Normal file
25
backend/env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# TK-MP-Project 환경변수 설정 예시
|
||||
# 실제 사용 시 .env 파일로 복사하여 사용
|
||||
|
||||
# 환경 설정 (development, production, synology)
|
||||
ENVIRONMENT=development
|
||||
|
||||
# 애플리케이션 설정
|
||||
APP_NAME=TK-MP BOM Management API
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=true
|
||||
|
||||
# 데이터베이스 설정
|
||||
DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom
|
||||
|
||||
# Redis 설정
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# 보안 설정
|
||||
# CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"] # 필요시 직접 설정
|
||||
MAX_FILE_SIZE=52428800 # 50MB in bytes
|
||||
ALLOWED_FILE_EXTENSIONS=[".xlsx",".xls",".csv"]
|
||||
|
||||
# 로깅 설정
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/app.log
|
||||
60
backend/pytest.ini
Normal file
60
backend/pytest.ini
Normal file
@@ -0,0 +1,60 @@
|
||||
[tool:pytest]
|
||||
# pytest 설정 파일
|
||||
|
||||
# 테스트 디렉토리
|
||||
testpaths = tests
|
||||
|
||||
# 테스트 파일 패턴
|
||||
python_files = test_*.py *_test.py
|
||||
|
||||
# 테스트 클래스 패턴
|
||||
python_classes = Test*
|
||||
|
||||
# 테스트 함수 패턴
|
||||
python_functions = test_*
|
||||
|
||||
# 마커 정의
|
||||
markers =
|
||||
unit: 단위 테스트
|
||||
integration: 통합 테스트
|
||||
performance: 성능 테스트
|
||||
slow: 느린 테스트 (시간이 오래 걸리는 테스트)
|
||||
api: API 테스트
|
||||
database: 데이터베이스 테스트
|
||||
cache: 캐시 테스트
|
||||
classifier: 분류기 테스트
|
||||
|
||||
# 출력 설정
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
--color=yes
|
||||
--durations=10
|
||||
--cov=app
|
||||
--cov-report=term-missing
|
||||
--cov-report=html:htmlcov
|
||||
--cov-fail-under=80
|
||||
|
||||
# 최소 커버리지 (80%)
|
||||
# --cov-fail-under=80
|
||||
|
||||
# 로그 설정
|
||||
log_cli = true
|
||||
log_cli_level = INFO
|
||||
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
|
||||
log_cli_date_format = %Y-%m-%d %H:%M:%S
|
||||
|
||||
# 경고 필터
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
ignore::UserWarning:sqlalchemy.*
|
||||
|
||||
# 테스트 발견 설정
|
||||
minversion = 6.0
|
||||
required_plugins =
|
||||
pytest-cov
|
||||
pytest-asyncio
|
||||
pytest-mock
|
||||
@@ -21,10 +21,19 @@ pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.25.2
|
||||
redis==5.0.1
|
||||
python-magic==0.4.27
|
||||
|
||||
# 인증 시스템
|
||||
PyJWT==2.8.0
|
||||
bcrypt==4.1.2
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.3.0
|
||||
|
||||
# 개발 도구
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
pytest-mock==3.12.0
|
||||
black==23.11.0
|
||||
flake8==6.1.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
184
backend/scripts/15_create_tubing_system.sql
Normal file
184
backend/scripts/15_create_tubing_system.sql
Normal file
@@ -0,0 +1,184 @@
|
||||
-- ================================
|
||||
-- Tubing 제품 관리 시스템
|
||||
-- 실행일: 2025.08.01
|
||||
-- ================================
|
||||
|
||||
-- 1. Tubing 카테고리 테이블 (일반, VCR, 기타 등)
|
||||
CREATE TABLE IF NOT EXISTS tubing_categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category_code VARCHAR(20) UNIQUE NOT NULL,
|
||||
category_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 2. Tubing 규격 마스터 테이블
|
||||
CREATE TABLE IF NOT EXISTS tubing_specifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category_id INTEGER REFERENCES tubing_categories(id),
|
||||
spec_code VARCHAR(50) UNIQUE NOT NULL,
|
||||
spec_name VARCHAR(200) NOT NULL,
|
||||
|
||||
-- 물리적 규격
|
||||
outer_diameter_mm DECIMAL(8,3), -- 외경 (mm)
|
||||
wall_thickness_mm DECIMAL(6,3), -- 두께 (mm)
|
||||
inner_diameter_mm DECIMAL(8,3), -- 내경 (mm, 계산 또는 실측)
|
||||
|
||||
-- 재질 정보
|
||||
material_grade VARCHAR(100), -- SS316, SS316L, Inconel625 등
|
||||
material_standard VARCHAR(100), -- ASTM A269, JIS G3463 등
|
||||
|
||||
-- 압력/온도 등급
|
||||
max_pressure_bar DECIMAL(8,2), -- 최대 압력 (bar)
|
||||
max_temperature_c DECIMAL(6,2), -- 최대 온도 (°C)
|
||||
min_temperature_c DECIMAL(6,2), -- 최소 온도 (°C)
|
||||
|
||||
-- 표준 규격
|
||||
standard_length_m DECIMAL(8,3), -- 표준 길이 (m)
|
||||
bend_radius_min_mm DECIMAL(8,2), -- 최소 벤딩 반경 (mm)
|
||||
|
||||
-- 기타 정보
|
||||
surface_finish VARCHAR(100), -- 표면 마감 (BA, #4, 2B 등)
|
||||
hardness VARCHAR(50), -- 경도
|
||||
notes TEXT,
|
||||
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 3. 제조사 정보 테이블
|
||||
CREATE TABLE IF NOT EXISTS tubing_manufacturers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
manufacturer_code VARCHAR(20) UNIQUE NOT NULL,
|
||||
manufacturer_name VARCHAR(200) NOT NULL,
|
||||
country VARCHAR(100),
|
||||
website VARCHAR(500),
|
||||
contact_info JSONB, -- 연락처 정보 (JSON)
|
||||
quality_certs JSONB, -- 품질 인증서 정보 (ISO, API 등)
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 4. 제조사별 제품 테이블 (품목번호 매핑)
|
||||
CREATE TABLE IF NOT EXISTS tubing_products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
specification_id INTEGER REFERENCES tubing_specifications(id),
|
||||
manufacturer_id INTEGER REFERENCES tubing_manufacturers(id),
|
||||
|
||||
-- 제조사 품목번호 정보
|
||||
manufacturer_part_number VARCHAR(200) NOT NULL, -- 제조사 품목번호
|
||||
manufacturer_product_name VARCHAR(300), -- 제조사 제품명
|
||||
|
||||
-- 가격/공급 정보
|
||||
list_price DECIMAL(12,2), -- 정가
|
||||
currency VARCHAR(10) DEFAULT 'KRW', -- 통화
|
||||
lead_time_days INTEGER, -- 리드타임 (일)
|
||||
minimum_order_qty DECIMAL(10,3), -- 최소 주문 수량
|
||||
standard_packaging_qty DECIMAL(10,3), -- 표준 포장 수량
|
||||
|
||||
-- 가용성 정보
|
||||
availability_status VARCHAR(50), -- 재고 상태
|
||||
last_price_update DATE, -- 마지막 가격 업데이트
|
||||
|
||||
-- 추가 정보
|
||||
datasheet_url VARCHAR(500), -- 데이터시트 URL
|
||||
catalog_page VARCHAR(100), -- 카탈로그 페이지
|
||||
notes TEXT,
|
||||
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 유니크 제약 (같은 규격의 같은 제조사 제품은 하나만)
|
||||
UNIQUE(specification_id, manufacturer_id, manufacturer_part_number)
|
||||
);
|
||||
|
||||
-- 5. BOM에서 사용되는 Tubing 매핑 테이블
|
||||
CREATE TABLE IF NOT EXISTS material_tubing_mapping (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
|
||||
tubing_product_id INTEGER REFERENCES tubing_products(id),
|
||||
|
||||
-- 매핑 정보
|
||||
confidence_score DECIMAL(3,2), -- 매핑 신뢰도 (0.00-1.00)
|
||||
mapping_method VARCHAR(50), -- 매핑 방법 (auto/manual)
|
||||
mapped_by VARCHAR(100), -- 매핑한 사용자
|
||||
mapped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 수량 정보
|
||||
required_length_m DECIMAL(10,3), -- 필요 길이 (m)
|
||||
calculated_quantity DECIMAL(10,3), -- 계산된 주문 수량
|
||||
|
||||
-- 검증 정보
|
||||
is_verified BOOLEAN DEFAULT FALSE,
|
||||
verified_by VARCHAR(100),
|
||||
verified_at TIMESTAMP,
|
||||
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ================================
|
||||
-- 인덱스 생성
|
||||
-- ================================
|
||||
|
||||
-- Tubing 규격 관련 인덱스
|
||||
CREATE INDEX idx_tubing_specs_category ON tubing_specifications(category_id);
|
||||
CREATE INDEX idx_tubing_specs_material ON tubing_specifications(material_grade);
|
||||
CREATE INDEX idx_tubing_specs_diameter ON tubing_specifications(outer_diameter_mm, wall_thickness_mm);
|
||||
|
||||
-- 제품 관련 인덱스
|
||||
CREATE INDEX idx_tubing_products_spec ON tubing_products(specification_id);
|
||||
CREATE INDEX idx_tubing_products_manufacturer ON tubing_products(manufacturer_id);
|
||||
CREATE INDEX idx_tubing_products_part_number ON tubing_products(manufacturer_part_number);
|
||||
|
||||
-- 매핑 관련 인덱스
|
||||
CREATE INDEX idx_material_tubing_mapping_material ON material_tubing_mapping(material_id);
|
||||
CREATE INDEX idx_material_tubing_mapping_product ON material_tubing_mapping(tubing_product_id);
|
||||
|
||||
-- ================================
|
||||
-- 기초 데이터 입력
|
||||
-- ================================
|
||||
|
||||
-- Tubing 카테고리 기초 데이터
|
||||
INSERT INTO tubing_categories (category_code, category_name, description) VALUES
|
||||
('GENERAL', '일반 Tubing', '일반적인 스테인리스 스틸 튜빙'),
|
||||
('VCR', 'VCR Tubing', 'VCR (Vacuum Coupling Radiation) 연결용 튜빙'),
|
||||
('SANITARY', 'Sanitary Tubing', '위생용 튜빙 (식품, 제약 등)'),
|
||||
('HVAC', 'HVAC Tubing', '공조용 튜빙'),
|
||||
('HYDRAULIC', 'Hydraulic Tubing', '유압용 튜빙'),
|
||||
('PNEUMATIC', 'Pneumatic Tubing', '공압용 튜빙'),
|
||||
('PROCESS', 'Process Tubing', '공정용 특수 튜빙'),
|
||||
('EXOTIC', 'Exotic Material', '특수 재질 튜빙 (Hastelloy, Inconel 등)')
|
||||
ON CONFLICT (category_code) DO NOTHING;
|
||||
|
||||
-- 주요 제조사 기초 데이터
|
||||
INSERT INTO tubing_manufacturers (manufacturer_code, manufacturer_name, country) VALUES
|
||||
('SWAGELOK', 'Swagelok Company', 'USA'),
|
||||
('PARKER', 'Parker Hannifin', 'USA'),
|
||||
('HAM_LET', 'Ham-Let Group', 'Israel'),
|
||||
('SUPERLOK', 'Superlok USA', 'USA'),
|
||||
('FITOK', 'Fitok Group', 'China'),
|
||||
('DK_LOK', 'DK-Lok Corporation', 'South Korea'),
|
||||
('GYROLOK', 'Gyrolok (Oliver Valves)', 'UK'),
|
||||
('AS_ONE', 'AS ONE Corporation', 'Japan')
|
||||
ON CONFLICT (manufacturer_code) DO NOTHING;
|
||||
|
||||
-- 기본 스테인리스 스틸 튜빙 규격 예시
|
||||
INSERT INTO tubing_specifications (
|
||||
category_id, spec_code, spec_name,
|
||||
outer_diameter_mm, wall_thickness_mm, inner_diameter_mm,
|
||||
material_grade, material_standard,
|
||||
max_pressure_bar, max_temperature_c, min_temperature_c,
|
||||
standard_length_m
|
||||
) VALUES
|
||||
(1, 'SS316-6MM-1MM', '6mm OD x 1mm WT SS316 Tubing', 6.0, 1.0, 4.0, 'SS316', 'ASTM A269', 413, 815, -196, 6.0),
|
||||
(1, 'SS316-8MM-1MM', '8mm OD x 1mm WT SS316 Tubing', 8.0, 1.0, 6.0, 'SS316', 'ASTM A269', 310, 815, -196, 6.0),
|
||||
(1, 'SS316-10MM-1MM', '10mm OD x 1mm WT SS316 Tubing', 10.0, 1.0, 8.0, 'SS316', 'ASTM A269', 248, 815, -196, 6.0),
|
||||
(1, 'SS316-12MM-1.5MM', '12mm OD x 1.5mm WT SS316 Tubing', 12.0, 1.5, 9.0, 'SS316', 'ASTM A269', 310, 815, -196, 6.0),
|
||||
(1, 'SS316L-6MM-1MM', '6mm OD x 1mm WT SS316L Tubing', 6.0, 1.0, 4.0, 'SS316L', 'ASTM A269', 413, 815, -196, 6.0)
|
||||
ON CONFLICT (spec_code) DO NOTHING;
|
||||
163
backend/scripts/16_performance_indexes.sql
Normal file
163
backend/scripts/16_performance_indexes.sql
Normal file
@@ -0,0 +1,163 @@
|
||||
-- ================================
|
||||
-- 성능 최적화를 위한 추가 인덱스
|
||||
-- 생성일: 2025.01 (Phase 2)
|
||||
-- ================================
|
||||
|
||||
-- 1. 복합 인덱스 (자주 함께 사용되는 컬럼들)
|
||||
-- ================================
|
||||
|
||||
-- files 테이블: job_no + revision 조합 (리비전 비교 시 자주 사용)
|
||||
CREATE INDEX IF NOT EXISTS idx_files_job_revision
|
||||
ON files(job_no, revision)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- files 테이블: job_no + upload_date (최신 파일 조회)
|
||||
CREATE INDEX IF NOT EXISTS idx_files_job_date
|
||||
ON files(job_no, upload_date DESC)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- materials 테이블: file_id + category (자재 분류별 조회)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_file_category
|
||||
ON materials(file_id, classified_category);
|
||||
|
||||
-- materials 테이블: category + material_grade (자재 종류별 재질 검색)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_category_grade
|
||||
ON materials(classified_category, material_grade);
|
||||
|
||||
-- 2. 검색 성능 향상 인덱스
|
||||
-- ================================
|
||||
|
||||
-- materials 테이블: description 텍스트 검색 (GIN 인덱스)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_description_gin
|
||||
ON materials USING gin(to_tsvector('english', original_description));
|
||||
|
||||
-- materials 테이블: 해시 기반 중복 검색
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_hash
|
||||
ON materials(material_hash)
|
||||
WHERE material_hash IS NOT NULL;
|
||||
|
||||
-- 3. 정렬 성능 향상 인덱스
|
||||
-- ================================
|
||||
|
||||
-- jobs 테이블: 상태별 생성일 정렬
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status_created
|
||||
ON jobs(status, created_at DESC)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- materials 테이블: 수량별 정렬 (대용량 자재 우선 표시)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_quantity_desc
|
||||
ON materials(quantity DESC);
|
||||
|
||||
-- 4. 조건부 인덱스 (특정 조건에서만 사용)
|
||||
-- ================================
|
||||
|
||||
-- 검증되지 않은 자재만 (분류 검토 필요한 항목)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_unverified
|
||||
ON materials(classified_category, classification_confidence)
|
||||
WHERE is_verified = false;
|
||||
|
||||
-- 신뢰도가 낮은 분류 (0.8 미만)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_low_confidence
|
||||
ON materials(file_id, classified_category)
|
||||
WHERE classification_confidence < 0.8;
|
||||
|
||||
-- 5. 외래키 성능 향상
|
||||
-- ================================
|
||||
|
||||
-- pipe_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_pipe_details_material
|
||||
ON pipe_details(material_id);
|
||||
|
||||
-- fitting_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_fitting_details_material
|
||||
ON fitting_details(material_id);
|
||||
|
||||
-- valve_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_valve_details_material
|
||||
ON valve_details(material_id);
|
||||
|
||||
-- flange_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_flange_details_material
|
||||
ON flange_details(material_id);
|
||||
|
||||
-- bolt_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_bolt_details_material
|
||||
ON bolt_details(material_id);
|
||||
|
||||
-- gasket_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_gasket_details_material
|
||||
ON gasket_details(material_id);
|
||||
|
||||
-- instrument_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_instrument_details_material
|
||||
ON instrument_details(material_id);
|
||||
|
||||
-- 6. 통계 및 집계 성능 향상
|
||||
-- ================================
|
||||
|
||||
-- 프로젝트별 자재 통계 (job_no 기준)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_job_stats
|
||||
ON materials(
|
||||
(SELECT job_no FROM files WHERE files.id = materials.file_id),
|
||||
classified_category
|
||||
);
|
||||
|
||||
-- 파이프 길이 집계용 (파이프 cutting 계산)
|
||||
CREATE INDEX IF NOT EXISTS idx_pipe_length_aggregation
|
||||
ON pipe_details(material_id, length_mm)
|
||||
WHERE length_mm > 0;
|
||||
|
||||
-- 7. 성능 모니터링을 위한 뷰 생성
|
||||
-- ================================
|
||||
|
||||
-- 인덱스 사용률 모니터링 뷰
|
||||
CREATE OR REPLACE VIEW index_usage_stats AS
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
indexname,
|
||||
idx_tup_read,
|
||||
idx_tup_fetch,
|
||||
idx_scan,
|
||||
CASE
|
||||
WHEN idx_scan = 0 THEN 'UNUSED'
|
||||
WHEN idx_scan < 10 THEN 'LOW_USAGE'
|
||||
WHEN idx_scan < 100 THEN 'MEDIUM_USAGE'
|
||||
ELSE 'HIGH_USAGE'
|
||||
END as usage_level
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY idx_scan DESC;
|
||||
|
||||
-- 테이블 크기 및 성능 모니터링 뷰
|
||||
CREATE OR REPLACE VIEW table_performance_stats AS
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
n_tup_ins as inserts,
|
||||
n_tup_upd as updates,
|
||||
n_tup_del as deletes,
|
||||
seq_scan as sequential_scans,
|
||||
seq_tup_read as sequential_reads,
|
||||
idx_scan as index_scans,
|
||||
idx_tup_fetch as index_reads,
|
||||
CASE
|
||||
WHEN seq_scan + idx_scan = 0 THEN 0
|
||||
ELSE ROUND((idx_scan::numeric / (seq_scan + idx_scan)) * 100, 2)
|
||||
END as index_usage_percentage
|
||||
FROM pg_stat_user_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY seq_scan + idx_scan DESC;
|
||||
|
||||
-- ================================
|
||||
-- 인덱스 생성 완료 로그
|
||||
-- ================================
|
||||
|
||||
-- 성능 최적화 인덱스 생성 완료 확인
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '성능 최적화 인덱스 생성 완료 - Phase 2 (2025.01)';
|
||||
RAISE NOTICE '총 생성된 인덱스: 복합 인덱스 4개, 검색 인덱스 2개, 정렬 인덱스 2개';
|
||||
RAISE NOTICE '조건부 인덱스 2개, 외래키 인덱스 7개, 집계 인덱스 2개';
|
||||
RAISE NOTICE '모니터링 뷰 2개 생성';
|
||||
END $$;
|
||||
29
backend/scripts/17_add_project_type_column.sql
Normal file
29
backend/scripts/17_add_project_type_column.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- jobs 테이블에 project_type 컬럼 추가
|
||||
-- TK-MP-Project 프로젝트 유형 관리를 위한 스키마 업데이트
|
||||
|
||||
-- project_type 컬럼 추가 (기존 데이터가 있을 수 있으므로 안전하게 추가)
|
||||
DO $$
|
||||
BEGIN
|
||||
-- project_type 컬럼이 존재하지 않으면 추가
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'jobs'
|
||||
AND column_name = 'project_type'
|
||||
) THEN
|
||||
ALTER TABLE jobs ADD COLUMN project_type VARCHAR(50) DEFAULT '냉동기';
|
||||
|
||||
-- 기존 데이터에 대한 기본값 설정
|
||||
UPDATE jobs SET project_type = '냉동기' WHERE project_type IS NULL;
|
||||
|
||||
-- NOT NULL 제약 조건 추가
|
||||
ALTER TABLE jobs ALTER COLUMN project_type SET NOT NULL;
|
||||
|
||||
-- 인덱스 추가 (프로젝트 유형별 조회 성능 향상)
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_project_type ON jobs(project_type);
|
||||
|
||||
RAISE NOTICE 'project_type 컬럼이 성공적으로 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'project_type 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
220
backend/scripts/18_create_auth_tables.sql
Normal file
220
backend/scripts/18_create_auth_tables.sql
Normal file
@@ -0,0 +1,220 @@
|
||||
-- TK-MP-Project 인증 시스템을 위한 사용자 및 로그인 테이블 생성
|
||||
-- TK-FB-Project 인증 시스템을 참고하여 구현
|
||||
|
||||
-- 1. 사용자 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
|
||||
-- 권한 관리
|
||||
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'system', 'leader', 'support', 'user')),
|
||||
access_level VARCHAR(20) DEFAULT 'worker' CHECK (access_level IN ('admin', 'system', 'group_leader', 'support_team', 'worker')),
|
||||
|
||||
-- 계정 상태 관리
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
failed_login_attempts INT DEFAULT 0,
|
||||
locked_until TIMESTAMP NULL,
|
||||
|
||||
-- 추가 정보
|
||||
department VARCHAR(50),
|
||||
position VARCHAR(50),
|
||||
phone VARCHAR(20),
|
||||
|
||||
-- 타임스탬프
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
-- 2. 로그인 이력 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS login_logs (
|
||||
log_id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
login_status VARCHAR(20) CHECK (login_status IN ('success', 'failed')),
|
||||
failure_reason VARCHAR(100),
|
||||
session_duration INT, -- 세션 지속 시간 (초)
|
||||
|
||||
-- 인덱스를 위한 컬럼
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 3. 사용자 세션 테이블 (JWT Refresh Token 관리)
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
session_id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
refresh_token VARCHAR(500) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 4. 권한 테이블 (확장 가능한 권한 시스템)
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
permission_id SERIAL PRIMARY KEY,
|
||||
permission_name VARCHAR(50) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
module VARCHAR(30), -- 모듈별 권한 관리 (bom, project, purchase 등)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 5. 역할-권한 매핑 테이블
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_permission_id SERIAL PRIMARY KEY,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
permission_id INT REFERENCES permissions(permission_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(role, permission_id)
|
||||
);
|
||||
|
||||
-- 6. 인덱스 생성 (성능 최적화)
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_login_logs_user_id ON login_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_logs_login_time ON login_logs(login_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_logs_ip_address ON login_logs(ip_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_logs_status ON login_logs(login_status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_refresh_token ON user_sessions(refresh_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_is_active ON user_sessions(is_active);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role);
|
||||
|
||||
-- 7. 기본 권한 데이터 삽입
|
||||
INSERT INTO permissions (permission_name, description, module) VALUES
|
||||
-- BOM 관리 권한
|
||||
('bom.view', 'BOM 조회 권한', 'bom'),
|
||||
('bom.create', 'BOM 생성 권한', 'bom'),
|
||||
('bom.edit', 'BOM 수정 권한', 'bom'),
|
||||
('bom.delete', 'BOM 삭제 권한', 'bom'),
|
||||
('bom.approve', 'BOM 승인 권한', 'bom'),
|
||||
|
||||
-- 프로젝트 관리 권한
|
||||
('project.view', '프로젝트 조회 권한', 'project'),
|
||||
('project.create', '프로젝트 생성 권한', 'project'),
|
||||
('project.edit', '프로젝트 수정 권한', 'project'),
|
||||
('project.delete', '프로젝트 삭제 권한', 'project'),
|
||||
('project.manage', '프로젝트 관리 권한', 'project'),
|
||||
|
||||
-- 파일 관리 권한
|
||||
('file.upload', '파일 업로드 권한', 'file'),
|
||||
('file.download', '파일 다운로드 권한', 'file'),
|
||||
('file.delete', '파일 삭제 권한', 'file'),
|
||||
|
||||
-- 사용자 관리 권한
|
||||
('user.view', '사용자 조회 권한', 'user'),
|
||||
('user.create', '사용자 생성 권한', 'user'),
|
||||
('user.edit', '사용자 수정 권한', 'user'),
|
||||
('user.delete', '사용자 삭제 권한', 'user'),
|
||||
|
||||
-- 시스템 관리 권한
|
||||
('system.admin', '시스템 관리 권한', 'system'),
|
||||
('system.logs', '로그 조회 권한', 'system'),
|
||||
('system.settings', '시스템 설정 권한', 'system')
|
||||
|
||||
ON CONFLICT (permission_name) DO NOTHING;
|
||||
|
||||
-- 8. 역할별 기본 권한 할당
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'admin', permission_id FROM permissions
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'system', permission_id FROM permissions
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'leader', permission_id FROM permissions
|
||||
WHERE permission_name IN (
|
||||
'bom.view', 'bom.create', 'bom.edit', 'bom.approve',
|
||||
'project.view', 'project.create', 'project.edit', 'project.manage',
|
||||
'file.upload', 'file.download', 'file.delete',
|
||||
'user.view'
|
||||
)
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'support', permission_id FROM permissions
|
||||
WHERE permission_name IN (
|
||||
'bom.view', 'bom.create', 'bom.edit',
|
||||
'project.view', 'project.create', 'project.edit',
|
||||
'file.upload', 'file.download'
|
||||
)
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'user', permission_id FROM permissions
|
||||
WHERE permission_name IN (
|
||||
'bom.view',
|
||||
'project.view',
|
||||
'file.upload', 'file.download'
|
||||
)
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
-- 9. 기본 관리자 계정 생성 (비밀번호: admin123)
|
||||
-- bcrypt 해시: $2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
|
||||
INSERT INTO users (username, password, name, email, role, access_level, department, position) VALUES
|
||||
('admin', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '시스템 관리자', 'admin@tkmp.com', 'admin', 'admin', 'IT', '시스템 관리자'),
|
||||
('system', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '시스템 계정', 'system@tkmp.com', 'system', 'system', 'IT', '시스템 계정')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- 10. 트리거 함수 생성 (updated_at 자동 업데이트)
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 11. 트리거 적용
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_user_sessions_updated_at BEFORE UPDATE ON user_sessions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- 12. 뷰 생성 (사용자 정보 조회용)
|
||||
CREATE OR REPLACE VIEW user_info_view AS
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.name,
|
||||
u.email,
|
||||
u.role,
|
||||
u.access_level,
|
||||
u.department,
|
||||
u.position,
|
||||
u.is_active,
|
||||
u.created_at,
|
||||
u.last_login_at,
|
||||
COUNT(ll.log_id) as login_count,
|
||||
MAX(ll.login_time) as last_successful_login
|
||||
FROM users u
|
||||
LEFT JOIN login_logs ll ON u.user_id = ll.user_id AND ll.login_status = 'success'
|
||||
GROUP BY u.user_id, u.username, u.name, u.email, u.role, u.access_level,
|
||||
u.department, u.position, u.is_active, u.created_at, u.last_login_at;
|
||||
|
||||
-- 완료 메시지
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ TK-MP-Project 인증 시스템 데이터베이스 스키마가 성공적으로 생성되었습니다!';
|
||||
RAISE NOTICE '📋 생성된 테이블: users, login_logs, user_sessions, permissions, role_permissions';
|
||||
RAISE NOTICE '👤 기본 계정: admin/admin123, system/admin123';
|
||||
RAISE NOTICE '🔐 권한 시스템: 5단계 역할 + 모듈별 세분화된 권한';
|
||||
END $$;
|
||||
4
backend/tests/__init__.py
Normal file
4
backend/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
테스트 모듈
|
||||
자동화된 테스트 케이스들
|
||||
"""
|
||||
160
backend/tests/conftest.py
Normal file
160
backend/tests/conftest.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
pytest 설정 및 공통 픽스처
|
||||
"""
|
||||
import pytest
|
||||
import asyncio
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from fastapi.testclient import TestClient
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
from app.models import Base
|
||||
|
||||
|
||||
# 테스트용 데이터베이스 설정
|
||||
TEST_DATABASE_URL = "sqlite:///./test.db"
|
||||
|
||||
engine = create_engine(
|
||||
TEST_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def override_get_db():
|
||||
"""테스트용 데이터베이스 세션"""
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""이벤트 루프 픽스처"""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db_session():
|
||||
"""테스트용 데이터베이스 세션 픽스처"""
|
||||
# 테이블 생성
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 세션 생성
|
||||
session = TestingSessionLocal()
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
# 테이블 삭제 (테스트 격리)
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db_session):
|
||||
"""테스트 클라이언트 픽스처"""
|
||||
# 데이터베이스 의존성 오버라이드
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
# 의존성 오버라이드 정리
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_excel_file():
|
||||
"""샘플 엑셀 파일 픽스처"""
|
||||
import pandas as pd
|
||||
|
||||
# 샘플 데이터 생성
|
||||
data = {
|
||||
'Description': [
|
||||
'PIPE, SEAMLESS, A333-6, 6", SCH40',
|
||||
'ELBOW, 90DEG, A234-WPB, 4", SCH40',
|
||||
'VALVE, GATE, A216-WCB, 2", 150LB',
|
||||
'FLANGE, WELD NECK, A105, 3", 150LB',
|
||||
'BOLT, HEX HEAD, A193-B7, M16X50'
|
||||
],
|
||||
'Quantity': [10, 8, 2, 4, 20],
|
||||
'Unit': ['EA', 'EA', 'EA', 'EA', 'EA']
|
||||
}
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# 임시 파일 생성
|
||||
with tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) as tmp_file:
|
||||
df.to_excel(tmp_file.name, index=False)
|
||||
yield tmp_file.name
|
||||
|
||||
# 파일 정리
|
||||
os.unlink(tmp_file.name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csv_file():
|
||||
"""샘플 CSV 파일 픽스처"""
|
||||
import pandas as pd
|
||||
|
||||
# 샘플 데이터 생성
|
||||
data = {
|
||||
'Description': [
|
||||
'PIPE, SEAMLESS, A333-6, 8", SCH40',
|
||||
'TEE, EQUAL, A234-WPB, 6", SCH40',
|
||||
'VALVE, BALL, A216-WCB, 4", 150LB'
|
||||
],
|
||||
'Quantity': [5, 3, 1],
|
||||
'Unit': ['EA', 'EA', 'EA']
|
||||
}
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# 임시 파일 생성
|
||||
with tempfile.NamedTemporaryFile(suffix='.csv', delete=False, mode='w') as tmp_file:
|
||||
df.to_csv(tmp_file.name, index=False)
|
||||
yield tmp_file.name
|
||||
|
||||
# 파일 정리
|
||||
os.unlink(tmp_file.name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_job_data():
|
||||
"""샘플 작업 데이터 픽스처"""
|
||||
return {
|
||||
"job_no": "TEST-2025-001",
|
||||
"job_name": "테스트 프로젝트",
|
||||
"client_name": "테스트 고객사",
|
||||
"end_user": "테스트 사용자",
|
||||
"epc_company": "테스트 EPC",
|
||||
"status": "active"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_material_data():
|
||||
"""샘플 자재 데이터 픽스처"""
|
||||
return {
|
||||
"original_description": "PIPE, SEAMLESS, A333-6, 6\", SCH40",
|
||||
"classified_category": "PIPE",
|
||||
"classified_subcategory": "SEAMLESS",
|
||||
"material_grade": "A333-6",
|
||||
"schedule": "SCH40",
|
||||
"size_spec": "6\"",
|
||||
"quantity": 10.0,
|
||||
"unit": "EA",
|
||||
"classification_confidence": 0.95
|
||||
}
|
||||
|
||||
|
||||
# 테스트 설정
|
||||
pytest_plugins = []
|
||||
423
backend/tests/test_classifiers.py
Normal file
423
backend/tests/test_classifiers.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
자재 분류기 테스트
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestPipeClassifier:
|
||||
"""파이프 분류기 테스트"""
|
||||
|
||||
def test_pipe_classification_basic(self):
|
||||
"""기본 파이프 분류 테스트"""
|
||||
from app.services.pipe_classifier import classify_pipe
|
||||
|
||||
# 명확한 파이프 케이스
|
||||
result = classify_pipe("", "PIPE, SEAMLESS, A333-6, 6\", SCH40", "6\"", 1000)
|
||||
|
||||
assert result["category"] == "PIPE"
|
||||
assert result["confidence"] > 0.8
|
||||
assert result["material_grade"] == "A333-6"
|
||||
assert result["schedule"] == "SCH40"
|
||||
assert result["size"] == "6\""
|
||||
|
||||
def test_pipe_classification_welded(self):
|
||||
"""용접 파이프 분류 테스트"""
|
||||
from app.services.pipe_classifier import classify_pipe
|
||||
|
||||
result = classify_pipe("", "PIPE, WELDED, A53-B, 4\", SCH40", "4\"", 500)
|
||||
|
||||
assert result["category"] == "PIPE"
|
||||
assert result["subcategory"] == "WELDED"
|
||||
assert result["material_grade"] == "A53-B"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_pipe_classification_low_confidence(self):
|
||||
"""낮은 신뢰도 파이프 분류 테스트"""
|
||||
from app.services.pipe_classifier import classify_pipe
|
||||
|
||||
# 모호한 설명
|
||||
result = classify_pipe("", "STEEL TUBE, 2 INCH", "2\"", 100)
|
||||
|
||||
# 파이프로 분류되지만 신뢰도가 낮아야 함
|
||||
assert result["confidence"] < 0.7
|
||||
|
||||
def test_pipe_length_calculation(self):
|
||||
"""파이프 길이 계산 테스트"""
|
||||
from app.services.pipe_classifier import classify_pipe
|
||||
|
||||
result = classify_pipe("", "PIPE, SEAMLESS, A333-6, 6\", SCH40", "6\"", 6000)
|
||||
|
||||
assert "length_mm" in result
|
||||
assert result["length_mm"] == 6000
|
||||
assert "purchase_length" in result
|
||||
|
||||
|
||||
class TestFittingClassifier:
|
||||
"""피팅 분류기 테스트"""
|
||||
|
||||
def test_elbow_classification(self):
|
||||
"""엘보우 분류 테스트"""
|
||||
from app.services.fitting_classifier import classify_fitting
|
||||
|
||||
result = classify_fitting("", "ELBOW, 90DEG, A234-WPB, 4\", SCH40")
|
||||
|
||||
assert result["category"] == "FITTING"
|
||||
assert result["subcategory"] == "ELBOW"
|
||||
assert result["angle"] == "90DEG"
|
||||
assert result["material_grade"] == "A234-WPB"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_tee_classification(self):
|
||||
"""티 분류 테스트"""
|
||||
from app.services.fitting_classifier import classify_fitting
|
||||
|
||||
result = classify_fitting("", "TEE, EQUAL, A234-WPB, 6\", SCH40")
|
||||
|
||||
assert result["category"] == "FITTING"
|
||||
assert result["subcategory"] == "TEE"
|
||||
assert result["fitting_type"] == "EQUAL"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_reducer_classification(self):
|
||||
"""리듀서 분류 테스트"""
|
||||
from app.services.fitting_classifier import classify_fitting
|
||||
|
||||
result = classify_fitting("", "REDUCER, CONCENTRIC, A234-WPB, 8\"X6\", SCH40")
|
||||
|
||||
assert result["category"] == "FITTING"
|
||||
assert result["subcategory"] == "REDUCER"
|
||||
assert result["fitting_type"] == "CONCENTRIC"
|
||||
assert "8\"X6\"" in result["size"]
|
||||
|
||||
|
||||
class TestValveClassifier:
|
||||
"""밸브 분류기 테스트"""
|
||||
|
||||
def test_gate_valve_classification(self):
|
||||
"""게이트 밸브 분류 테스트"""
|
||||
from app.services.valve_classifier import classify_valve
|
||||
|
||||
result = classify_valve("", "VALVE, GATE, A216-WCB, 2\", 150LB")
|
||||
|
||||
assert result["category"] == "VALVE"
|
||||
assert result["subcategory"] == "GATE"
|
||||
assert result["material_grade"] == "A216-WCB"
|
||||
assert result["pressure_rating"] == "150LB"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_ball_valve_classification(self):
|
||||
"""볼 밸브 분류 테스트"""
|
||||
from app.services.valve_classifier import classify_valve
|
||||
|
||||
result = classify_valve("", "VALVE, BALL, A216-WCB, 4\", 300LB")
|
||||
|
||||
assert result["category"] == "VALVE"
|
||||
assert result["subcategory"] == "BALL"
|
||||
assert result["pressure_rating"] == "300LB"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_check_valve_classification(self):
|
||||
"""체크 밸브 분류 테스트"""
|
||||
from app.services.valve_classifier import classify_valve
|
||||
|
||||
result = classify_valve("", "VALVE, CHECK, SWING, A216-WCB, 3\", 150LB")
|
||||
|
||||
assert result["category"] == "VALVE"
|
||||
assert result["subcategory"] == "CHECK"
|
||||
assert result["valve_type"] == "SWING"
|
||||
|
||||
|
||||
class TestFlangeClassifier:
|
||||
"""플랜지 분류기 테스트"""
|
||||
|
||||
def test_weld_neck_flange_classification(self):
|
||||
"""용접목 플랜지 분류 테스트"""
|
||||
from app.services.flange_classifier import classify_flange
|
||||
|
||||
result = classify_flange("", "FLANGE, WELD NECK, A105, 3\", 150LB")
|
||||
|
||||
assert result["category"] == "FLANGE"
|
||||
assert result["subcategory"] == "WELD NECK"
|
||||
assert result["material_grade"] == "A105"
|
||||
assert result["pressure_rating"] == "150LB"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_slip_on_flange_classification(self):
|
||||
"""슬립온 플랜지 분류 테스트"""
|
||||
from app.services.flange_classifier import classify_flange
|
||||
|
||||
result = classify_flange("", "FLANGE, SLIP ON, A105, 4\", 300LB")
|
||||
|
||||
assert result["category"] == "FLANGE"
|
||||
assert result["subcategory"] == "SLIP ON"
|
||||
assert result["pressure_rating"] == "300LB"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_blind_flange_classification(self):
|
||||
"""블라인드 플랜지 분류 테스트"""
|
||||
from app.services.flange_classifier import classify_flange
|
||||
|
||||
result = classify_flange("", "FLANGE, BLIND, A105, 6\", 150LB")
|
||||
|
||||
assert result["category"] == "FLANGE"
|
||||
assert result["subcategory"] == "BLIND"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
|
||||
class TestBoltClassifier:
|
||||
"""볼트 분류기 테스트"""
|
||||
|
||||
def test_hex_bolt_classification(self):
|
||||
"""육각 볼트 분류 테스트"""
|
||||
from app.services.bolt_classifier import classify_bolt
|
||||
|
||||
result = classify_bolt("", "BOLT, HEX HEAD, A193-B7, M16X50")
|
||||
|
||||
assert result["category"] == "BOLT"
|
||||
assert result["subcategory"] == "HEX HEAD"
|
||||
assert result["material_grade"] == "A193-B7"
|
||||
assert result["size"] == "M16X50"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_stud_bolt_classification(self):
|
||||
"""스터드 볼트 분류 테스트"""
|
||||
from app.services.bolt_classifier import classify_bolt
|
||||
|
||||
result = classify_bolt("", "STUD BOLT, A193-B7, M20X80")
|
||||
|
||||
assert result["category"] == "BOLT"
|
||||
assert result["subcategory"] == "STUD"
|
||||
assert result["material_grade"] == "A193-B7"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_nut_classification(self):
|
||||
"""너트 분류 테스트"""
|
||||
from app.services.bolt_classifier import classify_bolt
|
||||
|
||||
result = classify_bolt("", "NUT, HEX, A194-2H, M16")
|
||||
|
||||
assert result["category"] == "BOLT"
|
||||
assert result["subcategory"] == "NUT"
|
||||
assert result["material_grade"] == "A194-2H"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
|
||||
class TestGasketClassifier:
|
||||
"""가스켓 분류기 테스트"""
|
||||
|
||||
def test_spiral_wound_gasket_classification(self):
|
||||
"""스파이럴 와운드 가스켓 분류 테스트"""
|
||||
from app.services.gasket_classifier import classify_gasket
|
||||
|
||||
result = classify_gasket("", "GASKET, SPIRAL WOUND, SS316+GRAPHITE, 4\", 150LB")
|
||||
|
||||
assert result["category"] == "GASKET"
|
||||
assert result["subcategory"] == "SPIRAL WOUND"
|
||||
assert "SS316" in result["material_grade"]
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_rtj_gasket_classification(self):
|
||||
"""RTJ 가스켓 분류 테스트"""
|
||||
from app.services.gasket_classifier import classify_gasket
|
||||
|
||||
result = classify_gasket("", "GASKET, RTJ, SS316, 6\", 300LB")
|
||||
|
||||
assert result["category"] == "GASKET"
|
||||
assert result["subcategory"] == "RTJ"
|
||||
assert result["material_grade"] == "SS316"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_flat_gasket_classification(self):
|
||||
"""플랫 가스켓 분류 테스트"""
|
||||
from app.services.gasket_classifier import classify_gasket
|
||||
|
||||
result = classify_gasket("", "GASKET, FLAT, RUBBER, 2\", 150LB")
|
||||
|
||||
assert result["category"] == "GASKET"
|
||||
assert result["subcategory"] == "FLAT"
|
||||
assert result["material_grade"] == "RUBBER"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
|
||||
class TestInstrumentClassifier:
|
||||
"""계기 분류기 테스트"""
|
||||
|
||||
def test_pressure_gauge_classification(self):
|
||||
"""압력계 분류 테스트"""
|
||||
from app.services.instrument_classifier import classify_instrument
|
||||
|
||||
result = classify_instrument("", "PRESSURE GAUGE, 0-10 BAR, 1/2\" NPT")
|
||||
|
||||
assert result["category"] == "INSTRUMENT"
|
||||
assert result["subcategory"] == "PRESSURE GAUGE"
|
||||
assert "0-10 BAR" in result["range"]
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_temperature_gauge_classification(self):
|
||||
"""온도계 분류 테스트"""
|
||||
from app.services.instrument_classifier import classify_instrument
|
||||
|
||||
result = classify_instrument("", "TEMPERATURE GAUGE, 0-200°C, 1/2\" NPT")
|
||||
|
||||
assert result["category"] == "INSTRUMENT"
|
||||
assert result["subcategory"] == "TEMPERATURE GAUGE"
|
||||
assert "0-200°C" in result["range"]
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_flow_meter_classification(self):
|
||||
"""유량계 분류 테스트"""
|
||||
from app.services.instrument_classifier import classify_instrument
|
||||
|
||||
result = classify_instrument("", "FLOW METER, ORIFICE, 4\", 150LB")
|
||||
|
||||
assert result["category"] == "INSTRUMENT"
|
||||
assert result["subcategory"] == "FLOW METER"
|
||||
assert result["instrument_type"] == "ORIFICE"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
|
||||
class TestIntegratedClassifier:
|
||||
"""통합 분류기 테스트"""
|
||||
|
||||
def test_integrated_classification_pipe(self):
|
||||
"""통합 분류기 파이프 테스트"""
|
||||
from app.services.integrated_classifier import classify_material_integrated
|
||||
|
||||
result = classify_material_integrated("PIPE, SEAMLESS, A333-6, 6\", SCH40")
|
||||
|
||||
assert result["category"] == "PIPE"
|
||||
assert result["confidence"] > 0.8
|
||||
assert "classification_details" in result
|
||||
|
||||
def test_integrated_classification_valve(self):
|
||||
"""통합 분류기 밸브 테스트"""
|
||||
from app.services.integrated_classifier import classify_material_integrated
|
||||
|
||||
result = classify_material_integrated("VALVE, GATE, A216-WCB, 2\", 150LB")
|
||||
|
||||
assert result["category"] == "VALVE"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
def test_exclusion_logic(self):
|
||||
"""제외 로직 테스트"""
|
||||
from app.services.integrated_classifier import should_exclude_material
|
||||
|
||||
# 제외되어야 하는 항목들
|
||||
assert should_exclude_material("INSULATION, MINERAL WOOL") is True
|
||||
assert should_exclude_material("PAINT, PRIMER") is True
|
||||
assert should_exclude_material("SUPPORT, STRUCTURAL") is True
|
||||
|
||||
# 제외되지 않아야 하는 항목들
|
||||
assert should_exclude_material("PIPE, SEAMLESS, A333-6") is False
|
||||
assert should_exclude_material("VALVE, GATE, A216-WCB") is False
|
||||
|
||||
def test_confidence_threshold(self):
|
||||
"""신뢰도 임계값 테스트"""
|
||||
from app.services.integrated_classifier import classify_material_integrated
|
||||
|
||||
# 모호한 설명으로 낮은 신뢰도 테스트
|
||||
result = classify_material_integrated("STEEL ITEM, UNKNOWN TYPE")
|
||||
|
||||
# 신뢰도가 낮아야 함
|
||||
assert result["confidence"] < 0.5
|
||||
assert result["category"] in ["UNKNOWN", "EXCLUDE"]
|
||||
|
||||
|
||||
class TestClassificationCaching:
|
||||
"""분류 결과 캐싱 테스트"""
|
||||
|
||||
@patch('app.services.integrated_classifier.tkmp_cache')
|
||||
def test_classification_cache_hit(self, mock_cache):
|
||||
"""분류 결과 캐시 히트 테스트"""
|
||||
from app.services.integrated_classifier import classify_material_integrated
|
||||
|
||||
# 캐시에서 결과 반환 설정
|
||||
cached_result = {
|
||||
"category": "PIPE",
|
||||
"confidence": 0.95,
|
||||
"cached": True
|
||||
}
|
||||
mock_cache.get_classification_result.return_value = cached_result
|
||||
|
||||
result = classify_material_integrated("PIPE, SEAMLESS, A333-6, 6\", SCH40")
|
||||
|
||||
assert result == cached_result
|
||||
mock_cache.get_classification_result.assert_called_once()
|
||||
|
||||
@patch('app.services.integrated_classifier.tkmp_cache')
|
||||
def test_classification_cache_miss(self, mock_cache):
|
||||
"""분류 결과 캐시 미스 테스트"""
|
||||
from app.services.integrated_classifier import classify_material_integrated
|
||||
|
||||
# 캐시에서 None 반환 (캐시 미스)
|
||||
mock_cache.get_classification_result.return_value = None
|
||||
|
||||
result = classify_material_integrated("PIPE, SEAMLESS, A333-6, 6\", SCH40")
|
||||
|
||||
assert result["category"] == "PIPE"
|
||||
assert result["confidence"] > 0.8
|
||||
|
||||
# 캐시 저장 호출 확인
|
||||
mock_cache.set_classification_result.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
class TestClassificationPerformance:
|
||||
"""분류 성능 테스트"""
|
||||
|
||||
def test_classification_speed(self):
|
||||
"""분류 속도 테스트"""
|
||||
import time
|
||||
from app.services.integrated_classifier import classify_material_integrated
|
||||
|
||||
descriptions = [
|
||||
"PIPE, SEAMLESS, A333-6, 6\", SCH40",
|
||||
"VALVE, GATE, A216-WCB, 2\", 150LB",
|
||||
"FLANGE, WELD NECK, A105, 3\", 150LB",
|
||||
"ELBOW, 90DEG, A234-WPB, 4\", SCH40",
|
||||
"BOLT, HEX HEAD, A193-B7, M16X50"
|
||||
]
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
for desc in descriptions:
|
||||
result = classify_material_integrated(desc)
|
||||
assert result["category"] != "UNKNOWN"
|
||||
|
||||
end_time = time.time()
|
||||
total_time = end_time - start_time
|
||||
|
||||
# 5개 항목을 1초 이내에 분류해야 함
|
||||
assert total_time < 1.0
|
||||
|
||||
# 평균 분류 시간이 100ms 이하여야 함
|
||||
avg_time = total_time / len(descriptions)
|
||||
assert avg_time < 0.1
|
||||
|
||||
def test_batch_classification(self):
|
||||
"""배치 분류 테스트"""
|
||||
from app.services.integrated_classifier import classify_material_integrated
|
||||
|
||||
descriptions = [
|
||||
"PIPE, SEAMLESS, A333-6, 6\", SCH40",
|
||||
"VALVE, GATE, A216-WCB, 2\", 150LB",
|
||||
"FLANGE, WELD NECK, A105, 3\", 150LB"
|
||||
] * 10 # 30개 항목
|
||||
|
||||
results = []
|
||||
for desc in descriptions:
|
||||
result = classify_material_integrated(desc)
|
||||
results.append(result)
|
||||
|
||||
# 모든 결과가 올바르게 분류되었는지 확인
|
||||
assert len(results) == 30
|
||||
|
||||
# 각 타입별로 올바르게 분류되었는지 확인
|
||||
pipe_results = [r for r in results if r["category"] == "PIPE"]
|
||||
valve_results = [r for r in results if r["category"] == "VALVE"]
|
||||
flange_results = [r for r in results if r["category"] == "FLANGE"]
|
||||
|
||||
assert len(pipe_results) == 10
|
||||
assert len(valve_results) == 10
|
||||
assert len(flange_results) == 10
|
||||
267
backend/tests/test_file_management.py
Normal file
267
backend/tests/test_file_management.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
파일 관리 API 테스트
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestFileManagementAPI:
|
||||
"""파일 관리 API 테스트 클래스"""
|
||||
|
||||
def test_get_files_empty_list(self, client: TestClient):
|
||||
"""빈 파일 목록 조회 테스트"""
|
||||
response = client.get("/files")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["total_count"] == 0
|
||||
assert data["data"] == []
|
||||
assert data["cache_hit"] is False
|
||||
|
||||
def test_get_files_with_job_no(self, client: TestClient):
|
||||
"""특정 작업 번호로 파일 조회 테스트"""
|
||||
response = client.get("/files?job_no=TEST-001")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "data" in data
|
||||
assert "total_count" in data
|
||||
|
||||
def test_get_files_with_history(self, client: TestClient):
|
||||
"""이력 포함 파일 조회 테스트"""
|
||||
response = client.get("/files?show_history=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
@patch('app.api.file_management.tkmp_cache')
|
||||
def test_get_files_cache_hit(self, mock_cache, client: TestClient):
|
||||
"""캐시 히트 테스트"""
|
||||
# 캐시에서 데이터 반환 설정
|
||||
mock_cache.get_file_list.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"filename": "test.xlsx",
|
||||
"original_filename": "test.xlsx",
|
||||
"job_no": "TEST-001",
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
|
||||
response = client.get("/files?job_no=TEST-001")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["cache_hit"] is True
|
||||
assert len(data["data"]) == 1
|
||||
|
||||
# 캐시 호출 확인
|
||||
mock_cache.get_file_list.assert_called_once_with("TEST-001", False)
|
||||
|
||||
def test_get_files_no_cache(self, client: TestClient):
|
||||
"""캐시 사용 안 함 테스트"""
|
||||
response = client.get("/files?use_cache=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["cache_hit"] is False
|
||||
|
||||
def test_delete_file_not_found(self, client: TestClient):
|
||||
"""존재하지 않는 파일 삭제 테스트"""
|
||||
response = client.delete("/files/999")
|
||||
|
||||
# 파일이 없어도 에러 응답이 아닌 정상 응답 구조를 가져야 함
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# 에러 케이스이므로 error 키가 있을 수 있음
|
||||
assert "error" in data or "success" in data
|
||||
|
||||
@patch('app.api.file_management.tkmp_cache')
|
||||
def test_delete_file_cache_invalidation(self, mock_cache, client: TestClient, db_session):
|
||||
"""파일 삭제 시 캐시 무효화 테스트"""
|
||||
# 실제 파일 데이터가 없으므로 mock으로 처리
|
||||
with patch('app.api.file_management.db') as mock_db:
|
||||
mock_result = MagicMock()
|
||||
mock_result.fetchone.return_value = MagicMock(
|
||||
id=1,
|
||||
original_filename="test.xlsx",
|
||||
job_no="TEST-001"
|
||||
)
|
||||
mock_db.execute.return_value = mock_result
|
||||
|
||||
response = client.delete("/files/1")
|
||||
|
||||
# 캐시 무효화 호출 확인
|
||||
mock_cache.invalidate_file_cache.assert_called_once_with(1)
|
||||
mock_cache.invalidate_job_cache.assert_called_once_with("TEST-001")
|
||||
|
||||
|
||||
class TestFileValidation:
|
||||
"""파일 검증 테스트"""
|
||||
|
||||
def test_file_extension_validation(self):
|
||||
"""파일 확장자 검증 테스트"""
|
||||
from app.utils.file_validator import file_validator
|
||||
|
||||
# 허용된 확장자
|
||||
assert file_validator.validate_file_extension("test.xlsx") is True
|
||||
assert file_validator.validate_file_extension("test.xls") is True
|
||||
assert file_validator.validate_file_extension("test.csv") is True
|
||||
|
||||
# 허용되지 않은 확장자
|
||||
assert file_validator.validate_file_extension("test.txt") is False
|
||||
assert file_validator.validate_file_extension("test.pdf") is False
|
||||
assert file_validator.validate_file_extension("test.exe") is False
|
||||
|
||||
def test_file_size_validation(self):
|
||||
"""파일 크기 검증 테스트"""
|
||||
from app.utils.file_validator import file_validator
|
||||
|
||||
# 허용된 크기 (50MB 이하)
|
||||
assert file_validator.validate_file_size(1024) is True # 1KB
|
||||
assert file_validator.validate_file_size(10 * 1024 * 1024) is True # 10MB
|
||||
assert file_validator.validate_file_size(50 * 1024 * 1024) is True # 50MB
|
||||
|
||||
# 허용되지 않은 크기 (50MB 초과)
|
||||
assert file_validator.validate_file_size(51 * 1024 * 1024) is False # 51MB
|
||||
assert file_validator.validate_file_size(100 * 1024 * 1024) is False # 100MB
|
||||
|
||||
def test_filename_validation(self):
|
||||
"""파일명 검증 테스트"""
|
||||
from app.utils.file_validator import file_validator
|
||||
|
||||
# 안전한 파일명
|
||||
assert file_validator.validate_filename("test.xlsx") is True
|
||||
assert file_validator.validate_filename("BOM_Rev1.xlsx") is True
|
||||
assert file_validator.validate_filename("자재목록_2025.csv") is True
|
||||
|
||||
# 위험한 파일명
|
||||
assert file_validator.validate_filename("../test.xlsx") is False
|
||||
assert file_validator.validate_filename("test/file.xlsx") is False
|
||||
assert file_validator.validate_filename("test:file.xlsx") is False
|
||||
assert file_validator.validate_filename("test*file.xlsx") is False
|
||||
|
||||
def test_filename_sanitization(self):
|
||||
"""파일명 정화 테스트"""
|
||||
from app.utils.file_validator import file_validator
|
||||
|
||||
# 위험한 문자 제거
|
||||
assert file_validator.sanitize_filename("../test.xlsx") == "__test.xlsx"
|
||||
assert file_validator.sanitize_filename("test/file.xlsx") == "test_file.xlsx"
|
||||
assert file_validator.sanitize_filename("test:file*.xlsx") == "test_file_.xlsx"
|
||||
|
||||
# 연속된 언더스코어 제거
|
||||
assert file_validator.sanitize_filename("test__file.xlsx") == "test_file.xlsx"
|
||||
|
||||
# 앞뒤 공백 및 점 제거
|
||||
assert file_validator.sanitize_filename(" test.xlsx ") == "test.xlsx"
|
||||
assert file_validator.sanitize_filename(".test.xlsx.") == "test.xlsx"
|
||||
|
||||
|
||||
class TestCacheManager:
|
||||
"""캐시 매니저 테스트"""
|
||||
|
||||
@patch('app.utils.cache_manager.redis')
|
||||
def test_cache_set_get(self, mock_redis):
|
||||
"""캐시 저장/조회 테스트"""
|
||||
from app.utils.cache_manager import CacheManager
|
||||
|
||||
# Redis 클라이언트 mock 설정
|
||||
mock_client = MagicMock()
|
||||
mock_redis.from_url.return_value = mock_client
|
||||
|
||||
cache_manager = CacheManager()
|
||||
|
||||
# 데이터 저장
|
||||
test_data = {"test": "data"}
|
||||
result = cache_manager.set("test_key", test_data, 3600)
|
||||
|
||||
# Redis 호출 확인
|
||||
mock_client.setex.assert_called_once()
|
||||
|
||||
def test_tkmp_cache_file_list(self):
|
||||
"""TK-MP 캐시 파일 목록 테스트"""
|
||||
from app.utils.cache_manager import TKMPCache
|
||||
|
||||
with patch('app.utils.cache_manager.CacheManager') as mock_cache_manager:
|
||||
mock_cache = MagicMock()
|
||||
mock_cache_manager.return_value = mock_cache
|
||||
|
||||
tkmp_cache = TKMPCache()
|
||||
|
||||
# 파일 목록 캐시 저장
|
||||
files = [{"id": 1, "name": "test.xlsx"}]
|
||||
tkmp_cache.set_file_list(files, "TEST-001", False)
|
||||
|
||||
# 캐시 호출 확인
|
||||
mock_cache.set.assert_called_once()
|
||||
|
||||
# 파일 목록 캐시 조회
|
||||
mock_cache.get.return_value = files
|
||||
result = tkmp_cache.get_file_list("TEST-001", False)
|
||||
|
||||
assert result == files
|
||||
mock_cache.get.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestFileProcessor:
|
||||
"""파일 프로세서 테스트"""
|
||||
|
||||
def test_file_info_analysis(self, sample_excel_file):
|
||||
"""파일 정보 분석 테스트"""
|
||||
from app.utils.file_processor import file_processor
|
||||
|
||||
info = file_processor.get_file_info(sample_excel_file)
|
||||
|
||||
assert "file_path" in info
|
||||
assert "file_size" in info
|
||||
assert "file_extension" in info
|
||||
assert info["file_extension"] == ".xlsx"
|
||||
assert info["file_type"] == "excel"
|
||||
assert "recommended_chunk_size" in info
|
||||
|
||||
def test_dataframe_memory_optimization(self):
|
||||
"""DataFrame 메모리 최적화 테스트"""
|
||||
import pandas as pd
|
||||
from app.utils.file_processor import file_processor
|
||||
|
||||
# 테스트 데이터 생성
|
||||
df = pd.DataFrame({
|
||||
'int_col': [1, 2, 3, 4, 5],
|
||||
'float_col': [1.1, 2.2, 3.3, 4.4, 5.5],
|
||||
'str_col': ['A', 'B', 'A', 'B', 'A']
|
||||
})
|
||||
|
||||
original_memory = df.memory_usage(deep=True).sum()
|
||||
optimized_df = file_processor.optimize_dataframe_memory(df)
|
||||
optimized_memory = optimized_df.memory_usage(deep=True).sum()
|
||||
|
||||
# 메모리 사용량이 감소했거나 같아야 함
|
||||
assert optimized_memory <= original_memory
|
||||
|
||||
# 데이터 무결성 확인
|
||||
assert len(optimized_df) == len(df)
|
||||
assert list(optimized_df.columns) == list(df.columns)
|
||||
|
||||
def test_optimal_chunk_size_calculation(self):
|
||||
"""최적 청크 크기 계산 테스트"""
|
||||
from app.utils.file_processor import file_processor
|
||||
|
||||
# 작은 파일 (1MB 미만)
|
||||
chunk_size = file_processor._calculate_optimal_chunk_size(500 * 1024) # 500KB
|
||||
assert chunk_size == 500
|
||||
|
||||
# 중간 파일 (10MB 미만)
|
||||
chunk_size = file_processor._calculate_optimal_chunk_size(5 * 1024 * 1024) # 5MB
|
||||
assert chunk_size == 1000
|
||||
|
||||
# 큰 파일 (50MB 이상)
|
||||
chunk_size = file_processor._calculate_optimal_chunk_size(100 * 1024 * 1024) # 100MB
|
||||
assert chunk_size == 5000
|
||||
65
deploy-synology.sh
Executable file
65
deploy-synology.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
# TK-MP-Project 시놀로지 배포 스크립트
|
||||
# 사용법: ./deploy-synology.sh [NAS_IP] [SSH_PORT]
|
||||
|
||||
NAS_IP=${1:-"192.168.0.3"}
|
||||
SSH_PORT=${2:-"22"}
|
||||
NAS_USER="admin"
|
||||
PROJECT_NAME="tk-mp-project"
|
||||
REMOTE_PATH="/volume1/docker/${PROJECT_NAME}"
|
||||
|
||||
echo "🚀 TK-MP-Project 시놀로지 배포 시작..."
|
||||
echo "📡 대상 NAS: ${NAS_IP}:${SSH_PORT}"
|
||||
|
||||
# 1. 프로젝트 압축
|
||||
echo "📦 프로젝트 압축 중..."
|
||||
tar -czf ${PROJECT_NAME}.tar.gz \
|
||||
--exclude='node_modules' \
|
||||
--exclude='venv' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='.git' \
|
||||
--exclude='uploads' \
|
||||
.
|
||||
|
||||
# 2. NAS로 파일 전송
|
||||
echo "📤 NAS로 파일 전송 중..."
|
||||
scp -P ${SSH_PORT} ${PROJECT_NAME}.tar.gz ${NAS_USER}@${NAS_IP}:/tmp/
|
||||
|
||||
# 3. NAS에서 배포 실행
|
||||
echo "🔧 NAS에서 배포 실행 중..."
|
||||
ssh -p ${SSH_PORT} ${NAS_USER}@${NAS_IP} << EOF
|
||||
# 디렉토리 생성
|
||||
sudo mkdir -p ${REMOTE_PATH}
|
||||
cd ${REMOTE_PATH}
|
||||
|
||||
# 기존 컨테이너 정지 및 제거
|
||||
sudo docker-compose -f docker-compose.synology.yml down || true
|
||||
|
||||
# 프로젝트 압축 해제
|
||||
sudo tar -xzf /tmp/${PROJECT_NAME}.tar.gz -C ${REMOTE_PATH}
|
||||
sudo rm /tmp/${PROJECT_NAME}.tar.gz
|
||||
|
||||
# Docker 이미지 빌드 및 실행
|
||||
sudo docker-compose -f docker-compose.synology.yml build
|
||||
sudo docker-compose -f docker-compose.synology.yml up -d
|
||||
|
||||
echo "✅ 배포 완료!"
|
||||
echo "🌐 프론트엔드: http://${NAS_IP}:10173"
|
||||
echo "🔧 백엔드 API: http://${NAS_IP}:10080"
|
||||
echo "🗄️ 데이터베이스: ${NAS_IP}:15432"
|
||||
echo "🔄 Redis: ${NAS_IP}:16379"
|
||||
EOF
|
||||
|
||||
# 4. 로컬 임시 파일 정리
|
||||
rm ${PROJECT_NAME}.tar.gz
|
||||
|
||||
echo "🎉 배포가 완료되었습니다!"
|
||||
echo ""
|
||||
echo "📋 서비스 URL:"
|
||||
echo " 프론트엔드: http://${NAS_IP}:10173"
|
||||
echo " 백엔드 API: http://${NAS_IP}:10080/docs"
|
||||
echo ""
|
||||
echo "🔍 컨테이너 상태 확인:"
|
||||
echo " ssh -p ${SSH_PORT} ${NAS_USER}@${NAS_IP} 'sudo docker ps'"
|
||||
@@ -4,10 +4,10 @@ version: '3.8'
|
||||
services:
|
||||
frontend:
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
- VITE_API_URL=http://localhost:18000
|
||||
build:
|
||||
args:
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
- VITE_API_URL=http://localhost:18000
|
||||
|
||||
backend:
|
||||
volumes:
|
||||
|
||||
76
docker-compose.synology.yml
Normal file
76
docker-compose.synology.yml
Normal file
@@ -0,0 +1,76 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL 데이터베이스
|
||||
tk-mp-postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: tk-mp-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: tk_mp_bom
|
||||
POSTGRES_USER: tkmp_user
|
||||
POSTGRES_PASSWORD: tkmp_password_2025
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
|
||||
ports:
|
||||
- "15432:5432"
|
||||
volumes:
|
||||
- tk_mp_postgres_data:/var/lib/postgresql/data
|
||||
- ./database/init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- tk-mp-network
|
||||
|
||||
# Redis (캐시 및 세션 관리용)
|
||||
tk-mp-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: tk-mp-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "16379:6379"
|
||||
volumes:
|
||||
- tk_mp_redis_data:/data
|
||||
networks:
|
||||
- tk-mp-network
|
||||
|
||||
# 백엔드 FastAPI 서비스
|
||||
tk-mp-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-mp-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10080:10080"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@tk-mp-postgres:5432/tk_mp_bom
|
||||
- REDIS_URL=redis://tk-mp-redis:6379
|
||||
- PYTHONPATH=/app
|
||||
depends_on:
|
||||
- tk-mp-postgres
|
||||
- tk-mp-redis
|
||||
networks:
|
||||
- tk-mp-network
|
||||
volumes:
|
||||
- tk_mp_uploads:/app/uploads
|
||||
|
||||
# 프론트엔드 Nginx 서비스
|
||||
tk-mp-frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-mp-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10173:10173"
|
||||
depends_on:
|
||||
- tk-mp-backend
|
||||
networks:
|
||||
- tk-mp-network
|
||||
|
||||
volumes:
|
||||
tk_mp_postgres_data:
|
||||
tk_mp_redis_data:
|
||||
tk_mp_uploads:
|
||||
|
||||
networks:
|
||||
tk-mp-network:
|
||||
driver: bridge
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
container_name: tk-mp-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "18000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom
|
||||
- REDIS_URL=redis://redis:6379
|
||||
@@ -79,7 +79,7 @@ services:
|
||||
container_name: tk-mp-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "13000:3000"
|
||||
environment:
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
depends_on:
|
||||
|
||||
18
frontend/.dockerignore
Normal file
18
frontend/.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.development
|
||||
dist
|
||||
coverage
|
||||
.nyc_output
|
||||
.cache
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -11,7 +11,7 @@ server {
|
||||
|
||||
# API 프록시 설정
|
||||
location /api/ {
|
||||
proxy_pass http://tk-mp-backend:8000/;
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -1,42 +1,249 @@
|
||||
/* 전역 스타일 리셋 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #2d3748;
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 로딩 스피너 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #f7fafc;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.loading-spinner-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top: 2px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 접근 거부 페이지 */
|
||||
.access-denied-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #f7fafc;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.access-denied-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
background: white;
|
||||
padding: 48px 32px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
.access-denied-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
.access-denied-content h2 {
|
||||
color: #2d3748;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.access-denied-content p {
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.permission-info,
|
||||
.role-info {
|
||||
background: #f7fafc;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.permission-info code,
|
||||
.role-info code {
|
||||
background: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 13px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
background: #edf2f7;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-info p {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* 유틸리티 클래스 */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.mb-1 { margin-bottom: 8px; }
|
||||
.mb-2 { margin-bottom: 16px; }
|
||||
.mb-3 { margin-bottom: 24px; }
|
||||
.mb-4 { margin-bottom: 32px; }
|
||||
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mt-1 { margin-top: 8px; }
|
||||
.mt-2 { margin-top: 16px; }
|
||||
.mt-3 { margin-top: 24px; }
|
||||
.mt-4 { margin-top: 32px; }
|
||||
|
||||
.p-0 { padding: 0; }
|
||||
.p-1 { padding: 8px; }
|
||||
.p-2 { padding: 16px; }
|
||||
.p-3 { padding: 24px; }
|
||||
.p-4 { padding: 32px; }
|
||||
|
||||
/* 반응형 유틸리티 */
|
||||
.hidden-mobile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hidden-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hidden-mobile {
|
||||
display: none;
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
.hidden-desktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
/* 스크롤바 스타일링 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0aec0;
|
||||
}
|
||||
|
||||
/* 포커스 스타일 */
|
||||
*:focus {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 선택 스타일 */
|
||||
::selection {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #2d3748;
|
||||
}
|
||||
@@ -1,26 +1,204 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import ProjectSelectionPage from './pages/ProjectSelectionPage';
|
||||
import MaterialsPage from './pages/MaterialsPage';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import SimpleLogin from './SimpleLogin';
|
||||
import NavigationMenu from './components/NavigationMenu';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import ProjectsPage from './pages/ProjectsPage';
|
||||
import BOMStatusPage from './pages/BOMStatusPage';
|
||||
import PurchaseConfirmationPage from './pages/PurchaseConfirmationPage';
|
||||
import SimpleMaterialsPage from './pages/SimpleMaterialsPage';
|
||||
import MaterialComparisonPage from './pages/MaterialComparisonPage';
|
||||
import RevisionPurchasePage from './pages/RevisionPurchasePage';
|
||||
import JobSelectionPage from './pages/JobSelectionPage';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
const [pageParams, setPageParams] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
// 저장된 토큰 확인
|
||||
const token = localStorage.getItem('access_token');
|
||||
const userData = localStorage.getItem('user_data');
|
||||
|
||||
if (token && userData) {
|
||||
setIsAuthenticated(true);
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 로그인 성공 시 호출될 함수
|
||||
const handleLoginSuccess = () => {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
// 로그아웃 함수
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_data');
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
setCurrentPage('dashboard');
|
||||
};
|
||||
|
||||
// 페이지 네비게이션 함수
|
||||
const navigateToPage = (page, params = {}) => {
|
||||
setCurrentPage(page);
|
||||
setPageParams(params);
|
||||
};
|
||||
|
||||
// 페이지 렌더링 함수
|
||||
const renderCurrentPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'dashboard':
|
||||
return <DashboardPage user={user} />;
|
||||
case 'projects':
|
||||
return <ProjectsPage user={user} />;
|
||||
case 'bom':
|
||||
return <JobSelectionPage onJobSelect={(jobNo, jobName) =>
|
||||
navigateToPage('bom-status', { job_no: jobNo, job_name: jobName })
|
||||
} />;
|
||||
case 'bom-status':
|
||||
return <BOMStatusPage
|
||||
jobNo={pageParams.job_no}
|
||||
jobName={pageParams.job_name}
|
||||
onNavigate={navigateToPage}
|
||||
/>;
|
||||
case 'materials':
|
||||
return <SimpleMaterialsPage
|
||||
fileId={pageParams.file_id}
|
||||
jobNo={pageParams.jobNo}
|
||||
bomName={pageParams.bomName}
|
||||
revision={pageParams.revision}
|
||||
filename={pageParams.filename}
|
||||
onNavigate={navigateToPage}
|
||||
/>;
|
||||
case 'material-comparison':
|
||||
return <MaterialComparisonPage
|
||||
jobNo={pageParams.job_no}
|
||||
currentRevision={pageParams.current_revision}
|
||||
previousRevision={pageParams.previous_revision}
|
||||
filename={pageParams.filename}
|
||||
onNavigate={navigateToPage}
|
||||
/>;
|
||||
case 'revision-purchase':
|
||||
return <RevisionPurchasePage
|
||||
jobNo={pageParams.job_no}
|
||||
revision={pageParams.revision}
|
||||
onNavigate={navigateToPage}
|
||||
/>;
|
||||
case 'quotes':
|
||||
return <div style={{ padding: '32px' }}>💰 견적 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'procurement':
|
||||
return <div style={{ padding: '32px' }}>🛒 구매 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'production':
|
||||
return <div style={{ padding: '32px' }}>🏭 생산 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'shipment':
|
||||
return <div style={{ padding: '32px' }}>🚚 출하 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'users':
|
||||
return <div style={{ padding: '32px' }}>👥 사용자 관리 페이지 (곧 구현 예정)</div>;
|
||||
case 'system':
|
||||
return <div style={{ padding: '32px' }}>⚙️ 시스템 설정 페이지 (곧 구현 예정)</div>;
|
||||
default:
|
||||
return <DashboardPage user={user} />;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectSelectionPage />} />
|
||||
{/* BOM 관리는 /bom-status로 통일 */}
|
||||
<Route path="/bom-manager" element={<Navigate to="/bom-status" replace />} />
|
||||
<Route path="/bom-status" element={<BOMStatusPage />} />
|
||||
<Route path="/materials" element={<MaterialsPage />} />
|
||||
<Route path="/purchase-confirmation" element={<PurchaseConfirmationPage />} />
|
||||
<Route path="/material-comparison" element={<MaterialComparisonPage />} />
|
||||
<Route path="/revision-purchase" element={<RevisionPurchasePage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<div>로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <SimpleLogin onLoginSuccess={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<NavigationMenu
|
||||
user={user}
|
||||
currentPage={currentPage}
|
||||
onPageChange={(page) => navigateToPage(page, {})}
|
||||
/>
|
||||
|
||||
{/* 메인 콘텐츠 영역 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
marginLeft: '280px', // 사이드바 너비만큼 여백
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
{/* 상단 헤더 */}
|
||||
<header style={{
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
padding: '16px 32px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: '0',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{currentPage === 'dashboard' && '대시보드'}
|
||||
{currentPage === 'projects' && '프로젝트 관리'}
|
||||
{currentPage === 'bom' && 'BOM 관리'}
|
||||
{currentPage === 'materials' && '자재 관리'}
|
||||
{currentPage === 'quotes' && '견적 관리'}
|
||||
{currentPage === 'procurement' && '구매 관리'}
|
||||
{currentPage === 'production' && '생산 관리'}
|
||||
{currentPage === 'shipment' && '출하 관리'}
|
||||
{currentPage === 'users' && '사용자 관리'}
|
||||
{currentPage === 'system' && '시스템 설정'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e53e3e',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
<span>🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 페이지 콘텐츠 */}
|
||||
<main>
|
||||
{renderCurrentPage()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
220
frontend/src/SimpleDashboard.jsx
Normal file
220
frontend/src/SimpleDashboard.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const SimpleDashboard = () => {
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 저장된 사용자 정보 불러오기
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_data');
|
||||
window.location.reload(); // 페이지 새로고침으로 로그인 페이지로 돌아가기
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<div>사용자 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
{/* 네비게이션 바 */}
|
||||
<nav style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '16px 24px',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '24px' }}>🚀</span>
|
||||
<div>
|
||||
<h1 style={{ margin: '0', fontSize: '20px', fontWeight: '700' }}>TK-MP System</h1>
|
||||
<span style={{ fontSize: '12px', opacity: '0.9' }}>통합 프로젝트 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600' }}>{user.name}</div>
|
||||
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||
{user.role} · {user.access_level}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main style={{ padding: '32px 24px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 환영 메시지 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<h2 style={{
|
||||
color: '#2d3748',
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
환영합니다, {user.name}님! 🎉
|
||||
</h2>
|
||||
<p style={{ color: '#718096', fontSize: '16px', margin: '0' }}>
|
||||
TK-MP 통합 프로젝트 관리 시스템에 성공적으로 로그인하셨습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 카드 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
color: '#2d3748',
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
👤 사용자 정보
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '16px' }}>
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>사용자명:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>이메일:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>역할:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.role}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>접근 레벨:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.access_level}</div>
|
||||
</div>
|
||||
{user.department && (
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>부서:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.department}</div>
|
||||
</div>
|
||||
)}
|
||||
{user.position && (
|
||||
<div>
|
||||
<strong style={{ color: '#4a5568' }}>직책:</strong>
|
||||
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.position}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권한 정보 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)'
|
||||
}}>
|
||||
<h3 style={{
|
||||
color: '#2d3748',
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
🔐 보유 권한
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px'
|
||||
}}>
|
||||
{user.permissions && user.permissions.length > 0 ? (
|
||||
user.permissions.map(permission => (
|
||||
<span
|
||||
key={permission}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
borderRadius: '20px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{permission}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: '#718096' }}>권한 정보가 없습니다.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다음 단계 안내 */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
color: 'white',
|
||||
marginTop: '32px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '20px', fontWeight: '600' }}>
|
||||
🚀 다음 단계
|
||||
</h3>
|
||||
<p style={{ margin: '0', fontSize: '16px', opacity: '0.9' }}>
|
||||
이제 복잡한 인증 시스템과 네비게이션을 단계적으로 추가할 준비가 되었습니다!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleDashboard;
|
||||
212
frontend/src/SimpleLogin.jsx
Normal file
212
frontend/src/SimpleLogin.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from './api';
|
||||
|
||||
const SimpleLogin = ({ onLoginSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.username || !formData.password) {
|
||||
setError('사용자명과 비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post('/auth/login', formData);
|
||||
const data = response.data;
|
||||
|
||||
if (data.success) {
|
||||
// 토큰과 사용자 정보 저장
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('user_data', JSON.stringify(data.user));
|
||||
|
||||
setSuccess('로그인 성공! 대시보드로 이동합니다...');
|
||||
|
||||
// 잠깐 성공 메시지 보여준 후 대시보드로 이동
|
||||
setTimeout(() => {
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess();
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(data.error?.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
setError(err.response?.data?.message || '서버 연결에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '20px',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)',
|
||||
padding: '40px',
|
||||
width: '100%',
|
||||
maxWidth: '400px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<h1 style={{ color: '#2d3748', fontSize: '28px', fontWeight: '700', margin: '0 0 8px 0' }}>
|
||||
🚀 TK-MP System
|
||||
</h1>
|
||||
<p style={{ color: '#718096', fontSize: '14px', margin: '0' }}>
|
||||
통합 프로젝트 관리 시스템
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
color: '#2d3748',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
사용자명
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="사용자명을 입력하세요"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
color: '#2d3748',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px 16px',
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #feb2b2',
|
||||
borderRadius: '8px',
|
||||
color: '#c53030',
|
||||
fontSize: '14px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<span>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#c6f6d5',
|
||||
border: '1px solid #9ae6b4',
|
||||
borderRadius: '8px',
|
||||
color: '#2f855a',
|
||||
fontSize: '14px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
✅ {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 24px',
|
||||
background: isLoading ? '#a0aec0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
marginTop: '8px'
|
||||
}}
|
||||
>
|
||||
{isLoading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '32px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#718096', fontSize: '14px', margin: '0 0 16px 0' }}>
|
||||
테스트 계정: admin / admin123 또는 testuser / test123
|
||||
</p>
|
||||
<div>
|
||||
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
TK-MP Project Management System v2.0
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleLogin;
|
||||
43
frontend/src/TestApp.jsx
Normal file
43
frontend/src/TestApp.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
const TestApp = () => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
color: '#2d3748',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<h1>🚀 TK-MP System</h1>
|
||||
<p>시스템이 정상적으로 작동 중입니다!</p>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => alert('테스트 성공!')}
|
||||
>
|
||||
테스트 버튼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestApp;
|
||||
@@ -174,3 +174,6 @@ export function getMaterialPurchaseStatus(jobNo, revision = null, status = null)
|
||||
params: { job_no: jobNo, revision, status }
|
||||
});
|
||||
}
|
||||
|
||||
// Default export for convenience
|
||||
export default api;
|
||||
157
frontend/src/components/BOMFileTable.jsx
Normal file
157
frontend/src/components/BOMFileTable.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
|
||||
const BOMFileTable = ({
|
||||
files,
|
||||
loading,
|
||||
groupFilesByBOM,
|
||||
handleViewMaterials,
|
||||
openRevisionDialog,
|
||||
handleDelete
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#bee3f8',
|
||||
border: '1px solid #63b3ed',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#2c5282'
|
||||
}}>
|
||||
업로드된 BOM이 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>자재 수</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
||||
bomFiles.map((file, index) => (
|
||||
<tr
|
||||
key={file.id}
|
||||
style={{
|
||||
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
fontWeight: index === 0 ? 'bold' : 'normal',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{file.bom_name || bomKey}
|
||||
</div>
|
||||
{index === 0 && bomFiles.length > 1 && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#4299e1',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
최신 리비전 (총 {bomFiles.length}개)
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
|
||||
{file.original_filename || file.filename}
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<span style={{
|
||||
background: index === 0 ? '#4299e1' : '#e2e8f0',
|
||||
color: index === 0 ? 'white' : '#4a5568',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
|
||||
{file.parsed_count || file.material_count || 0}개
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
|
||||
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => handleViewMaterials(file)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
🧮 구매수량 계산
|
||||
</button>
|
||||
|
||||
{index === 0 && (
|
||||
<button
|
||||
onClick={() => openRevisionDialog(file.bom_name || bomKey, file.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
리비전
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(file.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#f56565',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMFileTable;
|
||||
118
frontend/src/components/BOMFileUpload.jsx
Normal file
118
frontend/src/components/BOMFileUpload.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
|
||||
const BOMFileUpload = ({
|
||||
bomName,
|
||||
setBomName,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
uploading,
|
||||
handleUpload,
|
||||
error
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
새 BOM 업로드
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM 이름
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="예: PIPING_BOM_A구역"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
<p style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
margin: '4px 0 0 0'
|
||||
}}>
|
||||
동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: (!selectedFile || !bomName.trim() || uploading) ? '#e2e8f0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: (!selectedFile || !bomName.trim() || uploading) ? '#a0aec0' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: (!selectedFile || !bomName.trim() || uploading) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
margin: '0'
|
||||
}}>
|
||||
선택된 파일: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginTop: '16px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMFileUpload;
|
||||
537
frontend/src/components/NavigationBar.css
Normal file
537
frontend/src/components/NavigationBar.css
Normal file
@@ -0,0 +1,537 @@
|
||||
.navigation-bar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
/* 브랜드 로고 */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 32px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 모바일 메뉴 토글 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle span {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 메인 메뉴 */
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 사용자 메뉴 */
|
||||
.user-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 사용자 드롭다운 */
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
animation: dropdownSlide 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@keyframes dropdownSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown-header {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-details .user-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-badge,
|
||||
.access-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-badge { background: #bee3f8; color: #2b6cb0; }
|
||||
.access-badge { background: #c6f6d5; color: #2f855a; }
|
||||
|
||||
.user-department {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.user-dropdown-menu {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.dropdown-item.logout-item {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.dropdown-item.logout-item:hover {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.user-dropdown-footer {
|
||||
padding: 16px 24px;
|
||||
background: #f7fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.permissions-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.permissions-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.permissions-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.permission-tag {
|
||||
padding: 2px 6px;
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.permission-more {
|
||||
padding: 2px 6px;
|
||||
background: #cbd5e0;
|
||||
color: #2d3748;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 모바일 오버레이 */
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1024px) {
|
||||
.menu-items {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
padding: 0 16px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-menu.mobile-open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 16px;
|
||||
color: #2d3748;
|
||||
border-radius: 12px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #edf2f7;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav-container {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
width: calc(100vw - 24px);
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
270
frontend/src/components/NavigationBar.jsx
Normal file
270
frontend/src/components/NavigationBar.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './NavigationBar.css';
|
||||
|
||||
const NavigationBar = ({ currentPage, onNavigate }) => {
|
||||
const { user, logout, hasPermission, isAdmin, isManager } = useAuth();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// 메뉴 항목 정의 (권한별)
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: '대시보드',
|
||||
icon: '📊',
|
||||
path: '/dashboard',
|
||||
permission: null, // 모든 사용자 접근 가능
|
||||
description: '전체 현황 보기'
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
label: '프로젝트 관리',
|
||||
icon: '📋',
|
||||
path: '/projects',
|
||||
permission: 'project.view',
|
||||
description: '프로젝트 등록 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'bom',
|
||||
label: 'BOM 관리',
|
||||
icon: '📄',
|
||||
path: '/bom',
|
||||
permission: 'bom.view',
|
||||
description: 'BOM 파일 업로드 및 분석'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
label: '자재 관리',
|
||||
icon: '🔧',
|
||||
path: '/materials',
|
||||
permission: 'bom.view',
|
||||
description: '자재 목록 및 비교'
|
||||
},
|
||||
{
|
||||
id: 'purchase',
|
||||
label: '구매 관리',
|
||||
icon: '💰',
|
||||
path: '/purchase',
|
||||
permission: 'project.view',
|
||||
description: '구매 확인 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: '파일 관리',
|
||||
icon: '📁',
|
||||
path: '/files',
|
||||
permission: 'file.upload',
|
||||
description: '파일 업로드 및 관리'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: '사용자 관리',
|
||||
icon: '👥',
|
||||
path: '/users',
|
||||
permission: 'user.view',
|
||||
description: '사용자 계정 관리',
|
||||
adminOnly: true
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
path: '/system',
|
||||
permission: 'system.admin',
|
||||
description: '시스템 환경 설정',
|
||||
adminOnly: true
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자가 접근 가능한 메뉴만 필터링
|
||||
const accessibleMenuItems = menuItems.filter(item => {
|
||||
// 관리자 전용 메뉴 체크
|
||||
if (item.adminOnly && !isAdmin() && !isManager()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 권한 체크
|
||||
if (item.permission && !hasPermission(item.permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
setShowUserMenu(false);
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuClick = (item) => {
|
||||
onNavigate(item.id);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const getRoleDisplayName = (role) => {
|
||||
const roleMap = {
|
||||
'admin': '관리자',
|
||||
'system': '시스템',
|
||||
'leader': '팀장',
|
||||
'support': '지원',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
};
|
||||
|
||||
const getAccessLevelDisplayName = (level) => {
|
||||
const levelMap = {
|
||||
'manager': '관리자',
|
||||
'leader': '팀장',
|
||||
'worker': '작업자',
|
||||
'viewer': '조회자'
|
||||
};
|
||||
return levelMap[level] || level;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navigation-bar">
|
||||
<div className="nav-container">
|
||||
{/* 로고 및 브랜드 */}
|
||||
<div className="nav-brand">
|
||||
<div className="brand-logo">🚀</div>
|
||||
<div className="brand-text">
|
||||
<h1>TK-MP System</h1>
|
||||
<span>통합 프로젝트 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메뉴 토글 */}
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
{/* 메인 메뉴 */}
|
||||
<div className={`nav-menu ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<div className="menu-items">
|
||||
{accessibleMenuItems.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`menu-item ${currentPage === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-label">{item.label}</span>
|
||||
{item.adminOnly && (
|
||||
<span className="admin-badge">관리자</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용자 메뉴 */}
|
||||
<div className="user-menu-container">
|
||||
<button
|
||||
className="user-menu-trigger"
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
>
|
||||
<div className="user-avatar">
|
||||
{user?.name?.charAt(0) || '👤'}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<span className="user-name">{user?.name}</span>
|
||||
<span className="user-role">
|
||||
{getRoleDisplayName(user?.role)} · {getAccessLevelDisplayName(user?.access_level)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="user-dropdown">
|
||||
<div className="user-dropdown-header">
|
||||
<div className="user-avatar-large">
|
||||
{user?.name?.charAt(0) || '👤'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name}</div>
|
||||
<div className="user-username">@{user?.username}</div>
|
||||
<div className="user-email">{user?.email}</div>
|
||||
<div className="user-meta">
|
||||
<span className="role-badge role-{user?.role}">
|
||||
{getRoleDisplayName(user?.role)}
|
||||
</span>
|
||||
<span className="access-badge access-{user?.access_level}">
|
||||
{getAccessLevelDisplayName(user?.access_level)}
|
||||
</span>
|
||||
</div>
|
||||
{user?.department && (
|
||||
<div className="user-department">{user.department}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-dropdown-menu">
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">👤</span>
|
||||
프로필 설정
|
||||
</button>
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">🔐</span>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<button className="dropdown-item">
|
||||
<span className="item-icon">🔔</span>
|
||||
알림 설정
|
||||
</button>
|
||||
<div className="dropdown-divider"></div>
|
||||
<button
|
||||
className="dropdown-item logout-item"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span className="item-icon">🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="user-dropdown-footer">
|
||||
<div className="permissions-info">
|
||||
<span className="permissions-label">권한:</span>
|
||||
<div className="permissions-list">
|
||||
{user?.permissions?.slice(0, 3).map(permission => (
|
||||
<span key={permission} className="permission-tag">
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
{user?.permissions?.length > 3 && (
|
||||
<span className="permission-more">
|
||||
+{user.permissions.length - 3}개 더
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 오버레이 */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="mobile-overlay"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationBar;
|
||||
250
frontend/src/components/NavigationMenu.css
Normal file
250
frontend/src/components/NavigationMenu.css
Normal file
@@ -0,0 +1,250 @@
|
||||
/* 네비게이션 메뉴 스타일 */
|
||||
.navigation-menu {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 모바일 햄버거 버튼 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle span {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: #4a5568;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 메뉴 오버레이 (모바일) */
|
||||
.menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 사이드바 헤더 */
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 메뉴 섹션 */
|
||||
.menu-section {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-section-title {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.menu-button.active {
|
||||
background: #edf2f7;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-button.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 사이드바 푸터 */
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.menu-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 데스크톱에서 사이드바가 있을 때 메인 콘텐츠 여백 */
|
||||
@media (min-width: 769px) {
|
||||
.main-content-with-sidebar {
|
||||
margin-left: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
.menu-section::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.menu-section::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
174
frontend/src/components/NavigationMenu.jsx
Normal file
174
frontend/src/components/NavigationMenu.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from 'react';
|
||||
import './NavigationMenu.css';
|
||||
|
||||
const NavigationMenu = ({ user, currentPage, onPageChange }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// 권한별 메뉴 정의
|
||||
const getMenuItems = () => {
|
||||
const baseMenus = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: '대시보드',
|
||||
icon: '🏠',
|
||||
description: '시스템 현황 및 개요',
|
||||
requiredPermission: null // 모든 사용자
|
||||
}
|
||||
];
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'projects',
|
||||
title: '프로젝트 관리',
|
||||
icon: '📋',
|
||||
description: '프로젝트 등록 및 관리',
|
||||
requiredPermission: 'project_management'
|
||||
},
|
||||
{
|
||||
id: 'bom',
|
||||
title: 'BOM 관리',
|
||||
icon: '🔧',
|
||||
description: 'Bill of Materials 관리',
|
||||
requiredPermission: 'bom_management'
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
title: '자재 관리',
|
||||
icon: '📦',
|
||||
description: '자재 정보 및 재고 관리',
|
||||
requiredPermission: 'material_management'
|
||||
},
|
||||
{
|
||||
id: 'quotes',
|
||||
title: '견적 관리',
|
||||
icon: '💰',
|
||||
description: '견적서 작성 및 관리',
|
||||
requiredPermission: 'quote_management'
|
||||
},
|
||||
{
|
||||
id: 'procurement',
|
||||
title: '구매 관리',
|
||||
icon: '🛒',
|
||||
description: '구매 요청 및 발주 관리',
|
||||
requiredPermission: 'procurement_management'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
title: '생산 관리',
|
||||
icon: '🏭',
|
||||
description: '생산 계획 및 진행 관리',
|
||||
requiredPermission: 'production_management'
|
||||
},
|
||||
{
|
||||
id: 'shipment',
|
||||
title: '출하 관리',
|
||||
icon: '🚚',
|
||||
description: '출하 계획 및 배송 관리',
|
||||
requiredPermission: 'shipment_management'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: '사용자 관리',
|
||||
icon: '👥',
|
||||
description: '사용자 계정 및 권한 관리',
|
||||
requiredPermission: 'user_management'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
title: '시스템 설정',
|
||||
icon: '⚙️',
|
||||
description: '시스템 환경 설정',
|
||||
requiredPermission: 'system_admin'
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자 권한에 따라 메뉴 필터링
|
||||
const userPermissions = user?.permissions || [];
|
||||
const filteredMenus = menuItems.filter(menu =>
|
||||
!menu.requiredPermission ||
|
||||
userPermissions.includes(menu.requiredPermission) ||
|
||||
user?.role === 'admin' // 관리자는 모든 메뉴 접근 가능
|
||||
);
|
||||
|
||||
return [...baseMenus, ...filteredMenus];
|
||||
};
|
||||
|
||||
const menuItems = getMenuItems();
|
||||
|
||||
const handleMenuClick = (menuId) => {
|
||||
onPageChange(menuId);
|
||||
setIsMenuOpen(false); // 모바일에서 메뉴 닫기
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="navigation-menu">
|
||||
{/* 모바일 햄버거 버튼 */}
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label="메뉴 토글"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
{/* 메뉴 오버레이 (모바일) */}
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
className="menu-overlay"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 사이드바 메뉴 */}
|
||||
<nav className={`sidebar ${isMenuOpen ? 'open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<span className="logo-icon">🚀</span>
|
||||
<div className="logo-text">
|
||||
<h2>TK-MP</h2>
|
||||
<span>통합 관리 시스템</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="menu-section">
|
||||
<div className="menu-section-title">메인 메뉴</div>
|
||||
<ul className="menu-list">
|
||||
{menuItems.map(item => (
|
||||
<li key={item.id} className="menu-item">
|
||||
<button
|
||||
className={`menu-button ${currentPage === item.id ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(item.id)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-title">{item.title}</span>
|
||||
{currentPage === item.id && (
|
||||
<span className="active-indicator"></span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">
|
||||
<div className="user-avatar">
|
||||
{user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name}</div>
|
||||
<div className="user-role">{user?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationMenu;
|
||||
159
frontend/src/components/ProtectedRoute.jsx
Normal file
159
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
|
||||
const ProtectedRoute = ({
|
||||
children,
|
||||
requiredPermission = null,
|
||||
requiredRole = null,
|
||||
fallback = null
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading, user, hasPermission, hasRole } = useAuth();
|
||||
|
||||
// 로딩 중일 때
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-container" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: '#f7fafc',
|
||||
color: '#718096'
|
||||
}}>
|
||||
<div className="loading-spinner-large" style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
border: '4px solid #e2e8f0',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: '16px'
|
||||
}}></div>
|
||||
<p>인증 정보를 확인하는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 인증되지 않은 경우
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
// 특정 권한이 필요한 경우
|
||||
if (requiredPermission && !hasPermission(requiredPermission)) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">🔒</div>
|
||||
<h2>접근 권한이 없습니다</h2>
|
||||
<p>이 페이지에 접근하기 위한 권한이 없습니다.</p>
|
||||
<p className="permission-info">
|
||||
필요한 권한: <code>{requiredPermission}</code>
|
||||
</p>
|
||||
<div className="user-info">
|
||||
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
|
||||
<p>역할: <strong>{user?.role}</strong></p>
|
||||
<p>접근 레벨: <strong>{user?.access_level}</strong></p>
|
||||
</div>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
이전 페이지로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 특정 역할이 필요한 경우
|
||||
if (requiredRole && !hasRole(requiredRole)) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">👤</div>
|
||||
<h2>역할 권한이 없습니다</h2>
|
||||
<p>이 페이지에 접근하기 위한 역할 권한이 없습니다.</p>
|
||||
<p className="role-info">
|
||||
필요한 역할: <code>{requiredRole}</code>
|
||||
</p>
|
||||
<div className="user-info">
|
||||
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
|
||||
<p>현재 역할: <strong>{user?.role}</strong></p>
|
||||
</div>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
이전 페이지로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모든 조건을 만족하는 경우 자식 컴포넌트 렌더링
|
||||
return children;
|
||||
};
|
||||
|
||||
// 관리자 전용 라우트
|
||||
export const AdminRoute = ({ children, fallback = null }) => {
|
||||
return (
|
||||
<ProtectedRoute
|
||||
requiredRole="admin"
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 시스템 관리자 전용 라우트
|
||||
export const SystemRoute = ({ children, fallback = null }) => {
|
||||
const { hasRole } = useAuth();
|
||||
|
||||
if (!hasRole('admin') && !hasRole('system')) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">⚙️</div>
|
||||
<h2>시스템 관리자 권한이 필요합니다</h2>
|
||||
<p>이 페이지는 시스템 관리자만 접근할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 매니저 이상 권한 라우트
|
||||
export const ManagerRoute = ({ children, fallback = null }) => {
|
||||
const { isManager } = useAuth();
|
||||
|
||||
if (!isManager()) {
|
||||
return fallback || (
|
||||
<div className="access-denied-container">
|
||||
<div className="access-denied-content">
|
||||
<div className="access-denied-icon">👔</div>
|
||||
<h2>관리자 권한이 필요합니다</h2>
|
||||
<p>이 페이지는 관리자 이상의 권한이 필요합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
82
frontend/src/components/RevisionUploadDialog.jsx
Normal file
82
frontend/src/components/RevisionUploadDialog.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
|
||||
const RevisionUploadDialog = ({
|
||||
revisionDialog,
|
||||
setRevisionDialog,
|
||||
revisionFile,
|
||||
setRevisionFile,
|
||||
handleRevisionUpload,
|
||||
uploading
|
||||
}) => {
|
||||
if (!revisionDialog.open) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
width: '90%'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>
|
||||
리비전 업로드: {revisionDialog.bomName}
|
||||
</h3>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBottom: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRevisionUpload}
|
||||
disabled={!revisionFile || uploading}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: (!revisionFile || uploading) ? '#e2e8f0' : '#4299e1',
|
||||
color: (!revisionFile || uploading) ? '#a0aec0' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: (!revisionFile || uploading) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionUploadDialog;
|
||||
301
frontend/src/components/SimpleFileUpload.jsx
Normal file
301
frontend/src/components/SimpleFileUpload.jsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileUpload(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!selectedProject) {
|
||||
setError('프로젝트를 먼저 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 유효성 검사
|
||||
const allowedTypes = ['.xlsx', '.xls', '.csv'];
|
||||
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedTypes.includes(fileExtension)) {
|
||||
setError(`지원하지 않는 파일 형식입니다. 허용된 확장자: ${allowedTypes.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('파일 크기는 10MB를 초과할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
setUploadResult(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', selectedProject.job_no);
|
||||
formData.append('revision', 'Rev.0');
|
||||
|
||||
// 업로드 진행률 시뮬레이션
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(progressInterval);
|
||||
return 90;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
const response = await api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadResult({
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
file: response.data.file,
|
||||
job: response.data.job,
|
||||
sampleMaterials: response.data.sample_materials || []
|
||||
});
|
||||
|
||||
// 업로드 완료 콜백 호출
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete(response.data);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '업로드 실패');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('업로드 에러:', err);
|
||||
setError(err.response?.data?.detail || err.message || '파일 업로드에 실패했습니다.');
|
||||
setUploadProgress(0);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
style={{
|
||||
border: `2px dashed ${dragActive ? '#667eea' : '#e2e8f0'}`,
|
||||
borderRadius: '12px',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
background: dragActive ? '#f7fafc' : 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('file-input').click()}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||
{uploading ? '⏳' : '📤'}
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '8px' }}>
|
||||
{uploading ? '업로드 중...' : 'BOM 파일을 업로드하세요'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096', marginBottom: '16px' }}>
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#a0aec0' }}>
|
||||
지원 형식: Excel (.xlsx, .xls), CSV (.csv) | 최대 크기: 10MB
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 업로드 진행률 */}
|
||||
{uploading && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
업로드 진행률
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', color: '#667eea' }}>
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e2e8f0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{ color: '#c53030', fontSize: '16px' }}>⚠️</span>
|
||||
<span style={{ color: '#c53030', fontSize: '14px' }}>{error}</span>
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#c53030',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 성공 결과 */}
|
||||
{uploadResult && uploadResult.success && (
|
||||
<div style={{
|
||||
background: '#c6f6d5',
|
||||
border: '1px solid #68d391',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<span style={{ color: '#2f855a', fontSize: '20px' }}>✅</span>
|
||||
<span style={{ color: '#2f855a', fontSize: '16px', fontWeight: '600' }}>
|
||||
업로드 완료!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#2f855a', fontSize: '14px', marginBottom: '16px' }}>
|
||||
{uploadResult.message}
|
||||
</div>
|
||||
|
||||
{/* 파일 정보 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
|
||||
📄 파일 정보
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '12px' }}>
|
||||
<div><strong>파일명:</strong> {uploadResult.file?.original_filename}</div>
|
||||
<div><strong>분석된 자재:</strong> {uploadResult.file?.parsed_count}개</div>
|
||||
<div><strong>저장된 자재:</strong> {uploadResult.file?.saved_count}개</div>
|
||||
<div><strong>프로젝트:</strong> {uploadResult.job?.job_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 샘플 자재 미리보기 */}
|
||||
{uploadResult.sampleMaterials && uploadResult.sampleMaterials.length > 0 && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
|
||||
🔧 자재 샘플 (처음 3개)
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{uploadResult.sampleMaterials.map((material, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '8px 12px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<strong>{material.description || material.item_code}</strong>
|
||||
{material.category && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{material.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleFileUpload;
|
||||
263
frontend/src/contexts/AuthContext.jsx
Normal file
263
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// 토큰 관리
|
||||
const getToken = () => localStorage.getItem('access_token');
|
||||
const getRefreshToken = () => localStorage.getItem('refresh_token');
|
||||
|
||||
const setTokens = (accessToken, refreshToken) => {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
};
|
||||
|
||||
const clearTokens = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_data');
|
||||
};
|
||||
|
||||
// 사용자 권한 확인
|
||||
const hasPermission = (permission) => {
|
||||
if (!user || !user.permissions) return false;
|
||||
return user.permissions.includes(permission);
|
||||
};
|
||||
|
||||
const hasRole = (role) => {
|
||||
if (!user) return false;
|
||||
return user.role === role;
|
||||
};
|
||||
|
||||
const isAdmin = () => hasRole('admin') || hasRole('system');
|
||||
const isManager = () => hasRole('admin') || hasRole('system') || hasRole('leader');
|
||||
|
||||
// 로그인
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await api.post('/auth/login', {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const { access_token, refresh_token, user: userData } = response.data;
|
||||
|
||||
setTokens(access_token, refresh_token);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// 사용자 데이터 로컬 저장
|
||||
localStorage.setItem('user_data', JSON.stringify(userData));
|
||||
|
||||
return userData;
|
||||
} else {
|
||||
throw new Error(response.data.error?.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
if (error.response?.data?.error?.message) {
|
||||
throw new Error(error.response.data.error.message);
|
||||
} else if (error.response?.status === 401) {
|
||||
throw new Error('아이디 또는 비밀번호가 올바르지 않습니다.');
|
||||
} else if (error.response?.status >= 500) {
|
||||
throw new Error('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
} else {
|
||||
throw new Error('로그인에 실패했습니다. 네트워크 연결을 확인해주세요.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 로그아웃
|
||||
const logout = async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
await api.post('/auth/logout');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 토큰 갱신
|
||||
const refreshAccessToken = async () => {
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await api.post('/auth/refresh', {
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const { access_token } = response.data;
|
||||
setTokens(access_token);
|
||||
return access_token;
|
||||
} else {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
await logout();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
const getCurrentUser = async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const response = await api.get('/auth/me');
|
||||
if (response.data.success) {
|
||||
const userData = response.data.user;
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
localStorage.setItem('user_data', JSON.stringify(userData));
|
||||
return userData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
// 토큰이 만료되었거나 인증 실패한 경우 갱신 시도
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
return await getCurrentUser();
|
||||
} catch (refreshError) {
|
||||
await logout();
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 인증 상태 확인
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const savedUserData = localStorage.getItem('user_data');
|
||||
|
||||
if (token && savedUserData) {
|
||||
try {
|
||||
const userData = JSON.parse(savedUserData);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// 백그라운드에서 토큰 유효성 검증 (선택적)
|
||||
getCurrentUser().catch(error => {
|
||||
console.warn('Background token validation failed:', error);
|
||||
// 백그라운드 검증 실패는 무시 (사용자 경험 우선)
|
||||
});
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse saved user data:', parseError);
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} else {
|
||||
// 토큰이나 사용자 데이터가 없으면 로그아웃 상태로 설정
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
// API 요청 인터셉터 설정
|
||||
useEffect(() => {
|
||||
const requestInterceptor = api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
const responseInterceptor = api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
const token = getToken();
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
await logout();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
api.interceptors.request.eject(requestInterceptor);
|
||||
api.interceptors.response.eject(responseInterceptor);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
hasPermission,
|
||||
hasRole,
|
||||
isAdmin,
|
||||
isManager,
|
||||
refreshAccessToken
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
431
frontend/src/pages/BOMManagementPage.jsx
Normal file
431
frontend/src/pages/BOMManagementPage.jsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import SimpleFileUpload from '../components/SimpleFileUpload';
|
||||
import MaterialList from '../components/MaterialList';
|
||||
import { fetchMaterials, fetchFiles } from '../api';
|
||||
|
||||
const BOMManagementPage = ({ user }) => {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState({
|
||||
totalFiles: 0,
|
||||
totalMaterials: 0,
|
||||
recentUploads: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
loadProjectFiles();
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [files, materials]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProjects(data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProjectFiles = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 기존 API 함수 사용 - 파일 목록 로딩
|
||||
const filesResponse = await fetchFiles({ job_no: selectedProject.job_no });
|
||||
setFiles(Array.isArray(filesResponse.data) ? filesResponse.data : []);
|
||||
|
||||
// 기존 API 함수 사용 - 자재 목록 로딩
|
||||
const materialsResponse = await fetchMaterials({ job_no: selectedProject.job_no, limit: 1000 });
|
||||
setMaterials(materialsResponse.data?.materials || []);
|
||||
} catch (error) {
|
||||
console.error('프로젝트 데이터 로딩 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
// 실제 통계 계산 - 더미 데이터 없이
|
||||
const totalFiles = files.length;
|
||||
const totalMaterials = materials.length;
|
||||
|
||||
setStats({
|
||||
totalFiles,
|
||||
totalMaterials,
|
||||
recentUploads: files.slice(0, 5) // 최근 5개 파일
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('통계 로딩 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (uploadData) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 기존 FileUpload 컴포넌트의 업로드 로직 활용
|
||||
await loadProjectFiles(); // 업로드 후 데이터 새로고침
|
||||
await loadStats();
|
||||
} catch (error) {
|
||||
console.error('파일 업로드 후 새로고침 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const StatCard = ({ title, value, icon, color = '#667eea' }) => (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '12px',
|
||||
background: color + '20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px'
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
🔧 BOM 관리
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
Bill of Materials 업로드, 분석 및 관리를 수행하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<StatCard
|
||||
title="총 업로드 파일"
|
||||
value={stats.totalFiles}
|
||||
icon="📄"
|
||||
color="#667eea"
|
||||
/>
|
||||
<StatCard
|
||||
title="분석된 자재"
|
||||
value={stats.totalMaterials}
|
||||
icon="🔧"
|
||||
color="#48bb78"
|
||||
/>
|
||||
<StatCard
|
||||
title="활성 프로젝트"
|
||||
value={projects.length}
|
||||
icon="📋"
|
||||
color="#ed8936"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #e2e8f0'
|
||||
}}>
|
||||
{[
|
||||
{ id: 'upload', label: '📤 파일 업로드', icon: '📤' },
|
||||
{ id: 'files', label: '📁 파일 관리', icon: '📁' },
|
||||
{ id: 'materials', label: '🔧 자재 목록', icon: '🔧' },
|
||||
{ id: 'analysis', label: '📊 분석 결과', icon: '📊' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px 20px',
|
||||
background: activeTab === tab.id ? '#f7fafc' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #667eea' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: activeTab === tab.id ? '600' : '500',
|
||||
color: activeTab === tab.id ? '#667eea' : '#4a5568',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div style={{ padding: '24px' }}>
|
||||
{activeTab === 'upload' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📤 BOM 파일 업로드
|
||||
</h3>
|
||||
|
||||
{/* 프로젝트 선택 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
프로젝트 선택
|
||||
</label>
|
||||
<select
|
||||
value={selectedProject?.job_no || ''}
|
||||
onChange={(e) => {
|
||||
const project = projects.find(p => p.job_no === e.target.value);
|
||||
setSelectedProject(project);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.job_no} value={project.job_no}>
|
||||
{project.job_no} - {project.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProject ? (
|
||||
<div>
|
||||
<div style={{
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 8px 0', color: '#2d3748' }}>
|
||||
선택된 프로젝트: {selectedProject.job_name}
|
||||
</h4>
|
||||
<p style={{ margin: '0', fontSize: '14px', color: '#718096' }}>
|
||||
Job No: {selectedProject.job_no} |
|
||||
고객사: {selectedProject.client_name} |
|
||||
상태: {selectedProject.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SimpleFileUpload
|
||||
selectedProject={selectedProject}
|
||||
onUploadComplete={handleFileUpload}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
먼저 프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'files' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📁 업로드된 파일 목록
|
||||
</h3>
|
||||
|
||||
{selectedProject ? (
|
||||
loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
파일 목록을 불러오는 중...
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{files.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', color: '#2d3748' }}>
|
||||
{file.original_filename || file.filename}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#718096' }}>
|
||||
업로드: {new Date(file.created_at).toLocaleString()} |
|
||||
자재 수: {file.parsed_count || 0}개
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
업로드된 파일이 없습니다.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'materials' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
🔧 자재 목록
|
||||
</h3>
|
||||
|
||||
{selectedProject ? (
|
||||
<MaterialList
|
||||
selectedProject={selectedProject}
|
||||
key={selectedProject.job_no} // 프로젝트 변경 시 컴포넌트 재렌더링
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analysis' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📊 분석 결과
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
background: '#fff3cd',
|
||||
border: '1px solid #ffeaa7',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', color: '#856404' }}>
|
||||
🚧 분석 결과 페이지는 곧 구현될 예정입니다.
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#856404', marginTop: '8px' }}>
|
||||
자재 분류, 통계, 비교 분석 기능이 추가됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMManagementPage;
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert, TextField, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi } from '../api';
|
||||
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api';
|
||||
import BOMFileUpload from '../components/BOMFileUpload';
|
||||
import BOMFileTable from '../components/BOMFileTable';
|
||||
import RevisionUploadDialog from '../components/RevisionUploadDialog';
|
||||
|
||||
const BOMStatusPage = () => {
|
||||
const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -12,10 +13,24 @@ const BOMStatusPage = () => {
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
|
||||
const [revisionFile, setRevisionFile] = useState(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const jobNo = searchParams.get('job_no');
|
||||
const jobName = searchParams.get('job_name');
|
||||
const navigate = useNavigate();
|
||||
const [purchaseModal, setPurchaseModal] = useState({ open: false, data: null, fileInfo: null });
|
||||
|
||||
// 카테고리별 색상 함수
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'pipe': '#4299e1',
|
||||
'fitting': '#48bb78',
|
||||
'valve': '#ed8936',
|
||||
'flange': '#9f7aea',
|
||||
'bolt': '#38b2ac',
|
||||
'gasket': '#f56565',
|
||||
'instrument': '#d69e2e',
|
||||
'material': '#718096',
|
||||
'integrated': '#319795',
|
||||
'unknown': '#a0aec0'
|
||||
};
|
||||
return colors[category?.toLowerCase()] || colors.unknown;
|
||||
};
|
||||
|
||||
// 파일 목록 불러오기
|
||||
const fetchFiles = async () => {
|
||||
@@ -26,52 +41,33 @@ const BOMStatusPage = () => {
|
||||
const response = await fetchFilesApi({ job_no: jobNo });
|
||||
console.log('API 응답:', response);
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
console.log('데이터 배열 형태:', response.data.length, '개');
|
||||
if (response.data && response.data.data && Array.isArray(response.data.data)) {
|
||||
setFiles(response.data.data);
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
setFiles(response.data);
|
||||
} else if (response.data && Array.isArray(response.data.files)) {
|
||||
console.log('데이터.files 배열 형태:', response.data.files.length, '개');
|
||||
setFiles(response.data.files);
|
||||
} else {
|
||||
console.log('빈 배열로 설정');
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('파일 목록을 불러오지 못했습니다.');
|
||||
console.error('파일 목록 로드 에러:', e);
|
||||
} catch (err) {
|
||||
console.error('파일 목록 불러오기 실패:', err);
|
||||
setError('파일 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('useEffect 실행 - jobNo:', jobNo);
|
||||
if (jobNo) {
|
||||
fetchFiles();
|
||||
} else {
|
||||
console.log('jobNo가 없어서 fetchFiles 실행하지 않음');
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [jobNo]);
|
||||
|
||||
// BOM 이름 중복 체크
|
||||
const checkDuplicateBOM = () => {
|
||||
return files.some(file =>
|
||||
file.bom_name === bomName ||
|
||||
file.original_filename === bomName ||
|
||||
file.filename === bomName
|
||||
);
|
||||
};
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
// 파일 업로드
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
setError('파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bomName.trim()) {
|
||||
setError('BOM 이름을 입력해주세요.');
|
||||
if (!selectedFile || !bomName.trim()) {
|
||||
setError('파일과 BOM 이름을 모두 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,49 +75,107 @@ const BOMStatusPage = () => {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const isDuplicate = checkDuplicateBOM();
|
||||
if (isDuplicate && !confirm(`"${bomName}"은(는) 이미 존재하는 BOM입니다. 새로운 리비전으로 업로드하시겠습니까?`)) {
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('revision', 'Rev.0');
|
||||
formData.append('bom_name', bomName);
|
||||
formData.append('bom_type', 'excel');
|
||||
formData.append('description', '');
|
||||
formData.append('bom_name', bomName.trim());
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
const uploadResult = await uploadFileApi(formData);
|
||||
|
||||
if (response.data.success) {
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
// 업로드 완료 후 자동으로 구매 수량 계산 실행
|
||||
if (uploadResult && uploadResult.file_id) {
|
||||
// 잠시 후 구매 수량 계산 페이지로 이동
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 구매 수량 계산 API 호출
|
||||
const response = await fetch(`/api/purchase/calculate?job_no=${jobNo}&revision=Rev.0&file_id=${uploadResult.file_id}`);
|
||||
const purchaseData = await response.json();
|
||||
|
||||
if (purchaseData.success) {
|
||||
// 구매 수량 계산 결과를 모달로 표시하거나 별도 페이지로 이동
|
||||
alert(`업로드 및 분류 완료!\n구매 수량이 계산되었습니다.\n\n파이프: ${purchaseData.purchase_items?.filter(item => item.category === 'PIPE').length || 0}개 항목\n기타 자재: ${purchaseData.purchase_items?.filter(item => item.category !== 'PIPE').length || 0}개 항목`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 실패:', error);
|
||||
}
|
||||
}, 2000); // 2초 후 실행 (분류 완료 대기)
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
setSelectedFile(null);
|
||||
setBomName('');
|
||||
// 파일 input 초기화
|
||||
const fileInput = document.getElementById('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
document.getElementById('file-input').value = '';
|
||||
|
||||
fetchFiles();
|
||||
alert(`업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision || 'Rev.0'}`);
|
||||
} else {
|
||||
setError(response.data.message || '업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('업로드 에러:', e);
|
||||
if (e.response?.data?.detail) {
|
||||
setError(e.response.data.detail);
|
||||
} else {
|
||||
} catch (err) {
|
||||
console.error('파일 업로드 실패:', err);
|
||||
setError('파일 업로드에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 핸들러
|
||||
// 파일 삭제
|
||||
const handleDelete = async (fileId) => {
|
||||
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteFileApi(fileId);
|
||||
await fetchFiles(); // 목록 새로고침
|
||||
} catch (err) {
|
||||
console.error('파일 삭제 실패:', err);
|
||||
setError('파일 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 확인 페이지로 이동
|
||||
// 구매 수량 계산 (자재 목록 페이지 거치지 않음)
|
||||
const handleViewMaterials = async (file) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 구매 수량 계산 API 호출
|
||||
console.log('구매 수량 계산 API 호출:', {
|
||||
job_no: file.job_no,
|
||||
revision: file.revision || 'Rev.0',
|
||||
file_id: file.id
|
||||
});
|
||||
|
||||
const response = await api.get(`/purchase/items/calculate?job_no=${file.job_no}&revision=${file.revision || 'Rev.0'}&file_id=${file.id}`);
|
||||
|
||||
console.log('구매 수량 계산 응답:', response.data);
|
||||
const purchaseData = response.data;
|
||||
|
||||
if (purchaseData.success && purchaseData.items) {
|
||||
// 구매 수량 계산 결과를 모달로 표시
|
||||
setPurchaseModal({
|
||||
open: true,
|
||||
data: purchaseData.items,
|
||||
fileInfo: file
|
||||
});
|
||||
} else {
|
||||
alert('구매 수량 계산에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 오류:', error);
|
||||
alert('구매 수량 계산 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 리비전 업로드 다이얼로그 열기
|
||||
const openRevisionDialog = (bomName, parentId) => {
|
||||
setRevisionDialog({ open: true, bomName, parentId });
|
||||
};
|
||||
|
||||
// 리비전 업로드
|
||||
const handleRevisionUpload = async () => {
|
||||
if (!revisionFile) {
|
||||
if (!revisionFile || !revisionDialog.bomName) {
|
||||
setError('파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
@@ -133,27 +187,21 @@ const BOMStatusPage = () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', revisionFile);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('revision', 'Rev.0'); // 백엔드에서 자동 증가
|
||||
formData.append('bom_name', revisionDialog.bomName);
|
||||
formData.append('parent_file_id', revisionDialog.parentId);
|
||||
formData.append('parent_id', revisionDialog.parentId);
|
||||
|
||||
const response = await uploadFileApi(formData);
|
||||
await uploadFileApi(formData);
|
||||
|
||||
if (response.data.success) {
|
||||
// 업로드 성공 후 파일 목록 새로고침
|
||||
await fetchFiles();
|
||||
|
||||
// 다이얼로그 닫기
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
fetchFiles();
|
||||
alert(`리비전 업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision}`);
|
||||
} else {
|
||||
setError(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('리비전 업로드 에러:', e);
|
||||
if (e.response?.data?.detail) {
|
||||
setError(e.response.data.detail);
|
||||
} else {
|
||||
|
||||
} catch (err) {
|
||||
console.error('리비전 업로드 실패:', err);
|
||||
setError('리비전 업로드에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -183,235 +231,273 @@ const BOMStatusPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
||||
← 뒤로가기
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
|
||||
{jobNo && jobName && (
|
||||
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
|
||||
{jobNo} - {jobName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 파일 업로드 폼 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>새 BOM 업로드</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="BOM 이름"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="예: PIPING_BOM_A구역"
|
||||
required
|
||||
size="small"
|
||||
helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</Button>
|
||||
</Box>
|
||||
{selectedFile && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
선택된 파일: {selectedFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>업로드된 BOM 목록</Typography>
|
||||
{loading && <CircularProgress />}
|
||||
{!loading && files.length === 0 && (
|
||||
<Alert severity="info">업로드된 BOM이 없습니다.</Alert>
|
||||
)}
|
||||
{!loading && files.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>BOM 이름</TableCell>
|
||||
<TableCell>파일명</TableCell>
|
||||
<TableCell>리비전</TableCell>
|
||||
<TableCell>자재 수</TableCell>
|
||||
<TableCell>업로드 일시</TableCell>
|
||||
<TableCell>작업</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
||||
bomFiles.map((file, index) => (
|
||||
<TableRow key={file.id} sx={{
|
||||
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
|
||||
{file.bom_name || bomKey}
|
||||
</Typography>
|
||||
{index === 0 && bomFiles.length > 1 && (
|
||||
<Typography variant="caption" color="primary">
|
||||
(최신 리비전)
|
||||
</Typography>
|
||||
)}
|
||||
{index > 0 && (
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
(이전 버전)
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.filename || file.original_filename}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={index === 0 ? 'primary' : 'textSecondary'}
|
||||
fontWeight={index === 0 ? 'bold' : 'normal'}
|
||||
>
|
||||
{file.revision || 'Rev.0'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.parsed_count || 0}개
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
|
||||
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="small"
|
||||
variant={index === 0 ? "contained" : "outlined"}
|
||||
onClick={() => navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.filename || file.original_filename)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
자재확인
|
||||
</Button>
|
||||
{index === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setRevisionDialog({
|
||||
open: true,
|
||||
bomName: file.bom_name || bomKey,
|
||||
parentId: file.id
|
||||
})}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
리비전
|
||||
</Button>
|
||||
)}
|
||||
{file.revision !== 'Rev.0' && index < 3 && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${file.revision}&filename=${encodeURIComponent(file.original_filename)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
비교
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="success"
|
||||
onClick={() => navigate(`/revision-purchase?job_no=${jobNo}¤t_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
구매 필요
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={async () => {
|
||||
if (confirm(`정말 "${file.revision || 'Rev.0'}"을 삭제하시겠습니까?`)) {
|
||||
try {
|
||||
const response = await deleteFileApi(file.id);
|
||||
if (response.data.success) {
|
||||
fetchFiles();
|
||||
alert('삭제되었습니다.');
|
||||
} else {
|
||||
alert('삭제 실패: ' + (response.data.message || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('삭제 오류:', e);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('bom')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
← 뒤로가기
|
||||
</button>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📊 BOM 관리 시스템
|
||||
</h1>
|
||||
|
||||
{jobNo && jobName && (
|
||||
<h2 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#4299e1',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
{jobNo} - {jobName}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 컴포넌트 */}
|
||||
<BOMFileUpload
|
||||
bomName={bomName}
|
||||
setBomName={setBomName}
|
||||
selectedFile={selectedFile}
|
||||
setSelectedFile={setSelectedFile}
|
||||
uploading={uploading}
|
||||
handleUpload={handleUpload}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* BOM 목록 */}
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '32px 0 16px 0'
|
||||
}}>
|
||||
업로드된 BOM 목록
|
||||
</h3>
|
||||
|
||||
{/* 파일 테이블 컴포넌트 */}
|
||||
<BOMFileTable
|
||||
files={files}
|
||||
loading={loading}
|
||||
groupFilesByBOM={groupFilesByBOM}
|
||||
handleViewMaterials={handleViewMaterials}
|
||||
openRevisionDialog={openRevisionDialog}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 리비전 업로드 다이얼로그 */}
|
||||
<Dialog open={revisionDialog.open} onClose={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}>
|
||||
<DialogTitle>리비전 업로드</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
BOM 이름: <strong>{revisionDialog.bomName}</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다.
|
||||
</Typography>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||
style={{ marginTop: 16 }}
|
||||
<RevisionUploadDialog
|
||||
revisionDialog={revisionDialog}
|
||||
setRevisionDialog={setRevisionDialog}
|
||||
revisionFile={revisionFile}
|
||||
setRevisionFile={setRevisionFile}
|
||||
handleRevisionUpload={handleRevisionUpload}
|
||||
uploading={uploading}
|
||||
/>
|
||||
{revisionFile && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
선택된 파일: {revisionFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => {
|
||||
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||
setRevisionFile(null);
|
||||
|
||||
{/* 구매 수량 계산 결과 모달 */}
|
||||
{purchaseModal.open && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleRevisionUpload}
|
||||
disabled={!revisionFile || uploading}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '1000px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
margin: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: 0
|
||||
}}>
|
||||
🧮 구매 수량 계산 결과
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setPurchaseModal({ open: false, data: null, fileInfo: null })}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px', color: '#4a5568' }}>
|
||||
<div><strong>프로젝트:</strong> {purchaseModal.fileInfo?.job_no}</div>
|
||||
<div><strong>BOM:</strong> {purchaseModal.fileInfo?.bom_name}</div>
|
||||
<div><strong>리비전:</strong> {purchaseModal.fileInfo?.revision || 'Rev.0'}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{purchaseModal.data?.map((item, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(item.category),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.specification}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#e6fffa',
|
||||
color: '#065f46',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.size_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#fef7e0',
|
||||
color: '#92400e',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.material_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${Math.round(item.bom_quantity)}mm` :
|
||||
item.bom_quantity
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
|
||||
item.calculated_qty
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.unit}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
|
||||
{item.category === 'PIPE' && (
|
||||
<div>
|
||||
<div>절단수: {item.cutting_count}회</div>
|
||||
<div>절단손실: {item.cutting_loss}mm</div>
|
||||
<div>활용률: {Math.round(item.utilization_rate)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{item.category !== 'PIPE' && item.safety_factor && (
|
||||
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
|
||||
<div>• <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
|
||||
<div>• <strong>FITTING:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>VALVE:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>BOLT:</strong> 5% 여유율 후 4의 배수 올림</div>
|
||||
<div>• <strong>GASKET:</strong> 5의 배수 올림</div>
|
||||
<div>• <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
264
frontend/src/pages/DashboardPage.jsx
Normal file
264
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const DashboardPage = ({ user }) => {
|
||||
const [stats, setStats] = useState({
|
||||
totalProjects: 0,
|
||||
activeProjects: 0,
|
||||
completedProjects: 0,
|
||||
totalMaterials: 0,
|
||||
pendingQuotes: 0,
|
||||
recentActivities: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 실제로는 API에서 데이터를 가져올 예정
|
||||
// 현재는 더미 데이터 사용
|
||||
setStats({
|
||||
totalProjects: 25,
|
||||
activeProjects: 8,
|
||||
completedProjects: 17,
|
||||
totalMaterials: 1250,
|
||||
pendingQuotes: 3,
|
||||
recentActivities: [
|
||||
{ id: 1, type: 'project', message: '냉동기 프로젝트 #2024-001 생성됨', time: '2시간 전' },
|
||||
{ id: 2, type: 'bom', message: 'BOG 시스템 BOM 업데이트됨', time: '4시간 전' },
|
||||
{ id: 3, type: 'quote', message: '다이아프람 펌프 견적서 승인됨', time: '6시간 전' },
|
||||
{ id: 4, type: 'material', message: '스테인리스 파이프 재고 부족 알림', time: '1일 전' },
|
||||
{ id: 5, type: 'shipment', message: '드라이어 시스템 출하 완료', time: '2일 전' }
|
||||
]
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getActivityIcon = (type) => {
|
||||
const icons = {
|
||||
project: '📋',
|
||||
bom: '🔧',
|
||||
quote: '💰',
|
||||
material: '📦',
|
||||
shipment: '🚚'
|
||||
};
|
||||
return icons[type] || '📌';
|
||||
};
|
||||
|
||||
const StatCard = ({ title, value, icon, color = '#667eea' }) => (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '32px',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
안녕하세요, {user?.name}님! 👋
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
오늘도 TK-MP 시스템과 함께 효율적인 업무를 시작해보세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: '24px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<StatCard
|
||||
title="전체 프로젝트"
|
||||
value={stats.totalProjects}
|
||||
icon="📋"
|
||||
color="#667eea"
|
||||
/>
|
||||
<StatCard
|
||||
title="진행중인 프로젝트"
|
||||
value={stats.activeProjects}
|
||||
icon="🚀"
|
||||
color="#48bb78"
|
||||
/>
|
||||
<StatCard
|
||||
title="완료된 프로젝트"
|
||||
value={stats.completedProjects}
|
||||
icon="✅"
|
||||
color="#38b2ac"
|
||||
/>
|
||||
<StatCard
|
||||
title="등록된 자재"
|
||||
value={stats.totalMaterials}
|
||||
icon="📦"
|
||||
color="#ed8936"
|
||||
/>
|
||||
<StatCard
|
||||
title="대기중인 견적"
|
||||
value={stats.pendingQuotes}
|
||||
icon="💰"
|
||||
color="#9f7aea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* 최근 활동 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📈 최근 활동
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{stats.recentActivities.map(activity => (
|
||||
<div key={activity.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{getActivityIcon(activity.type)}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{activity.message}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{activity.time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빠른 작업 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
⚡ 빠른 작업
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{[
|
||||
{ title: '새 프로젝트 등록', icon: '➕', color: '#667eea' },
|
||||
{ title: 'BOM 업로드', icon: '📤', color: '#48bb78' },
|
||||
{ title: '견적서 작성', icon: '📝', color: '#ed8936' },
|
||||
{ title: '자재 검색', icon: '🔍', color: '#38b2ac' }
|
||||
].map((action, index) => (
|
||||
<button key={index} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#f7fafc';
|
||||
e.target.style.borderColor = action.color;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'transparent';
|
||||
e.target.style.borderColor = '#e2e8f0';
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>{action.icon}</span>
|
||||
<span>{action.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
334
frontend/src/pages/JobRegistrationPage.css
Normal file
334
frontend/src/pages/JobRegistrationPage.css
Normal file
@@ -0,0 +1,334 @@
|
||||
.job-registration-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.job-registration-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.registration-form {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 25px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group label.required::after {
|
||||
content: ' *';
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group select.error,
|
||||
.form-group textarea.error {
|
||||
border-color: #e53e3e;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e53e3e;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #f7fafc;
|
||||
color: #4a5568;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: #edf2f7;
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.job-registration-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.registration-form {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 프로젝트 유형 관리 스타일 */
|
||||
.project-type-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-type-container select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-type-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.add-type-btn,
|
||||
.remove-type-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-type-btn {
|
||||
color: #38a169;
|
||||
border-color: #38a169;
|
||||
}
|
||||
|
||||
.add-type-btn:hover {
|
||||
background: #38a169;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.remove-type-btn {
|
||||
color: #e53e3e;
|
||||
border-color: #e53e3e;
|
||||
}
|
||||
|
||||
.remove-type-btn:hover {
|
||||
background: #e53e3e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-project-type-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: #f7fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.add-project-type-form input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.add-project-type-form button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-project-type-form button:first-of-type {
|
||||
background: #38a169;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-project-type-form button:first-of-type:hover {
|
||||
background: #2f855a;
|
||||
}
|
||||
|
||||
.add-project-type-form button:last-of-type {
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.add-project-type-form button:last-of-type:hover {
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
/* 태블릿 반응형 */
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.job-registration-container {
|
||||
margin: 20px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 모바일에서 프로젝트 유형 관리 */
|
||||
@media (max-width: 768px) {
|
||||
.project-type-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-type-actions {
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.add-project-type-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.add-project-type-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
359
frontend/src/pages/JobRegistrationPage.jsx
Normal file
359
frontend/src/pages/JobRegistrationPage.jsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import './JobRegistrationPage.css';
|
||||
|
||||
const JobRegistrationPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
jobNo: '',
|
||||
projectName: '',
|
||||
clientName: '',
|
||||
location: '',
|
||||
contractDate: '',
|
||||
deliveryDate: '',
|
||||
deliveryMethod: '',
|
||||
description: '',
|
||||
projectType: '냉동기',
|
||||
status: 'PLANNING'
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [projectTypes, setProjectTypes] = useState([
|
||||
{ value: '냉동기', label: '냉동기' },
|
||||
{ value: 'BOG', label: 'BOG' },
|
||||
{ value: '다이아프람', label: '다이아프람' },
|
||||
{ value: '드라이어', label: '드라이어' }
|
||||
]);
|
||||
|
||||
const [newProjectType, setNewProjectType] = useState('');
|
||||
const [showAddProjectType, setShowAddProjectType] = useState(false);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'PLANNING', label: '계획' },
|
||||
{ value: 'DESIGN', label: '설계' },
|
||||
{ value: 'PROCUREMENT', label: '조달' },
|
||||
{ value: 'CONSTRUCTION', label: '시공' },
|
||||
{ value: 'COMPLETED', label: '완료' }
|
||||
];
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 입력 시 에러 제거
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const addProjectType = () => {
|
||||
if (newProjectType.trim() && !projectTypes.find(type => type.value === newProjectType.trim())) {
|
||||
const newType = { value: newProjectType.trim(), label: newProjectType.trim() };
|
||||
setProjectTypes(prev => [...prev, newType]);
|
||||
setFormData(prev => ({ ...prev, projectType: newProjectType.trim() }));
|
||||
setNewProjectType('');
|
||||
setShowAddProjectType(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeProjectType = (valueToRemove) => {
|
||||
if (projectTypes.length > 1) { // 최소 1개는 유지
|
||||
setProjectTypes(prev => prev.filter(type => type.value !== valueToRemove));
|
||||
if (formData.projectType === valueToRemove) {
|
||||
setFormData(prev => ({ ...prev, projectType: projectTypes[0].value }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.jobNo.trim()) {
|
||||
newErrors.jobNo = 'Job No.는 필수 입력 항목입니다.';
|
||||
}
|
||||
|
||||
if (!formData.projectName.trim()) {
|
||||
newErrors.projectName = '프로젝트명은 필수 입력 항목입니다.';
|
||||
}
|
||||
|
||||
if (!formData.clientName.trim()) {
|
||||
newErrors.clientName = '고객사명은 필수 입력 항목입니다.';
|
||||
}
|
||||
|
||||
if (formData.contractDate && formData.deliveryDate && new Date(formData.contractDate) > new Date(formData.deliveryDate)) {
|
||||
newErrors.deliveryDate = '납기일은 수주일 이후여야 합니다.';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Job 생성 API 호출
|
||||
const response = await api.post('/jobs', {
|
||||
job_no: formData.jobNo,
|
||||
job_name: formData.projectName,
|
||||
client_name: formData.clientName,
|
||||
project_site: formData.location || null,
|
||||
contract_date: formData.contractDate || null,
|
||||
delivery_date: formData.deliveryDate || null,
|
||||
delivery_terms: formData.deliveryMethod || null,
|
||||
description: formData.description || null,
|
||||
project_type: formData.projectType,
|
||||
status: formData.status
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
alert('프로젝트가 성공적으로 등록되었습니다!');
|
||||
navigate('/project-selection');
|
||||
} else {
|
||||
alert('등록에 실패했습니다: ' + response.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Job 등록 오류:', error);
|
||||
if (error.response?.data?.detail) {
|
||||
alert('등록 실패: ' + error.response.data.detail);
|
||||
} else {
|
||||
alert('등록 중 오류가 발생했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="job-registration-page">
|
||||
<div className="job-registration-container">
|
||||
<header className="page-header">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
← 메인으로 돌아가기
|
||||
</button>
|
||||
<h1>프로젝트 기본정보 등록</h1>
|
||||
<p>새로운 프로젝트의 Job No. 및 기본 정보를 입력해주세요</p>
|
||||
</header>
|
||||
|
||||
<form className="registration-form" onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="jobNo" className="required">Job No.</label>
|
||||
<input
|
||||
type="text"
|
||||
id="jobNo"
|
||||
name="jobNo"
|
||||
value={formData.jobNo}
|
||||
onChange={handleInputChange}
|
||||
placeholder="예: TK-2025-001"
|
||||
className={errors.jobNo ? 'error' : ''}
|
||||
/>
|
||||
{errors.jobNo && <span className="error-message">{errors.jobNo}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="projectName" className="required">프로젝트명</label>
|
||||
<input
|
||||
type="text"
|
||||
id="projectName"
|
||||
name="projectName"
|
||||
value={formData.projectName}
|
||||
onChange={handleInputChange}
|
||||
placeholder="프로젝트명을 입력하세요"
|
||||
className={errors.projectName ? 'error' : ''}
|
||||
/>
|
||||
{errors.projectName && <span className="error-message">{errors.projectName}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="clientName" className="required">고객사명</label>
|
||||
<input
|
||||
type="text"
|
||||
id="clientName"
|
||||
name="clientName"
|
||||
value={formData.clientName}
|
||||
onChange={handleInputChange}
|
||||
placeholder="고객사명을 입력하세요"
|
||||
className={errors.clientName ? 'error' : ''}
|
||||
/>
|
||||
{errors.clientName && <span className="error-message">{errors.clientName}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="location">프로젝트 위치</label>
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleInputChange}
|
||||
placeholder="예: 울산광역시 남구"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="projectType">프로젝트 유형</label>
|
||||
<div className="project-type-container">
|
||||
<select
|
||||
id="projectType"
|
||||
name="projectType"
|
||||
value={formData.projectType}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
{projectTypes.map(type => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="project-type-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="add-type-btn"
|
||||
onClick={() => setShowAddProjectType(true)}
|
||||
title="프로젝트 유형 추가"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{projectTypes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="remove-type-btn"
|
||||
onClick={() => removeProjectType(formData.projectType)}
|
||||
title="현재 선택된 유형 삭제"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddProjectType && (
|
||||
<div className="add-project-type-form">
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectType}
|
||||
onChange={(e) => setNewProjectType(e.target.value)}
|
||||
placeholder="새 프로젝트 유형 입력"
|
||||
onKeyPress={(e) => e.key === 'Enter' && addProjectType()}
|
||||
/>
|
||||
<button type="button" onClick={addProjectType}>추가</button>
|
||||
<button type="button" onClick={() => setShowAddProjectType(false)}>취소</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="status">프로젝트 상태</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
{statusOptions.map(status => (
|
||||
<option key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="contractDate">수주일</label>
|
||||
<input
|
||||
type="date"
|
||||
id="contractDate"
|
||||
name="contractDate"
|
||||
value={formData.contractDate}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="deliveryDate">납기일</label>
|
||||
<input
|
||||
type="date"
|
||||
id="deliveryDate"
|
||||
name="deliveryDate"
|
||||
value={formData.deliveryDate}
|
||||
onChange={handleInputChange}
|
||||
className={errors.deliveryDate ? 'error' : ''}
|
||||
/>
|
||||
{errors.deliveryDate && <span className="error-message">{errors.deliveryDate}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="deliveryMethod">납품 방법</label>
|
||||
<select
|
||||
id="deliveryMethod"
|
||||
name="deliveryMethod"
|
||||
value={formData.deliveryMethod}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
<option value="">납품 방법 선택</option>
|
||||
<option value="FOB">FOB (Free On Board)</option>
|
||||
<option value="CIF">CIF (Cost, Insurance and Freight)</option>
|
||||
<option value="EXW">EXW (Ex Works)</option>
|
||||
<option value="DDP">DDP (Delivered Duty Paid)</option>
|
||||
<option value="직접납품">직접납품</option>
|
||||
<option value="택배">택배</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group full-width">
|
||||
<label htmlFor="description">프로젝트 설명</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleInputChange}
|
||||
placeholder="프로젝트에 대한 상세 설명을 입력하세요"
|
||||
rows="4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-button"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '등록 중...' : '프로젝트 등록'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobRegistrationPage;
|
||||
@@ -1,15 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Button, CircularProgress, Alert } from '@mui/material';
|
||||
import { fetchJobs } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const JobSelectionPage = () => {
|
||||
const JobSelectionPage = ({ onJobSelect }) => {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selectedJobNo, setSelectedJobNo] = useState('');
|
||||
const [selectedJobName, setSelectedJobName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadJobs() {
|
||||
@@ -39,46 +36,122 @@ const JobSelectionPage = () => {
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedJobNo && selectedJobName) {
|
||||
navigate(`/bom-status?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`);
|
||||
if (selectedJobNo && selectedJobName && onJobSelect) {
|
||||
onJobSelect(selectedJobNo, selectedJobName);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
|
||||
<Typography variant="h4" gutterBottom>프로젝트 선택</Typography>
|
||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
|
||||
<InputLabel>프로젝트</InputLabel>
|
||||
<Select
|
||||
value={selectedJobNo}
|
||||
label="프로젝트"
|
||||
onChange={handleSelect}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">선택</MenuItem>
|
||||
{jobs.map(job => (
|
||||
<MenuItem key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} ({job.job_name})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedJobNo && (
|
||||
<Alert severity="info" sx={{ mt: 3 }}>
|
||||
선택된 프로젝트: <b>{selectedJobNo} ({selectedJobName})</b>
|
||||
</Alert>
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📋 프로젝트 선택
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0 0 32px 0'
|
||||
}}>
|
||||
BOM 관리할 프로젝트를 선택하세요.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ mt: 4, minWidth: 120 }}
|
||||
disabled={!selectedJobNo}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px'
|
||||
}}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
프로젝트 선택
|
||||
</label>
|
||||
<select
|
||||
value={selectedJobNo}
|
||||
onChange={handleSelect}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
{jobs.map(job => (
|
||||
<option key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} - {job.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{selectedJobNo && selectedJobName && (
|
||||
<div style={{
|
||||
background: '#c6f6d5',
|
||||
border: '1px solid #68d391',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '20px',
|
||||
color: '#2f855a'
|
||||
}}>
|
||||
선택된 프로젝트: <strong>{selectedJobNo} - {selectedJobName}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedJobNo}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 24px',
|
||||
background: selectedJobNo ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : '#e2e8f0',
|
||||
color: selectedJobNo ? 'white' : '#a0aec0',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: selectedJobNo ? 'pointer' : 'not-allowed',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</Box>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
219
frontend/src/pages/LoginPage.css
Normal file
219
frontend/src/pages/LoginPage.css
Normal file
@@ -0,0 +1,219 @@
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
color: #2d3748;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fed7d7;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 8px;
|
||||
color: #c53030;
|
||||
font-size: 14px;
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
padding: 14px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.login-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.system-info small {
|
||||
color: #a0aec0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 다크모드 지원 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.login-card {
|
||||
background: #1a202c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
background: #2d3748;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
116
frontend/src/pages/LoginPage.jsx
Normal file
116
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './LoginPage.css';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// 입력 시 에러 메시지 초기화
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.username || !formData.password) {
|
||||
setError('사용자명과 비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(formData.username, formData.password);
|
||||
} catch (err) {
|
||||
setError(err.message || '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>🚀 TK-MP System</h1>
|
||||
<p>통합 프로젝트 관리 시스템</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">사용자명</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="사용자명을 입력하세요"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<span className="error-icon">⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="loading-spinner"></span>
|
||||
로그인 중...
|
||||
</>
|
||||
) : (
|
||||
'로그인'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>계정이 없으신가요? 관리자에게 문의하세요.</p>
|
||||
<div className="system-info">
|
||||
<small>TK-MP Project Management System v2.0</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
215
frontend/src/pages/MainPage.css
Normal file
215
frontend/src/pages/MainPage.css
Normal file
@@ -0,0 +1,215 @@
|
||||
.main-page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
text-align: center;
|
||||
padding: 60px 40px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.main-header h1 {
|
||||
font-size: 2.25rem;
|
||||
color: #1a202c;
|
||||
margin: 0 0 12px 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.main-header p {
|
||||
font-size: 1rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.banner-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.main-banner {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.main-banner:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08);
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.job-registration-banner:hover {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.bom-management-banner:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.job-registration-banner .banner-icon {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.bom-management-banner .banner-icon {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.banner-content h2 {
|
||||
font-size: 1.25rem;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.banner-content p {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.banner-action {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.job-registration-banner .banner-action {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.bom-management-banner .banner-action {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 48px;
|
||||
padding-top: 48px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.feature-item h3 {
|
||||
font-size: 1rem;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-item p {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.main-footer p {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.main-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.main-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.banner-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.main-banner {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 25px 20px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
}
|
||||
85
frontend/src/pages/MainPage.jsx
Normal file
85
frontend/src/pages/MainPage.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './MainPage.css';
|
||||
|
||||
const MainPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="main-page">
|
||||
<div className="main-container">
|
||||
<header className="main-header">
|
||||
<h1>TK Material Planning System</h1>
|
||||
<p>자재 계획 및 BOM 관리 시스템</p>
|
||||
</header>
|
||||
|
||||
<div className="main-content">
|
||||
<div className="banner-container">
|
||||
<div
|
||||
className="main-banner job-registration-banner"
|
||||
onClick={() => navigate('/job-registration')}
|
||||
>
|
||||
<div className="banner-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14,2 14,8 20,8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="banner-content">
|
||||
<h2>기본정보 등록</h2>
|
||||
<p>새로운 프로젝트의 Job No. 및 기본 정보를 등록합니다</p>
|
||||
<div className="banner-action">등록하기 →</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="main-banner bom-management-banner"
|
||||
onClick={() => navigate('/project-selection')}
|
||||
>
|
||||
<div className="banner-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
||||
<path d="M7 2v4"/>
|
||||
<path d="M17 2v4"/>
|
||||
<path d="M14 12h.01"/>
|
||||
<path d="M10 12h.01"/>
|
||||
<path d="M16 16h.01"/>
|
||||
<path d="M12 16h.01"/>
|
||||
<path d="M8 16h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="banner-content">
|
||||
<h2>BOM 관리</h2>
|
||||
<p>기존 프로젝트의 BOM 자료를 관리하고 분석합니다</p>
|
||||
<div className="banner-action">관리하기 →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feature-info">
|
||||
<div className="feature-item">
|
||||
<h3>📊 자재 분석</h3>
|
||||
<p>엑셀 파일 업로드를 통한 자동 자재 분류 및 분석</p>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<h3>💰 구매 최적화</h3>
|
||||
<p>리비전별 자재 비교 및 구매 확정 관리</p>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<h3>🔧 Tubing 관리</h3>
|
||||
<p>제조사별 튜빙 규격 및 품목번호 통합 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="main-footer">
|
||||
<p>© 2025 Technical Korea. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainPage;
|
||||
486
frontend/src/pages/MaterialsManagementPage.jsx
Normal file
486
frontend/src/pages/MaterialsManagementPage.jsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import MaterialList from '../components/MaterialList';
|
||||
import { fetchMaterials } from '../api';
|
||||
|
||||
const MaterialsManagementPage = ({ user }) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [filteredMaterials, setFilteredMaterials] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
project: '',
|
||||
category: '',
|
||||
status: '',
|
||||
search: ''
|
||||
});
|
||||
const [stats, setStats] = useState({
|
||||
totalMaterials: 0,
|
||||
categorizedMaterials: 0,
|
||||
uncategorizedMaterials: 0,
|
||||
categories: {}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
loadAllMaterials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
}, [materials, filters]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProjects(data.jobs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAllMaterials = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 기존 API 함수 사용 - 모든 자재 데이터 로딩
|
||||
const response = await fetchMaterials({ limit: 10000 }); // 충분히 큰 limit
|
||||
const materialsData = response.data?.materials || [];
|
||||
|
||||
setMaterials(materialsData);
|
||||
calculateStats(materialsData);
|
||||
} catch (error) {
|
||||
console.error('자재 데이터 로딩 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateStats = (materialsData) => {
|
||||
const totalMaterials = materialsData.length;
|
||||
const categorizedMaterials = materialsData.filter(m => m.classified_category && m.classified_category !== 'Unknown').length;
|
||||
const uncategorizedMaterials = totalMaterials - categorizedMaterials;
|
||||
|
||||
// 카테고리별 통계
|
||||
const categories = {};
|
||||
materialsData.forEach(material => {
|
||||
const category = material.classified_category || 'Unknown';
|
||||
categories[category] = (categories[category] || 0) + 1;
|
||||
});
|
||||
|
||||
setStats({
|
||||
totalMaterials,
|
||||
categorizedMaterials,
|
||||
uncategorizedMaterials,
|
||||
categories
|
||||
});
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
let filtered = [...materials];
|
||||
|
||||
// 프로젝트 필터
|
||||
if (filters.project) {
|
||||
filtered = filtered.filter(m => m.job_no === filters.project);
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (filters.category) {
|
||||
filtered = filtered.filter(m => m.classified_category === filters.category);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filters.status) {
|
||||
if (filters.status === 'categorized') {
|
||||
filtered = filtered.filter(m => m.classified_category && m.classified_category !== 'Unknown');
|
||||
} else if (filters.status === 'uncategorized') {
|
||||
filtered = filtered.filter(m => !m.classified_category || m.classified_category === 'Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (filters.search) {
|
||||
const searchTerm = filters.search.toLowerCase();
|
||||
filtered = filtered.filter(m =>
|
||||
(m.original_description && m.original_description.toLowerCase().includes(searchTerm)) ||
|
||||
(m.size_spec && m.size_spec.toLowerCase().includes(searchTerm)) ||
|
||||
(m.classified_category && m.classified_category.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredMaterials(filtered);
|
||||
};
|
||||
|
||||
const handleFilterChange = (filterType, value) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[filterType]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
project: '',
|
||||
category: '',
|
||||
status: '',
|
||||
search: ''
|
||||
});
|
||||
};
|
||||
|
||||
const StatCard = ({ title, value, icon, color = '#667eea', subtitle }) => (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: color + '20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px'
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📦 자재 관리
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
전체 프로젝트의 자재 정보를 통합 관리하고 분석하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<StatCard
|
||||
title="전체 자재"
|
||||
value={stats.totalMaterials}
|
||||
icon="📦"
|
||||
color="#667eea"
|
||||
subtitle={`${projects.length}개 프로젝트`}
|
||||
/>
|
||||
<StatCard
|
||||
title="분류 완료"
|
||||
value={stats.categorizedMaterials}
|
||||
icon="✅"
|
||||
color="#48bb78"
|
||||
subtitle={`${Math.round((stats.categorizedMaterials / stats.totalMaterials) * 100) || 0}% 완료`}
|
||||
/>
|
||||
<StatCard
|
||||
title="미분류"
|
||||
value={stats.uncategorizedMaterials}
|
||||
icon="⚠️"
|
||||
color="#ed8936"
|
||||
subtitle="분류 작업 필요"
|
||||
/>
|
||||
<StatCard
|
||||
title="카테고리"
|
||||
value={Object.keys(stats.categories).length}
|
||||
icon="🏷️"
|
||||
color="#9f7aea"
|
||||
subtitle="자재 분류 유형"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필터 섹션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0'
|
||||
}}>
|
||||
🔍 필터 및 검색
|
||||
</h3>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{/* 프로젝트 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
프로젝트
|
||||
</label>
|
||||
<select
|
||||
value={filters.project}
|
||||
onChange={(e) => handleFilterChange('project', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체 프로젝트</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.job_no} value={project.job_no}>
|
||||
{project.job_no} - {project.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
카테고리
|
||||
</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => handleFilterChange('category', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체 카테고리</option>
|
||||
{Object.keys(stats.categories).map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category} ({stats.categories[category]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
분류 상태
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="categorized">분류 완료</option>
|
||||
<option value="uncategorized">미분류</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
검색
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 코드, 카테고리 검색..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 결과 요약 */}
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<strong>{filteredMaterials.length.toLocaleString()}</strong>개의 자재가 검색되었습니다.
|
||||
{filters.project && ` (프로젝트: ${filters.project})`}
|
||||
{filters.category && ` (카테고리: ${filters.category})`}
|
||||
{filters.status && ` (상태: ${filters.status === 'categorized' ? '분류완료' : '미분류'})`}
|
||||
{filters.search && ` (검색: "${filters.search}")`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
background: '#f7fafc',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
자재 목록 ({filteredMaterials.length.toLocaleString()}개)
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
📊 분석 리포트
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
📤 Excel 내보내기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MaterialList
|
||||
selectedProject={null} // 전체 자재 보기
|
||||
showProjectInfo={true}
|
||||
enableSelection={true}
|
||||
key="all-materials" // 전체 자재 모드
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsManagementPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, CircularProgress, Alert, Button } from '@mui/material';
|
||||
import { fetchJobs } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const ProjectSelectionPage = () => {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selectedJob, setSelectedJob] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadJobs() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetchJobs({});
|
||||
if (res.data && Array.isArray(res.data.jobs)) {
|
||||
setJobs(res.data.jobs);
|
||||
} else {
|
||||
setJobs([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('프로젝트 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
|
||||
<Typography variant="h4" gutterBottom>프로젝트(Job No) 선택</Typography>
|
||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
|
||||
<InputLabel>Job No</InputLabel>
|
||||
<Select
|
||||
value={selectedJob}
|
||||
label="Job No"
|
||||
onChange={e => setSelectedJob(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">선택</MenuItem>
|
||||
{jobs.map(job => (
|
||||
<MenuItem key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} ({job.job_name})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedJob && (
|
||||
<Alert severity="info" sx={{ mt: 3 }}>
|
||||
선택된 Job No: <b>{selectedJob}</b>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ mt: 4, minWidth: 120 }}
|
||||
disabled={!selectedJob}
|
||||
onClick={() => navigate(`/bom-status?job_no=${selectedJob}`)}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSelectionPage;
|
||||
388
frontend/src/pages/ProjectsPage.jsx
Normal file
388
frontend/src/pages/ProjectsPage.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const ProjectsPage = ({ user }) => {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 실제로는 API에서 프로젝트 데이터를 가져올 예정
|
||||
// 현재는 더미 데이터 사용
|
||||
setTimeout(() => {
|
||||
setProjects([
|
||||
{
|
||||
id: 1,
|
||||
name: '냉동기 시스템 개발',
|
||||
type: '냉동기',
|
||||
status: '진행중',
|
||||
startDate: '2024-01-15',
|
||||
endDate: '2024-06-30',
|
||||
deliveryMethod: 'FOB',
|
||||
progress: 65,
|
||||
manager: '김철수'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'BOG 처리 시스템',
|
||||
type: 'BOG',
|
||||
status: '계획',
|
||||
startDate: '2024-02-01',
|
||||
endDate: '2024-08-15',
|
||||
deliveryMethod: 'CIF',
|
||||
progress: 15,
|
||||
manager: '이영희'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '다이아프람 펌프 제작',
|
||||
type: '다이아프람',
|
||||
status: '완료',
|
||||
startDate: '2023-10-01',
|
||||
endDate: '2024-01-31',
|
||||
deliveryMethod: 'FOB',
|
||||
progress: 100,
|
||||
manager: '박민수'
|
||||
}
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'계획': '#ed8936',
|
||||
'진행중': '#48bb78',
|
||||
'완료': '#38b2ac',
|
||||
'보류': '#e53e3e'
|
||||
};
|
||||
return colors[status] || '#718096';
|
||||
};
|
||||
|
||||
const getProgressColor = (progress) => {
|
||||
if (progress >= 80) return '#48bb78';
|
||||
if (progress >= 50) return '#ed8936';
|
||||
return '#e53e3e';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
fontSize: '16px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
프로젝트 목록을 불러오는 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📋 프로젝트 관리
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
전체 프로젝트를 관리하고 진행 상황을 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.transform = 'translateY(-1px)'}
|
||||
onMouseLeave={(e) => e.target.style.transform = 'translateY(0)'}
|
||||
>
|
||||
<span>➕</span>
|
||||
새 프로젝트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 통계 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
{[
|
||||
{ label: '전체', count: projects.length, color: '#667eea' },
|
||||
{ label: '진행중', count: projects.filter(p => p.status === '진행중').length, color: '#48bb78' },
|
||||
{ label: '완료', count: projects.filter(p => p.status === '완료').length, color: '#38b2ac' },
|
||||
{ label: '계획', count: projects.filter(p => p.status === '계획').length, color: '#ed8936' }
|
||||
].map((stat, index) => (
|
||||
<div key={index} style={{
|
||||
background: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: stat.color,
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{stat.count}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 목록 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
background: '#f7fafc'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
프로젝트 목록 ({projects.length}개)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse'
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
{['프로젝트명', '유형', '상태', '수주일', '납기일', '납품방법', '진행률', '담당자'].map(header => (
|
||||
<th key={header} style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
borderBottom: '1px solid #e2e8f0'
|
||||
}}>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(project => (
|
||||
<tr key={project.id} style={{
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
transition: 'background 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#f7fafc'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={{ padding: '16px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{project.name}
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
background: '#edf2f7',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
{project.type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
background: getStatusColor(project.status) + '20',
|
||||
color: getStatusColor(project.status),
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{project.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: '#4a5568' }}>
|
||||
{project.startDate}
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: '#4a5568' }}>
|
||||
{project.endDate}
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: '#4a5568' }}>
|
||||
{project.deliveryMethod}
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
background: '#e2e8f0',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${project.progress}%`,
|
||||
height: '100%',
|
||||
background: getProgressColor(project.progress),
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: getProgressColor(project.progress),
|
||||
minWidth: '35px'
|
||||
}}>
|
||||
{project.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: '#4a5568' }}>
|
||||
{project.manager}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트가 없을 때 */}
|
||||
{projects.length === 0 && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '60px 40px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
등록된 프로젝트가 없습니다
|
||||
</h3>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
첫 번째 프로젝트를 등록해보세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
새 프로젝트 등록
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 생성 폼 모달 (향후 구현) */}
|
||||
{showCreateForm && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>새 프로젝트 등록</h3>
|
||||
<p style={{ color: '#718096', margin: '0 0 24px 0' }}>
|
||||
이 기능은 곧 구현될 예정입니다.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
742
frontend/src/pages/SimpleMaterialsPage.jsx
Normal file
742
frontend/src/pages/SimpleMaterialsPage.jsx
Normal file
@@ -0,0 +1,742 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
const SimpleMaterialsPage = ({ fileId, jobNo: propJobNo, bomName: propBomName, revision: propRevision, filename: propFilename, onNavigate }) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [jobNo, setJobNo] = useState('');
|
||||
const [bomName, setBomName] = useState('');
|
||||
const [currentRevision, setCurrentRevision] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
const [filterConfidence, setFilterConfidence] = useState('all');
|
||||
const [showPurchaseCalculation, setShowPurchaseCalculation] = useState(false);
|
||||
const [purchaseData, setPurchaseData] = useState(null);
|
||||
const [calculatingPurchase, setCalculatingPurchase] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Props로 받은 값들을 초기화
|
||||
if (propJobNo) setJobNo(propJobNo);
|
||||
if (propBomName) setBomName(propBomName);
|
||||
if (propRevision) setCurrentRevision(propRevision);
|
||||
if (propFilename) setFileName(propFilename);
|
||||
|
||||
if (fileId) {
|
||||
loadMaterials(fileId);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError('파일 ID가 지정되지 않았습니다.');
|
||||
}
|
||||
}, [fileId, propJobNo, propBomName, propRevision, propFilename]);
|
||||
|
||||
const loadMaterials = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/files/materials', {
|
||||
params: { file_id: parseInt(id), limit: 10000 }
|
||||
});
|
||||
|
||||
if (response.data && response.data.materials) {
|
||||
setMaterials(response.data.materials);
|
||||
|
||||
// 파일 정보 설정
|
||||
if (response.data.materials.length > 0) {
|
||||
const firstMaterial = response.data.materials[0];
|
||||
setFileName(firstMaterial.filename || '');
|
||||
setJobNo(firstMaterial.project_code || '');
|
||||
setBomName(firstMaterial.filename || '');
|
||||
setCurrentRevision('Rev.0'); // API에서 revision 정보가 없으므로 기본값
|
||||
}
|
||||
} else {
|
||||
setMaterials([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('자재 목록 로드 실패:', err);
|
||||
setError('자재 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 구매 수량 계산 함수 (기존 BOM 규칙 적용)
|
||||
const calculatePurchaseQuantities = async () => {
|
||||
if (!jobNo || !currentRevision) {
|
||||
alert('프로젝트 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCalculatingPurchase(true);
|
||||
try {
|
||||
const response = await api.get(`/purchase/calculate`, {
|
||||
params: {
|
||||
job_no: jobNo,
|
||||
revision: currentRevision,
|
||||
file_id: fileId
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
setPurchaseData(response.data.purchase_items);
|
||||
setShowPurchaseCalculation(true);
|
||||
} else {
|
||||
throw new Error('구매 수량 계산 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('구매 수량 계산 오류:', error);
|
||||
alert('구매 수량 계산에 실패했습니다.');
|
||||
} finally {
|
||||
setCalculatingPurchase(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 자재 목록 (기존 BOM 규칙 적용)
|
||||
const filteredMaterials = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.original_description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.size_spec?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.material_grade?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory = filterCategory === 'all' ||
|
||||
material.classified_category === filterCategory;
|
||||
|
||||
// 신뢰도 필터링 (기존 BOM 규칙)
|
||||
const matchesConfidence = filterConfidence === 'all' ||
|
||||
(filterConfidence === 'high' && material.classification_confidence >= 0.9) ||
|
||||
(filterConfidence === 'medium' && material.classification_confidence >= 0.7 && material.classification_confidence < 0.9) ||
|
||||
(filterConfidence === 'low' && material.classification_confidence < 0.7);
|
||||
|
||||
return matchesSearch && matchesCategory && matchesConfidence;
|
||||
});
|
||||
|
||||
// 카테고리별 통계
|
||||
const categoryStats = materials.reduce((acc, material) => {
|
||||
const category = material.classified_category || 'unknown';
|
||||
acc[category] = (acc[category] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const categories = Object.keys(categoryStats).sort();
|
||||
|
||||
// 카테고리별 색상 함수
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'pipe': '#4299e1',
|
||||
'fitting': '#48bb78',
|
||||
'valve': '#ed8936',
|
||||
'flange': '#9f7aea',
|
||||
'bolt': '#38b2ac',
|
||||
'gasket': '#f56565',
|
||||
'instrument': '#d69e2e',
|
||||
'material': '#718096',
|
||||
'integrated': '#319795',
|
||||
'unknown': '#a0aec0'
|
||||
};
|
||||
return colors[category?.toLowerCase()] || colors.unknown;
|
||||
};
|
||||
|
||||
// 신뢰도 배지 함수 (기존 BOM 규칙 적용)
|
||||
const getConfidenceBadge = (confidence) => {
|
||||
if (!confidence) return '-';
|
||||
|
||||
const conf = parseFloat(confidence);
|
||||
let color, text;
|
||||
|
||||
if (conf >= 0.9) {
|
||||
color = '#48bb78'; // 녹색
|
||||
text = '높음';
|
||||
} else if (conf >= 0.7) {
|
||||
color = '#ed8936'; // 주황색
|
||||
text = '보통';
|
||||
} else {
|
||||
color = '#f56565'; // 빨간색
|
||||
text = '낮음';
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{
|
||||
background: color,
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#718096' }}>
|
||||
{Math.round(conf * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 상세정보 표시 함수 (기존 BOM 규칙 적용)
|
||||
const getDetailInfo = (material) => {
|
||||
const details = [];
|
||||
|
||||
// PIPE 상세정보
|
||||
if (material.pipe_details) {
|
||||
const pipe = material.pipe_details;
|
||||
if (pipe.schedule) details.push(`SCH ${pipe.schedule}`);
|
||||
if (pipe.manufacturing_method) details.push(pipe.manufacturing_method);
|
||||
if (pipe.end_preparation) details.push(pipe.end_preparation);
|
||||
}
|
||||
|
||||
// FITTING 상세정보
|
||||
if (material.fitting_details) {
|
||||
const fitting = material.fitting_details;
|
||||
if (fitting.fitting_type) details.push(fitting.fitting_type);
|
||||
if (fitting.connection_method && fitting.connection_method !== 'UNKNOWN') {
|
||||
details.push(fitting.connection_method);
|
||||
}
|
||||
if (fitting.pressure_rating && fitting.pressure_rating !== 'UNKNOWN') {
|
||||
details.push(fitting.pressure_rating);
|
||||
}
|
||||
}
|
||||
|
||||
// VALVE 상세정보
|
||||
if (material.valve_details) {
|
||||
const valve = material.valve_details;
|
||||
if (valve.valve_type) details.push(valve.valve_type);
|
||||
if (valve.connection_type) details.push(valve.connection_type);
|
||||
if (valve.pressure_rating) details.push(valve.pressure_rating);
|
||||
}
|
||||
|
||||
// BOLT 상세정보
|
||||
if (material.bolt_details) {
|
||||
const bolt = material.bolt_details;
|
||||
if (bolt.fastener_type) details.push(bolt.fastener_type);
|
||||
if (bolt.thread_specification) details.push(bolt.thread_specification);
|
||||
if (bolt.length_mm) details.push(`L${bolt.length_mm}mm`);
|
||||
}
|
||||
|
||||
// FLANGE 상세정보
|
||||
if (material.flange_details) {
|
||||
const flange = material.flange_details;
|
||||
if (flange.flange_type) details.push(flange.flange_type);
|
||||
if (flange.pressure_rating) details.push(flange.pressure_rating);
|
||||
if (flange.facing_type) details.push(flange.facing_type);
|
||||
}
|
||||
|
||||
return details.length > 0 ? (
|
||||
<div style={{ fontSize: '11px', color: '#4a5568' }}>
|
||||
{details.slice(0, 2).map((detail, idx) => (
|
||||
<div key={idx} style={{
|
||||
background: '#f7fafc',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '3px',
|
||||
marginBottom: '2px',
|
||||
display: 'inline-block',
|
||||
marginRight: '4px'
|
||||
}}>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
{details.length > 2 && (
|
||||
<span style={{ color: '#718096' }}>+{details.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
) : '-';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ padding: '40px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fed7d7',
|
||||
border: '1px solid #fc8181',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#c53030'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('bom-status', { job_no: jobNo, job_name: bomName })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
← 뒤로가기
|
||||
</button>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📦 자재 목록
|
||||
</h1>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
margin: '0 0 24px 0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
<div><strong>프로젝트:</strong> {jobNo}</div>
|
||||
<div><strong>BOM:</strong> {bomName}</div>
|
||||
<div><strong>리비전:</strong> {currentRevision}</div>
|
||||
<div><strong>총 자재 수:</strong> {materials.length}개</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={calculatePurchaseQuantities}
|
||||
disabled={calculatingPurchase}
|
||||
style={{
|
||||
background: calculatingPurchase ? '#a0aec0' : '#48bb78',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: calculatingPurchase ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{calculatingPurchase ? '계산중...' : '🧮 구매수량 계산'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 200px 200px',
|
||||
gap: '16px',
|
||||
alignItems: 'end'
|
||||
}}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
자재 검색
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="자재명, 규격, 설명으로 검색..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
카테고리 필터
|
||||
</label>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="all">전체 ({materials.length})</option>
|
||||
{categories.map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category} ({categoryStats[category]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
신뢰도 필터
|
||||
</label>
|
||||
<select
|
||||
value={filterConfidence}
|
||||
onChange={(e) => setFilterConfidence(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="high">높음 (90%+)</option>
|
||||
<option value="medium">보통 (70-89%)</option>
|
||||
<option value="low">낮음 (70% 미만)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
{categories.slice(0, 6).map(category => (
|
||||
<div key={category} style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#4299e1' }}>
|
||||
{categoryStats[category]}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#718096', marginTop: '4px' }}>
|
||||
{category}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<h3 style={{ margin: '0', fontSize: '18px', fontWeight: '600' }}>
|
||||
자재 목록 ({filteredMaterials.length}개)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>No.</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>자재명</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>규격</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>신뢰도</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>상세정보</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMaterials.map((material, index) => (
|
||||
<tr key={material.id || index} style={{
|
||||
borderBottom: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.line_number || index + 1}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', fontWeight: '500' }}>
|
||||
{material.original_description || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.size_spec || material.main_nom || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{material.quantity || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.unit || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(material.classified_category),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{material.classified_category || 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{material.material_grade || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{getConfidenceBadge(material.classification_confidence)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{getDetailInfo(material)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#718096'
|
||||
}}>
|
||||
검색 조건에 맞는 자재가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구매 수량 계산 결과 모달 */}
|
||||
{showPurchaseCalculation && purchaseData && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '1000px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
margin: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: 0
|
||||
}}>
|
||||
🧮 구매 수량 계산 결과
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowPurchaseCalculation(false)}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7fafc' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{purchaseData.map((item, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
background: getCategoryColor(item.category),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.specification}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#e6fffa',
|
||||
color: '#065f46',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.size_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
|
||||
{item.category !== 'PIPE' && (
|
||||
<span style={{
|
||||
background: '#fef7e0',
|
||||
color: '#92400e',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{item.material_spec || '-'}
|
||||
</span>
|
||||
)}
|
||||
{item.category === 'PIPE' && (
|
||||
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
사양에 포함
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${Math.round(item.bom_quantity)}mm` :
|
||||
item.bom_quantity
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
|
||||
{item.category === 'PIPE' ?
|
||||
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
|
||||
item.calculated_qty
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{item.unit}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
|
||||
{item.category === 'PIPE' && (
|
||||
<div>
|
||||
<div>절단수: {item.cutting_count}회</div>
|
||||
<div>절단손실: {item.cutting_loss}mm</div>
|
||||
<div>활용률: {Math.round(item.utilization_rate)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{item.category !== 'PIPE' && item.safety_factor && (
|
||||
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
|
||||
<div>• <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
|
||||
<div>• <strong>FITTING:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>VALVE:</strong> BOM 수량 그대로</div>
|
||||
<div>• <strong>BOLT:</strong> 5% 여유율 후 4의 배수 올림</div>
|
||||
<div>• <strong>GASKET:</strong> 5의 배수 올림</div>
|
||||
<div>• <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleMaterialsPage;
|
||||
430
frontend/src/pages/UserManagementPage.css
Normal file
430
frontend/src/pages/UserManagementPage.css
Normal file
@@ -0,0 +1,430 @@
|
||||
.user-management-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #2d3748;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.create-user-btn {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-user-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(72, 187, 120, 0.3);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fed7d7;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 8px;
|
||||
color: #c53030;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.close-error {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c53030;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-denied {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.access-denied h2 {
|
||||
color: #2d3748;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.user-form-modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
color: #2d3748;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.user-form-modal form {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-group input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 권한 설정 섹션 */
|
||||
.permissions-section {
|
||||
margin-bottom: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.permissions-section h3 {
|
||||
color: #2d3748;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.permission-category {
|
||||
background: #f7fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.permission-category h4 {
|
||||
color: #2d3748;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-item input {
|
||||
margin: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.permission-item:hover {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
/* 폼 액션 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 12px 32px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 12px 32px;
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
/* 사용자 테이블 */
|
||||
.users-list {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.users-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
padding: 16px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
/* 배지 스타일 */
|
||||
.role-badge,
|
||||
.access-badge,
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-admin { background: #fed7d7; color: #c53030; }
|
||||
.role-system { background: #fbb6ce; color: #b83280; }
|
||||
.role-leader { background: #bee3f8; color: #2b6cb0; }
|
||||
.role-support { background: #c6f6d5; color: #2f855a; }
|
||||
.role-user { background: #e2e8f0; color: #4a5568; }
|
||||
|
||||
.access-manager { background: #fed7d7; color: #c53030; }
|
||||
.access-leader { background: #bee3f8; color: #2b6cb0; }
|
||||
.access-worker { background: #c6f6d5; color: #2f855a; }
|
||||
.access-viewer { background: #faf089; color: #744210; }
|
||||
|
||||
.status-badge.active { background: #c6f6d5; color: #2f855a; }
|
||||
.status-badge.inactive { background: #fed7d7; color: #c53030; }
|
||||
|
||||
/* 액션 버튼 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-btn,
|
||||
.toggle-btn {
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: #bee3f8;
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #90cdf4;
|
||||
}
|
||||
|
||||
.toggle-btn.deactivate {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.toggle-btn.activate {
|
||||
background: #c6f6d5;
|
||||
color: #2f855a;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.user-management-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.permissions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.submit-btn,
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
499
frontend/src/pages/UserManagementPage.jsx
Normal file
499
frontend/src/pages/UserManagementPage.jsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import api from '../api';
|
||||
import './UserManagementPage.css';
|
||||
|
||||
const UserManagementPage = () => {
|
||||
const { user, hasPermission, isAdmin } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
access_level: 'worker',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
permissions: []
|
||||
});
|
||||
|
||||
// 권한 목록 정의
|
||||
const availablePermissions = [
|
||||
{ id: 'bom.view', name: 'BOM 조회', category: 'BOM' },
|
||||
{ id: 'bom.edit', name: 'BOM 편집', category: 'BOM' },
|
||||
{ id: 'bom.delete', name: 'BOM 삭제', category: 'BOM' },
|
||||
{ id: 'project.view', name: '프로젝트 조회', category: '프로젝트' },
|
||||
{ id: 'project.create', name: '프로젝트 생성', category: '프로젝트' },
|
||||
{ id: 'project.edit', name: '프로젝트 편집', category: '프로젝트' },
|
||||
{ id: 'project.delete', name: '프로젝트 삭제', category: '프로젝트' },
|
||||
{ id: 'file.upload', name: '파일 업로드', category: '파일' },
|
||||
{ id: 'file.download', name: '파일 다운로드', category: '파일' },
|
||||
{ id: 'file.delete', name: '파일 삭제', category: '파일' },
|
||||
{ id: 'user.view', name: '사용자 조회', category: '사용자' },
|
||||
{ id: 'user.create', name: '사용자 생성', category: '사용자' },
|
||||
{ id: 'user.edit', name: '사용자 편집', category: '사용자' },
|
||||
{ id: 'user.delete', name: '사용자 삭제', category: '사용자' },
|
||||
{ id: 'system.admin', name: '시스템 관리', category: '시스템' }
|
||||
];
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: '관리자', description: '모든 권한' },
|
||||
{ value: 'system', label: '시스템', description: '시스템 관리' },
|
||||
{ value: 'leader', label: '팀장', description: '팀 관리' },
|
||||
{ value: 'support', label: '지원', description: '지원 업무' },
|
||||
{ value: 'user', label: '사용자', description: '일반 사용자' }
|
||||
];
|
||||
|
||||
const accessLevelOptions = [
|
||||
{ value: 'manager', label: '관리자', description: '전체 관리 권한' },
|
||||
{ value: 'leader', label: '팀장', description: '팀 관리 권한' },
|
||||
{ value: 'worker', label: '작업자', description: '기본 작업 권한' },
|
||||
{ value: 'viewer', label: '조회자', description: '조회 전용' }
|
||||
];
|
||||
|
||||
// 사용자 목록 조회
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/auth/users');
|
||||
if (response.data.success) {
|
||||
setUsers(response.data.users);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
setError('사용자 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPermission('user.view') || isAdmin()) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 폼 데이터 변경 처리
|
||||
const handleFormChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
// 권한 변경 처리
|
||||
const handlePermissionChange = (permissionId, checked) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: checked
|
||||
? [...prev.permissions, permissionId]
|
||||
: prev.permissions.filter(p => p !== permissionId)
|
||||
}));
|
||||
};
|
||||
|
||||
// 사용자 생성
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await api.post('/auth/register', formData);
|
||||
if (response.data.success) {
|
||||
setShowCreateForm(false);
|
||||
setFormData({
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
access_level: 'worker',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
permissions: []
|
||||
});
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error);
|
||||
setError(error.response?.data?.error?.message || '사용자 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 편집
|
||||
const handleEditUser = (userData) => {
|
||||
setEditingUser(userData.user_id);
|
||||
setFormData({
|
||||
username: userData.username,
|
||||
password: '',
|
||||
name: userData.name,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
access_level: userData.access_level,
|
||||
department: userData.department || '',
|
||||
position: userData.position || '',
|
||||
phone: userData.phone || '',
|
||||
is_active: userData.is_active,
|
||||
permissions: userData.permissions || []
|
||||
});
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
// 사용자 업데이트
|
||||
const handleUpdateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const updateData = { ...formData };
|
||||
if (!updateData.password) {
|
||||
delete updateData.password; // 비밀번호가 비어있으면 제외
|
||||
}
|
||||
|
||||
const response = await api.put(`/auth/users/${editingUser}`, updateData);
|
||||
if (response.data.success) {
|
||||
setShowCreateForm(false);
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
access_level: 'worker',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
permissions: []
|
||||
});
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error);
|
||||
setError(error.response?.data?.error?.message || '사용자 수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 활성화/비활성화
|
||||
const handleToggleUserStatus = async (userId, currentStatus) => {
|
||||
try {
|
||||
const response = await api.put(`/auth/users/${userId}`, {
|
||||
is_active: !currentStatus
|
||||
});
|
||||
if (response.data.success) {
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle user status:', error);
|
||||
setError('사용자 상태 변경에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasPermission('user.view') && !isAdmin()) {
|
||||
return (
|
||||
<div className="access-denied">
|
||||
<h2>접근 권한이 없습니다</h2>
|
||||
<p>사용자 관리 페이지에 접근할 권한이 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-management-page">
|
||||
<div className="page-header">
|
||||
<h1>👥 사용자 관리</h1>
|
||||
<p>시스템 사용자 계정을 관리하고 권한을 설정합니다.</p>
|
||||
|
||||
{(hasPermission('user.create') || isAdmin()) && (
|
||||
<button
|
||||
className="create-user-btn"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
➕ 새 사용자 생성
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<span className="error-icon">⚠️</span>
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="close-error">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자 생성/편집 폼 */}
|
||||
{showCreateForm && (
|
||||
<div className="modal-overlay">
|
||||
<div className="user-form-modal">
|
||||
<div className="modal-header">
|
||||
<h2>{editingUser ? '사용자 편집' : '새 사용자 생성'}</h2>
|
||||
<button
|
||||
className="close-modal"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser}>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label>사용자명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
disabled={editingUser} // 편집 시 사용자명 변경 불가
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>비밀번호 {!editingUser && '*'}</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleFormChange}
|
||||
required={!editingUser}
|
||||
placeholder={editingUser ? '변경하지 않으려면 비워두세요' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>이름 *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>이메일 *</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>역할</label>
|
||||
<select
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleFormChange}
|
||||
>
|
||||
{roleOptions.map(role => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label} - {role.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>접근 레벨</label>
|
||||
<select
|
||||
name="access_level"
|
||||
value={formData.access_level}
|
||||
onChange={handleFormChange}
|
||||
>
|
||||
{accessLevelOptions.map(level => (
|
||||
<option key={level.value} value={level.value}>
|
||||
{level.label} - {level.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>부서</label>
|
||||
<input
|
||||
type="text"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>직책</label>
|
||||
<input
|
||||
type="text"
|
||||
name="position"
|
||||
value={formData.position}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>전화번호</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
계정 활성화
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권한 설정 */}
|
||||
<div className="permissions-section">
|
||||
<h3>권한 설정</h3>
|
||||
<div className="permissions-grid">
|
||||
{Object.entries(
|
||||
availablePermissions.reduce((acc, perm) => {
|
||||
if (!acc[perm.category]) acc[perm.category] = [];
|
||||
acc[perm.category].push(perm);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([category, perms]) => (
|
||||
<div key={category} className="permission-category">
|
||||
<h4>{category}</h4>
|
||||
{perms.map(perm => (
|
||||
<label key={perm.id} className="permission-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions.includes(perm.id)}
|
||||
onChange={(e) => handlePermissionChange(perm.id, e.target.checked)}
|
||||
/>
|
||||
{perm.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="submit-btn">
|
||||
{editingUser ? '수정' : '생성'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-btn"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
<div className="users-list">
|
||||
{isLoading ? (
|
||||
<div className="loading">사용자 목록을 불러오는 중...</div>
|
||||
) : (
|
||||
<div className="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>이름</th>
|
||||
<th>이메일</th>
|
||||
<th>역할</th>
|
||||
<th>접근 레벨</th>
|
||||
<th>부서</th>
|
||||
<th>상태</th>
|
||||
<th>마지막 로그인</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(userData => (
|
||||
<tr key={userData.user_id}>
|
||||
<td>{userData.username}</td>
|
||||
<td>{userData.name}</td>
|
||||
<td>{userData.email}</td>
|
||||
<td>
|
||||
<span className={`role-badge role-${userData.role}`}>
|
||||
{roleOptions.find(r => r.value === userData.role)?.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`access-badge access-${userData.access_level}`}>
|
||||
{accessLevelOptions.find(l => l.value === userData.access_level)?.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>{userData.department || '-'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${userData.is_active ? 'active' : 'inactive'}`}>
|
||||
{userData.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{userData.last_login_at
|
||||
? new Date(userData.last_login_at).toLocaleString('ko-KR')
|
||||
: '없음'
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
{(hasPermission('user.edit') || isAdmin()) && (
|
||||
<button
|
||||
className="edit-btn"
|
||||
onClick={() => handleEditUser(userData)}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{(hasPermission('user.edit') || isAdmin()) && (
|
||||
<button
|
||||
className={`toggle-btn ${userData.is_active ? 'deactivate' : 'activate'}`}
|
||||
onClick={() => handleToggleUserStatus(userData.user_id, userData.is_active)}
|
||||
title={userData.is_active ? '비활성화' : '활성화'}
|
||||
>
|
||||
{userData.is_active ? '🔒' : '🔓'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagementPage;
|
||||
@@ -21,21 +21,44 @@ const groupMaterialsByCategory = (materials) => {
|
||||
|
||||
/**
|
||||
* 동일한 자재끼리 합치기 (자재 설명 + 사이즈 기준)
|
||||
* 엑셀 내보내기용 특별 처리:
|
||||
* - PIPE: 끝단 정보 제거 (BOE-POE, POE-TOE 등)
|
||||
* - NIPPLE: 길이별 구분 (75mm, 100mm 등)
|
||||
*/
|
||||
const consolidateMaterials = (materials, isComparison = false) => {
|
||||
const consolidated = {};
|
||||
|
||||
materials.forEach(material => {
|
||||
const category = material.classified_category || material.category || 'UNCATEGORIZED';
|
||||
const description = material.original_description || material.description || '';
|
||||
let description = material.original_description || material.description || '';
|
||||
const sizeSpec = material.size_spec || '';
|
||||
|
||||
// 그룹화 키: 카테고리 + 자재설명 + 사이즈
|
||||
const groupKey = `${category}|${description}|${sizeSpec}`;
|
||||
// 파이프 끝단 정보 제거 (엑셀 내보내기용)
|
||||
if (category === 'PIPE') {
|
||||
description = description
|
||||
.replace(/\s+(BOE-POE|POE-TOE|BOE-TOE|BOE-BBE|BBE-POE|BBE-BBE|POE-POE|TOE-TOE)\s*$/i, '')
|
||||
.replace(/\s+(BOE|POE|TOE|BBE)\s*-\s*(BOE|POE|TOE|BBE)\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 니플의 경우 길이 정보를 그룹화 키에 포함
|
||||
let lengthInfo = '';
|
||||
if (category === 'FITTING' && description.toLowerCase().includes('nipple')) {
|
||||
const lengthMatch = description.match(/(\d+)\s*mm/i);
|
||||
if (lengthMatch) {
|
||||
lengthInfo = `_${lengthMatch[1]}mm`;
|
||||
}
|
||||
}
|
||||
|
||||
// 그룹화 키: 카테고리 + 정제된자재설명 + 사이즈 + 길이정보
|
||||
const groupKey = `${category}|${description}|${sizeSpec}${lengthInfo}`;
|
||||
|
||||
if (!consolidated[groupKey]) {
|
||||
consolidated[groupKey] = {
|
||||
...material,
|
||||
// 정제된 설명으로 덮어쓰기
|
||||
original_description: description,
|
||||
description: description,
|
||||
quantity: 0,
|
||||
totalLength: 0, // 파이프용
|
||||
itemCount: 0, // 파이프 개수
|
||||
@@ -99,12 +122,23 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
const category = material.classified_category || material.category || '-';
|
||||
const isPipe = category === 'PIPE';
|
||||
|
||||
// 엑셀용 자재 설명 정제
|
||||
let cleanDescription = material.original_description || material.description || '-';
|
||||
|
||||
// 파이프 끝단 정보 제거
|
||||
if (category === 'PIPE') {
|
||||
cleanDescription = cleanDescription
|
||||
.replace(/\s+(BOE-POE|POE-TOE|BOE-TOE|BOE-BBE|BBE-POE|BBE-BBE|POE-POE|TOE-TOE)\s*$/i, '')
|
||||
.replace(/\s+(BOE|POE|TOE|BBE)\s*-\s*(BOE|POE|TOE|BBE)\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||
|
||||
const base = {
|
||||
'카테고리': category,
|
||||
'자재 설명': material.original_description || material.description || '-',
|
||||
'자재 설명': cleanDescription,
|
||||
'사이즈': material.size_spec || '-'
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
/**
|
||||
* 파이프 구매 수량 계산
|
||||
* @param {number} lengthMm - 파이프 총 길이 (mm)
|
||||
* @param {number} totalLengthMm - 파이프 총 길이 (mm)
|
||||
* @param {number} quantity - BOM 수량 (개수)
|
||||
* @returns {object} 구매 계산 결과
|
||||
*/
|
||||
export const calculatePipePurchase = (lengthMm, quantity) => {
|
||||
if (!lengthMm || lengthMm <= 0 || !quantity || quantity <= 0) {
|
||||
export const calculatePipePurchase = (totalLengthMm, quantity) => {
|
||||
if (!totalLengthMm || totalLengthMm <= 0 || !quantity || quantity <= 0) {
|
||||
return {
|
||||
purchaseQuantity: 0,
|
||||
standardLength: 6000,
|
||||
@@ -18,17 +18,18 @@ export const calculatePipePurchase = (lengthMm, quantity) => {
|
||||
};
|
||||
}
|
||||
|
||||
// 절단 여유분: 조각마다 2mm 추가
|
||||
const cutLength = lengthMm + (quantity * 2);
|
||||
// 절단 여유분: 절단 개수만큼 3mm 추가 (백엔드와 동일)
|
||||
const cuttingLoss = quantity * 3;
|
||||
const requiredLength = totalLengthMm + cuttingLoss;
|
||||
|
||||
// 6,000mm 단위로 올림 계산
|
||||
const pipeCount = Math.ceil(cutLength / 6000);
|
||||
const pipeCount = Math.ceil(requiredLength / 6000);
|
||||
|
||||
return {
|
||||
purchaseQuantity: pipeCount,
|
||||
standardLength: 6000,
|
||||
cutLength: cutLength,
|
||||
calculation: `${lengthMm}mm + ${quantity * 2}mm(여유분) = ${cutLength}mm → ${pipeCount}본`
|
||||
cutLength: requiredLength,
|
||||
calculation: `${totalLengthMm}mm + ${cuttingLoss}mm(절단손실) = ${requiredLength}mm → ${pipeCount}본`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
BIN
tk-mp-project.tar.gz
Normal file
BIN
tk-mp-project.tar.gz
Normal file
Binary file not shown.
Reference in New Issue
Block a user