feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산)
- UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인)
- 자재별 그룹핑 로직 개선:
  * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시
  * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등)
  * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시
  * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계)
  * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시
  * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑
- 전체 카테고리 버튼 제거 (표시 복잡도 감소)
- 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
Hyungi Ahn
2025-09-09 09:24:45 +09:00
parent 4f8e395f87
commit 83b90ef05c
101 changed files with 10841 additions and 4813 deletions

193
DOCKER-GUIDE.md Normal file
View File

@@ -0,0 +1,193 @@
# TK-MP-Project Docker 가이드
## 🚀 빠른 시작
### 1. 개발 환경 실행
```bash
./docker-run.sh dev up
```
### 2. 프로덕션 환경 실행
```bash
./docker-run.sh prod up
```
### 3. 시놀로지 NAS 환경 실행
```bash
./docker-run.sh synology up
```
## 📋 사용 가능한 명령어
| 명령어 | 설명 |
|--------|------|
| `up` | 컨테이너 시작 (기본값) |
| `down` | 컨테이너 중지 |
| `build` | 이미지 빌드 |
| `rebuild` | 이미지 재빌드 (캐시 무시) |
| `logs` | 로그 실시간 확인 |
| `ps` 또는 `status` | 서비스 상태 확인 |
| `restart` | 컨테이너 재시작 |
## 🌍 환경별 설정
### 개발 환경 (dev)
- **포트**: 모든 서비스 외부 노출
- Frontend: http://localhost:13000
- Backend API: http://localhost:18000
- PostgreSQL: localhost:5432
- Redis: localhost:6379
- pgAdmin: http://localhost:5050
- **특징**:
- 코드 실시간 반영 (Hot Reload)
- 디버그 모드 활성화
- 모든 로그 레벨 출력
### 프로덕션 환경 (prod)
- **포트**: Nginx를 통한 리버스 프록시
- Web: http://localhost (Nginx)
- HTTPS: https://localhost (SSL 설정 필요)
- **특징**:
- 내부 서비스 포트 비노출
- 최적화된 빌드
- 로그 레벨 INFO
- pgAdmin 비활성화
### 시놀로지 NAS 환경 (synology)
- **포트**: 포트 충돌 방지를 위한 커스텀 포트
- Frontend: http://localhost:10173
- Backend API: http://localhost:10080
- PostgreSQL: localhost:15432
- Redis: localhost:16379
- pgAdmin: http://localhost:15050
- **특징**:
- 명명된 볼륨 사용
- 시놀로지 Container Manager 호환
## 🔧 환경 설정 파일
각 환경별 설정은 다음 파일에서 관리됩니다:
- `env.development` - 개발 환경 설정
- `env.production` - 프로덕션 환경 설정
- `env.synology` - 시놀로지 환경 설정
### 주요 환경 변수
```bash
# 배포 환경
DEPLOY_ENV=development|production|synology
# 포트 설정
FRONTEND_EXTERNAL_PORT=13000
BACKEND_EXTERNAL_PORT=18000
POSTGRES_EXTERNAL_PORT=5432
# 데이터베이스 설정
POSTGRES_DB=tk_mp_bom
POSTGRES_USER=tkmp_user
POSTGRES_PASSWORD=tkmp_password_2025
# 디버그 설정
DEBUG=true|false
LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
```
## 🛠️ 사용 예시
### 개발 시작
```bash
# 개발 환경 시작
./docker-run.sh dev up
# 로그 확인
./docker-run.sh dev logs
# 상태 확인
./docker-run.sh dev ps
```
### 프로덕션 배포
```bash
# 이미지 빌드
./docker-run.sh prod build
# 프로덕션 시작
./docker-run.sh prod up
# 상태 확인
./docker-run.sh prod ps
```
### 시놀로지 NAS 배포
```bash
# 시놀로지 환경 시작
./docker-run.sh synology up
# 로그 확인
./docker-run.sh synology logs
```
### 컨테이너 관리
```bash
# 컨테이너 중지
./docker-run.sh dev down
# 컨테이너 재시작
./docker-run.sh dev restart
# 이미지 재빌드 (캐시 무시)
./docker-run.sh dev rebuild
```
## 🔍 트러블슈팅
### 포트 충돌 해결
환경 설정 파일에서 `*_EXTERNAL_PORT` 변수를 수정하세요.
### 볼륨 권한 문제
```bash
# 볼륨 삭제 후 재생성
docker volume prune
./docker-run.sh dev up
```
### 이미지 빌드 문제
```bash
# 캐시 없이 재빌드
./docker-run.sh dev rebuild
```
## 📁 파일 구조
```
TK-MP-Project/
├── docker-compose.yml # 통합 Docker Compose 파일
├── docker-run.sh # 실행 스크립트
├── env.development # 개발 환경 설정
├── env.production # 프로덕션 환경 설정
├── env.synology # 시놀로지 환경 설정
├── docker-backup/ # 기존 파일 백업
│ ├── docker-compose.yml
│ ├── docker-compose.prod.yml
│ ├── docker-compose.synology.yml
│ └── docker-compose.override.yml
└── DOCKER-GUIDE.md # 이 가이드 파일
```
## 🎯 마이그레이션 가이드
기존 Docker Compose 파일을 사용하던 경우:
1. **기존 컨테이너 중지**
```bash
docker-compose down
```
2. **새로운 방식으로 시작**
```bash
./docker-run.sh dev up
```
3. **기존 파일은 `docker-backup/` 폴더에 보관됨**

952
RULES.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,166 +0,0 @@
from flask import Flask, request, jsonify
import psycopg2
from contextlib import contextmanager
app = Flask(__name__)
@contextmanager
def get_db_connection():
conn = psycopg2.connect(
host="localhost",
database="tkmp_db",
user="tkmp_user",
password="tkmp2024!",
port="5432"
)
try:
yield conn
finally:
conn.close()
@app.route('/')
def home():
return {"message": "API 작동 중"}
@app.route('/api/materials')
def get_materials():
job_number = request.args.get('job_number')
if not job_number:
return {"error": "job_number 필요"}, 400
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT id, job_number, item_number, description,
category, quantity, unit, created_at
FROM materials
WHERE job_number = %s
ORDER BY item_number
""", (job_number,))
rows = cur.fetchall()
materials = []
for r in rows:
item = {
'id': r[0],
'job_number': r[1],
'item_number': r[2],
'description': r[3],
'category': r[4],
'quantity': r[5],
'unit': r[6],
'created_at': str(r[7]) if r[7] else None
}
materials.append(item)
return {
'success': True,
'data': materials,
'count': len(materials)
}
except Exception as e:
return {"error": f"DB 오류: {str(e)}"}, 500
if __name__ == '__main__':
print("🚀 서버 시작: http://localhost:5000")
app.run(debug=True, port=5000)
# 수정된 get_materials API (올바른 컬럼명 사용)
@app.route('/api/materials-fixed', methods=['GET'])
def get_materials_fixed():
"""올바른 컬럼명을 사용한 자재 조회 API"""
try:
file_id = request.args.get('file_id')
if not file_id:
return jsonify({
'success': False,
'error': 'file_id parameter is required'
}), 400
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT
id, file_id, line_number, original_description,
classified_category, classified_subcategory,
quantity, unit, created_at
FROM materials
WHERE file_id = %s
ORDER BY line_number
""", (file_id,))
materials = []
for item in cur.fetchall():
material = {
'id': item[0],
'file_id': item[1],
'line_number': item[2],
'original_description': item[3],
'classified_category': item[4],
'classified_subcategory': item[5],
'quantity': float(item[6]) if item[6] else 0,
'unit': item[7],
'created_at': item[8].isoformat() if item[8] else None
}
materials.append(material)
return jsonify({
'success': True,
'data': materials,
'count': len(materials),
'file_id': file_id
})
except Exception as e:
print(f"Error in get_materials_fixed: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.get("/api/materials-test")
def get_materials_test(file_id: int):
"""테스트용 자재 조회 API"""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT
id, file_id, line_number, original_description,
classified_category, quantity, unit
FROM materials
WHERE file_id = %s
ORDER BY line_number
LIMIT 5
""", (file_id,))
rows = cur.fetchall()
materials = []
for r in rows:
materials.append({
'id': r[0],
'file_id': r[1],
'line_number': r[2],
'description': r[3],
'category': r[4],
'quantity': float(r[5]) if r[5] else 0,
'unit': r[6]
})
return {
'success': True,
'data': materials,
'count': len(materials)
}
except Exception as e:
return {'error': str(e)}

View File

@@ -1,56 +0,0 @@
"""
파일 관리 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"]
)

File diff suppressed because it is too large Load Diff

View File

@@ -61,3 +61,19 @@ __all__ = [
'RolePermission', 'RolePermission',
'UserRepository' 'UserRepository'
] ]

View File

@@ -391,3 +391,19 @@ async def delete_user(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="사용자 삭제 중 오류가 발생했습니다" detail="사용자 삭제 중 오류가 발생했습니다"
) )

View File

@@ -249,3 +249,19 @@ class JWTService:
# JWT 서비스 인스턴스 # JWT 서비스 인스턴스
jwt_service = JWTService() jwt_service = JWTService()

View File

@@ -303,3 +303,19 @@ async def get_current_user_optional(
except Exception as e: except Exception as e:
logger.debug(f"Optional auth failed: {str(e)}") logger.debug(f"Optional auth failed: {str(e)}")
return None return None

View File

@@ -352,3 +352,19 @@ class UserRepository:
self.db.rollback() self.db.rollback()
logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}") logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}")
raise raise

View File

@@ -205,8 +205,10 @@ class Settings(BaseSettings):
"development": [ "development": [
"http://localhost:3000", "http://localhost:3000",
"http://localhost:5173", "http://localhost:5173",
"http://localhost:13000",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
"http://127.0.0.1:5173" "http://127.0.0.1:5173",
"http://127.0.0.1:13000"
], ],
"production": [ "production": [
"https://your-domain.com", "https://your-domain.com",

View File

@@ -18,7 +18,7 @@ settings = get_settings()
# 로거 설정 # 로거 설정
logger = get_logger(__name__) logger = get_logger(__name__)
# FastAPI 앱 생성 # FastAPI 앱 생성 (요청 크기 제한 증가)
app = FastAPI( app = FastAPI(
title=settings.app_name, title=settings.app_name,
description="자재 분류 및 프로젝트 관리 시스템", description="자재 분류 및 프로젝트 관리 시스템",
@@ -26,6 +26,27 @@ app = FastAPI(
debug=settings.debug debug=settings.debug
) )
# 요청 크기 제한 설정 (100MB로 증가)
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, max_request_size: int = 100 * 1024 * 1024): # 100MB
super().__init__(app)
self.max_request_size = max_request_size
async def dispatch(self, request: Request, call_next):
if "content-length" in request.headers:
content_length = int(request.headers["content-length"])
if content_length > self.max_request_size:
return Response("Request Entity Too Large", status_code=413)
return await call_next(request)
# 요청 크기 제한 미들웨어 추가
app.add_middleware(RequestSizeLimitMiddleware, max_request_size=100 * 1024 * 1024)
# 에러 핸들러 설정 # 에러 핸들러 설정
setup_error_handlers(app) setup_error_handlers(app)
@@ -38,10 +59,11 @@ app.add_middleware(
logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}") logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}")
# 라우터들 import 및 등록 # 라우터들 import 및 등록 - files 라우터를 최우선으로 등록
try: try:
from .routers import files from .routers import files
app.include_router(files.router, prefix="/files", tags=["files"]) app.include_router(files.router, prefix="/files", tags=["files"])
logger.info("FILES 라우터 등록 완료 - 최우선")
except ImportError: except ImportError:
logger.warning("files 라우터를 찾을 수 없습니다") logger.warning("files 라우터를 찾을 수 없습니다")
@@ -63,19 +85,26 @@ try:
except ImportError: except ImportError:
logger.warning("material_comparison 라우터를 찾을 수 없습니다") logger.warning("material_comparison 라우터를 찾을 수 없습니다")
try:
from .routers import dashboard
app.include_router(dashboard.router, tags=["dashboard"])
except ImportError:
logger.warning("dashboard 라우터를 찾을 수 없습니다")
try: try:
from .routers import tubing from .routers import tubing
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"]) app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
except ImportError: except ImportError:
logger.warning("tubing 라우터를 찾을 수 없습니다") logger.warning("tubing 라우터를 찾을 수 없습니다")
# 파일 관리 API 라우터 등록 # 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
try: # try:
from .api import file_management # from .api import file_management
app.include_router(file_management.router, tags=["file-management"]) # app.include_router(file_management.router, tags=["file-management"])
logger.info("파일 관리 API 라우터 등록 완료") # logger.info("파일 관리 API 라우터 등록 완료")
except ImportError as e: # except ImportError as e:
logger.warning(f"파일 관리 라우터를 찾을 수 없습니다: {e}") # logger.warning(f"파일 관리 라우터를 찾을 수 없습니다: {e}")
logger.info("파일 관리 API 라우터 비활성화됨 (files 라우터 사용)")
# 인증 API 라우터 등록 # 인증 API 라우터 등록
try: try:

View File

@@ -0,0 +1,427 @@
"""
대시보드 API
사용자별 맞춤형 대시보드 데이터 제공
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text, func
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.activity_logger import ActivityLogger
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/stats")
async def get_dashboard_stats(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
사용자별 맞춤형 대시보드 통계 데이터 조회
Returns:
dict: 사용자 역할에 맞는 통계 데이터
"""
try:
username = current_user.get('username')
user_role = current_user.get('role', 'user')
# 역할별 맞춤 통계 생성
if user_role == 'admin':
stats = await get_admin_stats(db)
elif user_role == 'manager':
stats = await get_manager_stats(db, username)
elif user_role == 'designer':
stats = await get_designer_stats(db, username)
elif user_role == 'purchaser':
stats = await get_purchaser_stats(db, username)
else:
stats = await get_user_stats(db, username)
return {
"success": True,
"user_role": user_role,
"stats": stats
}
except Exception as e:
logger.error(f"Dashboard stats error: {str(e)}")
raise HTTPException(status_code=500, detail=f"대시보드 통계 조회 실패: {str(e)}")
@router.get("/activities")
async def get_user_activities(
current_user: dict = Depends(get_current_user),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db)
):
"""
사용자 활동 이력 조회
Args:
limit: 조회할 활동 수 (1-50)
Returns:
dict: 사용자 활동 이력
"""
try:
username = current_user.get('username')
activity_logger = ActivityLogger(db)
activities = activity_logger.get_user_activities(
username=username,
limit=limit
)
return {
"success": True,
"activities": activities,
"total": len(activities)
}
except Exception as e:
logger.error(f"User activities error: {str(e)}")
raise HTTPException(status_code=500, detail=f"활동 이력 조회 실패: {str(e)}")
@router.get("/recent-activities")
async def get_recent_activities(
current_user: dict = Depends(get_current_user),
days: int = Query(7, ge=1, le=30),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""
최근 전체 활동 조회 (관리자/매니저용)
Args:
days: 조회 기간 (일)
limit: 조회할 활동 수
Returns:
dict: 최근 활동 이력
"""
try:
user_role = current_user.get('role', 'user')
# 관리자와 매니저만 전체 활동 조회 가능
if user_role not in ['admin', 'manager']:
raise HTTPException(status_code=403, detail="권한이 없습니다")
activity_logger = ActivityLogger(db)
activities = activity_logger.get_recent_activities(
days=days,
limit=limit
)
return {
"success": True,
"activities": activities,
"period_days": days,
"total": len(activities)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Recent activities error: {str(e)}")
raise HTTPException(status_code=500, detail=f"최근 활동 조회 실패: {str(e)}")
async def get_admin_stats(db: Session) -> Dict[str, Any]:
"""관리자용 통계"""
try:
# 전체 프로젝트 수
total_projects_query = text("SELECT COUNT(*) FROM jobs WHERE status != 'deleted'")
total_projects = db.execute(total_projects_query).scalar()
# 활성 사용자 수 (최근 30일 로그인)
active_users_query = text("""
SELECT COUNT(DISTINCT username)
FROM user_activity_logs
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
""")
active_users = db.execute(active_users_query).scalar() or 0
# 오늘 업로드된 파일 수
today_uploads_query = text("""
SELECT COUNT(*)
FROM files
WHERE DATE(upload_date) = CURRENT_DATE
""")
today_uploads = db.execute(today_uploads_query).scalar() or 0
# 전체 자재 수
total_materials_query = text("SELECT COUNT(*) FROM materials")
total_materials = db.execute(total_materials_query).scalar() or 0
return {
"title": "시스템 관리자",
"subtitle": "전체 시스템을 관리하고 모니터링합니다",
"metrics": [
{"label": "전체 프로젝트 수", "value": total_projects, "icon": "📋", "color": "#667eea"},
{"label": "활성 사용자 수", "value": active_users, "icon": "👥", "color": "#48bb78"},
{"label": "시스템 상태", "value": "정상", "icon": "🟢", "color": "#38b2ac"},
{"label": "오늘 업로드", "value": today_uploads, "icon": "📤", "color": "#ed8936"}
]
}
except Exception as e:
logger.error(f"Admin stats error: {str(e)}")
raise
async def get_manager_stats(db: Session, username: str) -> Dict[str, Any]:
"""매니저용 통계"""
try:
# 담당 프로젝트 수 (향후 assigned_to 필드 활용)
assigned_projects_query = text("""
SELECT COUNT(*)
FROM jobs
WHERE (assigned_to = :username OR created_by = :username)
AND status != 'deleted'
""")
assigned_projects = db.execute(assigned_projects_query, {"username": username}).scalar() or 0
# 이번 주 완료된 작업 (활동 로그 기반)
week_completed_query = text("""
SELECT COUNT(*)
FROM user_activity_logs
WHERE activity_type IN ('PROJECT_CREATE', 'PURCHASE_CONFIRM')
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
week_completed = db.execute(week_completed_query).scalar() or 0
# 승인 대기 (구매 확정 대기 등)
pending_approvals_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'PENDING'
OR purchase_status = 'REQUESTED'
""")
pending_approvals = db.execute(pending_approvals_query).scalar() or 0
return {
"title": "프로젝트 매니저",
"subtitle": "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
"metrics": [
{"label": "담당 프로젝트", "value": assigned_projects, "icon": "📋", "color": "#667eea"},
{"label": "팀 진행률", "value": "87%", "icon": "📈", "color": "#48bb78"},
{"label": "승인 대기", "value": pending_approvals, "icon": "", "color": "#ed8936"},
{"label": "이번 주 완료", "value": week_completed, "icon": "", "color": "#38b2ac"}
]
}
except Exception as e:
logger.error(f"Manager stats error: {str(e)}")
raise
async def get_designer_stats(db: Session, username: str) -> Dict[str, Any]:
"""설계자용 통계"""
try:
# 내가 업로드한 BOM 파일 수
my_files_query = text("""
SELECT COUNT(*)
FROM files
WHERE uploaded_by = :username
AND is_active = true
""")
my_files = db.execute(my_files_query, {"username": username}).scalar() or 0
# 분류된 자재 수
classified_materials_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
AND m.classified_category IS NOT NULL
""")
classified_materials = db.execute(classified_materials_query, {"username": username}).scalar() or 0
# 검증 대기 자재 수
pending_verification_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
AND m.is_verified = false
""")
pending_verification = db.execute(pending_verification_query, {"username": username}).scalar() or 0
# 이번 주 업로드 수
week_uploads_query = text("""
SELECT COUNT(*)
FROM files
WHERE uploaded_by = :username
AND upload_date >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
week_uploads = db.execute(week_uploads_query, {"username": username}).scalar() or 0
# 분류 완료율 계산
total_materials_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
""")
total_materials = db.execute(total_materials_query, {"username": username}).scalar() or 1
classification_rate = f"{(classified_materials / total_materials * 100):.0f}%" if total_materials > 0 else "0%"
return {
"title": "설계 담당자",
"subtitle": "BOM 파일을 관리하고 자재를 분류합니다",
"metrics": [
{"label": "내 BOM 파일", "value": my_files, "icon": "📄", "color": "#667eea"},
{"label": "분류 완료율", "value": classification_rate, "icon": "🎯", "color": "#48bb78"},
{"label": "검증 대기", "value": pending_verification, "icon": "", "color": "#ed8936"},
{"label": "이번 주 업로드", "value": week_uploads, "icon": "📤", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"Designer stats error: {str(e)}")
raise
async def get_purchaser_stats(db: Session, username: str) -> Dict[str, Any]:
"""구매자용 통계"""
try:
# 구매 요청 수
purchase_requests_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status IN ('PENDING', 'REQUESTED')
""")
purchase_requests = db.execute(purchase_requests_query).scalar() or 0
# 발주 완료 수
orders_completed_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'CONFIRMED'
AND confirmed_by = :username
""")
orders_completed = db.execute(orders_completed_query, {"username": username}).scalar() or 0
# 입고 대기 수
receiving_pending_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'ORDERED'
""")
receiving_pending = db.execute(receiving_pending_query).scalar() or 0
# 이번 달 구매 금액 (임시 데이터)
monthly_amount = "₩2.3M" # 실제로는 계산 필요
return {
"title": "구매 담당자",
"subtitle": "구매 요청을 처리하고 발주를 관리합니다",
"metrics": [
{"label": "구매 요청", "value": purchase_requests, "icon": "🛒", "color": "#667eea"},
{"label": "발주 완료", "value": orders_completed, "icon": "", "color": "#48bb78"},
{"label": "입고 대기", "value": receiving_pending, "icon": "📦", "color": "#ed8936"},
{"label": "이번 달 금액", "value": monthly_amount, "icon": "💰", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"Purchaser stats error: {str(e)}")
raise
async def get_user_stats(db: Session, username: str) -> Dict[str, Any]:
"""일반 사용자용 통계"""
try:
# 내 활동 수 (최근 7일)
my_activities_query = text("""
SELECT COUNT(*)
FROM user_activity_logs
WHERE username = :username
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
my_activities = db.execute(my_activities_query, {"username": username}).scalar() or 0
# 접근 가능한 프로젝트 수 (임시)
accessible_projects = 5
return {
"title": "일반 사용자",
"subtitle": "할당된 업무를 수행하고 프로젝트에 참여합니다",
"metrics": [
{"label": "내 업무", "value": 6, "icon": "📋", "color": "#667eea"},
{"label": "완료율", "value": "75%", "icon": "📈", "color": "#48bb78"},
{"label": "대기 중", "value": 2, "icon": "", "color": "#ed8936"},
{"label": "이번 주 활동", "value": my_activities, "icon": "🎯", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"User stats error: {str(e)}")
raise
@router.get("/quick-actions")
async def get_quick_actions(
current_user: dict = Depends(get_current_user)
):
"""
사용자 역할별 빠른 작업 메뉴 조회
Returns:
dict: 역할별 빠른 작업 목록
"""
try:
user_role = current_user.get('role', 'user')
quick_actions = {
"admin": [
{"title": "사용자 관리", "icon": "👤", "path": "/admin/users", "color": "#667eea"},
{"title": "시스템 설정", "icon": "⚙️", "path": "/admin/settings", "color": "#48bb78"},
{"title": "백업 관리", "icon": "💾", "path": "/admin/backup", "color": "#ed8936"},
{"title": "활동 로그", "icon": "📊", "path": "/admin/logs", "color": "#9f7aea"}
],
"manager": [
{"title": "프로젝트 생성", "icon": "", "path": "/projects/new", "color": "#667eea"},
{"title": "팀 관리", "icon": "👥", "path": "/team", "color": "#48bb78"},
{"title": "진행 상황", "icon": "📊", "path": "/progress", "color": "#38b2ac"},
{"title": "승인 처리", "icon": "", "path": "/approvals", "color": "#ed8936"}
],
"designer": [
{"title": "BOM 업로드", "icon": "📤", "path": "/upload", "color": "#667eea"},
{"title": "자재 분류", "icon": "🔧", "path": "/materials", "color": "#48bb78"},
{"title": "리비전 관리", "icon": "🔄", "path": "/revisions", "color": "#38b2ac"},
{"title": "분류 검증", "icon": "", "path": "/verify", "color": "#ed8936"}
],
"purchaser": [
{"title": "구매 확정", "icon": "🛒", "path": "/purchase", "color": "#667eea"},
{"title": "발주 관리", "icon": "📋", "path": "/orders", "color": "#48bb78"},
{"title": "공급업체", "icon": "🏢", "path": "/suppliers", "color": "#38b2ac"},
{"title": "입고 처리", "icon": "📦", "path": "/receiving", "color": "#ed8936"}
],
"user": [
{"title": "내 업무", "icon": "📋", "path": "/my-tasks", "color": "#667eea"},
{"title": "프로젝트 보기", "icon": "👁️", "path": "/projects", "color": "#48bb78"},
{"title": "리포트 다운로드", "icon": "📊", "path": "/reports", "color": "#38b2ac"},
{"title": "도움말", "icon": "", "path": "/help", "color": "#9f7aea"}
]
}
return {
"success": True,
"user_role": user_role,
"quick_actions": quick_actions.get(user_role, quick_actions["user"])
}
except Exception as e:
logger.error(f"Quick actions error: {str(e)}")
raise HTTPException(status_code=500, detail=f"빠른 작업 조회 실패: {str(e)}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,399 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
import os
import shutil
from datetime import datetime
import uuid
import pandas as pd
import re
from pathlib import Path
from ..database import get_db
router = APIRouter()
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
@router.get("/")
async def get_files_info():
return {
"message": "파일 관리 API",
"allowed_extensions": list(ALLOWED_EXTENSIONS),
"upload_directory": str(UPLOAD_DIR)
}
@router.get("/test")
async def test_endpoint():
return {"status": "파일 API가 정상 작동합니다!"}
@router.post("/add-missing-columns")
async def add_missing_columns(db: Session = Depends(get_db)):
"""누락된 컬럼들 추가"""
try:
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER"))
db.commit()
return {
"success": True,
"message": "누락된 컬럼들이 추가되었습니다",
"added_columns": ["files.parsed_count", "materials.row_number"]
}
except Exception as e:
db.rollback()
return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"}
def validate_file_extension(filename: str) -> bool:
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
def generate_unique_filename(original_filename: str) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = str(uuid.uuid4())[:8]
stem = Path(original_filename).stem
suffix = Path(original_filename).suffix
return f"{stem}_{timestamp}_{unique_id}{suffix}"
def parse_dataframe(df):
df = df.dropna(how='all')
df.columns = df.columns.str.strip().str.lower()
column_mapping = {
'description': ['description', 'item', 'material', '품명', '자재명'],
'quantity': ['qty', 'quantity', 'ea', '수량'],
'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'],
'red_size': ['red_nom', 'reduced_diameter', '축소배관'],
'length': ['length', 'len', '길이'],
'weight': ['weight', 'wt', '중량'],
'dwg_name': ['dwg_name', 'drawing', '도면명'],
'line_num': ['line_num', 'line_number', '라인번호']
}
mapped_columns = {}
for standard_col, possible_names in column_mapping.items():
for possible_name in possible_names:
if possible_name in df.columns:
mapped_columns[standard_col] = possible_name
break
materials = []
for index, row in df.iterrows():
description = str(row.get(mapped_columns.get('description', ''), ''))
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
try:
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
except:
quantity = 0
material_grade = ""
if "ASTM" in description.upper():
astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper())
if astm_match:
material_grade = astm_match.group(0).strip()
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
if main_size != 'nan' and red_size != 'nan' and red_size != '':
size_spec = f"{main_size} x {red_size}"
elif main_size != 'nan' and main_size != '':
size_spec = main_size
else:
size_spec = ""
if description and description not in ['nan', 'None', '']:
materials.append({
'original_description': description,
'quantity': quantity,
'unit': "EA",
'size_spec': size_spec,
'material_grade': material_grade,
'line_number': index + 1,
'row_number': index + 1
})
return materials
def parse_file_data(file_path):
file_extension = Path(file_path).suffix.lower()
try:
if file_extension == ".csv":
df = pd.read_csv(file_path, encoding='utf-8')
elif file_extension in [".xlsx", ".xls"]:
df = pd.read_excel(file_path, sheet_name=0)
else:
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식")
return parse_dataframe(df)
except Exception as e:
raise HTTPException(status_code=400, detail=f"파일 파싱 실패: {str(e)}")
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...),
revision: str = Form("Rev.0"),
db: Session = Depends(get_db)
):
if not validate_file_extension(file.filename):
raise HTTPException(
status_code=400,
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
)
if file.size and file.size > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다")
unique_filename = generate_unique_filename(file.filename)
file_path = UPLOAD_DIR / unique_filename
try:
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
try:
materials_data = parse_file_data(str(file_path))
parsed_count = len(materials_data)
# 파일 정보 저장
file_insert_query = text("""
INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active)
RETURNING id
""")
file_result = db.execute(file_insert_query, {
"filename": unique_filename,
"original_filename": file.filename,
"file_path": str(file_path),
"job_no": job_no,
"revision": revision,
"description": f"BOM 파일 - {parsed_count}개 자재",
"file_size": file.size,
"parsed_count": parsed_count,
"is_active": True
})
file_id = file_result.fetchone()[0]
# 자재 데이터 저장
materials_inserted = 0
for material_data in materials_data:
material_insert_query = text("""
INSERT INTO materials (
file_id, original_description, quantity, unit, size_spec,
material_grade, line_number, row_number, classified_category,
classification_confidence, is_verified, created_at
)
VALUES (
:file_id, :original_description, :quantity, :unit, :size_spec,
:material_grade, :line_number, :row_number, :classified_category,
:classification_confidence, :is_verified, :created_at
)
""")
db.execute(material_insert_query, {
"file_id": file_id,
"original_description": material_data["original_description"],
"quantity": material_data["quantity"],
"unit": material_data["unit"],
"size_spec": material_data["size_spec"],
"material_grade": material_data["material_grade"],
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
"classified_category": None,
"classification_confidence": None,
"is_verified": False,
"created_at": datetime.now()
})
materials_inserted += 1
db.commit()
return {
"success": True,
"message": f"완전한 DB 저장 성공! {materials_inserted}개 자재 저장됨",
"original_filename": file.filename,
"file_id": file_id,
"parsed_materials_count": parsed_count,
"saved_materials_count": materials_inserted,
"sample_materials": materials_data[:3] if materials_data else []
}
except Exception as e:
db.rollback()
if os.path.exists(file_path):
os.remove(file_path)
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
@router.get("/materials")
async def get_materials(
job_no: Optional[str] = None,
file_id: Optional[str] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""저장된 자재 목록 조회"""
try:
query = """
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
m.size_spec, m.material_grade, m.line_number, m.row_number,
m.created_at,
f.original_filename, f.job_no,
j.job_no, j.job_name
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
LEFT JOIN jobs j ON f.job_no = j.job_no
WHERE 1=1
"""
params = {}
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip"
params["limit"] = limit
params["skip"] = skip
result = db.execute(text(query), params)
materials = result.fetchall()
# 전체 개수 조회
count_query = """
SELECT COUNT(*) as total
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE 1=1
"""
count_params = {}
if job_no:
count_query += " AND f.job_no = :job_no"
count_params["job_no"] = job_no
if file_id:
count_query += " AND m.file_id = :file_id"
count_params["file_id"] = file_id
count_result = db.execute(text(count_query), count_params)
total_count = count_result.fetchone()[0]
return {
"success": True,
"total_count": total_count,
"returned_count": len(materials),
"skip": skip,
"limit": limit,
"materials": [
{
"id": m.id,
"file_id": m.file_id,
"filename": m.original_filename,
"job_no": m.job_no,
"project_code": m.official_project_code,
"project_name": m.project_name,
"original_description": m.original_description,
"quantity": float(m.quantity) if m.quantity else 0,
"unit": m.unit,
"size_spec": m.size_spec,
"material_grade": m.material_grade,
"line_number": m.line_number,
"row_number": m.row_number,
"created_at": m.created_at
}
for m in materials
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}")
@router.get("/materials/summary")
async def get_materials_summary(
job_no: Optional[str] = None,
file_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""자재 요약 통계"""
try:
query = """
SELECT
COUNT(*) as total_items,
COUNT(DISTINCT m.original_description) as unique_descriptions,
COUNT(DISTINCT m.size_spec) as unique_sizes,
COUNT(DISTINCT m.material_grade) as unique_materials,
SUM(m.quantity) as total_quantity,
AVG(m.quantity) as avg_quantity,
MIN(m.created_at) as earliest_upload,
MAX(m.created_at) as latest_upload
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE 1=1
"""
params = {}
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
result = db.execute(text(query), params)
summary = result.fetchone()
return {
"success": True,
"summary": {
"total_items": summary.total_items,
"unique_descriptions": summary.unique_descriptions,
"unique_sizes": summary.unique_sizes,
"unique_materials": summary.unique_materials,
"total_quantity": float(summary.total_quantity) if summary.total_quantity else 0,
"avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0,
"earliest_upload": summary.earliest_upload,
"latest_upload": summary.latest_upload
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}")
# Job 검증 함수 (파일 끝에 추가할 예정)
async def validate_job_exists(job_no: str, db: Session):
"""Job 존재 여부 및 활성 상태 확인"""
try:
query = text("SELECT job_no, job_name, status FROM jobs WHERE job_no = :job_no AND is_active = true")
job = db.execute(query, {"job_no": job_no}).fetchone()
if not job:
return {"valid": False, "error": f"Job No. '{job_no}'를 찾을 수 없습니다"}
if job.status == '완료':
return {"valid": False, "error": f"완료된 Job '{job.job_name}'에는 파일을 업로드할 수 없습니다"}
return {
"valid": True,
"job": {
"job_no": job.job_no,
"job_name": job.job_name,
"status": job.status
}
}
except Exception as e:
return {"valid": False, "error": f"Job 검증 실패: {str(e)}"}

View File

@@ -157,6 +157,26 @@ async def confirm_material_purchase(
] ]
""" """
try: try:
# 입력 데이터 검증
if not job_no or not revision:
raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다")
if not confirmations:
raise HTTPException(status_code=400, detail="확정할 자재가 없습니다")
# 각 확정 항목 검증
for i, confirmation in enumerate(confirmations):
if not confirmation.get("material_hash"):
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다")
confirmed_qty = confirmation.get("confirmed_quantity")
if confirmed_qty is None or confirmed_qty < 0:
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다")
unit_price = confirmation.get("unit_price", 0)
if unit_price < 0:
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 단가가 유효하지 않습니다")
confirmed_items = [] confirmed_items = []
for confirmation in confirmations: for confirmation in confirmations:
@@ -470,7 +490,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
"""파일의 자재를 해시별로 그룹화하여 조회""" """파일의 자재를 해시별로 그룹화하여 조회"""
import hashlib import hashlib
print(f"🚨🚨🚨 get_materials_by_hash 호출됨! file_id={file_id} 🚨🚨🚨") # 로그 제거
query = text(""" query = text("""
SELECT SELECT
@@ -492,11 +512,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
result = db.execute(query, {"file_id": file_id}) result = db.execute(query, {"file_id": file_id})
materials = result.fetchall() materials = result.fetchall()
print(f"🔍 쿼리 결과 개수: {len(materials)}") # 로그 제거
if len(materials) > 0:
print(f"🔍 첫 번째 자료 샘플: {materials[0]}")
else:
print(f"❌ 자료가 없음! file_id={file_id}")
# 🔄 같은 파이프들을 Python에서 올바르게 그룹핑 # 🔄 같은 파이프들을 Python에서 올바르게 그룹핑
materials_dict = {} materials_dict = {}
@@ -505,38 +521,41 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}" hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}"
material_hash = hashlib.md5(hash_source.encode()).hexdigest() material_hash = hashlib.md5(hash_source.encode()).hexdigest()
print(f"📝 개별 자재: {mat[1][:50]}... ({mat[2]}) - 수량: {mat[4]}, 길이: {mat[7]}mm") # 개별 자재 로그 제거 (너무 많음)
if material_hash in materials_dict: if material_hash in materials_dict:
# 🔄 기존 항목에 수량 합계 # 🔄 기존 항목에 수량 합계
existing = materials_dict[material_hash] existing = materials_dict[material_hash]
existing["quantity"] += float(mat[4]) if mat[4] else 0.0 # 파이프가 아닌 경우만 quantity 합산 (파이프는 개별 길이가 다르므로 합산하지 않음)
if mat[5] != 'PIPE':
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
existing["line_number"] += f", {mat[8]}" if mat[8] else "" existing["line_number"] += f", {mat[8]}" if mat[8] else ""
# 파이프인 경우 길이 정보 합산 # 파이프인 경우 길이 정보 합산
if mat[5] == 'PIPE' and mat[7] is not None: if mat[5] == 'PIPE' and mat[7] is not None:
if "pipe_details" in existing: if "pipe_details" in existing:
# 총길이 합산: 기존 총길이 + (현재 수량 × 현재 길이) # 총길이 합산: 기존 총길이 + 현재 파이프의 실제 길이 (DB에 저장된 개별 길이)
current_total = existing["pipe_details"]["total_length_mm"] current_total = existing["pipe_details"]["total_length_mm"]
current_count = existing["pipe_details"]["pipe_count"] current_count = existing["pipe_details"]["pipe_count"]
new_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이 # ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음
existing["pipe_details"]["total_length_mm"] = current_total + new_length individual_length = float(mat[7]) # 개별 파이프의 실제 길이
existing["pipe_details"]["pipe_count"] = current_count + float(mat[4]) existing["pipe_details"]["total_length_mm"] = current_total + individual_length
existing["pipe_details"]["pipe_count"] = current_count + 1 # 파이프 개수는 1개씩 증가
# 평균 단위 길이 재계산 # 평균 단위 길이 재계산
total_length = existing["pipe_details"]["total_length_mm"] total_length = existing["pipe_details"]["total_length_mm"]
total_count = existing["pipe_details"]["pipe_count"] total_count = existing["pipe_details"]["pipe_count"]
existing["pipe_details"]["length_mm"] = total_length / total_count existing["pipe_details"]["length_mm"] = total_length / total_count
print(f"🔄 파이프 합산: {mat[1]} ({mat[2]}) - 총길이: {total_length}mm, 총개수: {total_count}개, 평균: {total_length/total_count:.1f}mm") # 파이프 합산 로그 제거 (너무 많음)
else: else:
# 첫 파이프 정보 설정 # 첫 파이프 정보 설정
pipe_length = float(mat[4]) * float(mat[7]) individual_length = float(mat[7]) # 개별 파이프의 실제 길이
existing["pipe_details"] = { existing["pipe_details"] = {
"length_mm": float(mat[7]), "length_mm": individual_length,
"total_length_mm": pipe_length, "total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
"pipe_count": float(mat[4]) "pipe_count": 1 # 첫 번째 파이프이므로 1개
} }
else: else:
# 🆕 새 항목 생성 # 🆕 새 항목 생성
@@ -553,27 +572,22 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
# 파이프인 경우 pipe_details 정보 추가 # 파이프인 경우 pipe_details 정보 추가
if mat[5] == 'PIPE' and mat[7] is not None: if mat[5] == 'PIPE' and mat[7] is not None:
pipe_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이 individual_length = float(mat[7]) # 개별 파이프의 실제 길이
material_data["pipe_details"] = { material_data["pipe_details"] = {
"length_mm": float(mat[7]), # 단위 길이 "length_mm": individual_length, # 개별 파이프 길이
"total_length_mm": pipe_length, # 총 길이 "total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
"pipe_count": float(mat[4]) # 파이프 개수 "pipe_count": 1 # 첫 번째 파이프이므로 1개
} }
print(f"🆕 파이프 신규: {mat[1]} ({mat[2]}) - 단위: {mat[7]}mm, 총길이: {pipe_length}mm") # 파이프는 quantity를 1로 설정 (pipe_count와 동일)
material_data["quantity"] = 1
materials_dict[material_hash] = material_data materials_dict[material_hash] = material_data
# 파이프 데이터가 포함되었는지 확인 # 파이프 데이터 요약만 출력
pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE') pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE')
pipe_with_details = sum(1 for data in materials_dict.values() pipe_with_details = sum(1 for data in materials_dict.values()
if data.get('category') == 'PIPE' and 'pipe_details' in data) if data.get('category') == 'PIPE' and 'pipe_details' in data)
print(f"🔍 반환 결과: 총 {len(materials_dict)} 자재, 파이프 {pipe_count}, pipe_details 있는 파이프 {pipe_with_details}") print(f"✅ 자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count} (길이정보: {pipe_with_details})")
# 첫 번째 파이프 데이터 샘플 출력
for hash_key, data in materials_dict.items():
if data.get('category') == 'PIPE':
print(f"🔍 파이프 샘플: {data}")
break
return materials_dict return materials_dict

View File

@@ -5,11 +5,13 @@
- 리비전 비교 - 리비전 비교
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel
import json import json
from datetime import datetime
from ..database import get_db from ..database import get_db
from ..services.purchase_calculator import ( from ..services.purchase_calculator import (
@@ -21,6 +23,28 @@ from ..services.purchase_calculator import (
router = APIRouter(prefix="/purchase", tags=["purchase"]) router = APIRouter(prefix="/purchase", tags=["purchase"])
# Pydantic 모델 (최적화된 구조)
class PurchaseItemMinimal(BaseModel):
"""구매 확정용 최소 필수 데이터"""
item_code: str
category: str
specification: str
size: str = ""
material: str = ""
bom_quantity: float
calculated_qty: float
unit: str = "EA"
safety_factor: float = 1.0
class PurchaseConfirmRequest(BaseModel):
job_no: str
file_id: int
bom_name: Optional[str] = None # 선택적 필드로 변경
revision: str
purchase_items: List[PurchaseItemMinimal] # 최적화된 구조 사용
confirmed_at: str
confirmed_by: str
@router.get("/items/calculate") @router.get("/items/calculate")
async def calculate_purchase_items( async def calculate_purchase_items(
job_no: str = Query(..., description="Job 번호"), job_no: str = Query(..., description="Job 번호"),
@@ -39,7 +63,7 @@ async def calculate_purchase_items(
file_query = text(""" file_query = text("""
SELECT id FROM files SELECT id FROM files
WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE
ORDER BY created_at DESC ORDER BY updated_at DESC
LIMIT 1 LIMIT 1
""") """)
file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone() file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone()
@@ -62,6 +86,139 @@ async def calculate_purchase_items(
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"구매 품목 계산 실패: {str(e)}") raise HTTPException(status_code=500, detail=f"구매 품목 계산 실패: {str(e)}")
@router.post("/confirm")
async def confirm_purchase_quantities(
request: PurchaseConfirmRequest,
db: Session = Depends(get_db)
):
"""
구매 수량 확정
- 계산된 구매 수량을 확정 상태로 저장
- 자재별 확정 수량 및 상태 업데이트
- 리비전 비교를 위한 기준 데이터 생성
"""
try:
# 1. 기존 확정 데이터 확인 및 업데이트 또는 삽입
existing_query = text("""
SELECT id FROM purchase_confirmations
WHERE file_id = :file_id
""")
existing_result = db.execute(existing_query, {"file_id": request.file_id}).fetchone()
if existing_result:
# 기존 데이터 업데이트
confirmation_id = existing_result[0]
update_query = text("""
UPDATE purchase_confirmations
SET job_no = :job_no,
bom_name = :bom_name,
revision = :revision,
confirmed_at = :confirmed_at,
confirmed_by = :confirmed_by,
is_active = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = :confirmation_id
""")
db.execute(update_query, {
"confirmation_id": confirmation_id,
"job_no": request.job_no,
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
# 기존 확정 품목들 삭제
delete_items_query = text("""
DELETE FROM confirmed_purchase_items
WHERE confirmation_id = :confirmation_id
""")
db.execute(delete_items_query, {"confirmation_id": confirmation_id})
else:
# 새로운 확정 데이터 삽입
confirm_query = text("""
INSERT INTO purchase_confirmations (
job_no, file_id, bom_name, revision,
confirmed_at, confirmed_by, is_active, created_at
) VALUES (
:job_no, :file_id, :bom_name, :revision,
:confirmed_at, :confirmed_by, TRUE, CURRENT_TIMESTAMP
) RETURNING id
""")
confirm_result = db.execute(confirm_query, {
"job_no": request.job_no,
"file_id": request.file_id,
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
confirmation_id = confirm_result.fetchone()[0]
# 3. 확정된 구매 품목들 저장
saved_items = 0
for item in request.purchase_items:
item_query = text("""
INSERT INTO confirmed_purchase_items (
confirmation_id, item_code, category, specification,
size, material, bom_quantity, calculated_qty,
unit, safety_factor, created_at
) VALUES (
:confirmation_id, :item_code, :category, :specification,
:size, :material, :bom_quantity, :calculated_qty,
:unit, :safety_factor, CURRENT_TIMESTAMP
)
""")
db.execute(item_query, {
"confirmation_id": confirmation_id,
"item_code": item.item_code or f"{item.category}-{saved_items+1}",
"category": item.category,
"specification": item.specification,
"size": item.size or "",
"material": item.material or "",
"bom_quantity": item.bom_quantity,
"calculated_qty": item.calculated_qty,
"unit": item.unit,
"safety_factor": item.safety_factor
})
saved_items += 1
# 4. 파일 상태를 확정으로 업데이트
file_update_query = text("""
UPDATE files
SET purchase_confirmed = TRUE,
confirmed_at = :confirmed_at,
confirmed_by = :confirmed_by,
updated_at = CURRENT_TIMESTAMP
WHERE id = :file_id
""")
db.execute(file_update_query, {
"file_id": request.file_id,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
db.commit()
return {
"success": True,
"message": "구매 수량이 성공적으로 확정되었습니다",
"confirmation_id": confirmation_id,
"confirmed_items": saved_items,
"job_no": request.job_no,
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"구매 수량 확정 실패: {str(e)}")
@router.post("/items/save") @router.post("/items/save")
async def save_purchase_items( async def save_purchase_items(
job_no: str, job_no: str,

View File

@@ -0,0 +1,362 @@
"""
사용자 활동 로그 서비스
모든 업무 활동을 추적하고 기록하는 서비스
"""
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Optional, Dict, Any
from fastapi import Request
import json
from datetime import datetime
from ..utils.logger import get_logger
logger = get_logger(__name__)
class ActivityLogger:
"""사용자 활동 로그 관리 클래스"""
def __init__(self, db: Session):
self.db = db
def log_activity(
self,
username: str,
activity_type: str,
activity_description: str,
target_id: Optional[int] = None,
target_type: Optional[str] = None,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> int:
"""
사용자 활동 로그 기록
Args:
username: 사용자명 (필수)
activity_type: 활동 유형 (FILE_UPLOAD, PROJECT_CREATE 등)
activity_description: 활동 설명
target_id: 대상 ID (파일, 프로젝트 등)
target_type: 대상 유형 (FILE, PROJECT 등)
user_id: 사용자 ID
ip_address: IP 주소
user_agent: 브라우저 정보
metadata: 추가 메타데이터
Returns:
int: 생성된 로그 ID
"""
try:
insert_query = text("""
INSERT INTO user_activity_logs (
user_id, username, activity_type, activity_description,
target_id, target_type, ip_address, user_agent, metadata
) VALUES (
:user_id, :username, :activity_type, :activity_description,
:target_id, :target_type, :ip_address, :user_agent, :metadata
) RETURNING id
""")
result = self.db.execute(insert_query, {
'user_id': user_id,
'username': username,
'activity_type': activity_type,
'activity_description': activity_description,
'target_id': target_id,
'target_type': target_type,
'ip_address': ip_address,
'user_agent': user_agent,
'metadata': json.dumps(metadata) if metadata else None
})
log_id = result.fetchone()[0]
self.db.commit()
logger.info(f"Activity logged: {username} - {activity_type} - {activity_description}")
return log_id
except Exception as e:
logger.error(f"Failed to log activity: {str(e)}")
self.db.rollback()
raise
def log_file_upload(
self,
username: str,
file_id: int,
filename: str,
file_size: int,
job_no: str,
revision: str,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""파일 업로드 활동 로그"""
metadata = {
'filename': filename,
'file_size': file_size,
'job_no': job_no,
'revision': revision,
'upload_time': datetime.now().isoformat()
}
return self.log_activity(
username=username,
activity_type='FILE_UPLOAD',
activity_description=f'BOM 파일 업로드: {filename} (Job: {job_no}, Rev: {revision})',
target_id=file_id,
target_type='FILE',
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)
def log_project_create(
self,
username: str,
project_id: int,
project_name: str,
job_no: str,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""프로젝트 생성 활동 로그"""
metadata = {
'project_name': project_name,
'job_no': job_no,
'create_time': datetime.now().isoformat()
}
return self.log_activity(
username=username,
activity_type='PROJECT_CREATE',
activity_description=f'프로젝트 생성: {project_name} ({job_no})',
target_id=project_id,
target_type='PROJECT',
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)
def log_material_classify(
self,
username: str,
file_id: int,
classified_count: int,
job_no: str,
revision: str,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""자재 분류 활동 로그"""
metadata = {
'classified_count': classified_count,
'job_no': job_no,
'revision': revision,
'classify_time': datetime.now().isoformat()
}
return self.log_activity(
username=username,
activity_type='MATERIAL_CLASSIFY',
activity_description=f'자재 분류 완료: {classified_count}개 자재 (Job: {job_no}, Rev: {revision})',
target_id=file_id,
target_type='FILE',
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)
def log_purchase_confirm(
self,
username: str,
job_no: str,
revision: str,
confirmed_count: int,
total_amount: Optional[float] = None,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""구매 확정 활동 로그"""
metadata = {
'job_no': job_no,
'revision': revision,
'confirmed_count': confirmed_count,
'total_amount': total_amount,
'confirm_time': datetime.now().isoformat()
}
return self.log_activity(
username=username,
activity_type='PURCHASE_CONFIRM',
activity_description=f'구매 확정: {confirmed_count}개 품목 (Job: {job_no}, Rev: {revision})',
target_id=None, # 구매는 특정 ID가 없음
target_type='PURCHASE',
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)
def get_user_activities(
self,
username: str,
activity_type: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> list:
"""사용자 활동 이력 조회"""
try:
where_clause = "WHERE username = :username"
params = {'username': username}
if activity_type:
where_clause += " AND activity_type = :activity_type"
params['activity_type'] = activity_type
query = text(f"""
SELECT
id, activity_type, activity_description,
target_id, target_type, metadata, created_at
FROM user_activity_logs
{where_clause}
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
""")
params.update({'limit': limit, 'offset': offset})
result = self.db.execute(query, params)
activities = []
for row in result.fetchall():
activity = {
'id': row[0],
'activity_type': row[1],
'activity_description': row[2],
'target_id': row[3],
'target_type': row[4],
'metadata': json.loads(row[5]) if row[5] else {},
'created_at': row[6].isoformat() if row[6] else None
}
activities.append(activity)
return activities
except Exception as e:
logger.error(f"Failed to get user activities: {str(e)}")
return []
def get_recent_activities(
self,
days: int = 7,
limit: int = 100
) -> list:
"""최근 활동 조회 (전체 사용자)"""
try:
query = text("""
SELECT
username, activity_type, activity_description,
target_id, target_type, created_at
FROM user_activity_logs
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'
ORDER BY created_at DESC
LIMIT :limit
""" % days)
result = self.db.execute(query, {'limit': limit})
activities = []
for row in result.fetchall():
activity = {
'username': row[0],
'activity_type': row[1],
'activity_description': row[2],
'target_id': row[3],
'target_type': row[4],
'created_at': row[5].isoformat() if row[5] else None
}
activities.append(activity)
return activities
except Exception as e:
logger.error(f"Failed to get recent activities: {str(e)}")
return []
def get_client_info(request: Request) -> tuple:
"""
요청에서 클라이언트 정보 추출
Args:
request: FastAPI Request 객체
Returns:
tuple: (ip_address, user_agent)
"""
# IP 주소 추출 (프록시 고려)
ip_address = (
request.headers.get('x-forwarded-for', '').split(',')[0].strip() or
request.headers.get('x-real-ip', '') or
request.client.host if request.client else 'unknown'
)
# User-Agent 추출
user_agent = request.headers.get('user-agent', 'unknown')
return ip_address, user_agent
def log_activity_from_request(
db: Session,
request: Request,
username: str,
activity_type: str,
activity_description: str,
target_id: Optional[int] = None,
target_type: Optional[str] = None,
user_id: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None
) -> int:
"""
요청 정보를 포함한 활동 로그 기록 (편의 함수)
Args:
db: 데이터베이스 세션
request: FastAPI Request 객체
username: 사용자명
activity_type: 활동 유형
activity_description: 활동 설명
target_id: 대상 ID
target_type: 대상 유형
user_id: 사용자 ID
metadata: 추가 메타데이터
Returns:
int: 생성된 로그 ID
"""
ip_address, user_agent = get_client_info(request)
activity_logger = ActivityLogger(db)
return activity_logger.log_activity(
username=username,
activity_type=activity_type,
activity_description=activity_description,
target_id=target_id,
target_type=target_type,
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)

View File

@@ -29,13 +29,13 @@ PIPE_MANUFACTURING = {
# ========== PIPE 끝 가공별 분류 ========== # ========== PIPE 끝 가공별 분류 ==========
PIPE_END_PREP = { PIPE_END_PREP = {
"BOTH_ENDS_BEVELED": { "BOTH_ENDS_BEVELED": {
"codes": ["BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"], "codes": ["BBE", "BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"],
"cutting_note": "양쪽 개선", "cutting_note": "양쪽 개선",
"machining_required": True, "machining_required": True,
"confidence": 0.95 "confidence": 0.95
}, },
"ONE_END_BEVELED": { "ONE_END_BEVELED": {
"codes": ["BE", "BEV", "PBE", "PIPE BEVELED END"], "codes": ["BE", "BEV", "PBE", "PIPE BEVELED END", "POE"],
"cutting_note": "한쪽 개선", "cutting_note": "한쪽 개선",
"machining_required": True, "machining_required": True,
"confidence": 0.95 "confidence": 0.95
@@ -45,9 +45,85 @@ PIPE_END_PREP = {
"cutting_note": "무 개선", "cutting_note": "무 개선",
"machining_required": False, "machining_required": False,
"confidence": 0.95 "confidence": 0.95
},
"THREADED": {
"codes": ["TOE", "THE", "THREADED", "나사", "스레드"],
"cutting_note": "나사 가공",
"machining_required": True,
"confidence": 0.90
} }
} }
# ========== 구매용 파이프 분류 (끝단 가공 제외) ==========
def get_purchase_pipe_description(description: str) -> str:
"""구매용 파이프 설명 - 끝단 가공 정보 제거"""
# 모든 끝단 가공 코드들을 수집
end_prep_codes = []
for prep_data in PIPE_END_PREP.values():
end_prep_codes.extend(prep_data["codes"])
# 설명에서 끝단 가공 코드 제거
clean_description = description.upper()
# 끝단 가공 코드들을 길이 순으로 정렬 (긴 것부터 처리)
end_prep_codes.sort(key=len, reverse=True)
for code in end_prep_codes:
# 단어 경계를 고려하여 제거 (부분 매칭 방지)
pattern = r'\b' + re.escape(code) + r'\b'
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
# 끝단 가공 관련 패턴들 추가 제거
# BOE-POE, POE-TOE 같은 조합 패턴들
end_prep_patterns = [
r'\b[A-Z]{2,3}E-[A-Z]{2,3}E\b', # BOE-POE, POE-TOE 등
r'\b[A-Z]{2,3}E-[A-Z]{2,3}\b', # BOE-TO, POE-TO 등
r'\b[A-Z]{2,3}-[A-Z]{2,3}E\b', # BO-POE, PO-TOE 등
r'\b[A-Z]{2,3}-[A-Z]{2,3}\b', # BO-PO, PO-TO 등
]
for pattern in end_prep_patterns:
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
# 남은 하이픈과 공백 정리
clean_description = re.sub(r'\s*-\s*', ' ', clean_description) # 하이픈 제거
clean_description = re.sub(r'\s+', ' ', clean_description).strip() # 연속 공백 정리
return clean_description
def extract_end_preparation_info(description: str) -> Dict:
"""파이프 설명에서 끝단 가공 정보 추출"""
desc_upper = description.upper()
# 끝단 가공 코드 찾기
for prep_type, prep_data in PIPE_END_PREP.items():
for code in prep_data["codes"]:
if code in desc_upper:
return {
"end_preparation_type": prep_type,
"end_preparation_code": code,
"machining_required": prep_data["machining_required"],
"cutting_note": prep_data["cutting_note"],
"confidence": prep_data["confidence"],
"matched_pattern": code,
"original_description": description,
"clean_description": get_purchase_pipe_description(description)
}
# 기본값: PBE (양쪽 무개선)
return {
"end_preparation_type": "NO_BEVEL", # PBE로 매핑될 예정
"end_preparation_code": "PBE",
"machining_required": False,
"cutting_note": "양쪽 무개선 (기본값)",
"confidence": 0.5,
"matched_pattern": "DEFAULT",
"original_description": description,
"clean_description": get_purchase_pipe_description(description)
}
# ========== PIPE 스케줄별 분류 ========== # ========== PIPE 스케줄별 분류 ==========
PIPE_SCHEDULE = { PIPE_SCHEDULE = {
"patterns": [ "patterns": [
@@ -62,6 +138,23 @@ PIPE_SCHEDULE = {
] ]
} }
def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str,
length: Optional[float] = None) -> Dict:
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
# 끝단 가공 정보 제거한 설명으로 분류
clean_description = get_purchase_pipe_description(description)
# 기본 파이프 분류 수행
result = classify_pipe(dat_file, clean_description, main_nom, length)
# 구매용임을 표시
result["purchase_classification"] = True
result["original_description"] = description
result["clean_description"] = clean_description
return result
def classify_pipe(dat_file: str, description: str, main_nom: str, def classify_pipe(dat_file: str, description: str, main_nom: str,
length: Optional[float] = None) -> Dict: length: Optional[float] = None) -> Dict:
""" """

View File

@@ -0,0 +1,289 @@
"""
리비전 비교 서비스
- 기존 확정 자재와 신규 자재 비교
- 변경된 자재만 분류 처리
- 리비전 업로드 최적화
"""
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Dict, Tuple, Optional
import hashlib
import logging
logger = logging.getLogger(__name__)
class RevisionComparator:
"""리비전 비교 및 차이 분석 클래스"""
def __init__(self, db: Session):
self.db = db
def get_previous_confirmed_materials(self, job_no: str, current_revision: str) -> Optional[Dict]:
"""
이전 확정된 자재 목록 조회
Args:
job_no: 프로젝트 번호
current_revision: 현재 리비전 (예: Rev.1)
Returns:
확정된 자재 정보 딕셔너리 또는 None
"""
try:
# 현재 리비전 번호 추출
current_rev_num = self._extract_revision_number(current_revision)
# 이전 리비전들 중 확정된 것 찾기 (역순으로 검색)
for prev_rev_num in range(current_rev_num - 1, -1, -1):
prev_revision = f"Rev.{prev_rev_num}"
# 해당 리비전의 확정 데이터 조회
query = text("""
SELECT pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by,
COUNT(cpi.id) as confirmed_items_count
FROM purchase_confirmations pc
LEFT JOIN confirmed_purchase_items cpi ON pc.id = cpi.confirmation_id
WHERE pc.job_no = :job_no
AND pc.revision = :revision
AND pc.is_active = TRUE
GROUP BY pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by
ORDER BY pc.confirmed_at DESC
LIMIT 1
""")
result = self.db.execute(query, {
"job_no": job_no,
"revision": prev_revision
}).fetchone()
if result and result.confirmed_items_count > 0:
logger.info(f"이전 확정 자료 발견: {job_no} {prev_revision} ({result.confirmed_items_count}개 품목)")
# 확정된 품목들 상세 조회
items_query = text("""
SELECT cpi.item_code, cpi.category, cpi.specification,
cpi.size, cpi.material, cpi.bom_quantity,
cpi.calculated_qty, cpi.unit, cpi.safety_factor
FROM confirmed_purchase_items cpi
WHERE cpi.confirmation_id = :confirmation_id
ORDER BY cpi.category, cpi.specification
""")
items_result = self.db.execute(items_query, {
"confirmation_id": result.id
}).fetchall()
return {
"confirmation_id": result.id,
"revision": result.revision,
"confirmed_at": result.confirmed_at,
"confirmed_by": result.confirmed_by,
"items": [dict(item) for item in items_result],
"items_count": len(items_result)
}
logger.info(f"이전 확정 자료 없음: {job_no} (현재: {current_revision})")
return None
except Exception as e:
logger.error(f"이전 확정 자료 조회 실패: {str(e)}")
return None
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
"""
기존 확정 자재와 신규 자재 비교
Args:
previous_confirmed: 이전 확정 자재 정보
new_materials: 신규 업로드된 자재 목록
Returns:
비교 결과 딕셔너리
"""
try:
# 이전 확정 자재를 해시맵으로 변환 (빠른 검색을 위해)
confirmed_materials = {}
for item in previous_confirmed["items"]:
material_hash = self._generate_material_hash(
item["specification"],
item["size"],
item["material"]
)
confirmed_materials[material_hash] = item
# 신규 자재 분석
unchanged_materials = [] # 변경 없음 (분류 불필요)
changed_materials = [] # 변경됨 (재분류 필요)
new_materials_list = [] # 신규 추가 (분류 필요)
for new_material in new_materials:
# 자재 해시 생성 (description 기반)
description = new_material.get("description", "")
size = self._extract_size_from_description(description)
material = self._extract_material_from_description(description)
material_hash = self._generate_material_hash(description, size, material)
if material_hash in confirmed_materials:
confirmed_item = confirmed_materials[material_hash]
# 수량 비교
new_qty = float(new_material.get("quantity", 0))
confirmed_qty = float(confirmed_item["bom_quantity"])
if abs(new_qty - confirmed_qty) > 0.001: # 수량 변경
changed_materials.append({
**new_material,
"change_type": "QUANTITY_CHANGED",
"previous_quantity": confirmed_qty,
"previous_item": confirmed_item
})
else:
# 수량 동일 - 기존 분류 결과 재사용
unchanged_materials.append({
**new_material,
"reuse_classification": True,
"previous_item": confirmed_item
})
else:
# 신규 자재
new_materials_list.append({
**new_material,
"change_type": "NEW_MATERIAL"
})
# 삭제된 자재 찾기 (이전에는 있었지만 현재는 없는 것)
new_material_hashes = set()
for material in new_materials:
description = material.get("description", "")
size = self._extract_size_from_description(description)
material_grade = self._extract_material_from_description(description)
hash_key = self._generate_material_hash(description, size, material_grade)
new_material_hashes.add(hash_key)
removed_materials = []
for hash_key, confirmed_item in confirmed_materials.items():
if hash_key not in new_material_hashes:
removed_materials.append({
"change_type": "REMOVED",
"previous_item": confirmed_item
})
comparison_result = {
"has_previous_confirmation": True,
"previous_revision": previous_confirmed["revision"],
"previous_confirmed_at": previous_confirmed["confirmed_at"],
"unchanged_count": len(unchanged_materials),
"changed_count": len(changed_materials),
"new_count": len(new_materials_list),
"removed_count": len(removed_materials),
"total_materials": len(new_materials),
"classification_needed": len(changed_materials) + len(new_materials_list),
"unchanged_materials": unchanged_materials,
"changed_materials": changed_materials,
"new_materials": new_materials_list,
"removed_materials": removed_materials
}
logger.info(f"리비전 비교 완료: 변경없음 {len(unchanged_materials)}, "
f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, "
f"삭제됨 {len(removed_materials)}")
return comparison_result
except Exception as e:
logger.error(f"자재 비교 실패: {str(e)}")
raise
def _extract_revision_number(self, revision: str) -> int:
"""리비전 문자열에서 숫자 추출 (Rev.1 → 1)"""
try:
if revision.startswith("Rev."):
return int(revision.replace("Rev.", ""))
return 0
except ValueError:
return 0
def _generate_material_hash(self, description: str, size: str, material: str) -> str:
"""자재 고유성 판단을 위한 해시 생성"""
# RULES.md의 코딩 컨벤션 준수
hash_input = f"{description}|{size}|{material}".lower().strip()
return hashlib.md5(hash_input.encode()).hexdigest()
def _extract_size_from_description(self, description: str) -> str:
"""자재 설명에서 사이즈 정보 추출"""
# 간단한 사이즈 패턴 추출 (실제로는 더 정교한 로직 필요)
import re
size_patterns = [
r'(\d+(?:\.\d+)?)\s*(?:mm|MM|인치|inch|")',
r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)',
r'DN\s*(\d+)',
r'(\d+)\s*A'
]
for pattern in size_patterns:
match = re.search(pattern, description, re.IGNORECASE)
if match:
return match.group(0)
return ""
def _extract_material_from_description(self, description: str) -> str:
"""자재 설명에서 재질 정보 추출"""
# 일반적인 재질 패턴
materials = ["SS304", "SS316", "SS316L", "A105", "WCB", "CF8M", "CF8", "CS"]
description_upper = description.upper()
for material in materials:
if material in description_upper:
return material
return ""
def get_revision_comparison(db: Session, job_no: str, current_revision: str,
new_materials: List[Dict]) -> Dict:
"""
리비전 비교 수행 (편의 함수)
Args:
db: 데이터베이스 세션
job_no: 프로젝트 번호
current_revision: 현재 리비전
new_materials: 신규 자재 목록
Returns:
비교 결과 또는 전체 분류 필요 정보
"""
comparator = RevisionComparator(db)
# 이전 확정 자료 조회
previous_confirmed = comparator.get_previous_confirmed_materials(job_no, current_revision)
if previous_confirmed is None:
# 이전 확정 자료가 없으면 전체 분류 필요
return {
"has_previous_confirmation": False,
"classification_needed": len(new_materials),
"all_materials_need_classification": True,
"materials_to_classify": new_materials,
"message": "이전 확정 자료가 없어 전체 자재를 분류합니다."
}
# 이전 확정 자료가 있으면 비교 수행
return comparator.compare_materials(previous_confirmed, new_materials)

View File

@@ -1,41 +0,0 @@
from app.services.integrated_classifier import LEVEL1_TYPE_KEYWORDS
test = "NIPPLE, SMLS, SCH 80, ASTM A106 GR B PBE"
print(f"테스트: {test}")
desc_upper = test.upper()
desc_parts = [part.strip() for part in desc_upper.split(',')]
print(f"대문자 변환: {desc_upper}")
print(f"쉼표 분리: {desc_parts}")
# 단계별 디버깅
detected_types = []
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
type_found = False
for keyword in keywords:
# 전체 문자열에서 찾기
if keyword in desc_upper:
print(f"{material_type}: '{keyword}' 전체 문자열에서 발견")
detected_types.append((material_type, keyword))
type_found = True
break
# 각 부분에서도 정확히 매칭되는지 확인
for part in desc_parts:
if keyword == part or keyword in part:
print(f"{material_type}: '{keyword}' 부분 '{part}'에서 발견")
detected_types.append((material_type, keyword))
type_found = True
break
if type_found:
break
print(f"\n감지된 타입들: {detected_types}")
print(f"감지된 타입 개수: {len(detected_types)}")
if len(detected_types) == 1:
print(f"단일 타입 확정: {detected_types[0][0]}")
elif len(detected_types) > 1:
print(f"복수 타입 감지: {detected_types}")
else:
print("Level 1 키워드 없음 - 재질 기반 분류로 이동")

View File

@@ -1,30 +0,0 @@
"""
수정된 스풀 시스템 사용 예시
"""
# 시나리오: A-1 도면에서 파이프 3개 발견
examples = [
{
"dwg_name": "A-1",
"pipes": [
{"description": "PIPE 1", "user_input_spool": "A"}, # A-1-A
{"description": "PIPE 2", "user_input_spool": "A"}, # A-1-A (같은 스풀)
{"description": "PIPE 3", "user_input_spool": "B"} # A-1-B (다른 스풀)
],
"area_assignment": "#01" # 별도: A-1 도면은 #01 구역에 위치
}
]
# 결과:
spool_identifiers = [
"A-1-A", # 파이프 1, 2가 속함
"A-1-B" # 파이프 3이 속함
]
area_assignment = {
"#01": ["A-1"] # A-1 도면은 #01 구역에 물리적으로 위치
}
print("✅ 수정된 스풀 구조가 적용되었습니다!")
print(f"스풀 식별자: {spool_identifiers}")
print(f"에리어 할당: {area_assignment}")

View File

@@ -1,108 +0,0 @@
#!/usr/bin/env python3
"""
더미 프로젝트 데이터 생성 스크립트
"""
import sys
import os
from datetime import datetime, date
from sqlalchemy import create_engine, text
# 프로젝트 루트를 Python path에 추가
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def create_dummy_jobs():
"""더미 Job 데이터 생성"""
# 간단한 SQLite 연결 (실제 DB 설정에 맞게 수정)
try:
# 실제 프로젝트의 database.py 설정 사용
from app.database import engine
print("✅ 데이터베이스 연결 성공")
except ImportError:
# 직접 연결 (개발용)
DATABASE_URL = "sqlite:///./test.db" # 실제 DB URL로 변경
engine = create_engine(DATABASE_URL)
print("⚠️ 직접 데이터베이스 연결")
# 더미 데이터 정의
dummy_jobs = [
{
'job_no': 'J24-001',
'job_name': '울산 SK에너지 정유시설 증설 배관공사',
'client_name': '삼성엔지니어링',
'end_user': 'SK에너지',
'epc_company': '삼성엔지니어링',
'project_site': '울산광역시 온산공단 SK에너지 정유공장',
'contract_date': '2024-03-15',
'delivery_date': '2024-08-30',
'delivery_terms': 'FOB 울산항',
'status': '진행중',
'description': '정유시설 증설을 위한 배관 자재 공급 프로젝트. 고온고압 배관 및 특수 밸브 포함.',
'created_by': 'admin'
},
{
'job_no': 'J24-002',
'job_name': '포스코 광양 제철소 배관 정비공사',
'client_name': '포스코',
'end_user': '포스코',
'epc_company': None,
'project_site': '전남 광양시 포스코 광양제철소',
'contract_date': '2024-04-02',
'delivery_date': '2024-07-15',
'delivery_terms': 'DDP 광양제철소 현장',
'status': '진행중',
'description': '제철소 정기 정비를 위한 배관 부품 교체. 내열성 특수강 배관 포함.',
'created_by': 'admin'
}
]
try:
with engine.connect() as conn:
# 기존 더미 데이터 삭제 (개발용)
print("🧹 기존 더미 데이터 정리...")
conn.execute(text("DELETE FROM jobs WHERE job_no IN ('J24-001', 'J24-002')"))
# 새 더미 데이터 삽입
print("📝 더미 데이터 삽입 중...")
for job in dummy_jobs:
insert_query = text("""
INSERT INTO jobs (
job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
status, description, created_by, is_active
) VALUES (
:job_no, :job_name, :client_name, :end_user, :epc_company,
:project_site, :contract_date, :delivery_date, :delivery_terms,
:status, :description, :created_by, :is_active
)
""")
conn.execute(insert_query, {**job, 'is_active': True})
print(f"{job['job_no']}: {job['job_name']}")
# 커밋
conn.commit()
# 결과 확인
result = conn.execute(text("""
SELECT job_no, job_name, client_name, status
FROM jobs
WHERE job_no IN ('J24-001', 'J24-002')
"""))
jobs = result.fetchall()
print(f"\n🎉 총 {len(jobs)}개 더미 Job 생성 완료!")
print("\n📋 생성된 더미 데이터:")
for job in jobs:
print(f"{job[0]}: {job[1]} ({job[2]}) - {job[3]}")
return True
except Exception as e:
print(f"❌ 더미 데이터 생성 실패: {e}")
return False
if __name__ == "__main__":
create_dummy_jobs()

View File

@@ -218,3 +218,19 @@ BEGIN
RAISE NOTICE '👤 기본 계정: admin/admin123, system/admin123'; RAISE NOTICE '👤 기본 계정: admin/admin123, system/admin123';
RAISE NOTICE '🔐 권한 시스템: 5단계 역할 + 모듈별 세분화된 권한'; RAISE NOTICE '🔐 권한 시스템: 5단계 역할 + 모듈별 세분화된 권한';
END $$; END $$;

View File

@@ -0,0 +1,142 @@
-- 사용자 추적 및 담당자 기록 필드 추가
-- 생성일: 2025.01
-- 목적: RULES 가이드라인에 따른 사용자 추적 시스템 구축
-- ================================
-- 1. 기존 테이블에 담당자 필드 추가
-- ================================
-- files 테이블 수정 (uploaded_by는 이미 존재)
ALTER TABLE files
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
-- jobs 테이블 수정
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS created_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS assigned_to VARCHAR(100);
-- materials 테이블 수정
ALTER TABLE materials
ADD COLUMN IF NOT EXISTS classified_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100);
-- ================================
-- 2. 사용자 활동 로그 테이블 생성
-- ================================
CREATE TABLE IF NOT EXISTS user_activity_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER, -- users 테이블 참조 (외래키 제약 없음 - 유연성)
username VARCHAR(100) NOT NULL, -- 사용자명 (필수)
-- 활동 정보
activity_type VARCHAR(50) NOT NULL, -- 'FILE_UPLOAD', 'PROJECT_CREATE', 'PURCHASE_CONFIRM' 등
activity_description TEXT, -- 상세 활동 내용
-- 대상 정보
target_id INTEGER, -- 대상 ID (파일, 프로젝트 등)
target_type VARCHAR(50), -- 'FILE', 'PROJECT', 'MATERIAL', 'PURCHASE' 등
-- 세션 정보
ip_address VARCHAR(45), -- IP 주소
user_agent TEXT, -- 브라우저 정보
-- 추가 메타데이터 (JSON)
metadata JSONB, -- 추가 정보 (파일 크기, 처리 시간 등)
-- 시간 정보
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ================================
-- 3. 구매 관련 테이블 수정
-- ================================
-- purchase_items 테이블 수정 (이미 created_by 존재하는지 확인 후 추가)
ALTER TABLE purchase_items
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
-- material_purchase_tracking 테이블 수정 (이미 confirmed_by 존재)
ALTER TABLE material_purchase_tracking
ADD COLUMN IF NOT EXISTS ordered_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS ordered_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
-- ================================
-- 4. 인덱스 생성 (성능 최적화)
-- ================================
-- 사용자 활동 로그 인덱스
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_username ON user_activity_logs(username);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_activity_type ON user_activity_logs(activity_type);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id);
-- 담당자 필드 인덱스
CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by);
CREATE INDEX IF NOT EXISTS idx_files_updated_by ON files(updated_by);
CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by);
CREATE INDEX IF NOT EXISTS idx_jobs_assigned_to ON jobs(assigned_to);
CREATE INDEX IF NOT EXISTS idx_materials_classified_by ON materials(classified_by);
-- ================================
-- 5. 트리거 생성 (자동 updated_at 갱신)
-- ================================
-- files 테이블 updated_at 자동 갱신
CREATE OR REPLACE FUNCTION update_files_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS trigger_files_updated_at ON files;
CREATE TRIGGER trigger_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW
EXECUTE FUNCTION update_files_updated_at();
-- jobs 테이블 updated_at 자동 갱신
CREATE OR REPLACE FUNCTION update_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS trigger_jobs_updated_at ON jobs;
CREATE TRIGGER trigger_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION update_jobs_updated_at();
-- ================================
-- 6. 기본 데이터 설정
-- ================================
-- 기존 데이터에 기본 담당자 설정 (시스템 마이그레이션용)
UPDATE files SET uploaded_by = 'system' WHERE uploaded_by IS NULL;
UPDATE jobs SET created_by = 'system' WHERE created_by IS NULL;
-- ================================
-- 7. 권한 및 보안 설정
-- ================================
-- 활동 로그 테이블은 INSERT만 허용 (수정/삭제 방지)
-- 실제 운영에서는 별도 권한 관리 필요
COMMENT ON TABLE user_activity_logs IS '사용자 활동 로그 - 모든 업무 활동 추적';
COMMENT ON COLUMN user_activity_logs.activity_type IS '활동 유형: FILE_UPLOAD, PROJECT_CREATE, PURCHASE_CONFIRM, MATERIAL_CLASSIFY 등';
COMMENT ON COLUMN user_activity_logs.metadata IS '추가 정보 JSON: 파일크기, 처리시간, 변경내용 등';
-- 완료 메시지
SELECT 'User tracking system tables created successfully!' as result;

View File

@@ -0,0 +1,49 @@
-- 파이프 끝단 가공 정보 테이블 생성
-- 각 파이프별로 끝단 가공 정보를 별도 저장
CREATE TABLE IF NOT EXISTS pipe_end_preparations (
id SERIAL PRIMARY KEY,
material_id INTEGER NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
-- 끝단 가공 정보
end_preparation_type VARCHAR(50) DEFAULT 'PBE', -- PBE(양쪽무개선), BBE(양쪽개선), POE(한쪽개선), PE(무개선)
end_preparation_code VARCHAR(20), -- 원본 코드 (BBE, POE, PBE 등)
machining_required BOOLEAN DEFAULT FALSE, -- 가공 필요 여부
cutting_note TEXT, -- 가공 메모
-- 원본 정보 보존
original_description TEXT NOT NULL, -- 끝단 가공 포함된 원본 설명
clean_description TEXT NOT NULL, -- 끝단 가공 제외한 구매용 설명
-- 메타데이터
confidence FLOAT DEFAULT 0.0, -- 분류 신뢰도
matched_pattern VARCHAR(100), -- 매칭된 패턴
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_material_id ON pipe_end_preparations(material_id);
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_file_id ON pipe_end_preparations(file_id);
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_type ON pipe_end_preparations(end_preparation_type);
-- 기본 끝단 가공 타입 정의
COMMENT ON COLUMN pipe_end_preparations.end_preparation_type IS 'PBE: 양쪽무개선(기본값), BBE: 양쪽개선, POE: 한쪽개선, PE: 무개선';
COMMENT ON COLUMN pipe_end_preparations.machining_required IS '가공이 필요한지 여부 (개선 작업 등)';
COMMENT ON COLUMN pipe_end_preparations.clean_description IS '구매 시 사용할 끝단 가공 정보가 제거된 설명';
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_pipe_end_preparations_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_pipe_end_preparations_updated_at
BEFORE UPDATE ON pipe_end_preparations
FOR EACH ROW
EXECUTE FUNCTION update_pipe_end_preparations_updated_at();

View File

@@ -1,81 +0,0 @@
#!/usr/bin/env python3
import sys
import os
from datetime import datetime, date
# 프로젝트 루트를 Python path에 추가
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from app.database import engine
from sqlalchemy import text
print("✅ 데이터베이스 연결 성공")
except ImportError as e:
print(f"❌ 임포트 실패: {e}")
sys.exit(1)
def insert_dummy_data():
dummy_jobs = [
{
'job_no': 'J24-001',
'job_name': '울산 SK에너지 정유시설 증설 배관공사',
'client_name': '삼성엔지니어링',
'end_user': 'SK에너지',
'epc_company': '삼성엔지니어링',
'project_site': '울산광역시 온산공단',
'contract_date': '2024-03-15',
'delivery_date': '2024-08-30',
'delivery_terms': 'FOB 울산항',
'description': '정유시설 증설을 위한 배관 자재 공급',
'created_by': 'admin'
},
{
'job_no': 'J24-002',
'job_name': '포스코 광양 제철소 배관 정비공사',
'client_name': '포스코',
'end_user': '포스코',
'epc_company': None,
'project_site': '전남 광양시 포스코 제철소',
'contract_date': '2024-04-02',
'delivery_date': '2024-07-15',
'delivery_terms': 'DDP 광양제철소',
'description': '제철소 정기 정비용 배관 부품',
'created_by': 'admin'
}
]
try:
with engine.connect() as conn:
# 기존 더미 데이터 삭제
conn.execute(text("DELETE FROM jobs WHERE job_no IN ('J24-001', 'J24-002')"))
# 새 데이터 삽입
for job in dummy_jobs:
query = text("""
INSERT INTO jobs (
job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
description, created_by, is_active
) VALUES (
:job_no, :job_name, :client_name, :end_user, :epc_company,
:project_site, :contract_date, :delivery_date, :delivery_terms,
:description, :created_by, :is_active
)
""")
conn.execute(query, {**job, 'is_active': True})
print(f"{job['job_no']}: {job['job_name']}")
conn.commit()
print(f"\n🎉 {len(dummy_jobs)}개 더미 Job 생성 완료!")
# 확인
result = conn.execute(text("SELECT job_no, job_name, client_name FROM jobs"))
for row in result:
print(f"{row[0]}: {row[1]} ({row[2]})")
except Exception as e:
print(f"❌ 오류: {e}")
if __name__ == "__main__":
insert_dummy_data()

View File

@@ -1,5 +0,0 @@
# main.py에 추가할 import
from .api import spools
# app.include_router 추가
app.include_router(spools.router, prefix="/api/spools", tags=["스풀 관리"])

View File

@@ -1,120 +0,0 @@
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...),
revision: str = Form("Rev.0"),
db: Session = Depends(get_db)
):
# 1. Job 검증 (새로 추가!)
job_validation = await validate_job_exists(job_no, db)
if not job_validation["valid"]:
raise HTTPException(
status_code=400,
detail=f"Job 오류: {job_validation['error']}"
)
job_info = job_validation["job"]
print(f"✅ Job 검증 완료: {job_info['job_no']} - {job_info['job_name']}")
# 2. 파일 검증
if not validate_file_extension(file.filename):
raise HTTPException(
status_code=400,
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
)
if file.size and file.size > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다")
# 3. 파일 저장
unique_filename = generate_unique_filename(file.filename)
file_path = UPLOAD_DIR / unique_filename
try:
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
# 4. 파일 파싱 및 자재 추출
try:
materials_data = parse_file_data(str(file_path))
parsed_count = len(materials_data)
# 파일 정보 저장 (job_no 사용!)
file_insert_query = text("""
INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active)
RETURNING id
""")
file_result = db.execute(file_insert_query, {
"filename": unique_filename,
"original_filename": file.filename,
"file_path": str(file_path),
"job_no": job_no, # job_no 사용!
"revision": revision,
"description": f"BOM 파일 - {parsed_count}개 자재 ({job_info['job_name']})",
"file_size": file.size,
"parsed_count": parsed_count,
"is_active": True
})
file_id = file_result.fetchone()[0]
# 자재 데이터 저장
materials_inserted = 0
for material_data in materials_data:
material_insert_query = text("""
INSERT INTO materials (
file_id, original_description, quantity, unit, size_spec,
material_grade, line_number, row_number, classified_category,
classification_confidence, is_verified, created_at
)
VALUES (
:file_id, :original_description, :quantity, :unit, :size_spec,
:material_grade, :line_number, :row_number, :classified_category,
:classification_confidence, :is_verified, :created_at
)
""")
db.execute(material_insert_query, {
"file_id": file_id,
"original_description": material_data["original_description"],
"quantity": material_data["quantity"],
"unit": material_data["unit"],
"size_spec": material_data["size_spec"],
"material_grade": material_data["material_grade"],
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
"classified_category": None,
"classification_confidence": None,
"is_verified": False,
"created_at": datetime.now()
})
materials_inserted += 1
db.commit()
return {
"success": True,
"message": f"Job '{job_info['job_name']}'에 BOM 파일 업로드 완료!",
"job": {
"job_no": job_info["job_no"],
"job_name": job_info["job_name"],
"status": job_info["status"]
},
"file": {
"id": file_id,
"original_filename": file.filename,
"parsed_count": parsed_count,
"saved_count": materials_inserted
},
"sample_materials": materials_data[:3] if materials_data else []
}
except Exception as e:
db.rollback()
if os.path.exists(file_path):
os.remove(file_path)
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")

View File

@@ -1,13 +0,0 @@
# upload 함수에 추가할 Job 검증 로직
# Form 파라미터 받은 직후에 추가:
# Job 검증
job_validation = await validate_job_exists(job_no, db)
if not job_validation["valid"]:
raise HTTPException(
status_code=400,
detail=f"Job 오류: {job_validation['error']}"
)
job_info = job_validation["job"]
print(f"✅ Job 검증 완료: {job_info['job_no']} - {job_info['job_name']}")

View File

@@ -1,5 +0,0 @@
Description,Quantity,Unit,Size
"PIPE ASTM A106 GR.B",10,EA,4"
"ELBOW 90° ASTM A234",5,EA,4"
"VALVE GATE ASTM A216",2,EA,4"
"FLANGE WELD NECK",8,EA,4"
Can't render this file because it contains an unexpected character in line 2 and column 30.

View File

@@ -1,89 +0,0 @@
#!/usr/bin/env python3
"""
main_nom, red_nom 기능 테스트 스크립트
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from app.services.fitting_classifier import classify_fitting
from app.services.flange_classifier import classify_flange
def test_main_red_nom():
"""main_nom과 red_nom 분류 테스트"""
print("🔧 main_nom/red_nom 분류 테스트 시작!")
print("=" * 60)
test_cases = [
{
"name": "일반 TEE (동일 사이즈)",
"description": "TEE, SCH 40, ASTM A234 GR WPB",
"main_nom": "4\"",
"red_nom": None,
"expected": "EQUAL TEE"
},
{
"name": "리듀싱 TEE (다른 사이즈)",
"description": "TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB",
"main_nom": "4\"",
"red_nom": "2\"",
"expected": "REDUCING TEE"
},
{
"name": "동심 리듀서",
"description": "RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB",
"main_nom": "6\"",
"red_nom": "4\"",
"expected": "CONCENTRIC REDUCER"
},
{
"name": "리듀싱 플랜지",
"description": "FLG REDUCING, 300LB, ASTM A105",
"main_nom": "6\"",
"red_nom": "4\"",
"expected": "REDUCING FLANGE"
}
]
for i, test in enumerate(test_cases, 1):
print(f"\n{i}. {test['name']}")
print(f" 설명: {test['description']}")
print(f" MAIN_NOM: {test['main_nom']}")
print(f" RED_NOM: {test['red_nom']}")
# 피팅 분류 테스트
fitting_result = classify_fitting(
"",
test['description'],
test['main_nom'],
test['red_nom']
)
print(f" 🔧 FITTING 분류 결과:")
print(f" 카테고리: {fitting_result.get('category')}")
print(f" 타입: {fitting_result.get('fitting_type', {}).get('type')}")
print(f" 서브타입: {fitting_result.get('fitting_type', {}).get('subtype')}")
print(f" 신뢰도: {fitting_result.get('overall_confidence', 0):.2f}")
# 사이즈 정보 확인
size_info = fitting_result.get('size_info', {})
print(f" 메인 사이즈: {size_info.get('main_size')}")
print(f" 축소 사이즈: {size_info.get('reduced_size')}")
print(f" 사이즈 설명: {size_info.get('size_description')}")
# RED_NOM이 있는 경우 REDUCING 분류 확인
if test['red_nom']:
fitting_type = fitting_result.get('fitting_type', {})
if 'REDUCING' in fitting_type.get('subtype', '').upper():
print(f" ✅ REDUCING 타입 정상 인식!")
else:
print(f" ❌ REDUCING 타입 인식 실패")
print("-" * 50)
print("\n🎯 테스트 완료!")
if __name__ == "__main__":
test_main_red_nom()

View File

@@ -1,6 +0,0 @@
Description,Quantity,Unit,Size
"PIPE ASTM A106 GR.B",10,EA,4"
"GATE VALVE ASTM A216",2,EA,4"
"FLANGE WELD NECK RF",8,EA,4"
"90 DEG ELBOW",5,EA,4"
"GASKET SPIRAL WOUND",4,EA,4"
Can't render this file because it contains an unexpected character in line 2 and column 30.

View File

@@ -1,6 +0,0 @@
description,qty,main_nom,red_nom,length
"TEE EQUAL, SCH 40, ASTM A234 GR WPB",2,4",,"
"TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB",1,4",2","
"RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB",1,6",4","
"90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS",4,3",,"
"PIPE SMLS, SCH 40, ASTM A106 GR B",1,2",,6000
Can't render this file because it contains an unexpected character in line 2 and column 42.

View File

@@ -0,0 +1,72 @@
-- 구매 수량 확정 관련 테이블 생성
-- 1. 구매 확정 마스터 테이블
CREATE TABLE IF NOT EXISTS purchase_confirmations (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) NOT NULL,
file_id INTEGER REFERENCES files(id),
bom_name VARCHAR(255) NOT NULL,
revision VARCHAR(50) NOT NULL DEFAULT 'Rev.0',
confirmed_at TIMESTAMP NOT NULL,
confirmed_by VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 2. 확정된 구매 품목 테이블
CREATE TABLE IF NOT EXISTS confirmed_purchase_items (
id SERIAL PRIMARY KEY,
confirmation_id INTEGER REFERENCES purchase_confirmations(id) ON DELETE CASCADE,
item_code VARCHAR(100) NOT NULL,
category VARCHAR(50) NOT NULL,
specification TEXT,
size VARCHAR(100),
material VARCHAR(100),
bom_quantity DECIMAL(15,3) NOT NULL DEFAULT 0,
calculated_qty DECIMAL(15,3) NOT NULL DEFAULT 0,
unit VARCHAR(20) NOT NULL DEFAULT 'EA',
safety_factor DECIMAL(5,3) NOT NULL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 3. files 테이블에 확정 관련 컬럼 추가 (이미 있으면 무시)
ALTER TABLE files
ADD COLUMN IF NOT EXISTS purchase_confirmed BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS confirmed_by VARCHAR(100);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_purchase_confirmations_job_revision
ON purchase_confirmations(job_no, revision, is_active);
CREATE INDEX IF NOT EXISTS idx_confirmed_purchase_items_confirmation
ON confirmed_purchase_items(confirmation_id);
CREATE INDEX IF NOT EXISTS idx_confirmed_purchase_items_category
ON confirmed_purchase_items(category);
CREATE INDEX IF NOT EXISTS idx_files_purchase_confirmed
ON files(purchase_confirmed);
-- 코멘트 추가
COMMENT ON TABLE purchase_confirmations IS '구매 수량 확정 마스터 테이블';
COMMENT ON TABLE confirmed_purchase_items IS '확정된 구매 품목 상세 테이블';
COMMENT ON COLUMN files.purchase_confirmed IS '구매 수량 확정 여부';
COMMENT ON COLUMN files.confirmed_at IS '구매 수량 확정 시간';
COMMENT ON COLUMN files.confirmed_by IS '구매 수량 확정자';

View File

@@ -0,0 +1,33 @@
# 개발 환경용 오버라이드 (기본값)
# docker-compose up 시 자동으로 적용됨
# version: '3.8' # Docker Compose v2에서는 version 필드가 선택사항
services:
backend:
volumes:
# 개발 시 코드 변경 실시간 반영
- ./backend:/app
environment:
- DEBUG=true
- RELOAD=true
- LOG_LEVEL=DEBUG
frontend:
environment:
- VITE_API_URL=http://localhost:18000
build:
args:
- VITE_API_URL=http://localhost:18000
# 개발 환경에서는 모든 포트를 외부에 노출
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
pgadmin:
ports:
- "5050:80"

View File

@@ -0,0 +1,55 @@
# 프로덕션 환경용 오버라이드
version: '3.8'
services:
backend:
environment:
- ENVIRONMENT=production
- DEBUG=false
- RELOAD=false
- LOG_LEVEL=INFO
# 프로덕션에서는 코드 볼륨 마운트 제거
volumes:
- ./backend/uploads:/app/uploads
frontend:
environment:
- VITE_API_URL=/api
build:
args:
- VITE_API_URL=/api
# 프로덕션용 리버스 프록시
nginx:
image: nginx:alpine
container_name: tk-mp-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- frontend
- backend
networks:
- tk-mp-network
# 프로덕션에서는 외부 포트 접근 차단
postgres:
ports: []
redis:
ports: []
backend:
ports: []
frontend:
ports: []
# pgAdmin은 프로덕션에서 비활성화
pgadmin:
profiles:
- disabled

View File

@@ -0,0 +1,57 @@
# 시놀로지 NAS 환경용 오버라이드
version: '3.8'
services:
postgres:
container_name: tk-mp-postgres-synology
ports:
- "15432:5432"
volumes:
- tk_mp_postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
redis:
container_name: tk-mp-redis-synology
ports:
- "16379:6379"
volumes:
- tk_mp_redis_data:/data
backend:
container_name: tk-mp-backend-synology
ports:
- "10080:8000"
environment:
- ENVIRONMENT=synology
- DEBUG=false
- RELOAD=false
- LOG_LEVEL=INFO
- DATABASE_URL=postgresql://${POSTGRES_USER:-tkmp_user}:${POSTGRES_PASSWORD:-tkmp_password_2025}@postgres:5432/${POSTGRES_DB:-tk_mp_bom}
- REDIS_URL=redis://redis:6379
volumes:
- tk_mp_uploads:/app/uploads
frontend:
container_name: tk-mp-frontend-synology
ports:
- "10173:3000"
environment:
- VITE_API_URL=http://localhost:10080
build:
args:
- VITE_API_URL=http://localhost:10080
# 시놀로지에서는 pgAdmin 포트 변경
pgadmin:
container_name: tk-mp-pgadmin-synology
ports:
- "15050:80"
# 시놀로지용 명명된 볼륨
volumes:
tk_mp_postgres_data:
external: false
tk_mp_redis_data:
external: false
tk_mp_uploads:
external: false

View File

@@ -0,0 +1,124 @@
# TK-MP-Project Docker Compose 설정
# 기본 설정 (개발 환경 기준)
# version: '3.8' # Docker Compose v2에서는 version 필드가 선택사항
services:
# PostgreSQL 데이터베이스
postgres:
image: postgres:15-alpine
container_name: tk-mp-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-tk_mp_bom}
POSTGRES_USER: ${POSTGRES_USER:-tkmp_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tkmp_password_2025}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
networks:
- tk-mp-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tkmp_user} -d ${POSTGRES_DB:-tk_mp_bom}"]
interval: 30s
timeout: 10s
retries: 3
# Redis (캐시 및 세션 관리용)
redis:
image: redis:7-alpine
container_name: tk-mp-redis
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
networks:
- tk-mp-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# 백엔드 FastAPI 서비스
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: tk-mp-backend
restart: unless-stopped
ports:
- "${BACKEND_PORT:-18000}:8000"
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-tkmp_user}:${POSTGRES_PASSWORD:-tkmp_password_2025}@postgres:5432/${POSTGRES_DB:-tk_mp_bom}
- REDIS_URL=redis://redis:6379
- ENVIRONMENT=${ENVIRONMENT:-development}
- DEBUG=${DEBUG:-true}
- PYTHONPATH=/app
depends_on:
- postgres
- redis
networks:
- tk-mp-network
volumes:
- ./backend/uploads:/app/uploads
# 개발 환경에서는 코드 변경 실시간 반영 (오버라이드에서 설정)
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
# interval: 30s
# timeout: 10s
# retries: 3
# 프론트엔드 React + Nginx 서비스
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
container_name: tk-mp-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-13000}:3000"
environment:
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
depends_on:
- backend
networks:
- tk-mp-network
# pgAdmin 웹 관리도구 (개발/테스트 환경용)
pgadmin:
image: dpage/pgadmin4:latest
container_name: tk-mp-pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@example.com}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin2025}
PGADMIN_CONFIG_SERVER_MODE: 'False'
ports:
- "${PGADMIN_PORT:-5050}:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- tk-mp-network
profiles:
- dev
- test
volumes:
postgres_data:
driver: local
pgadmin_data:
driver: local
redis_data:
driver: local
networks:
tk-mp-network:
driver: bridge

View File

@@ -1,26 +0,0 @@
version: '3.8'
# 개발 환경용 오버라이드
services:
frontend:
environment:
- VITE_API_URL=http://localhost:18000
build:
args:
- VITE_API_URL=http://localhost:18000
backend:
volumes:
- ./backend:/app # 개발 시 코드 변경 실시간 반영
environment:
- DEBUG=True
- RELOAD=True
# 개발용 포트 매핑
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"

View File

@@ -0,0 +1,34 @@
# 개발 환경용 오버라이드 (기본값)
# docker-compose up 시 자동으로 적용됨
# version: '3.8' # Docker Compose v2에서는 version 필드가 선택사항
services:
backend:
volumes:
# 개발 시 코드 변경 실시간 반영
- ./backend:/app
environment:
- DEBUG=true
- RELOAD=true
- LOG_LEVEL=DEBUG
frontend:
environment:
- VITE_API_URL=http://localhost:18000
build:
args:
- VITE_API_URL=http://localhost:18000
# 개발 환경에서는 모든 포트를 외부에 노출
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
pgadmin:
ports:
- "5050:80"

View File

@@ -1,46 +0,0 @@
version: '3.8'
# 프로덕션 환경용 오버라이드
services:
frontend:
environment:
- VITE_API_URL=/api
build:
args:
- VITE_API_URL=/api
# 포트를 외부에 노출하지 않음 (리버스 프록시 사용)
ports: []
backend:
environment:
- DEBUG=False
- RELOAD=False
# 포트를 외부에 노출하지 않음
ports: []
# 프로덕션용 리버스 프록시 (예: Nginx)
nginx:
image: nginx:alpine
container_name: tk-mp-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/ssl:/etc/nginx/ssl # SSL 인증서
depends_on:
- frontend
- backend
networks:
- tk-mp-network
# 데이터베이스 접근 제한
postgres:
ports: [] # 외부 접근 차단
redis:
ports: [] # 외부 접근 차단
pgadmin:
ports: [] # 외부 접근 차단 (필요시 SSH 터널링)

View File

@@ -1,76 +0,0 @@
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

160
docker-compose.unified.yml Normal file
View File

@@ -0,0 +1,160 @@
# TK-MP-Project 통합 Docker Compose 설정
# 환경 변수 DEPLOY_ENV로 환경 구분: development(기본), production, synology
services:
# PostgreSQL 데이터베이스
postgres:
image: postgres:15-alpine
container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-postgres${CONTAINER_SUFFIX:-}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-tk_mp_bom}
POSTGRES_USER: ${POSTGRES_USER:-tkmp_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tkmp_password_2025}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
ports:
# 개발: 5432, 프로덕션: 없음, 시놀로지: 15432
- "${POSTGRES_EXTERNAL_PORT:-5432}:5432"
volumes:
- ${POSTGRES_DATA_VOLUME:-postgres_data}:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
networks:
- tk-mp-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tkmp_user} -d ${POSTGRES_DB:-tk_mp_bom}"]
interval: 30s
timeout: 10s
retries: 3
profiles:
- ${POSTGRES_PROFILE:-default}
# Redis (캐시 및 세션 관리용)
redis:
image: redis:7-alpine
container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-redis${CONTAINER_SUFFIX:-}
restart: unless-stopped
ports:
# 개발: 6379, 프로덕션: 없음, 시놀로지: 16379
- "${REDIS_EXTERNAL_PORT:-6379}:6379"
volumes:
- ${REDIS_DATA_VOLUME:-redis_data}:/data
networks:
- tk-mp-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
profiles:
- ${REDIS_PROFILE:-default}
# 백엔드 FastAPI 서비스
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-backend${CONTAINER_SUFFIX:-}
restart: unless-stopped
ports:
# 개발: 18000, 프로덕션: 없음, 시놀로지: 10080
- "${BACKEND_EXTERNAL_PORT:-18000}:8000"
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-tkmp_user}:${POSTGRES_PASSWORD:-tkmp_password_2025}@postgres:5432/${POSTGRES_DB:-tk_mp_bom}
- REDIS_URL=redis://redis:6379
- ENVIRONMENT=${DEPLOY_ENV:-development}
- DEBUG=${DEBUG:-true}
- RELOAD=${RELOAD:-true}
- LOG_LEVEL=${LOG_LEVEL:-DEBUG}
- PYTHONPATH=/app
depends_on:
- postgres
- redis
networks:
- tk-mp-network
volumes:
# 개발: 코드 마운트, 프로덕션/시놀로지: 업로드만
- ${BACKEND_CODE_VOLUME:-./backend}:/app
- ${UPLOADS_VOLUME:-./backend/uploads}:/app/uploads
profiles:
- ${BACKEND_PROFILE:-default}
# 프론트엔드 React + Nginx 서비스
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-frontend${CONTAINER_SUFFIX:-}
restart: unless-stopped
ports:
# 개발: 13000, 프로덕션: 없음, 시놀로지: 10173
- "${FRONTEND_EXTERNAL_PORT:-13000}:3000"
environment:
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
depends_on:
- backend
networks:
- tk-mp-network
profiles:
- ${FRONTEND_PROFILE:-default}
# Nginx 리버스 프록시 (프로덕션 전용)
nginx:
image: nginx:alpine
container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-nginx${CONTAINER_SUFFIX:-}
restart: unless-stopped
ports:
- "${NGINX_HTTP_PORT:-80}:80"
- "${NGINX_HTTPS_PORT:-443}:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- frontend
- backend
networks:
- tk-mp-network
profiles:
- production
# pgAdmin 웹 관리도구
pgadmin:
image: dpage/pgadmin4:latest
container_name: ${COMPOSE_PROJECT_NAME:-tk-mp}-pgadmin${CONTAINER_SUFFIX:-}
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@example.com}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin2025}
PGADMIN_CONFIG_SERVER_MODE: 'False'
ports:
# 개발: 5050, 시놀로지: 15050, 프로덕션: 비활성화
- "${PGADMIN_EXTERNAL_PORT:-5050}:80"
volumes:
- ${PGADMIN_DATA_VOLUME:-pgadmin_data}:/var/lib/pgadmin
depends_on:
- postgres
networks:
- tk-mp-network
profiles:
- ${PGADMIN_PROFILE:-dev}
volumes:
postgres_data:
driver: local
pgadmin_data:
driver: local
redis_data:
driver: local
# 시놀로지용 명명된 볼륨
tk_mp_postgres_data:
external: false
tk_mp_redis_data:
external: false
tk_mp_uploads:
external: false
networks:
tk-mp-network:
driver: bridge

View File

@@ -1,4 +1,6 @@
version: '3.8' # TK-MP-Project Docker Compose 설정
# 기본 설정 (개발 환경 기준)
# version: '3.8' # Docker Compose v2에서는 version 필드가 선택사항
services: services:
# PostgreSQL 데이터베이스 # PostgreSQL 데이터베이스
@@ -7,35 +9,22 @@ services:
container_name: tk-mp-postgres container_name: tk-mp-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: tk_mp_bom POSTGRES_DB: ${POSTGRES_DB:-tk_mp_bom}
POSTGRES_USER: tkmp_user POSTGRES_USER: ${POSTGRES_USER:-tkmp_user}
POSTGRES_PASSWORD: tkmp_password_2025 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tkmp_password_2025}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
ports: ports:
- "5432:5432" - "${POSTGRES_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d - ./database/init:/docker-entrypoint-initdb.d
networks: networks:
- tk-mp-network - tk-mp-network
healthcheck:
# pgAdmin 웹 관리도구 test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tkmp_user} -d ${POSTGRES_DB:-tk_mp_bom}"]
pgadmin: interval: 30s
image: dpage/pgadmin4:latest timeout: 10s
container_name: tk-mp-pgadmin retries: 3
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: admin2025
PGADMIN_CONFIG_SERVER_MODE: 'False'
ports:
- "5050:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- tk-mp-network
# Redis (캐시 및 세션 관리용) # Redis (캐시 및 세션 관리용)
redis: redis:
@@ -43,11 +32,16 @@ services:
container_name: tk-mp-redis container_name: tk-mp-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
- "6379:6379" - "${REDIS_PORT:-6379}:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data
networks: networks:
- tk-mp-network - tk-mp-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# 백엔드 FastAPI 서비스 # 백엔드 FastAPI 서비스
backend: backend:
@@ -57,10 +51,13 @@ services:
container_name: tk-mp-backend container_name: tk-mp-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "18000:8000" - "${BACKEND_PORT:-18000}:8000"
environment: environment:
- DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom - DATABASE_URL=postgresql://${POSTGRES_USER:-tkmp_user}:${POSTGRES_PASSWORD:-tkmp_password_2025}@postgres:5432/${POSTGRES_DB:-tk_mp_bom}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- ENVIRONMENT=${ENVIRONMENT:-development}
- DEBUG=${DEBUG:-true}
- PYTHONPATH=/app
depends_on: depends_on:
- postgres - postgres
- redis - redis
@@ -68,25 +65,52 @@ services:
- tk-mp-network - tk-mp-network
volumes: volumes:
- ./backend/uploads:/app/uploads - ./backend/uploads:/app/uploads
# 개발 환경에서는 코드 변경 실시간 반영 (오버라이드에서 설정)
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
# interval: 30s
# timeout: 10s
# retries: 3
# 프론트엔드 Nginx 서비스 # 프론트엔드 React + Nginx 서비스
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- VITE_API_URL=${VITE_API_URL:-/api} - VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
container_name: tk-mp-frontend container_name: tk-mp-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "13000:3000" - "${FRONTEND_PORT:-13000}:3000"
environment: environment:
- VITE_API_URL=${VITE_API_URL:-/api} - VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
depends_on: depends_on:
- backend - backend
networks: networks:
- tk-mp-network - tk-mp-network
# pgAdmin 웹 관리도구 (개발/테스트 환경용)
pgadmin:
image: dpage/pgadmin4:latest
container_name: tk-mp-pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@example.com}
PGLADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin2025}
PGADMIN_CONFIG_SERVER_MODE: 'False'
ports:
- "${PGADMIN_PORT:-5050}:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- tk-mp-network
profiles:
- dev
- test
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
@@ -97,4 +121,4 @@ volumes:
networks: networks:
tk-mp-network: tk-mp-network:
driver: bridge driver: bridge

104
docker-run.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/bin/bash
# TK-MP-Project Docker 실행 스크립트
# 사용법: ./docker-run.sh [환경] [명령]
# 환경: dev (기본), prod, synology
# 명령: up, down, build, logs, ps
set -e
# 기본값 설정
ENV=${1:-dev}
CMD=${2:-up}
# 환경별 설정 파일 경로
case $ENV in
"dev"|"development")
ENV_FILE="env.development"
COMPOSE_FILE="docker-compose.yml"
echo "🔧 개발 환경으로 실행합니다..."
;;
"prod"|"production")
ENV_FILE="env.production"
COMPOSE_FILE="docker-compose.yml"
echo "🚀 프로덕션 환경으로 실행합니다..."
;;
"synology"|"nas")
ENV_FILE="env.synology"
COMPOSE_FILE="docker-compose.yml"
echo "🏠 시놀로지 NAS 환경으로 실행합니다..."
;;
*)
echo "❌ 지원하지 않는 환경입니다: $ENV"
echo "사용 가능한 환경: dev, prod, synology"
exit 1
;;
esac
# 환경 파일 존재 확인
if [ ! -f "$ENV_FILE" ]; then
echo "❌ 환경 파일을 찾을 수 없습니다: $ENV_FILE"
exit 1
fi
# Docker Compose 파일 존재 확인
if [ ! -f "$COMPOSE_FILE" ]; then
echo "❌ Docker Compose 파일을 찾을 수 없습니다: $COMPOSE_FILE"
exit 1
fi
echo "📋 환경 파일: $ENV_FILE"
echo "🐳 Compose 파일: $COMPOSE_FILE"
# Docker Compose 명령 실행
case $CMD in
"up")
echo "🚀 컨테이너를 시작합니다..."
docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d
echo "✅ 컨테이너가 시작되었습니다!"
echo ""
echo "📊 서비스 상태:"
docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps
;;
"down")
echo "🛑 컨테이너를 중지합니다..."
docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" down
echo "✅ 컨테이너가 중지되었습니다!"
;;
"build")
echo "🔨 이미지를 빌드합니다..."
docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build
echo "✅ 이미지 빌드가 완료되었습니다!"
;;
"rebuild")
echo "🔨 이미지를 재빌드합니다..."
docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build --no-cache
echo "✅ 이미지 재빌드가 완료되었습니다!"
;;
"logs")
echo "📋 로그를 확인합니다..."
docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" logs -f
;;
"ps"|"status")
echo "📊 서비스 상태:"
docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps
;;
"restart")
echo "🔄 컨테이너를 재시작합니다..."
docker-compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" restart
echo "✅ 컨테이너가 재시작되었습니다!"
;;
*)
echo "❌ 지원하지 않는 명령입니다: $CMD"
echo "사용 가능한 명령: up, down, build, rebuild, logs, ps, restart"
exit 1
;;
esac
echo ""
echo "🎯 사용법 예시:"
echo " 개발 환경 시작: ./docker-run.sh dev up"
echo " 프로덕션 환경 시작: ./docker-run.sh prod up"
echo " 시놀로지 환경 시작: ./docker-run.sh synology up"
echo " 로그 확인: ./docker-run.sh dev logs"
echo " 상태 확인: ./docker-run.sh dev ps"

43
env.development Normal file
View File

@@ -0,0 +1,43 @@
# 개발 환경 설정
DEPLOY_ENV=development
COMPOSE_PROJECT_NAME=tk-mp-dev
# 컨테이너 설정
CONTAINER_SUFFIX=
DEBUG=true
RELOAD=true
LOG_LEVEL=DEBUG
# 포트 설정 (개발 환경 - 모든 포트 외부 노출)
POSTGRES_EXTERNAL_PORT=5432
REDIS_EXTERNAL_PORT=6379
BACKEND_EXTERNAL_PORT=18000
FRONTEND_EXTERNAL_PORT=13000
PGADMIN_EXTERNAL_PORT=5050
# 볼륨 설정
POSTGRES_DATA_VOLUME=postgres_data
REDIS_DATA_VOLUME=redis_data
PGADMIN_DATA_VOLUME=pgadmin_data
BACKEND_CODE_VOLUME=./backend
UPLOADS_VOLUME=./backend/uploads
# 프로파일 설정
POSTGRES_PROFILE=default
REDIS_PROFILE=default
BACKEND_PROFILE=default
FRONTEND_PROFILE=default
PGADMIN_PROFILE=dev
# API URL
VITE_API_URL=http://localhost:18000
# 데이터베이스 설정
POSTGRES_DB=tk_mp_bom
POSTGRES_USER=tkmp_user
POSTGRES_PASSWORD=tkmp_password_2025
# pgAdmin 설정
PGADMIN_EMAIL=admin@example.com
PGADMIN_PASSWORD=admin2025

36
env.example Normal file
View File

@@ -0,0 +1,36 @@
# TK-MP-Project 환경변수 설정 예시
# 실제 사용 시 .env 파일로 복사하여 값을 수정하세요
# 환경 설정
ENVIRONMENT=development
DEBUG=true
# 데이터베이스 설정
POSTGRES_DB=tk_mp_bom
POSTGRES_USER=tkmp_user
POSTGRES_PASSWORD=tkmp_password_2025
POSTGRES_PORT=5432
# Redis 설정
REDIS_PORT=6379
# 애플리케이션 포트 설정
BACKEND_PORT=18000
FRONTEND_PORT=13000
# API URL 설정
VITE_API_URL=http://localhost:18000
# pgAdmin 설정
PGADMIN_EMAIL=admin@example.com
PGADMIN_PASSWORD=admin2025
PGADMIN_PORT=5050
# JWT 설정 (프로덕션에서는 반드시 변경)
JWT_SECRET_KEY=your-super-secure-secret-key-here
# 로깅 설정
LOG_LEVEL=DEBUG
# 보안 설정
CORS_ORIGINS=http://localhost:3000,http://localhost:13000,http://localhost:5173

43
env.production Normal file
View File

@@ -0,0 +1,43 @@
# 프로덕션 환경 설정
DEPLOY_ENV=production
COMPOSE_PROJECT_NAME=tk-mp-prod
# 컨테이너 설정
CONTAINER_SUFFIX=-prod
DEBUG=false
RELOAD=false
LOG_LEVEL=INFO
# 포트 설정 (프로덕션 환경 - 내부 서비스는 포트 비노출)
POSTGRES_EXTERNAL_PORT=
REDIS_EXTERNAL_PORT=
BACKEND_EXTERNAL_PORT=
FRONTEND_EXTERNAL_PORT=
PGADMIN_EXTERNAL_PORT=
# Nginx 포트 (프로덕션에서만 사용)
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
# 볼륨 설정
POSTGRES_DATA_VOLUME=postgres_data
REDIS_DATA_VOLUME=redis_data
PGADMIN_DATA_VOLUME=pgadmin_data
BACKEND_CODE_VOLUME=
UPLOADS_VOLUME=./backend/uploads
# 프로파일 설정 (pgAdmin 비활성화)
POSTGRES_PROFILE=default
REDIS_PROFILE=default
BACKEND_PROFILE=default
FRONTEND_PROFILE=default
PGADMIN_PROFILE=disabled
# API URL (프로덕션에서는 상대 경로)
VITE_API_URL=/api
# 데이터베이스 설정
POSTGRES_DB=tk_mp_bom
POSTGRES_USER=tkmp_user
POSTGRES_PASSWORD=tkmp_password_2025

43
env.synology Normal file
View File

@@ -0,0 +1,43 @@
# 시놀로지 NAS 환경 설정
DEPLOY_ENV=synology
COMPOSE_PROJECT_NAME=tk-mp-synology
# 컨테이너 설정
CONTAINER_SUFFIX=-synology
DEBUG=false
RELOAD=false
LOG_LEVEL=INFO
# 포트 설정 (시놀로지 환경 - 포트 충돌 방지)
POSTGRES_EXTERNAL_PORT=15432
REDIS_EXTERNAL_PORT=16379
BACKEND_EXTERNAL_PORT=10080
FRONTEND_EXTERNAL_PORT=10173
PGADMIN_EXTERNAL_PORT=15050
# 볼륨 설정 (시놀로지용 명명된 볼륨)
POSTGRES_DATA_VOLUME=tk_mp_postgres_data
REDIS_DATA_VOLUME=tk_mp_redis_data
PGLADMIN_DATA_VOLUME=pgadmin_data
BACKEND_CODE_VOLUME=
UPLOADS_VOLUME=tk_mp_uploads
# 프로파일 설정
POSTGRES_PROFILE=default
REDIS_PROFILE=default
BACKEND_PROFILE=default
FRONTEND_PROFILE=default
PGADMIN_PROFILE=dev
# API URL (시놀로지 환경)
VITE_API_URL=http://localhost:10080
# 데이터베이스 설정
POSTGRES_DB=tk_mp_bom
POSTGRES_USER=tkmp_user
POSTGRES_PASSWORD=tkmp_password_2025
# pgAdmin 설정
PGADMIN_EMAIL=admin@example.com
PGLADMIN_PASSWORD=admin2025

View File

@@ -3,6 +3,9 @@ server {
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
# 🔧 요청 크기 제한 증가 (413 오류 해결)
client_max_body_size 100M;
# SPA를 위한 설정 (React Router 등) # SPA를 위한 설정 (React Router 등)
location / { location / {
@@ -16,6 +19,10 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# 프록시 요청 크기 제한 증가
proxy_request_buffering off;
client_max_body_size 100M;
} }
# 정적 파일 캐싱 # 정적 파일 캐싱

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,13 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import SimpleLogin from './SimpleLogin'; import SimpleLogin from './SimpleLogin';
import NavigationMenu from './components/NavigationMenu'; import BOMWorkspacePage from './pages/BOMWorkspacePage';
import DashboardPage from './pages/DashboardPage'; import NewMaterialsPage from './pages/NewMaterialsPage';
import ProjectsPage from './pages/ProjectsPage'; import SystemSettingsPage from './pages/SystemSettingsPage';
import BOMStatusPage from './pages/BOMStatusPage';
import SimpleMaterialsPage from './pages/SimpleMaterialsPage';
import MaterialComparisonPage from './pages/MaterialComparisonPage';
import RevisionPurchasePage from './pages/RevisionPurchasePage';
import JobSelectionPage from './pages/JobSelectionPage';
import './App.css'; import './App.css';
function App() { function App() {
@@ -16,6 +11,7 @@ function App() {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [currentPage, setCurrentPage] = useState('dashboard'); const [currentPage, setCurrentPage] = useState('dashboard');
const [pageParams, setPageParams] = useState({}); const [pageParams, setPageParams] = useState({});
const [selectedProject, setSelectedProject] = useState(null);
useEffect(() => { useEffect(() => {
// 저장된 토큰 확인 // 저장된 토큰 확인
@@ -28,6 +24,24 @@ function App() {
} }
setIsLoading(false); setIsLoading(false);
// 자재 목록 페이지로 이동 이벤트 리스너
const handleNavigateToMaterials = (event) => {
const { jobNo, revision, bomName, message, file_id } = event.detail;
navigateToPage('materials', {
jobNo: jobNo,
revision: revision,
bomName: bomName,
message: message,
file_id: file_id // file_id 추가
});
};
window.addEventListener('navigateToMaterials', handleNavigateToMaterials);
return () => {
window.removeEventListener('navigateToMaterials', handleNavigateToMaterials);
};
}, []); }, []);
// 로그인 성공 시 호출될 함수 // 로그인 성공 시 호출될 함수
@@ -54,152 +68,393 @@ function App() {
setPageParams(params); setPageParams(params);
}; };
// 핵심 기능만 제공
const getCoreFeatures = () => {
return [
{
id: 'bom',
title: '📋 BOM 업로드 & 분류',
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 엑셀 내보내기',
color: '#4299e1'
}
];
};
// 관리자 전용 기능
const getAdminFeatures = () => {
if (user?.role !== 'admin') return [];
return [
{
id: 'system-settings',
title: '⚙️ 시스템 설정',
description: '사용자 계정 관리',
color: '#dc2626'
}
];
};
// 페이지 렌더링 함수 // 페이지 렌더링 함수
const renderCurrentPage = () => { const renderCurrentPage = () => {
console.log('현재 페이지:', currentPage, '페이지 파라미터:', pageParams);
switch (currentPage) { switch (currentPage) {
case 'dashboard': case 'dashboard':
return <DashboardPage user={user} />; const coreFeatures = getCoreFeatures();
case 'projects': const adminFeatures = getAdminFeatures();
return <ProjectsPage user={user} />;
return (
<div style={{ minHeight: '100vh', background: '#f7fafc' }}>
{/* 상단 헤더 */}
<div style={{
background: 'white',
borderBottom: '1px solid #e2e8f0',
padding: '16px 32px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
🏭 TK-MP BOM 관리 시스템
</h1>
<p style={{ color: '#718096', fontSize: '14px', margin: '4px 0 0 0' }}>
{user?.full_name || user?.username} 환영합니다
</p>
</div>
<button
onClick={handleLogout}
style={{
background: '#e2e8f0',
color: '#4a5568',
border: 'none',
borderRadius: '6px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
로그아웃
</button>
</div>
{/* 메인 콘텐츠 */}
<div style={{ padding: '32px', maxWidth: '800px', margin: '0 auto' }}>
{/* 프로젝트 선택 */}
<div style={{ marginBottom: '32px' }}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
📁 프로젝트 선택
</h2>
<select
value={selectedProject?.official_project_code || ''}
onChange={(e) => {
const projectCode = e.target.value;
if (projectCode) {
setSelectedProject({
official_project_code: projectCode,
project_name: e.target.options[e.target.selectedIndex].text.split(' - ')[1]
});
} else {
setSelectedProject(null);
}
}}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white'
}}
>
<option value="">프로젝트를 선택하세요</option>
<option value="J24-001">J24-001 - 테스트 프로젝트 A</option>
<option value="J24-002">J24-002 - 테스트 프로젝트 B</option>
<option value="J24-003">J24-003 - 테스트 프로젝트 C</option>
</select>
</div>
{/* 핵심 기능 */}
{selectedProject && (
<>
<div style={{ marginBottom: '32px' }}>
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
📋 BOM 관리 워크플로우
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '16px'
}}>
{coreFeatures.map((feature) => (
<div
key={feature.id}
style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
border: '1px solid #e2e8f0',
cursor: 'pointer',
transition: 'transform 0.2s, box-shadow 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 8px 12px rgba(0, 0, 0, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)';
}}
>
<h3 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
{feature.title}
</h3>
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
{feature.description}
</p>
<button
onClick={() => navigateToPage(feature.id, { selectedProject })}
style={{
background: feature.color,
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '12px 20px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
width: '100%'
}}
>
시작하기
</button>
</div>
))}
</div>
</div>
{/* 관리자 기능 (있는 경우만) */}
{adminFeatures.length > 0 && (
<div style={{ marginBottom: '32px' }}>
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
시스템 관리
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '16px'
}}>
{adminFeatures.map((feature) => (
<div
key={feature.id}
style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
border: '1px solid #e2e8f0',
cursor: 'pointer',
transition: 'transform 0.2s, box-shadow 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 8px 12px rgba(0, 0, 0, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)';
}}
>
<h3 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
{feature.title}
</h3>
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
{feature.description}
</p>
<div style={{ marginBottom: '12px' }}>
<span style={{
background: '#fef7e0',
color: '#92400e',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
관리자 전용
</span>
</div>
<button
onClick={() => navigateToPage(feature.id)}
style={{
background: feature.color,
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '12px 20px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
width: '100%'
}}
>
관리하기
</button>
</div>
))}
</div>
</div>
)}
{/* 간단한 사용법 안내 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
border: '1px solid #e2e8f0'
}}>
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
📖 간단한 사용법
</h3>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
background: '#4299e1',
color: 'white',
borderRadius: '50%',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: '600'
}}>1</span>
<span style={{ fontSize: '14px', color: '#4a5568' }}>BOM 업로드</span>
</div>
<span style={{ color: '#a0aec0' }}></span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
background: '#ed8936',
color: 'white',
borderRadius: '50%',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: '600'
}}>2</span>
<span style={{ fontSize: '14px', color: '#4a5568' }}>자동 분류</span>
</div>
<span style={{ color: '#a0aec0' }}></span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
background: '#48bb78',
color: 'white',
borderRadius: '50%',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: '600'
}}>3</span>
<span style={{ fontSize: '14px', color: '#4a5568' }}>엑셀 내보내기</span>
</div>
</div>
</div>
</>
)} {/* selectedProject 조건문 닫기 */}
</div>
</div>
);
case 'bom': case 'bom':
return <JobSelectionPage onJobSelect={(jobNo, jobName) => return (
navigateToPage('bom-status', { job_no: jobNo, job_name: jobName }) <BOMWorkspacePage
} />; project={pageParams.selectedProject}
case 'bom-status': onNavigate={navigateToPage}
return <BOMStatusPage onBack={() => navigateToPage('dashboard')}
jobNo={pageParams.job_no} />
jobName={pageParams.job_name} );
onNavigate={navigateToPage}
/>;
case 'materials': case 'materials':
return <SimpleMaterialsPage return (
fileId={pageParams.file_id} <NewMaterialsPage
jobNo={pageParams.jobNo} onNavigate={navigateToPage}
bomName={pageParams.bomName} selectedProject={pageParams.selectedProject}
revision={pageParams.revision} fileId={pageParams.file_id}
filename={pageParams.filename} jobNo={pageParams.jobNo}
onNavigate={navigateToPage} bomName={pageParams.bomName}
/>; revision={pageParams.revision}
case 'material-comparison': filename={pageParams.filename}
return <MaterialComparisonPage />
jobNo={pageParams.job_no} );
currentRevision={pageParams.current_revision}
previousRevision={pageParams.previous_revision} case 'system-settings':
filename={pageParams.filename} return (
onNavigate={navigateToPage} <SystemSettingsPage
/>; onNavigate={navigateToPage}
case 'revision-purchase': user={user}
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: default:
return <DashboardPage user={user} />; return (
<div style={{ padding: '32px', textAlign: 'center' }}>
<h2>페이지를 찾을 없습니다</h2>
<button
onClick={() => navigateToPage('dashboard')}
style={{
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '12px 24px',
cursor: 'pointer',
marginTop: '16px'
}}
>
대시보드로 돌아가기
</button>
</div>
);
} }
}; };
// 로딩 중
if (isLoading) { if (isLoading) {
return ( return (
<div style={{ <div style={{
minHeight: '100vh', display: 'flex',
display: 'flex', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', height: '100vh',
background: '#f7fafc' background: '#f7fafc'
}}> }}>
<div>로딩 ...</div> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}>🔄</div>
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 ...</div>
</div>
</div> </div>
); );
} }
// 로그인하지 않은 경우
if (!isAuthenticated) { if (!isAuthenticated) {
return <SimpleLogin onLoginSuccess={handleLoginSuccess} />; return <SimpleLogin onLoginSuccess={handleLoginSuccess} />;
} }
// 메인 애플리케이션
return ( return (
<div style={{ display: 'flex', minHeight: '100vh' }}> <div style={{ minHeight: '100vh', background: '#f7fafc' }}>
<NavigationMenu {renderCurrentPage()}
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> </div>
); );
} }
export default App; export default App;

View File

@@ -218,3 +218,19 @@ const SimpleDashboard = () => {
}; };
export default SimpleDashboard; export default SimpleDashboard;

View File

@@ -61,146 +61,150 @@ const SimpleLogin = ({ onLoginSuccess }) => {
return ( return (
<div style={{ <div style={{
minHeight: '100vh', minHeight: '100vh',
margin: '0',
padding: '0',
background: 'url("/img/login-bg.jpeg") no-repeat center center fixed',
backgroundSize: 'cover',
fontFamily: 'Malgun Gothic, sans-serif',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center'
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '20px',
fontFamily: 'Arial, sans-serif'
}}> }}>
<div style={{ <div style={{
background: 'white', background: 'rgba(0, 0, 0, 0.65)',
borderRadius: '16px', width: '400px',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)',
padding: '40px', padding: '40px',
width: '100%', borderRadius: '12px',
maxWidth: '400px' textAlign: 'center',
color: 'white',
boxShadow: '0 0 20px rgba(0,0,0,0.3)'
}}> }}>
<div style={{ textAlign: 'center', marginBottom: '32px' }}> <img
<h1 style={{ color: '#2d3748', fontSize: '28px', fontWeight: '700', margin: '0 0 8px 0' }}> src="/img/logo.png"
🚀 TK-MP System alt="테크니컬코리아 로고"
</h1> style={{
<p style={{ color: '#718096', fontSize: '14px', margin: '0' }}> width: '200px',
통합 프로젝트 관리 시스템 marginBottom: '20px'
</p> }}
</div> />
<h1 style={{
color: 'white',
fontSize: '24px',
fontWeight: '700',
margin: '0 0 8px 0'
}}>
()테크니컬코리아
</h1>
<h3 style={{
color: 'rgba(255, 255, 255, 0.9)',
fontSize: '18px',
fontWeight: '400',
margin: '0 0 32px 0'
}}>
통합 관리 시스템
</h3>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}> <input
<label style={{ type="text"
display: 'block', name="username"
color: '#2d3748', value={formData.username}
fontWeight: '600', onChange={handleChange}
fontSize: '14px', placeholder="아이디"
marginBottom: '8px' disabled={isLoading}
}}> required
사용자명 autoComplete="username"
</label> style={{
<input display: 'block',
type="text" width: '100%',
name="username" margin: '15px 0',
value={formData.username} padding: '12px',
onChange={handleChange} fontSize: '1rem',
placeholder="사용자명을 입력하세요" borderRadius: '6px',
disabled={isLoading} border: 'none',
style={{ boxSizing: 'border-box'
width: '100%', }}
padding: '12px 16px', />
border: '2px solid #e2e8f0', <input
borderRadius: '8px', type="password"
fontSize: '16px', name="password"
boxSizing: 'border-box' value={formData.password}
}} onChange={handleChange}
/> placeholder="비밀번호"
</div> disabled={isLoading}
required
<div style={{ marginBottom: '20px' }}> autoComplete="current-password"
<label style={{ style={{
display: 'block', display: 'block',
color: '#2d3748', width: '100%',
fontWeight: '600', margin: '15px 0',
fontSize: '14px', padding: '12px',
marginBottom: '8px' fontSize: '1rem',
}}> borderRadius: '6px',
비밀번호 border: 'none',
</label> boxSizing: 'border-box'
<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 <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
style={{ style={{
width: '100%', padding: '12px 20px',
padding: '14px 24px', fontSize: '1rem',
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', cursor: isLoading ? 'not-allowed' : 'pointer',
marginTop: '8px' border: 'none',
backgroundColor: isLoading ? '#a0aec0' : '#1976d2',
color: 'white',
borderRadius: '6px',
transition: 'background-color 0.3s',
width: '100%',
marginTop: '10px'
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.target.style.backgroundColor = '#1565c0';
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.target.style.backgroundColor = '#1976d2';
}
}} }}
> >
{isLoading ? '로그인 중...' : '로그인'} {isLoading ? '로그인 중...' : '로그인'}
</button> </button>
</form> </form>
{error && (
<div style={{
marginTop: '10px',
color: '#ff6b6b',
fontWeight: 'bold',
fontSize: '14px'
}}>
{error}
</div>
)}
{success && (
<div style={{
marginTop: '10px',
color: '#4caf50',
fontWeight: 'bold',
fontSize: '14px'
}}>
{success}
</div>
)}
<div style={{ marginTop: '32px', textAlign: 'center' }}> <div style={{ marginTop: '32px', textAlign: 'center' }}>
<p style={{ color: '#718096', fontSize: '14px', margin: '0 0 16px 0' }}> <p style={{ color: 'rgba(255, 255, 255, 0.8)', fontSize: '14px', margin: '0 0 16px 0' }}>
테스트 계정: admin / admin123 또는 testuser / test123 테스트 계정: admin / admin123
</p> </p>
<div> <div>
<small style={{ color: '#a0aec0', fontSize: '12px' }}> <small style={{ color: 'rgba(255, 255, 255, 0.6)', fontSize: '12px' }}>
TK-MP Project Management System v2.0 TK-MP 통합 관리 시스템 v2.0
</small> </small>
</div> </div>
</div> </div>

View File

@@ -1,43 +0,0 @@
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;

View File

@@ -6,7 +6,8 @@ const BOMFileTable = ({
groupFilesByBOM, groupFilesByBOM,
handleViewMaterials, handleViewMaterials,
openRevisionDialog, openRevisionDialog,
handleDelete handleDelete,
onNavigate
}) => { }) => {
if (loading) { if (loading) {
return ( return (
@@ -97,6 +98,32 @@ const BOMFileTable = ({
</td> </td>
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}> <td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={() => {
// ( )
if (window.onNavigate) {
window.onNavigate('materials', {
file_id: file.id,
jobNo: file.job_no,
bomName: file.bom_name,
revision: file.revision,
filename: file.original_filename
});
}
}}
style={{
padding: '6px 12px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
📋 자재 보기
</button>
<button <button
onClick={() => handleViewMaterials(file)} onClick={() => handleViewMaterials(file)}
style={{ style={{
@@ -112,22 +139,20 @@ const BOMFileTable = ({
🧮 구매수량 계산 🧮 구매수량 계산
</button> </button>
{index === 0 && ( <button
<button onClick={() => openRevisionDialog(file.bom_name || bomKey, file.id)}
onClick={() => openRevisionDialog(file.bom_name || bomKey, file.id)} style={{
style={{ padding: '6px 12px',
padding: '6px 12px', background: 'white',
background: 'white', color: '#4299e1',
color: '#4299e1', border: '1px solid #4299e1',
border: '1px solid #4299e1', borderRadius: '4px',
borderRadius: '4px', cursor: 'pointer',
cursor: 'pointer', fontSize: '12px'
fontSize: '12px' }}
}} >
> 📝 리비전
리비전 </button>
</button>
)}
<button <button
onClick={() => handleDelete(file.id)} onClick={() => handleDelete(file.id)}

View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
import BOMFileUpload from '../components/BOMFileUpload';
const BOMStatusPage = ({ jobNo, jobName, onNavigate, selectedProject }) => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [bomName, setBomName] = useState('');
useEffect(() => {
if (jobNo) {
fetchFilesList();
}
}, [jobNo]);
const fetchFilesList = async () => {
try {
setLoading(true);
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
// API가 배열로 직접 반환하는 경우
if (Array.isArray(response.data)) {
setFiles(response.data);
} else if (response.data && response.data.success) {
setFiles(response.data.files || []);
} else {
setFiles([]);
}
} catch (err) {
console.error('파일 목록 로딩 실패:', err);
setError('파일 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
// 파일 업로드
const handleUpload = async () => {
if (!selectedFile || !bomName.trim()) {
alert('파일과 BOM 이름을 모두 입력해주세요.');
return;
}
try {
setUploading(true);
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('bom_name', bomName.trim());
formData.append('job_no', jobNo);
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data && response.data.success) {
alert('파일이 성공적으로 업로드되었습니다!');
setSelectedFile(null);
setBomName('');
await fetchFilesList(); // 목록 새로고침
} else {
throw new Error(response.data?.message || '업로드 실패');
}
} catch (err) {
console.error('파일 업로드 실패:', err);
setError('파일 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
};
// 파일 삭제
const handleDelete = async (fileId) => {
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
return;
}
try {
await deleteFileApi(fileId);
await fetchFilesList(); // 목록 새로고침
} catch (err) {
console.error('파일 삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 자재 관리 페이지로 바로 이동 (단순화)
const handleViewMaterials = (file) => {
if (onNavigate) {
onNavigate('materials', {
file_id: file.id,
jobNo: file.job_no,
bomName: file.bom_name || file.original_filename,
revision: file.revision,
filename: file.original_filename
});
}
};
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<button
onClick={() => {
if (onNavigate) {
onNavigate('dashboard');
}
}}
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'
}}>
📊 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>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
로딩 ...
</div>
) : (
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
<th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>자재 </th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
<th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>작업</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '16px' }}>
<div style={{ fontWeight: '600', color: '#2d3748' }}>
{file.bom_name || file.original_filename}
</div>
<div style={{ fontSize: '12px', color: '#718096' }}>
{file.description || ''}
</div>
</td>
<td style={{ padding: '16px', fontSize: '14px', color: '#4a5568' }}>
{file.original_filename}
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
<span style={{
background: '#e6fffa',
color: '#065f46',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{file.revision || 'Rev.0'}
</span>
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
{file.parsed_count || 0}
</td>
<td style={{ padding: '16px', textAlign: 'center', fontSize: '14px', color: '#718096' }}>
{new Date(file.created_at).toLocaleDateString()}
</td>
<td style={{ padding: '16px', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<button
onClick={() => handleViewMaterials(file)}
style={{
padding: '6px 12px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
📋 자재 보기
</button>
<button
onClick={() => {
// 리비전 업로드 기능 (추후 구현)
alert('리비전 업로드 기능은 준비 중입니다.');
}}
style={{
padding: '6px 12px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
📝 리비전
</button>
<button
onClick={() => handleDelete(file.id)}
style={{
padding: '6px 12px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
🗑 삭제
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{files.length === 0 && (
<div style={{
padding: '40px',
textAlign: 'center',
color: '#718096'
}}>
업로드된 BOM 파일이 없습니다.
</div>
)}
</div>
)}
</div>
</div>
);
};
export default BOMStatusPage;

View File

@@ -0,0 +1,416 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const BOMUploadPage = ({ projectInfo, onNavigate, onBack }) => {
const [selectedFile, setSelectedFile] = useState(null);
const [jobNo, setJobNo] = useState(projectInfo?.job_no || '');
const [revision, setRevision] = useState('Rev.0');
const [bomName, setBomName] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadResult, setUploadResult] = useState(null);
useEffect(() => {
if (projectInfo) {
setJobNo(projectInfo.job_no);
setBomName(projectInfo.project_name || '');
}
}, [projectInfo]);
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) {
setSelectedFile(file);
setUploadResult(null);
}
};
const handleUpload = async () => {
if (!selectedFile) {
alert('파일을 선택해주세요.');
return;
}
if (!jobNo.trim()) {
alert('Job 번호를 입력해주세요.');
return;
}
setIsUploading(true);
setUploadResult(null);
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('job_no', jobNo.trim());
formData.append('revision', revision);
if (bomName.trim()) {
formData.append('bom_name', bomName.trim());
}
const response = await api.post('/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (response.data.success) {
setUploadResult({
success: true,
message: response.data.message,
fileId: response.data.file_id,
materialsCount: response.data.materials_count,
revision: response.data.revision,
uploadedBy: response.data.uploaded_by
});
// 파일 선택 초기화
setSelectedFile(null);
const fileInput = document.getElementById('file-input');
if (fileInput) fileInput.value = '';
} else {
setUploadResult({
success: false,
message: response.data.message || '업로드에 실패했습니다.'
});
}
} catch (error) {
console.error('업로드 오류:', error);
setUploadResult({
success: false,
message: error.response?.data?.detail || '업로드 중 오류가 발생했습니다.'
});
} finally {
setIsUploading(false);
}
};
const handleViewMaterials = () => {
if (uploadResult && uploadResult.fileId) {
onNavigate('materials', {
file_id: uploadResult.fileId,
jobNo: jobNo,
bomName: bomName,
revision: uploadResult.revision,
filename: selectedFile?.name
});
}
};
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '32px'
}}>
{onBack && (
<button
onClick={onBack}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
marginRight: '16px',
padding: '8px'
}}
>
</button>
)}
<div>
<h1 style={{
margin: '0 0 8px 0',
fontSize: '28px',
fontWeight: 'bold',
color: '#2d3748'
}}>
📤 BOM 파일 업로드
</h1>
{projectInfo && (
<div style={{
fontSize: '16px',
color: '#718096'
}}>
{projectInfo.project_name} ({projectInfo.job_no})
</div>
)}
</div>
</div>
{/* 업로드 폼 */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
marginBottom: '24px'
}}>
<div style={{
display: 'grid',
gap: '24px'
}}>
{/* Job 번호 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
Job 번호 *
</label>
<input
type="text"
value={jobNo}
onChange={(e) => setJobNo(e.target.value)}
placeholder="예: TK-2024-001"
disabled={!!projectInfo?.job_no}
style={{
width: '100%',
padding: '12px 16px',
border: '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '16px',
backgroundColor: projectInfo?.job_no ? '#f9fafb' : 'white'
}}
/>
</div>
{/* 리비전 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
리비전
</label>
<input
type="text"
value={revision}
onChange={(e) => setRevision(e.target.value)}
placeholder="예: Rev.0, Rev.1"
style={{
width: '100%',
padding: '12px 16px',
border: '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '16px'
}}
/>
</div>
{/* BOM 이름 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
BOM 이름 (선택사항)
</label>
<input
type="text"
value={bomName}
onChange={(e) => setBomName(e.target.value)}
placeholder="BOM 파일 설명"
style={{
width: '100%',
padding: '12px 16px',
border: '1px solid #d1d5db',
borderRadius: '8px',
fontSize: '16px'
}}
/>
</div>
{/* 파일 선택 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
BOM 파일 *
</label>
<div style={{
border: '2px dashed #d1d5db',
borderRadius: '12px',
padding: '32px',
textAlign: 'center',
backgroundColor: selectedFile ? '#f0f9ff' : '#fafafa',
borderColor: selectedFile ? '#3b82f6' : '#d1d5db'
}}>
<input
id="file-input"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<label
htmlFor="file-input"
style={{
cursor: 'pointer',
display: 'block'
}}
>
{selectedFile ? (
<div>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
<div style={{
fontSize: '18px',
fontWeight: '600',
color: '#1f2937',
marginBottom: '8px'
}}>
{selectedFile.name}
</div>
<div style={{
fontSize: '14px',
color: '#6b7280'
}}>
크기: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</div>
<div style={{
fontSize: '14px',
color: '#3b82f6',
marginTop: '8px'
}}>
다른 파일을 선택하려면 클릭하세요
</div>
</div>
) : (
<div>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📁</div>
<div style={{
fontSize: '18px',
fontWeight: '600',
color: '#1f2937',
marginBottom: '8px'
}}>
파일을 선택하거나 드래그하세요
</div>
<div style={{
fontSize: '14px',
color: '#6b7280'
}}>
Excel (.xlsx, .xls) 또는 CSV 파일만 지원됩니다
</div>
</div>
)}
</label>
</div>
</div>
{/* 업로드 버튼 */}
<button
onClick={handleUpload}
disabled={!selectedFile || !jobNo.trim() || isUploading}
style={{
width: '100%',
padding: '16px',
backgroundColor: (!selectedFile || !jobNo.trim() || isUploading) ? '#9ca3af' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '600',
cursor: (!selectedFile || !jobNo.trim() || isUploading) ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease'
}}
>
{isUploading ? '업로드 중...' : '📤 업로드 시작'}
</button>
</div>
</div>
{/* 업로드 결과 */}
{uploadResult && (
<div style={{
background: uploadResult.success ? '#f0f9ff' : '#fef2f2',
border: `1px solid ${uploadResult.success ? '#3b82f6' : '#ef4444'}`,
borderRadius: '12px',
padding: '24px'
}}>
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: '12px'
}}>
<div style={{ fontSize: '24px' }}>
{uploadResult.success ? '✅' : '❌'}
</div>
<div style={{ flex: 1 }}>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '18px',
fontWeight: '600',
color: uploadResult.success ? '#1e40af' : '#dc2626'
}}>
{uploadResult.success ? '업로드 성공!' : '업로드 실패'}
</h3>
<p style={{
margin: '0 0 16px 0',
fontSize: '14px',
color: '#374151',
lineHeight: 1.5
}}>
{uploadResult.message}
</p>
{uploadResult.success && (
<div style={{
display: 'flex',
gap: '12px',
marginTop: '16px'
}}>
<button
onClick={handleViewMaterials}
style={{
padding: '8px 16px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer'
}}
>
자재 목록 보기
</button>
<div style={{
fontSize: '14px',
color: '#6b7280',
display: 'flex',
alignItems: 'center'
}}>
{uploadResult.materialsCount} 자재 분류됨 {uploadResult.uploadedBy}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default BOMUploadPage;

View File

@@ -21,6 +21,20 @@ export const api = axios.create({
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1초 const RETRY_DELAY = 1000; // 1초
// 요청 인터셉터: 토큰 자동 추가
api.interceptors.request.use(
config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 재시도 함수 // 재시도 함수
const retryRequest = async (config, retries = MAX_RETRIES) => { const retryRequest = async (config, retries = MAX_RETRIES) => {
try { try {
@@ -35,7 +49,7 @@ const retryRequest = async (config, retries = MAX_RETRIES) => {
} }
}; };
// 공통 에러 핸들링 (예시) // 응답 인터셉터: 에러 처리 및 자동 로그아웃
api.interceptors.response.use( api.interceptors.response.use(
response => response, response => response,
error => { error => {
@@ -47,7 +61,18 @@ api.interceptors.response.use(
message: error.message message: error.message
}); });
// 필요시 에러 로깅/알림 등 추가 // 401/403 에러 시 자동 로그아웃
if (error.response?.status === 401 || error.response?.status === 403) {
const token = localStorage.getItem('access_token');
if (token) {
console.log('토큰이 유효하지 않습니다. 자동 로그아웃 처리합니다.');
localStorage.removeItem('access_token');
localStorage.removeItem('user_data');
// 페이지 새로고침으로 로그인 페이지로 이동
window.location.reload();
}
}
return Promise.reject(error); return Promise.reject(error);
} }
); );
@@ -65,9 +90,9 @@ export function uploadFile(formData, options = {}) {
return retryRequest(config); return retryRequest(config);
} }
// 예시: 자재 목록 조회 // 예시: 자재 목록 조회 (신버전 API 사용)
export function fetchMaterials(params) { export function fetchMaterials(params) {
return api.get('/files/materials', { params }); return api.get('/files/materials-v2', { params });
} }
// 예시: 자재 요약 통계 // 예시: 자재 요약 통계
@@ -82,7 +107,7 @@ export function fetchFiles(params) {
// 파일 삭제 // 파일 삭제
export function deleteFile(fileId) { export function deleteFile(fileId) {
return api.delete(`/files/${fileId}`); return api.delete(`/files/delete/${fileId}`);
} }
// 예시: Job 목록 조회 // 예시: Job 목록 조회

View File

@@ -116,3 +116,19 @@ const BOMFileUpload = ({
}; };
export default BOMFileUpload; export default BOMFileUpload;

View File

@@ -464,7 +464,18 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}> <Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button <Button
variant="contained" variant="contained"
onClick={() => window.location.href = '/materials'} onClick={() => {
// 상태 기반 라우팅을 위한 이벤트 발생
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
detail: {
jobNo: selectedProject?.job_no,
revision: uploadResult?.revision || 'Rev.0',
bomName: uploadResult?.original_filename || uploadResult?.filename,
message: '파일 업로드 완료',
file_id: uploadResult?.file_id // file_id 추가
}
}));
}}
startIcon={<Description />} startIcon={<Description />}
> >
자재 목록 보기 자재 목록 보기

View File

@@ -761,11 +761,18 @@ function MaterialList({ selectedProject }) {
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
<Typography variant="h6" color="primary"> <Typography variant="h6" color="primary">
{material.quantity.toLocaleString()} {material.classified_category === 'PIPE' ? (() => {
const bomLength = material.pipe_details?.total_length_mm || 0;
const pipeCount = material.pipe_details?.pipe_count || 0;
const cuttingLoss = pipeCount * 2;
const requiredLength = bomLength + cuttingLoss;
const pipesNeeded = Math.ceil(requiredLength / 6000);
return pipesNeeded.toLocaleString();
})() : material.quantity.toLocaleString()}
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
<Chip label={material.unit} size="small" /> <Chip label={material.classified_category === 'PIPE' ? '본' : material.unit} size="small" />
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
<Chip <Chip

View File

@@ -535,3 +535,19 @@
right: 12px; right: 12px;
} }
} }

View File

@@ -268,3 +268,19 @@ const NavigationBar = ({ currentPage, onNavigate }) => {
}; };
export default NavigationBar; export default NavigationBar;

View File

@@ -248,3 +248,19 @@
.menu-section::-webkit-scrollbar-thumb:hover { .menu-section::-webkit-scrollbar-thumb:hover {
background: #a1a1a1; background: #a1a1a1;
} }

View File

@@ -172,3 +172,19 @@ const NavigationMenu = ({ user, currentPage, onPageChange }) => {
}; };
export default NavigationMenu; export default NavigationMenu;

View File

@@ -0,0 +1,482 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const PersonalizedDashboard = () => {
const [user, setUser] = useState(null);
const [dashboardData, setDashboardData] = useState(null);
const [loading, setLoading] = useState(true);
const [recentActivities, setRecentActivities] = useState([]);
useEffect(() => {
loadUserData();
loadDashboardData();
loadRecentActivities();
}, []);
const loadUserData = () => {
const userData = localStorage.getItem('user_data');
if (userData) {
setUser(JSON.parse(userData));
}
};
const loadDashboardData = async () => {
try {
// 실제 API에서 대시보드 데이터 로드
const response = await api.get('/dashboard/stats');
if (response.data && response.data.success) {
// API 데이터와 목 데이터를 병합 (quickActions 등 누락된 필드 보완)
const mockData = generateMockDataByRole();
const mergedData = {
...mockData,
...response.data.stats,
// quickActions가 없으면 목 데이터의 것을 사용
quickActions: response.data.stats.quickActions || mockData?.quickActions || []
};
setDashboardData(mergedData);
} else {
// API 실패 시 목 데이터 사용
console.log('대시보드 API 응답이 없어 목 데이터를 사용합니다.');
const mockData = generateMockDataByRole();
setDashboardData(mockData);
}
} catch (error) {
console.log('대시보드 API가 구현되지 않아 목 데이터를 사용합니다:', error.response?.status);
// 에러 시 목 데이터 사용
const mockData = generateMockDataByRole();
setDashboardData(mockData);
} finally {
setLoading(false);
}
};
const loadRecentActivities = async () => {
try {
// 실제 API에서 활동 이력 로드
const response = await api.get('/dashboard/activities?limit=5');
if (response.data.success && response.data.activities.length > 0) {
setRecentActivities(response.data.activities);
} else {
// API 실패 시 목 데이터 사용
const mockActivities = generateMockActivitiesByRole();
setRecentActivities(mockActivities);
}
} catch (error) {
console.log('활동 이력 API가 구현되지 않아 목 데이터를 사용합니다:', error.response?.status);
// 에러 시 목 데이터 사용
const mockActivities = generateMockActivitiesByRole();
setRecentActivities(mockActivities);
}
};
const generateMockDataByRole = () => {
if (!user) return null;
const baseData = {
admin: {
title: "시스템 관리자",
subtitle: "전체 시스템을 관리하고 모니터링합니다",
metrics: [
{ label: "전체 프로젝트 수", value: 45, icon: "📋", color: "#667eea" },
{ label: "활성 사용자 수", value: 12, icon: "👥", color: "#48bb78" },
{ label: "시스템 상태", value: "정상", icon: "🟢", color: "#38b2ac" },
{ label: "오늘 업로드", value: 8, icon: "📤", color: "#ed8936" }
],
quickActions: [
{ title: "사용자 관리", icon: "👤", path: "/admin/users", color: "#667eea" },
{ title: "시스템 설정", icon: "⚙️", path: "/admin/settings", color: "#48bb78" },
{ title: "백업 관리", icon: "💾", path: "/admin/backup", color: "#ed8936" },
{ title: "활동 로그", icon: "📊", path: "/admin/logs", color: "#9f7aea" }
]
},
manager: {
title: "프로젝트 매니저",
subtitle: "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
metrics: [
{ label: "담당 프로젝트", value: 8, icon: "📋", color: "#667eea" },
{ label: "팀 진행률", value: "87%", icon: "📈", color: "#48bb78" },
{ label: "승인 대기", value: 3, icon: "⏳", color: "#ed8936" },
{ label: "이번 주 완료", value: 5, icon: "✅", color: "#38b2ac" }
],
quickActions: [
{ title: "프로젝트 생성", icon: "", path: "/projects/new", color: "#667eea" },
{ title: "팀 관리", icon: "👥", path: "/team", color: "#48bb78" },
{ title: "진행 상황", icon: "📊", path: "/progress", color: "#38b2ac" },
{ title: "승인 처리", icon: "✅", path: "/approvals", color: "#ed8936" }
]
},
designer: {
title: "설계 담당자",
subtitle: "BOM 파일을 관리하고 자재를 분류합니다",
metrics: [
{ label: "내 BOM 파일", value: 15, icon: "📄", color: "#667eea" },
{ label: "분류 완료율", value: "92%", icon: "🎯", color: "#48bb78" },
{ label: "검증 대기", value: 7, icon: "⏳", color: "#ed8936" },
{ label: "이번 주 업로드", value: 12, icon: "📤", color: "#9f7aea" }
],
quickActions: [
{ title: "BOM 업로드", icon: "📤", path: "/upload", color: "#667eea" },
{ title: "자재 분류", icon: "🔧", path: "/materials", color: "#48bb78" },
{ title: "리비전 관리", icon: "🔄", path: "/revisions", color: "#38b2ac" },
{ title: "분류 검증", icon: "✅", path: "/verify", color: "#ed8936" }
]
},
purchaser: {
title: "구매 담당자",
subtitle: "구매 요청을 처리하고 발주를 관리합니다",
metrics: [
{ label: "구매 요청", value: 23, icon: "🛒", color: "#667eea" },
{ label: "발주 완료", value: 18, icon: "✅", color: "#48bb78" },
{ label: "입고 대기", value: 5, icon: "📦", color: "#ed8936" },
{ label: "이번 달 금액", value: "₩2.3M", icon: "💰", color: "#9f7aea" }
],
quickActions: [
{ title: "구매 확정", icon: "🛒", path: "/purchase", color: "#667eea" },
{ title: "발주 관리", icon: "📋", path: "/orders", color: "#48bb78" },
{ title: "공급업체", icon: "🏢", path: "/suppliers", color: "#38b2ac" },
{ title: "입고 처리", icon: "📦", path: "/receiving", color: "#ed8936" }
]
},
user: {
title: "일반 사용자",
subtitle: "할당된 업무를 수행하고 프로젝트에 참여합니다",
metrics: [
{ label: "내 업무", value: 6, icon: "📋", color: "#667eea" },
{ label: "완료율", value: "75%", icon: "📈", color: "#48bb78" },
{ label: "대기 중", value: 2, icon: "⏳", color: "#ed8936" },
{ label: "이번 주 활동", value: 12, icon: "🎯", color: "#9f7aea" }
],
quickActions: [
{ title: "내 업무", icon: "📋", path: "/my-tasks", color: "#667eea" },
{ title: "프로젝트 보기", icon: "👁️", path: "/projects", color: "#48bb78" },
{ title: "리포트 다운로드", icon: "📊", path: "/reports", color: "#38b2ac" },
{ title: "도움말", icon: "❓", path: "/help", color: "#9f7aea" }
]
}
};
return baseData[user.role] || baseData.user;
};
const generateMockActivitiesByRole = () => {
if (!user) return [];
const activities = {
admin: [
{ type: "system", message: "새 사용자 3명이 등록되었습니다", time: "30분 전", icon: "👥" },
{ type: "backup", message: "일일 백업이 완료되었습니다", time: "2시간 전", icon: "💾" },
{ type: "alert", message: "시스템 리소스 사용률 85%", time: "4시간 전", icon: "⚠️" },
{ type: "update", message: "데이터베이스 인덱스가 최적화되었습니다", time: "6시간 전", icon: "🔧" }
],
manager: [
{ type: "approval", message: "냉동기 프로젝트 구매 승인 완료", time: "1시간 전", icon: "✅" },
{ type: "meeting", message: "주간 팀 미팅 일정이 등록되었습니다", time: "3시간 전", icon: "📅" },
{ type: "progress", message: "BOG 시스템 프로젝트 90% 진행", time: "5시간 전", icon: "📈" },
{ type: "task", message: "김설계님에게 새 업무가 할당되었습니다", time: "1일 전", icon: "👤" }
],
designer: [
{ type: "upload", message: "다이아프램 펌프 BOM 파일을 업로드했습니다", time: "45분 전", icon: "📤" },
{ type: "classify", message: "스테인리스 파이프 127개 자재 분류 완료", time: "2시간 전", icon: "🔧" },
{ type: "revision", message: "드라이어 시스템 Rev.2 업데이트", time: "4시간 전", icon: "🔄" },
{ type: "verify", message: "볼트 분류 검증 5건 완료", time: "1일 전", icon: "✅" }
],
purchaser: [
{ type: "purchase", message: "스테인리스 파이프 구매 확정", time: "20분 전", icon: "🛒" },
{ type: "order", message: "ABC 공급업체에 발주서 전송", time: "1시간 전", icon: "📋" },
{ type: "receive", message: "밸브 15개 입고 처리 완료", time: "3시간 전", icon: "📦" },
{ type: "quote", message: "새 견적서 3건 접수", time: "5시간 전", icon: "💰" }
],
user: [
{ type: "task", message: "자재 검증 업무 2건 완료", time: "1시간 전", icon: "✅" },
{ type: "view", message: "냉동기 프로젝트 진행상황 확인", time: "3시간 전", icon: "👁️" },
{ type: "download", message: "월간 리포트 다운로드", time: "6시간 전", icon: "📊" },
{ type: "help", message: "도움말 페이지 방문", time: "1일 전", icon: "❓" }
]
};
return activities[user.role] || activities.user;
};
const handleQuickAction = (action) => {
// 실제 네비게이션 구현 (향후)
console.log(`네비게이션: ${action.path}`);
alert(`${action.title} 기능은 곧 구현될 예정입니다.`);
};
const handleLogout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('user_data');
window.location.reload();
};
if (loading || !user || !dashboardData) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f7fafc'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div>대시보드를 불러오는 ...</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 || user.username}</div>
<div style={{ fontSize: '12px', opacity: '0.9' }}>
{dashboardData.title}
</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: `linear-gradient(135deg, ${dashboardData.metrics[0].color}20 0%, ${dashboardData.metrics[1].color}20 100%)`,
borderRadius: '16px',
padding: '32px',
marginBottom: '32px',
border: `1px solid ${dashboardData.metrics[0].color}40`
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '16px' }}>
<div style={{ fontSize: '48px' }}>
{user.role === 'admin' ? '👑' :
user.role === 'manager' ? '👨‍💼' :
user.role === 'designer' ? '🎨' :
user.role === 'purchaser' ? '🛒' : '👤'}
</div>
<div>
<h2 style={{
margin: '0 0 8px 0',
fontSize: '28px',
fontWeight: '700',
color: '#2d3748'
}}>
안녕하세요, {user.name || user.username}! 👋
</h2>
<p style={{
margin: '0',
fontSize: '16px',
color: '#4a5568',
fontWeight: '500'
}}>
{dashboardData.subtitle}
</p>
</div>
</div>
</div>
{/* 핵심 지표 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '24px',
marginBottom: '32px'
}}>
{(dashboardData.metrics || []).map((metric, index) => (
<div key={index} 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',
cursor: 'pointer'
}}
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'
}}>
{metric.label}
</div>
<div style={{
fontSize: '32px',
fontWeight: '700',
color: metric.color
}}>
{metric.value}
</div>
</div>
<div style={{
fontSize: '32px',
opacity: 0.8
}}>
{metric.icon}
</div>
</div>
</div>
))}
</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: 'grid', gap: '12px' }}>
{(dashboardData.quickActions || []).map((action, index) => (
<button
key={index}
onClick={() => handleQuickAction(action)}
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',
textAlign: 'left'
}}
onMouseEnter={(e) => {
e.target.style.background = `${action.color}10`;
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 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' }}>
{recentActivities.map((activity, index) => (
<div key={index} style={{
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
padding: '12px',
borderRadius: '8px',
background: '#f7fafc',
border: '1px solid #e2e8f0'
}}>
<span style={{ fontSize: '16px' }}>
{activity.icon}
</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>
</div>
</main>
</div>
);
};
export default PersonalizedDashboard;

View File

@@ -0,0 +1,319 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const ProjectSelector = ({ onProjectSelect, selectedProject }) => {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
useEffect(() => {
loadProjects();
}, []);
const loadProjects = async () => {
try {
const response = await api.get('/jobs/');
console.log('프로젝트 API 응답:', response.data);
// API 응답 구조에 맞게 처리
let projectsData = [];
if (response.data && response.data.success && Array.isArray(response.data.jobs)) {
// 실제 API 데이터를 프론트엔드 형식에 맞게 변환
projectsData = response.data.jobs.map(job => ({
job_no: job.job_no,
project_name: job.project_name || job.job_name,
status: job.status === '진행중' ? 'active' : 'completed',
progress: job.status === '진행중' ? 75 : 100, // 임시 진행률
client_name: job.client_name,
project_site: job.project_site,
delivery_date: job.delivery_date
}));
}
// 데이터가 없으면 목 데이터 사용
if (projectsData.length === 0) {
projectsData = [
{ job_no: 'TK-2024-001', project_name: '냉동기 시스템', status: 'active', progress: 75 },
{ job_no: 'TK-2024-002', project_name: 'BOG 처리 시스템', status: 'active', progress: 45 },
{ job_no: 'TK-2024-003', project_name: '다이아프램 펌프', status: 'active', progress: 90 },
{ job_no: 'TK-2024-004', project_name: '드라이어 시스템', status: 'completed', progress: 100 },
{ job_no: 'TK-2024-005', project_name: '열교환기 시스템', status: 'active', progress: 30 }
];
}
setProjects(projectsData);
} catch (error) {
console.error('프로젝트 목록 로딩 실패:', error);
// 목 데이터 사용
const mockProjects = [
{ job_no: 'TK-2024-001', project_name: '냉동기 시스템', status: 'active', progress: 75 },
{ job_no: 'TK-2024-002', project_name: 'BOG 처리 시스템', status: 'active', progress: 45 },
{ job_no: 'TK-2024-003', project_name: '다이아프램 펌프', status: 'active', progress: 90 },
{ job_no: 'TK-2024-004', project_name: '드라이어 시스템', status: 'completed', progress: 100 },
{ job_no: 'TK-2024-005', project_name: '열교환기 시스템', status: 'active', progress: 30 }
];
setProjects(mockProjects);
} finally {
setLoading(false);
}
};
const filteredProjects = projects.filter(project =>
project.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.job_no.toLowerCase().includes(searchTerm.toLowerCase())
);
const getStatusColor = (status) => {
const colors = {
'active': '#48bb78',
'completed': '#38b2ac',
'on_hold': '#ed8936',
'cancelled': '#e53e3e'
};
return colors[status] || '#718096';
};
const getStatusText = (status) => {
const texts = {
'active': '진행중',
'completed': '완료',
'on_hold': '보류',
'cancelled': '취소'
};
return texts[status] || '알 수 없음';
};
if (loading) {
return (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#666'
}}>
프로젝트 목록을 불러오는 ...
</div>
);
}
return (
<div style={{ position: 'relative', width: '100%' }}>
{/* 선택된 프로젝트 표시 또는 선택 버튼 */}
<div
onClick={() => setShowDropdown(!showDropdown)}
style={{
padding: '16px 20px',
background: selectedProject ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'white',
color: selectedProject ? 'white' : '#2d3748',
border: selectedProject ? 'none' : '2px dashed #cbd5e0',
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: selectedProject ? '0 4px 12px rgba(102, 126, 234, 0.3)' : '0 2px 4px rgba(0,0,0,0.1)'
}}
onMouseEnter={(e) => {
if (!selectedProject) {
e.target.style.borderColor = '#667eea';
e.target.style.backgroundColor = '#f7fafc';
}
}}
onMouseLeave={(e) => {
if (!selectedProject) {
e.target.style.borderColor = '#cbd5e0';
e.target.style.backgroundColor = 'white';
}
}}
>
<div>
{selectedProject ? (
<div>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
{selectedProject.project_name}
</div>
<div style={{ fontSize: '14px', opacity: '0.9' }}>
{selectedProject.job_no} {getStatusText(selectedProject.status)}
</div>
</div>
) : (
<div>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
🎯 프로젝트를 선택하세요
</div>
<div style={{ fontSize: '14px', color: '#718096' }}>
작업할 프로젝트를 선택하면 관련 업무를 시작할 있습니다
</div>
</div>
)}
</div>
<div style={{ fontSize: '20px' }}>
{showDropdown ? '🔼' : '🔽'}
</div>
</div>
{/* 드롭다운 메뉴 */}
{showDropdown && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: '8px',
background: 'white',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.15)',
border: '1px solid #e2e8f0',
zIndex: 1000,
maxHeight: '400px',
overflow: 'hidden'
}}>
{/* 검색 입력 */}
<div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0' }}>
<input
type="text"
placeholder="프로젝트 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #cbd5e0',
borderRadius: '6px',
fontSize: '14px',
outline: 'none'
}}
onFocus={(e) => e.target.style.borderColor = '#667eea'}
onBlur={(e) => e.target.style.borderColor = '#cbd5e0'}
/>
</div>
{/* 프로젝트 목록 */}
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{filteredProjects.length === 0 ? (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#718096'
}}>
검색 결과가 없습니다
</div>
) : (
filteredProjects.map((project) => (
<div
key={project.job_no}
onClick={() => {
onProjectSelect(project);
setShowDropdown(false);
setSearchTerm('');
}}
style={{
padding: '16px 20px',
cursor: 'pointer',
borderBottom: '1px solid #f7fafc',
transition: 'background-color 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.backgroundColor = '#f7fafc';
}}
onMouseLeave={(e) => {
e.target.style.backgroundColor = 'transparent';
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '16px',
fontWeight: '600',
color: '#2d3748',
marginBottom: '4px'
}}>
{project.project_name}
</div>
<div style={{
fontSize: '14px',
color: '#718096',
marginBottom: '8px'
}}>
{project.job_no}
</div>
{/* 진행률 바 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{
flex: 1,
height: '4px',
backgroundColor: '#e2e8f0',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${project.progress || 0}%`,
height: '100%',
backgroundColor: getStatusColor(project.status),
transition: 'width 0.3s ease'
}} />
</div>
<div style={{
fontSize: '12px',
color: '#718096',
minWidth: '35px'
}}>
{project.progress || 0}%
</div>
</div>
</div>
<div style={{
marginLeft: '16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end'
}}>
<span style={{
padding: '4px 8px',
backgroundColor: `${getStatusColor(project.status)}20`,
color: getStatusColor(project.status),
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500'
}}>
{getStatusText(project.status)}
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
)}
{/* 드롭다운 외부 클릭 시 닫기 */}
{showDropdown && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999
}}
onClick={() => setShowDropdown(false)}
/>
)}
</div>
);
};
export default ProjectSelector;

View File

@@ -80,3 +80,19 @@ const RevisionUploadDialog = ({
}; };
export default RevisionUploadDialog; export default RevisionUploadDialog;

View File

@@ -299,3 +299,19 @@ const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => {
}; };
export default SimpleFileUpload; export default SimpleFileUpload;

View File

@@ -1,114 +1,70 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api'; import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
import BOMFileUpload from '../components/BOMFileUpload'; import BOMFileUpload from '../components/BOMFileUpload';
import BOMFileTable from '../components/BOMFileTable';
import RevisionUploadDialog from '../components/RevisionUploadDialog';
const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => { const BOMStatusPage = ({ jobNo, jobName, onNavigate, selectedProject }) => {
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
const [bomName, setBomName] = useState(''); const [bomName, setBomName] = useState('');
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
const [revisionFile, setRevisionFile] = useState(null);
const [purchaseModal, setPurchaseModal] = useState({ open: false, data: null, fileInfo: null });
// 카테고리별 색상 함수 useEffect(() => {
const getCategoryColor = (category) => { if (jobNo) {
const colors = { fetchFilesList();
'pipe': '#4299e1', }
'fitting': '#48bb78', }, [jobNo]);
'valve': '#ed8936',
'flange': '#9f7aea',
'bolt': '#38b2ac',
'gasket': '#f56565',
'instrument': '#d69e2e',
'material': '#718096',
'integrated': '#319795',
'unknown': '#a0aec0'
};
return colors[category?.toLowerCase()] || colors.unknown;
};
// 파일 목록 불러오기 const fetchFilesList = async () => {
const fetchFiles = async () => {
setLoading(true);
setError('');
try { try {
console.log('fetchFiles 호출 - jobNo:', jobNo); setLoading(true);
const response = await fetchFilesApi({ job_no: jobNo }); const response = await api.get('/files/', {
console.log('API 응답:', response); params: { job_no: jobNo }
});
if (response.data && response.data.data && Array.isArray(response.data.data)) { // API가 배열로 직접 반환하는 경우
setFiles(response.data.data); if (Array.isArray(response.data)) {
} else if (response.data && Array.isArray(response.data)) {
setFiles(response.data); setFiles(response.data);
} else if (response.data && Array.isArray(response.data.files)) { } else if (response.data && response.data.success) {
setFiles(response.data.files); setFiles(response.data.files || []);
} else { } else {
setFiles([]); setFiles([]);
} }
} catch (err) { } catch (err) {
console.error('파일 목록 불러오기 실패:', err); console.error('파일 목록 로딩 실패:', err);
setError('파일 목록을 불러오는데 실패했습니다.'); setError('파일 목록을 불러오는데 실패했습니다.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => {
if (jobNo) {
fetchFiles();
}
}, [jobNo]);
// 파일 업로드 // 파일 업로드
const handleUpload = async () => { const handleUpload = async () => {
if (!selectedFile || !bomName.trim()) { if (!selectedFile || !bomName.trim()) {
setError('파일과 BOM 이름을 모두 입력해주세요.'); alert('파일과 BOM 이름을 모두 입력해주세요.');
return; return;
} }
setUploading(true);
setError('');
try { try {
setUploading(true);
const formData = new FormData(); const formData = new FormData();
formData.append('file', selectedFile); formData.append('file', selectedFile);
formData.append('job_no', jobNo);
formData.append('bom_name', bomName.trim()); formData.append('bom_name', bomName.trim());
formData.append('job_no', jobNo);
const uploadResult = await uploadFileApi(formData); const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
// 업로드 성공 후 파일 목록 새로고침 });
await fetchFiles();
if (response.data && response.data.success) {
// 업로드 완료 후 자동으로 구매 수량 계산 실행 alert('파일이 성공적으로 업로드되었습니다!');
if (uploadResult && uploadResult.file_id) { setSelectedFile(null);
// 잠시 후 구매 수량 계산 페이지로 이동 setBomName('');
setTimeout(async () => { await fetchFilesList(); // 목록 새로고침
try { } else {
// 구매 수량 계산 API 호출 throw new Error(response.data?.message || '업로드 실패');
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('');
document.getElementById('file-input').value = '';
} catch (err) { } catch (err) {
console.error('파일 업로드 실패:', err); console.error('파일 업로드 실패:', err);
setError('파일 업로드에 실패했습니다.'); setError('파일 업로드에 실패했습니다.');
@@ -125,111 +81,26 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
try { try {
await deleteFileApi(fileId); await deleteFileApi(fileId);
await fetchFiles(); // 목록 새로고침 await fetchFilesList(); // 목록 새로고침
} catch (err) { } catch (err) {
console.error('파일 삭제 실패:', err); console.error('파일 삭제 실패:', err);
setError('파일 삭제에 실패했습니다.'); setError('파일 삭제에 실패했습니다.');
} }
}; };
// 자재 확인 페이지로 이동 // 자재 관리 페이지로 바로 이동 (단순화)
// 구매 수량 계산 (자재 목록 페이지 거치지 않음) const handleViewMaterials = (file) => {
const handleViewMaterials = async (file) => { if (onNavigate) {
try { onNavigate('materials', {
setLoading(true); file_id: file.id,
jobNo: file.job_no,
// 구매 수량 계산 API 호출 bomName: file.bom_name || file.original_filename,
console.log('구매 수량 계산 API 호출:', { revision: file.revision,
job_no: file.job_no, filename: file.original_filename
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 || !revisionDialog.bomName) {
setError('파일을 선택해주세요.');
return;
}
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', revisionFile);
formData.append('job_no', jobNo);
formData.append('bom_name', revisionDialog.bomName);
formData.append('parent_id', revisionDialog.parentId);
await uploadFileApi(formData);
// 업로드 성공 후 파일 목록 새로고침
await fetchFiles();
// 다이얼로그 닫기
setRevisionDialog({ open: false, bomName: '', parentId: null });
setRevisionFile(null);
} catch (err) {
console.error('리비전 업로드 실패:', err);
setError('리비전 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
};
// BOM별로 그룹화
const groupFilesByBOM = () => {
const grouped = {};
files.forEach(file => {
const bomKey = file.bom_name || file.original_filename || file.filename;
if (!grouped[bomKey]) {
grouped[bomKey] = [];
}
grouped[bomKey].push(file);
});
// 각 그룹을 리비전 순으로 정렬
Object.keys(grouped).forEach(key => {
grouped[key].sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA; // 최신 리비전이 먼저 오도록
});
});
return grouped;
};
return ( return (
<div style={{ <div style={{
padding: '32px', padding: '32px',
@@ -240,7 +111,11 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
{/* 헤더 */} {/* 헤더 */}
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
<button <button
onClick={() => onNavigate && onNavigate('bom')} onClick={() => {
if (onNavigate) {
onNavigate('dashboard');
}
}}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
background: 'white', background: 'white',
@@ -250,7 +125,7 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
marginBottom: '16px' marginBottom: '16px'
}} }}
> >
뒤로가기 메인으로 돌아가기
</button> </button>
<h1 style={{ <h1 style={{
@@ -295,205 +170,126 @@ const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => {
업로드된 BOM 목록 업로드된 BOM 목록
</h3> </h3>
{/* 파일 테이블 컴포넌트 */} {loading ? (
<BOMFileTable <div style={{ textAlign: 'center', padding: '40px' }}>
files={files} 로딩 ...
loading={loading} </div>
groupFilesByBOM={groupFilesByBOM} ) : (
handleViewMaterials={handleViewMaterials}
openRevisionDialog={openRevisionDialog}
handleDelete={handleDelete}
/>
{/* 리비전 업로드 다이얼로그 */}
<RevisionUploadDialog
revisionDialog={revisionDialog}
setRevisionDialog={setRevisionDialog}
revisionFile={revisionFile}
setRevisionFile={setRevisionFile}
handleRevisionUpload={handleRevisionUpload}
uploading={uploading}
/>
{/* 구매 수량 계산 결과 모달 */}
{purchaseModal.open && (
<div style={{ <div style={{
position: 'fixed', background: 'white',
top: 0, borderRadius: '12px',
left: 0, boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
right: 0, overflow: 'hidden'
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}> }}>
<div style={{ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
background: 'white', <thead>
borderRadius: '12px', <tr style={{ background: '#f7fafc' }}>
padding: '24px', <th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
maxWidth: '1000px', <th style={{ padding: '16px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
maxHeight: '80vh', <th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
overflow: 'auto', <th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>자재 </th>
margin: '20px' <th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
}}> <th style={{ padding: '16px', textAlign: 'center', borderBottom: '1px solid #e2e8f0' }}>작업</th>
<div style={{ </tr>
display: 'flex', </thead>
justifyContent: 'space-between', <tbody>
alignItems: 'center', {files.map((file) => (
marginBottom: '24px' <tr key={file.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
}}> <td style={{ padding: '16px' }}>
<h3 style={{ <div style={{ fontWeight: '600', color: '#2d3748' }}>
fontSize: '20px', {file.bom_name || file.original_filename}
fontWeight: '700', </div>
color: '#2d3748', <div style={{ fontSize: '12px', color: '#718096' }}>
margin: 0 {file.description || ''}
}}> </div>
🧮 구매 수량 계산 결과 </td>
</h3> <td style={{ padding: '16px', fontSize: '14px', color: '#4a5568' }}>
<button {file.original_filename}
onClick={() => setPurchaseModal({ open: false, data: null, fileInfo: null })} </td>
style={{ <td style={{ padding: '16px', textAlign: 'center' }}>
background: '#e2e8f0', <span style={{
border: 'none', background: '#e6fffa',
borderRadius: '6px', color: '#065f46',
padding: '8px 12px', padding: '4px 8px',
cursor: 'pointer' borderRadius: '4px',
}} fontSize: '12px',
> fontWeight: '600'
닫기 }}>
</button> {file.revision || 'Rev.0'}
</div> </span>
</td>
<div style={{ marginBottom: '16px', color: '#4a5568' }}> <td style={{ padding: '16px', textAlign: 'center' }}>
<div><strong>프로젝트:</strong> {purchaseModal.fileInfo?.job_no}</div> {file.parsed_count || 0}
<div><strong>BOM:</strong> {purchaseModal.fileInfo?.bom_name}</div> </td>
<div><strong>리비전:</strong> {purchaseModal.fileInfo?.revision || 'Rev.0'}</div> <td style={{ padding: '16px', textAlign: 'center', fontSize: '14px', color: '#718096' }}>
</div> {new Date(file.created_at).toLocaleDateString()}
</td>
<div style={{ overflowX: 'auto' }}> <td style={{ padding: '16px', textAlign: 'center' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<thead> <button
<tr style={{ background: '#f7fafc' }}> onClick={() => handleViewMaterials(file)}
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th> style={{
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th> padding: '6px 12px',
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th> background: '#4299e1',
<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', color: 'white',
padding: '4px 8px', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px', fontSize: '12px',
fontWeight: '600' fontWeight: '600'
}}> }}
{item.category} >
</span> 📋 자재 보기
</td> </button>
<td style={{ padding: '12px', fontSize: '14px' }}> <button
{item.specification} onClick={() => {
</td> // 리비전 업로드 기능 (추후 구현)
<td style={{ padding: '12px', fontSize: '14px' }}> alert('리비전 업로드 기능은 준비 중입니다.');
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */} }}
{item.category !== 'PIPE' && ( style={{
<span style={{ padding: '6px 12px',
background: '#e6fffa', background: 'white',
color: '#065f46', color: '#4299e1',
padding: '2px 6px', border: '1px solid #4299e1',
borderRadius: '4px', borderRadius: '4px',
fontSize: '12px', cursor: 'pointer',
fontWeight: '500' fontSize: '12px',
}}> fontWeight: '600'
{item.size_spec || '-'} }}
</span> >
)} 📝 리비전
{item.category === 'PIPE' && ( </button>
<span style={{ color: '#a0aec0', fontSize: '12px' }}> <button
사양에 포함 onClick={() => handleDelete(file.id)}
</span> style={{
)} padding: '6px 12px',
</td> background: '#f56565',
<td style={{ padding: '12px', fontSize: '14px' }}> color: 'white',
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */} border: 'none',
{item.category !== 'PIPE' && ( borderRadius: '4px',
<span style={{ cursor: 'pointer',
background: '#fef7e0', fontSize: '12px',
color: '#92400e', fontWeight: '600'
padding: '2px 6px', }}
borderRadius: '4px', >
fontSize: '12px', 🗑 삭제
fontWeight: '500' </button>
}}> </div>
{item.material_spec || '-'} </td>
</span> </tr>
)} ))}
{item.category === 'PIPE' && ( </tbody>
<span style={{ color: '#a0aec0', fontSize: '12px' }}> </table>
사양에 포함
</span> {files.length === 0 && (
)} <div style={{
</td> padding: '40px',
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}> textAlign: 'center',
{item.category === 'PIPE' ? color: '#718096'
`${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> 업로드된 BOM 파일이 없습니다.
<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>
)} )}
</div> </div>

View File

@@ -0,0 +1,720 @@
import React, { useState, useEffect, useRef } from 'react';
import { api, fetchFiles, deleteFile as deleteFileApi } from '../api';
const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
// 상태 관리
const [files, setFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(300);
const [previewWidth, setPreviewWidth] = useState(400);
// 업로드 관련 상태
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef(null);
// 편집 상태
const [editingFile, setEditingFile] = useState(null);
const [editingField, setEditingField] = useState(null);
const [editValue, setEditValue] = useState('');
useEffect(() => {
console.log('🔄 프로젝트 변경됨:', project);
const jobNo = project?.official_project_code || project?.job_no;
if (jobNo) {
console.log('✅ 프로젝트 코드 확인:', jobNo);
// 프로젝트가 변경되면 기존 선택 초기화
setSelectedFile(null);
setFiles([]);
loadFiles();
} else {
console.warn('⚠️ 프로젝트 정보가 없습니다. 받은 프로젝트:', project);
setFiles([]);
setSelectedFile(null);
}
}, [project?.official_project_code, project?.job_no]); // 두 필드 모두 감시
const loadFiles = async () => {
const jobNo = project?.official_project_code || project?.job_no;
if (!jobNo) {
console.warn('프로젝트 정보가 없어서 파일을 로드할 수 없습니다:', project);
return;
}
try {
setLoading(true);
setError(''); // 에러 초기화
console.log('📂 파일 목록 로딩 시작:', jobNo);
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
console.log('📂 API 응답:', response.data);
const fileList = Array.isArray(response.data) ? response.data : response.data?.files || [];
console.log('📂 파싱된 파일 목록:', fileList);
setFiles(fileList);
// 기존 선택된 파일이 목록에 있는지 확인
if (selectedFile && !fileList.find(f => f.id === selectedFile.id)) {
setSelectedFile(null);
}
// 첫 번째 파일 자동 선택 (기존 선택이 없을 때만)
if (fileList.length > 0 && !selectedFile) {
console.log('📂 첫 번째 파일 자동 선택:', fileList[0].original_filename);
setSelectedFile(fileList[0]);
}
console.log('📂 파일 로딩 완료:', fileList.length, '개 파일');
} catch (err) {
console.error('📂 파일 로딩 실패:', err);
console.error('📂 에러 상세:', err.response?.data);
setError(`파일 목록을 불러오는데 실패했습니다: ${err.response?.data?.detail || err.message}`);
setFiles([]); // 에러 시 빈 배열로 초기화
} finally {
setLoading(false);
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = (e) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = async (e) => {
e.preventDefault();
setDragOver(false);
const droppedFiles = Array.from(e.dataTransfer.files);
console.log('드롭된 파일들:', droppedFiles.map(f => ({ name: f.name, type: f.type })));
const excelFiles = droppedFiles.filter(file => {
const fileName = file.name.toLowerCase();
const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`);
return isExcel;
});
if (excelFiles.length > 0) {
console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name));
await uploadFiles(excelFiles);
} else {
console.log('Excel 파일이 없음. 드롭된 파일들:', droppedFiles.map(f => f.name));
alert(`Excel 파일만 업로드 가능합니다.\n업로드하려는 파일: ${droppedFiles.map(f => f.name).join(', ')}`);
}
};
const handleFileSelect = (e) => {
const selectedFiles = Array.from(e.target.files);
console.log('선택된 파일들:', selectedFiles.map(f => ({ name: f.name, type: f.type })));
const excelFiles = selectedFiles.filter(file => {
const fileName = file.name.toLowerCase();
const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`);
return isExcel;
});
if (excelFiles.length > 0) {
console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name));
uploadFiles(excelFiles);
} else {
console.log('Excel 파일이 없음. 선택된 파일들:', selectedFiles.map(f => f.name));
alert(`Excel 파일만 업로드 가능합니다.\n선택하려는 파일: ${selectedFiles.map(f => f.name).join(', ')}`);
}
};
const uploadFiles = async (filesToUpload) => {
console.log('업로드 시작:', filesToUpload.map(f => ({ name: f.name, size: f.size, type: f.type })));
setUploading(true);
try {
for (const file of filesToUpload) {
console.log(`업로드 중: ${file.name} (${file.size} bytes, ${file.type})`);
const jobNo = project?.official_project_code || project?.job_no;
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', jobNo);
formData.append('bom_name', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
console.log('FormData 내용:', {
fileName: file.name,
jobNo: jobNo,
bomName: file.name.replace(/\.[^/.]+$/, "")
});
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log(`업로드 성공: ${file.name}`, response.data);
}
await loadFiles(); // 목록 새로고침
alert(`${filesToUpload.length}개 파일이 업로드되었습니다.`);
} catch (err) {
console.error('업로드 실패:', err);
console.error('에러 상세:', err.response?.data);
setError(`파일 업로드에 실패했습니다: ${err.response?.data?.detail || err.message}`);
} finally {
setUploading(false);
}
};
// 인라인 편집 핸들러
const startEdit = (file, field) => {
setEditingFile(file.id);
setEditingField(field);
setEditValue(file[field] || '');
};
const saveEdit = async () => {
try {
await api.put(`/files/${editingFile}`, {
[editingField]: editValue
});
// 로컬 상태 업데이트
setFiles(files.map(f =>
f.id === editingFile
? { ...f, [editingField]: editValue }
: f
));
if (selectedFile?.id === editingFile) {
setSelectedFile({ ...selectedFile, [editingField]: editValue });
}
cancelEdit();
} catch (err) {
console.error('수정 실패:', err);
alert('수정에 실패했습니다.');
}
};
const cancelEdit = () => {
setEditingFile(null);
setEditingField(null);
setEditValue('');
};
// 파일 삭제
const handleDelete = async (fileId) => {
if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
return;
}
try {
await deleteFileApi(fileId);
setFiles(files.filter(f => f.id !== fileId));
if (selectedFile?.id === fileId) {
const remainingFiles = files.filter(f => f.id !== fileId);
setSelectedFile(remainingFiles.length > 0 ? remainingFiles[0] : null);
}
} catch (err) {
console.error('삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 자재 보기
const viewMaterials = (file) => {
if (onNavigate) {
onNavigate('materials', {
file_id: file.id,
jobNo: file.job_no,
bomName: file.bom_name || file.original_filename,
revision: file.revision,
filename: file.original_filename
});
}
};
return (
<div style={{
display: 'flex',
height: '100vh',
background: '#f5f5f5',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}>
{/* 사이드바 - 프로젝트 정보 */}
<div style={{
width: `${sidebarWidth}px`,
background: '#ffffff',
borderRight: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column'
}}>
{/* 헤더 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa'
}}>
<button
onClick={onBack}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
fontSize: '14px',
marginBottom: '8px'
}}
>
대시보드로
</button>
<h2 style={{
margin: 0,
fontSize: '18px',
fontWeight: '600',
color: '#333'
}}>
{project?.project_name}
</h2>
<p style={{
margin: '4px 0 0 0',
fontSize: '14px',
color: '#666'
}}>
{project?.official_project_code || project?.job_no}
</p>
</div>
{/* 프로젝트 통계 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0'
}}>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '8px' }}>
프로젝트 현황
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
<div style={{ fontSize: '20px', fontWeight: '600', color: '#4299e1' }}>
{files.length}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>BOM 파일</div>
</div>
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
<div style={{ fontSize: '20px', fontWeight: '600', color: '#48bb78' }}>
{files.reduce((sum, f) => sum + (f.parsed_count || 0), 0)}
</div>
<div style={{ fontSize: '12px', color: '#666' }}> 자재</div>
</div>
</div>
</div>
{/* 업로드 영역 */}
<div
style={{
margin: '16px',
padding: '20px',
border: dragOver ? '2px dashed #4299e1' : '2px dashed #ddd',
borderRadius: '8px',
textAlign: 'center',
background: dragOver ? '#f0f9ff' : '#fafafa',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{uploading ? (
<div style={{ color: '#4299e1' }}>
📤 업로드 ...
</div>
) : (
<div>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📁</div>
<div style={{ fontSize: '14px', color: '#666' }}>
Excel 파일을 드래그하거나<br />클릭하여 업로드
</div>
</div>
)}
</div>
</div>
{/* 메인 패널 - 파일 목록 */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#ffffff'
}}>
{/* 툴바 */}
<div style={{
padding: '12px 16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
BOM 파일 목록 ({files.length})
</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={loadFiles}
disabled={loading}
style={{
padding: '6px 12px',
background: loading ? '#a0aec0' : '#48bb78',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '12px'
}}
>
{loading ? '🔄 로딩중...' : '🔄 새로고침'}
</button>
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '6px 12px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
+ 파일 추가
</button>
</div>
</div>
{/* 파일 목록 */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
로딩 ...
</div>
) : files.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
업로드된 BOM 파일이 없습니다.
</div>
) : (
<div>
{files.map((file) => (
<div
key={file.id}
style={{
padding: '12px 16px',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer',
background: selectedFile?.id === file.id ? '#f0f9ff' : 'transparent',
transition: 'background-color 0.2s ease'
}}
onClick={() => setSelectedFile(file)}
onMouseEnter={(e) => {
if (selectedFile?.id !== file.id) {
e.target.style.background = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (selectedFile?.id !== file.id) {
e.target.style.background = 'transparent';
}
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
{/* BOM 이름 (인라인 편집) */}
{editingFile === file.id && editingField === 'bom_name' ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}}
style={{
border: '1px solid #4299e1',
borderRadius: '2px',
padding: '2px 4px',
fontSize: '14px',
fontWeight: '600'
}}
autoFocus
/>
) : (
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: '#333',
cursor: 'text'
}}
onClick={(e) => {
e.stopPropagation();
startEdit(file, 'bom_name');
}}
>
{file.bom_name || file.original_filename}
</div>
)}
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
{file.original_filename} {file.parsed_count || 0} 자재 {file.revision || 'Rev.0'}
</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '2px' }}>
{new Date(file.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
<div style={{ display: 'flex', gap: '4px', marginLeft: '12px' }}>
<button
onClick={(e) => {
e.stopPropagation();
viewMaterials(file);
}}
style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
📋 자재
</button>
<button
onClick={(e) => {
e.stopPropagation();
alert('리비전 기능 준비 중');
}}
style={{
padding: '4px 8px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
📝 리비전
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(file.id);
}}
style={{
padding: '4px 8px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px'
}}
>
🗑
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 우측 패널 - 상세 정보 */}
{selectedFile && (
<div style={{
width: `${previewWidth}px`,
background: '#ffffff',
borderLeft: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column'
}}>
{/* 상세 정보 헤더 */}
<div style={{
padding: '16px',
borderBottom: '1px solid #e0e0e0',
background: '#fafafa'
}}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
파일 상세 정보
</h3>
</div>
{/* 상세 정보 내용 */}
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
BOM 이름
</label>
<div
style={{
padding: '8px',
border: '1px solid #e0e0e0',
borderRadius: '4px',
cursor: 'text',
background: '#fafafa'
}}
onClick={() => startEdit(selectedFile, 'bom_name')}
>
{selectedFile.bom_name || selectedFile.original_filename}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
파일명
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.original_filename}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
리비전
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.revision || 'Rev.0'}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
자재
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{selectedFile.parsed_count || 0}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
업로드 일시
</label>
<div style={{ fontSize: '14px', color: '#333' }}>
{new Date(selectedFile.created_at).toLocaleString('ko-KR')}
</div>
</div>
{/* 액션 버튼들 */}
<div style={{ marginTop: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
onClick={() => viewMaterials(selectedFile)}
style={{
width: '100%',
padding: '12px',
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
📋 자재 목록 보기
</button>
<button
onClick={() => alert('리비전 업로드 기능 준비 중')}
style={{
width: '100%',
padding: '12px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
📝 리비전 업로드
</button>
<button
onClick={() => handleDelete(selectedFile.id)}
style={{
width: '100%',
padding: '12px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
🗑 파일 삭제
</button>
</div>
</div>
</div>
)}
{/* 에러 메시지 */}
{error && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
background: '#fed7d7',
color: '#c53030',
padding: '12px 16px',
borderRadius: '6px',
border: '1px solid #fc8181',
zIndex: 1000
}}>
{error}
<button
onClick={() => setError('')}
style={{
marginLeft: '12px',
background: 'none',
border: 'none',
color: '#c53030',
cursor: 'pointer'
}}
>
</button>
</div>
)}
</div>
);
};
export default BOMWorkspacePage;

View File

@@ -262,3 +262,19 @@ const DashboardPage = ({ user }) => {
}; };
export default DashboardPage; export default DashboardPage;

View File

@@ -217,3 +217,19 @@
border-color: #667eea; border-color: #667eea;
} }
} }

View File

@@ -114,3 +114,19 @@ const LoginPage = () => {
}; };
export default LoginPage; export default LoginPage;

View File

@@ -0,0 +1,464 @@
/* NewMaterialsPage - DevonThink 스타일 */
* {
box-sizing: border-box;
}
.materials-page {
background: #f8f9fa;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
}
/* 헤더 */
.materials-header {
background: white;
border-bottom: 1px solid #e5e7eb;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.back-button:hover {
background: #5558e3;
transform: translateY(-1px);
}
.materials-header h1 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.job-info {
color: #6b7280;
font-size: 14px;
font-weight: 400;
}
.material-count {
color: #6b7280;
font-size: 14px;
background: #f3f4f6;
padding: 4px 12px;
border-radius: 12px;
}
/* 카테고리 필터 */
.category-filters {
background: white;
padding: 16px 24px;
display: flex;
gap: 8px;
align-items: center;
border-bottom: 1px solid #e5e7eb;
overflow-x: auto;
}
.category-filters::-webkit-scrollbar {
height: 6px;
}
.category-filters::-webkit-scrollbar-track {
background: #f3f4f6;
border-radius: 3px;
}
.category-filters::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.category-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: #4b5563;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.category-btn:hover {
background: #f9fafb;
border-color: #d1d5db;
}
.category-btn.active {
background: #eef2ff;
border-color: #6366f1;
color: #4f46e5;
}
.category-btn .count {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.category-btn.active .count {
background: #6366f1;
color: white;
}
/* 액션 바 */
.action-bar {
background: white;
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e5e7eb;
}
.selection-info {
font-size: 13px;
color: #6b7280;
}
.action-buttons {
display: flex;
gap: 8px;
}
.select-all-btn,
.export-btn {
padding: 6px 14px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.select-all-btn {
background: white;
border: 1px solid #e5e7eb;
color: #374151;
}
.select-all-btn:hover {
background: #f9fafb;
border-color: #d1d5db;
}
.export-btn {
background: #10b981;
color: white;
}
.export-btn:hover {
background: #059669;
}
.export-btn:disabled {
background: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
}
/* 자재 테이블 */
.materials-grid {
background: white;
margin: 0;
}
.detailed-grid-header {
display: grid;
grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px;
padding: 12px 24px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* 플랜지 전용 헤더 - 10개 컬럼 */
.detailed-grid-header.flange-header {
grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px;
}
/* 플랜지 전용 행 - 10개 컬럼 */
.detailed-material-row.flange-row {
grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px;
}
/* 피팅 전용 헤더 - 10개 컬럼 */
.detailed-grid-header.fitting-header {
grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px;
}
/* 피팅 전용 행 - 10개 컬럼 */
.detailed-material-row.fitting-row {
grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px;
}
/* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
.detailed-grid-header.valve-header {
grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px;
}
/* 밸브 전용 행 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
.detailed-material-row.valve-row {
grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px;
}
/* 가스켓 전용 헤더 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
.detailed-grid-header.gasket-header {
grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px;
}
/* 가스켓 전용 행 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
.detailed-material-row.gasket-row {
grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px;
}
/* UNKNOWN 전용 헤더 - 5개 컬럼 */
.detailed-grid-header.unknown-header {
grid-template-columns: 40px 100px 1fr 150px 100px;
}
/* UNKNOWN 전용 행 - 5개 컬럼 */
.detailed-material-row.unknown-row {
grid-template-columns: 40px 100px 1fr 150px 100px;
}
/* UNKNOWN 설명 셀 스타일 */
.description-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.description-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detailed-material-row {
display: grid;
grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px;
padding: 12px 24px;
border-bottom: 1px solid #f3f4f6;
align-items: center;
transition: background 0.15s;
font-size: 13px;
}
.detailed-material-row:hover {
background: #fafbfc;
}
.detailed-material-row.selected {
background: #f0f9ff;
}
.material-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 12px;
}
.material-cell input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
/* 타입 배지 */
.type-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.type-badge.pipe {
background: #10b981;
color: white;
}
.type-badge.fitting {
background: #3b82f6;
color: white;
}
.type-badge.valve {
background: #f59e0b;
color: white;
}
.type-badge.flange {
background: #8b5cf6;
color: white;
}
.type-badge.bolt {
background: #ef4444;
color: white;
}
.type-badge.gasket {
background: #06b6d4;
color: white;
}
.type-badge.unknown {
background: #6b7280;
color: white;
}
.type-badge.instrument {
background: #78716c;
color: white;
}
.type-badge.unknown {
background: #9ca3af;
color: white;
}
/* 텍스트 스타일 */
.subtype-text,
.size-text,
.material-grade {
color: #1f2937;
font-weight: 500;
}
/* 입력 필드 */
.user-req-input {
width: 100%;
padding: 4px 8px;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-size: 12px;
background: #fafbfc;
transition: all 0.2s;
}
.user-req-input:focus {
outline: none;
border-color: #6366f1;
background: white;
}
.user-req-input::placeholder {
color: #9ca3af;
}
/* 수량 정보 */
.quantity-info {
display: flex;
flex-direction: column;
gap: 2px;
}
/* 플랜지 압력 정보 */
.pressure-info {
font-weight: 600;
color: #0066cc;
}
.quantity-value {
font-weight: 600;
color: #1f2937;
font-size: 14px;
}
.quantity-details {
font-size: 11px;
color: #9ca3af;
}
/* 로딩 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
background: white;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f4f6;
border-top: 3px solid #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-container p {
margin-top: 16px;
color: #6b7280;
font-size: 14px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 스크롤바 스타일 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f3f4f6;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}

View File

@@ -0,0 +1,971 @@
import React, { useState, useEffect } from 'react';
import { fetchMaterials } from '../api';
import './NewMaterialsPage.css';
const NewMaterialsPage = ({
onNavigate,
selectedProject,
fileId,
jobNo,
bomName,
revision,
filename
}) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState('PIPE');
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple'
// 자재 데이터 로드
useEffect(() => {
if (fileId) {
loadMaterials(fileId);
}
}, [fileId]);
const loadMaterials = async (id) => {
try {
setLoading(true);
console.log('🔍 자재 데이터 로딩 중...', { file_id: id });
const response = await fetchMaterials({
file_id: parseInt(id),
limit: 10000
});
if (response.data?.materials) {
const materialsData = response.data.materials;
console.log(`${materialsData.length}개 자재 로드 완료`);
// 파이프 데이터 검증
const pipes = materialsData.filter(m => m.classified_category === 'PIPE');
if (pipes.length > 0) {
console.log('📊 파이프 데이터 샘플:', pipes[0]);
}
setMaterials(materialsData);
}
} catch (error) {
console.error('❌ 자재 로딩 실패:', error);
setMaterials([]);
} finally {
setLoading(false);
}
};
// 카테고리별 자재 수 계산
const getCategoryCounts = () => {
const counts = {};
materials.forEach(material => {
const category = material.classified_category || 'UNKNOWN';
counts[category] = (counts[category] || 0) + 1;
});
return counts;
};
// 파이프 구매 수량 계산 함수
const calculatePipePurchase = (material) => {
// 백엔드에서 이미 그룹핑된 데이터 사용
const totalLength = material.pipe_details?.total_length_mm || 0;
const pipeCount = material.pipe_details?.pipe_count || material.quantity || 0;
// 절단 손실: 각 단관마다 2mm
const cuttingLoss = pipeCount * 2;
// 총 필요 길이
const requiredLength = totalLength + cuttingLoss;
// 6M(6000mm) 단위로 구매 본수 계산
const purchaseCount = Math.ceil(requiredLength / 6000);
return {
pipeCount, // 단관 개수
totalLength, // 총 BOM 길이
cuttingLoss, // 절단 손실
requiredLength, // 필요 길이
purchaseCount // 구매 본수
};
};
// 자재 정보 파싱
const parseMaterialInfo = (material) => {
const category = material.classified_category;
if (category === 'PIPE') {
const calc = calculatePipePurchase(material);
return {
type: 'PIPE',
subtype: material.pipe_details?.manufacturing_method || 'SMLS',
size: material.size_spec || '-',
schedule: material.pipe_details?.schedule || '-',
grade: material.material_grade || '-',
quantity: calc.purchaseCount,
unit: '본',
details: calc
};
} else if (category === 'FITTING') {
const fittingDetails = material.fitting_details || {};
const fittingType = fittingDetails.fitting_type || '';
const fittingSubtype = fittingDetails.fitting_subtype || '';
const description = material.original_description || '';
// 피팅 타입별 상세 표시
let displayType = '';
// CAP과 PLUG 먼저 확인 (fitting_type이 없을 수 있음)
if (description.toUpperCase().includes('CAP')) {
// CAP: 연결 방식 표시 (예: CAP, NPT(F), 3000LB, ASTM A105)
if (description.includes('NPT(F)')) {
displayType = 'CAP NPT(F)';
} else if (description.includes('SW')) {
displayType = 'CAP SW';
} else if (description.includes('BW')) {
displayType = 'CAP BW';
} else {
displayType = 'CAP';
}
} else if (description.toUpperCase().includes('PLUG')) {
// PLUG: 타입과 연결 방식 표시 (예: HEX.PLUG, NPT(M), 6000LB, ASTM A105)
if (description.toUpperCase().includes('HEX')) {
if (description.includes('NPT(M)')) {
displayType = 'HEX PLUG NPT(M)';
} else {
displayType = 'HEX PLUG';
}
} else if (description.includes('NPT(M)')) {
displayType = 'PLUG NPT(M)';
} else if (description.includes('NPT')) {
displayType = 'PLUG NPT';
} else {
displayType = 'PLUG';
}
} else if (fittingType === 'NIPPLE') {
// 니플: 길이와 끝단 가공 정보
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
displayType = length ? `NIPPLE ${length}mm` : 'NIPPLE';
} else if (fittingType === 'ELBOW') {
// 엘보: 각도와 연결 방식
const angle = fittingSubtype === '90DEG' ? '90°' : fittingSubtype === '45DEG' ? '45°' : '';
const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : '';
displayType = `ELBOW ${angle} ${connection}`.trim();
} else if (fittingType === 'TEE') {
// 티: 타입과 연결 방식
const teeType = fittingSubtype === 'EQUAL' ? 'EQ' : fittingSubtype === 'REDUCING' ? 'RED' : '';
const connection = description.includes('SW') ? 'SW' : description.includes('BW') ? 'BW' : '';
displayType = `TEE ${teeType} ${connection}`.trim();
} else if (fittingType === 'REDUCER') {
// 레듀서: 콘센트릭/에센트릭
const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : '';
const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec;
displayType = `RED ${reducerType} ${sizes}`.trim();
} else if (fittingType === 'SWAGE') {
// 스웨이지: 타입 명시
const swageType = fittingSubtype || '';
displayType = `SWAGE ${swageType}`.trim();
} else if (!displayType) {
// 기타 피팅 타입
displayType = fittingType || 'FITTING';
}
// 압력 등급과 스케줄 추출
let pressure = '-';
let schedule = '-';
// 압력 등급 찾기 (3000LB, 6000LB 등)
const pressureMatch = description.match(/(\d+)LB/i);
if (pressureMatch) {
pressure = `${pressureMatch[1]}LB`;
}
// 스케줄 찾기
if (description.includes('SCH')) {
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
if (schMatch) {
schedule = `SCH ${schMatch[1]}`;
}
}
return {
type: 'FITTING',
subtype: displayType,
size: material.size_spec || '-',
pressure: pressure,
schedule: schedule,
grade: material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isFitting: true
};
} else if (category === 'VALVE') {
const valveDetails = material.valve_details || {};
const description = material.original_description || '';
// 밸브 타입 파싱 (GATE, BALL, CHECK, GLOBE 등)
let valveType = valveDetails.valve_type || '';
if (!valveType && description) {
if (description.includes('GATE')) valveType = 'GATE';
else if (description.includes('BALL')) valveType = 'BALL';
else if (description.includes('CHECK')) valveType = 'CHECK';
else if (description.includes('GLOBE')) valveType = 'GLOBE';
else if (description.includes('BUTTERFLY')) valveType = 'BUTTERFLY';
}
// 연결 방식 파싱 (FLG, SW, THRD 등)
let connectionType = '';
if (description.includes('FLG')) {
connectionType = 'FLG';
} else if (description.includes('SW X THRD')) {
connectionType = 'SW×THRD';
} else if (description.includes('SW')) {
connectionType = 'SW';
} else if (description.includes('THRD')) {
connectionType = 'THRD';
} else if (description.includes('BW')) {
connectionType = 'BW';
}
// 압력 등급 파싱
let pressure = '-';
const pressureMatch = description.match(/(\d+)LB/i);
if (pressureMatch) {
pressure = `${pressureMatch[1]}LB`;
}
// 스케줄은 밸브에는 일반적으로 없음
let schedule = '-';
return {
type: 'VALVE',
valveType: valveType,
connectionType: connectionType,
size: material.size_spec || '-',
pressure: pressure,
schedule: schedule,
grade: material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isValve: true
};
} else if (category === 'FLANGE') {
// 플랜지 타입 변환
const flangeTypeMap = {
'WELD_NECK': 'WN',
'SLIP_ON': 'SO',
'BLIND': 'BL',
'SOCKET_WELD': 'SW',
'LAP_JOINT': 'LJ',
'THREADED': 'TH',
'ORIFICE': 'ORIFICE' // 오리피스는 풀네임 표시
};
const flangeType = material.flange_details?.flange_type;
const displayType = flangeTypeMap[flangeType] || flangeType || '-';
// 원본 설명에서 스케줄 추출
let schedule = '-';
const description = material.original_description || '';
// SCH 40, SCH 80 등의 패턴 찾기
if (description.toUpperCase().includes('SCH')) {
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
if (schMatch && schMatch[1]) {
schedule = `SCH ${schMatch[1]}`;
}
}
return {
type: 'FLANGE',
subtype: displayType,
size: material.size_spec || '-',
pressure: material.flange_details?.pressure_rating || '-',
schedule: schedule,
grade: material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isFlange: true // 플랜지 구분용 플래그
};
} else if (category === 'BOLT') {
const qty = Math.round(material.quantity || 0);
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
return {
type: 'BOLT',
subtype: material.bolt_details?.bolt_type || '-',
size: material.size_spec || '-',
schedule: material.bolt_details?.length || '-',
grade: material.material_grade || '-',
quantity: purchaseQty,
unit: 'SETS'
};
} else if (category === 'GASKET') {
const qty = Math.round(material.quantity || 0);
const purchaseQty = Math.ceil(qty / 5) * 5; // 5의 배수
// original_description에서 재질 정보 파싱
const description = material.original_description || '';
let materialStructure = '-'; // H/F/I/O 부분
let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분
// H/F/I/O와 재질 상세 정보 추출
const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/);
if (materialMatch) {
materialStructure = 'H/F/I/O';
materialDetail = materialMatch[1].trim();
// 두께 정보 제거 (별도 추출)
materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
}
// 압력 정보 추출
let pressure = '-';
const pressureMatch = description.match(/(\d+LB)/);
if (pressureMatch) {
pressure = pressureMatch[1];
}
// 두께 정보 추출
let thickness = '-';
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
if (thicknessMatch) {
thickness = thicknessMatch[1] + 'mm';
}
return {
type: 'GASKET',
subtype: 'SWG', // 항상 SWG로 표시
size: material.size_spec || '-',
pressure: pressure,
materialStructure: materialStructure,
materialDetail: materialDetail,
thickness: thickness,
quantity: purchaseQty,
unit: '개',
isGasket: true
};
} else if (category === 'UNKNOWN') {
return {
type: 'UNKNOWN',
description: material.original_description || 'Unknown Item',
quantity: Math.round(material.quantity || 0),
unit: '개',
isUnknown: true
};
} else {
return {
type: category || 'UNKNOWN',
subtype: '-',
size: material.size_spec || '-',
schedule: '-',
grade: material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개'
};
}
};
// 필터링된 자재 목록
const filteredMaterials = materials.filter(material => {
return material.classified_category === selectedCategory;
});
// 카테고리 색상 (제거 - CSS에서 처리)
// 전체 선택/해제
const toggleAllSelection = () => {
if (selectedMaterials.size === filteredMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
}
};
// 개별 선택
const toggleMaterialSelection = (id) => {
const newSelection = new Set(selectedMaterials);
if (newSelection.has(id)) {
newSelection.delete(id);
} else {
newSelection.add(id);
}
setSelectedMaterials(newSelection);
};
// 엑셀 내보내기
const exportToExcel = () => {
const selectedData = materials.filter(m => selectedMaterials.has(m.id));
console.log('📊 엑셀 내보내기:', selectedData.length, '개 항목');
alert(`${selectedData.length}개 항목을 엑셀로 내보냅니다.`);
};
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>자재 목록을 불러오는 ...</p>
</div>
);
}
const categoryCounts = getCategoryCounts();
return (
<div className="materials-page">
{/* 헤더 */}
<div className="materials-header">
<div className="header-left">
<button onClick={() => onNavigate('bom')} className="back-button">
BOM 업로드로 돌아가기
</button>
<h1>자재 목록</h1>
{jobNo && (
<span className="job-info">
{jobNo} {revision && `(${revision})`}
</span>
)}
</div>
<div className="header-right">
<span className="material-count">
{materials.length} 자재
</span>
</div>
</div>
{/* 카테고리 필터 */}
<div className="category-filters">
{Object.entries(categoryCounts).map(([category, count]) => (
<button
key={category}
className={`category-btn ${selectedCategory === category ? 'active' : ''}`}
onClick={() => setSelectedCategory(category)}
>
{category} <span className="count">{count}</span>
</button>
))}
</div>
{/* 액션 바 */}
<div className="action-bar">
<div className="selection-info">
{selectedMaterials.size} {filteredMaterials.length} 선택
</div>
<div className="action-buttons">
<button
onClick={toggleAllSelection}
className="select-all-btn"
>
{selectedMaterials.size === filteredMaterials.length ? '전체 해제' : '전체 선택'}
</button>
<button
onClick={exportToExcel}
className="export-btn"
disabled={selectedMaterials.size === 0}
>
엑셀 내보내기 ({selectedMaterials.size})
</button>
</div>
</div>
{/* 자재 테이블 */}
<div className="materials-grid">
{/* 플랜지 전용 헤더 */}
{selectedCategory === 'FLANGE' ? (
<div className="detailed-grid-header flange-header">
<div>선택</div>
<div>종류</div>
<div>타입</div>
<div>크기</div>
<div>압력(파운드)</div>
<div>스케줄</div>
<div>재질</div>
<div>추가요구</div>
<div>사용자요구</div>
<div>수량</div>
</div>
) : selectedCategory === 'FITTING' ? (
<div className="detailed-grid-header fitting-header">
<div>선택</div>
<div>종류</div>
<div>타입/상세</div>
<div>크기</div>
<div>압력</div>
<div>스케줄</div>
<div>재질</div>
<div>추가요구</div>
<div>사용자요구</div>
<div>수량</div>
</div>
) : selectedCategory === 'GASKET' ? (
<div className="detailed-grid-header gasket-header">
<div>선택</div>
<div>종류</div>
<div>타입</div>
<div>크기</div>
<div>압력</div>
<div>재질</div>
<div>상세내역</div>
<div>두께</div>
<div>추가요구</div>
<div>사용자요구</div>
<div>수량</div>
</div>
) : selectedCategory === 'VALVE' ? (
<div className="detailed-grid-header valve-header">
<div>선택</div>
<div>타입</div>
<div>연결방식</div>
<div>크기</div>
<div>압력</div>
<div>재질</div>
<div>추가요구</div>
<div>사용자요구</div>
<div>수량</div>
</div>
) : selectedCategory === 'UNKNOWN' ? (
<div className="detailed-grid-header unknown-header">
<div>선택</div>
<div>종류</div>
<div>설명</div>
<div>사용자요구</div>
<div>수량</div>
</div>
) : (
<div className="detailed-grid-header">
<div>선택</div>
<div>종류</div>
<div>타입</div>
<div>크기</div>
<div>스케줄</div>
<div>재질</div>
<div>추가요구</div>
<div>사용자요구</div>
<div>수량</div>
</div>
)}
{filteredMaterials.map((material) => {
const info = parseMaterialInfo(material);
// 피팅인 경우 10개 컬럼
if (info.isFitting) {
return (
<div
key={material.id}
className={`detailed-material-row fitting-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
>
{/* 선택 */}
<div className="material-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={() => toggleMaterialSelection(material.id)}
/>
</div>
{/* 종류 */}
<div className="material-cell">
<span className={`type-badge ${info.type.toLowerCase()}`}>
{info.type}
</span>
</div>
{/* 타입/상세 */}
<div className="material-cell">
<span className="subtype-text">{info.subtype}</span>
</div>
{/* 크기 */}
<div className="material-cell">
<span className="size-text">{info.size}</span>
</div>
{/* 압력 */}
<div className="material-cell">
<span className="pressure-info">{info.pressure}</span>
</div>
{/* 스케줄 */}
<div className="material-cell">
<span>{info.schedule}</span>
</div>
{/* 재질 */}
<div className="material-cell">
<span className="material-grade">{info.grade}</span>
</div>
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
</div>
{/* 사용자요구 */}
<div className="material-cell">
<input
type="text"
className="user-req-input"
placeholder="요구사항 입력"
/>
</div>
{/* 수량 */}
<div className="material-cell">
<div className="quantity-info">
<span className="quantity-value">
{info.quantity} {info.unit}
</span>
</div>
</div>
</div>
);
}
// 밸브인 경우 10개 컬럼
if (info.isValve) {
return (
<div
key={material.id}
className={`detailed-material-row valve-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
>
{/* 선택 */}
<div className="material-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={() => toggleMaterialSelection(material.id)}
/>
</div>
{/* 타입 */}
<div className="material-cell">
<span className="valve-type">{info.valveType}</span>
</div>
{/* 연결방식 */}
<div className="material-cell">
<span className="connection-type">{info.connectionType}</span>
</div>
{/* 크기 */}
<div className="material-cell">
<span className="size-text">{info.size}</span>
</div>
{/* 압력 */}
<div className="material-cell">
<span className="pressure-info">{info.pressure}</span>
</div>
{/* 재질 */}
<div className="material-cell">
<span className="material-grade">{info.grade}</span>
</div>
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
</div>
{/* 사용자요구 */}
<div className="material-cell">
<input
type="text"
className="user-req-input"
placeholder="요구사항 입력"
/>
</div>
{/* 수량 */}
<div className="material-cell">
<div className="quantity-info">
<span className="quantity-value">
{info.quantity} {info.unit}
</span>
</div>
</div>
</div>
);
}
// 플랜지인 경우 10개 컬럼
if (info.isFlange) {
return (
<div
key={material.id}
className={`detailed-material-row flange-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
>
{/* 선택 */}
<div className="material-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={() => toggleMaterialSelection(material.id)}
/>
</div>
{/* 종류 */}
<div className="material-cell">
<span className={`type-badge ${info.type.toLowerCase()}`}>
{info.type}
</span>
</div>
{/* 타입 */}
<div className="material-cell">
<span className="subtype-text">{info.subtype}</span>
</div>
{/* 크기 */}
<div className="material-cell">
<span className="size-text">{info.size}</span>
</div>
{/* 압력(파운드) */}
<div className="material-cell">
<span className="pressure-info">{info.pressure}</span>
</div>
{/* 스케줄 */}
<div className="material-cell">
<span>{info.schedule}</span>
</div>
{/* 재질 */}
<div className="material-cell">
<span className="material-grade">{info.grade}</span>
</div>
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
</div>
{/* 사용자요구 */}
<div className="material-cell">
<input
type="text"
className="user-req-input"
placeholder="요구사항 입력"
/>
</div>
{/* 수량 */}
<div className="material-cell">
<div className="quantity-info">
<span className="quantity-value">
{info.quantity} {info.unit}
</span>
</div>
</div>
</div>
);
}
// UNKNOWN인 경우 5개 컬럼
if (info.isUnknown) {
return (
<div
key={material.id}
className={`detailed-material-row unknown-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
>
{/* 선택 */}
<div className="material-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={() => toggleMaterialSelection(material.id)}
/>
</div>
{/* 종류 */}
<div className="material-cell">
<span className={`type-badge unknown`}>
{info.type}
</span>
</div>
{/* 설명 */}
<div className="material-cell description-cell">
<span className="description-text" title={info.description}>
{info.description}
</span>
</div>
{/* 사용자요구 */}
<div className="material-cell">
<input
type="text"
className="user-req-input"
placeholder="요구사항 입력"
/>
</div>
{/* 수량 */}
<div className="material-cell">
<div className="quantity-info">
<span className="quantity-value">
{info.quantity} {info.unit}
</span>
</div>
</div>
</div>
);
}
// 가스켓인 경우 11개 컬럼
if (info.isGasket) {
return (
<div
key={material.id}
className={`detailed-material-row gasket-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
>
{/* 선택 */}
<div className="material-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={() => toggleMaterialSelection(material.id)}
/>
</div>
{/* 종류 */}
<div className="material-cell">
<span className={`type-badge ${info.type.toLowerCase()}`}>
{info.type}
</span>
</div>
{/* 타입 */}
<div className="material-cell">
<span className="subtype-text">{info.subtype}</span>
</div>
{/* 크기 */}
<div className="material-cell">
<span className="size-text">{info.size}</span>
</div>
{/* 압력 */}
<div className="material-cell">
<span className="pressure-info">{info.pressure}</span>
</div>
{/* 재질 */}
<div className="material-cell">
<span className="material-structure">{info.materialStructure}</span>
</div>
{/* 상세내역 */}
<div className="material-cell">
<span className="material-detail">{info.materialDetail}</span>
</div>
{/* 두께 */}
<div className="material-cell">
<span className="thickness-info">{info.thickness}</span>
</div>
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
</div>
{/* 사용자요구 */}
<div className="material-cell">
<input
type="text"
className="user-req-input"
placeholder="요구사항 입력"
/>
</div>
{/* 수량 */}
<div className="material-cell">
<div className="quantity-info">
<span className="quantity-value">
{info.quantity} {info.unit}
</span>
</div>
</div>
</div>
);
}
// 플랜지가 아닌 경우 9개 컬럼
return (
<div
key={material.id}
className={`detailed-material-row ${selectedMaterials.has(material.id) ? 'selected' : ''}`}
>
{/* 선택 */}
<div className="material-cell">
<input
type="checkbox"
checked={selectedMaterials.has(material.id)}
onChange={() => toggleMaterialSelection(material.id)}
/>
</div>
{/* 종류 */}
<div className="material-cell">
<span className={`type-badge ${info.type.toLowerCase()}`}>
{info.type}
</span>
</div>
{/* 타입 */}
<div className="material-cell">
<span className="subtype-text">{info.subtype}</span>
</div>
{/* 크기 */}
<div className="material-cell">
<span className="size-text">{info.size}</span>
</div>
{/* 스케줄 */}
<div className="material-cell">
<span>{info.schedule}</span>
</div>
{/* 재질 */}
<div className="material-cell">
<span className="material-grade">{info.grade}</span>
</div>
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
</div>
{/* 사용자요구 */}
<div className="material-cell">
<input
type="text"
className="user-req-input"
placeholder="요구사항 입력"
/>
</div>
{/* 수량 */}
<div className="material-cell">
<div className="quantity-info">
<span className="quantity-value">
{info.quantity} {info.unit}
</span>
{info.type === 'PIPE' && info.details && (
<div className="quantity-details">
<small>
단관 {info.details.pipeCount} {Math.round(info.details.totalLength)}mm
</small>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
export default NewMaterialsPage;

View File

@@ -0,0 +1,358 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const ProjectWorkspacePage = ({ project, user, onNavigate, onBackToDashboard }) => {
const [projectStats, setProjectStats] = useState(null);
const [recentFiles, setRecentFiles] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (project) {
loadProjectData();
}
}, [project]);
const loadProjectData = async () => {
try {
// 실제 파일 데이터만 로드
const filesResponse = await api.get(`/files?job_no=${project.job_no}&limit=5`);
if (filesResponse.data && Array.isArray(filesResponse.data)) {
setRecentFiles(filesResponse.data);
// 파일 데이터를 기반으로 통계 계산
const stats = {
totalFiles: filesResponse.data.length,
totalMaterials: filesResponse.data.reduce((sum, file) => sum + (file.parsed_count || 0), 0),
classifiedMaterials: 0, // API에서 분류 정보를 가져와야 함
pendingVerification: 0, // API에서 검증 정보를 가져와야 함
};
setProjectStats(stats);
} else {
setRecentFiles([]);
setProjectStats({
totalFiles: 0,
totalMaterials: 0,
classifiedMaterials: 0,
pendingVerification: 0
});
}
} catch (error) {
console.error('프로젝트 데이터 로딩 실패:', error);
setRecentFiles([]);
setProjectStats({
totalFiles: 0,
totalMaterials: 0,
classifiedMaterials: 0,
pendingVerification: 0
});
} finally {
setLoading(false);
}
};
const getAvailableActions = () => {
const userRole = user?.role || 'user';
const allActions = {
// BOM 관리 (통합)
'bom-management': {
title: 'BOM 관리',
description: 'BOM 파일 업로드, 관리 및 리비전 추적을 수행합니다',
icon: '📋',
color: '#667eea',
roles: ['designer', 'manager', 'admin'],
path: 'bom-status'
},
// 자재 관리
'material-management': {
title: '자재 관리',
description: '자재 분류, 검증 및 구매 관리를 수행합니다',
icon: '🔧',
color: '#48bb78',
roles: ['designer', 'purchaser', 'manager', 'admin'],
path: 'materials'
}
};
// 사용자 권한에 따라 필터링
return Object.entries(allActions).filter(([key, action]) =>
action.roles.includes(userRole)
);
};
const handleActionClick = (actionPath) => {
switch (actionPath) {
case 'bom-management':
onNavigate('bom-status', {
job_no: project.job_no,
job_name: project.project_name
});
break;
case 'material-management':
onNavigate('materials', {
job_no: project.job_no,
job_name: project.project_name
});
break;
default:
alert(`${actionPath} 기능은 곧 구현될 예정입니다.`);
}
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '200px'
}}>
<div>프로젝트 데이터를 불러오는 ...</div>
</div>
);
}
const availableActions = getAvailableActions();
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '32px'
}}>
<button
onClick={onBackToDashboard}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
marginRight: '16px',
padding: '8px'
}}
>
</button>
<div>
<h1 style={{
margin: '0 0 8px 0',
fontSize: '28px',
fontWeight: 'bold',
color: '#2d3748'
}}>
{project.project_name}
</h1>
<div style={{
fontSize: '16px',
color: '#718096'
}}>
{project.job_no} 진행률: {project.progress || 0}%
</div>
</div>
</div>
{/* 프로젝트 통계 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
{[
{ label: 'BOM 파일', value: projectStats.totalFiles, icon: '📄', color: '#667eea' },
{ label: '전체 자재', value: projectStats.totalMaterials, icon: '📦', color: '#48bb78' },
{ label: '분류 완료', value: projectStats.classifiedMaterials, icon: '✅', color: '#38b2ac' },
{ label: '검증 대기', value: projectStats.pendingVerification, icon: '⏳', color: '#ed8936' }
].map((stat, index) => (
<div key={index} style={{
background: 'white',
padding: '20px',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '1px solid #e2e8f0'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div>
<div style={{
fontSize: '14px',
color: '#718096',
marginBottom: '4px'
}}>
{stat.label}
</div>
<div style={{
fontSize: '24px',
fontWeight: 'bold',
color: stat.color
}}>
{stat.value}
</div>
</div>
<div style={{ fontSize: '24px' }}>
{stat.icon}
</div>
</div>
</div>
))}
</div>
{/* 업무 메뉴 */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
marginBottom: '32px'
}}>
<h2 style={{
margin: '0 0 24px 0',
fontSize: '20px',
fontWeight: 'bold',
color: '#2d3748'
}}>
🚀 사용 가능한 업무
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '20px'
}}>
{availableActions.map(([key, action]) => (
<div
key={key}
onClick={() => handleActionClick(key)}
style={{
padding: '20px',
border: '1px solid #e2e8f0',
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: 'white'
}}
onMouseEnter={(e) => {
e.target.style.borderColor = action.color;
e.target.style.boxShadow = `0 4px 12px ${action.color}20`;
e.target.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.target.style.borderColor = '#e2e8f0';
e.target.style.boxShadow = 'none';
e.target.style.transform = 'translateY(0)';
}}
>
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: '16px'
}}>
<div style={{
fontSize: '32px',
lineHeight: 1
}}>
{action.icon}
</div>
<div style={{ flex: 1 }}>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '16px',
fontWeight: 'bold',
color: action.color
}}>
{action.title}
</h3>
<p style={{
margin: 0,
fontSize: '14px',
color: '#718096',
lineHeight: 1.5
}}>
{action.description}
</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* 최근 활동 (옵션) */}
{recentFiles.length > 0 && (
<div style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}}>
<h2 style={{
margin: '0 0 24px 0',
fontSize: '20px',
fontWeight: 'bold',
color: '#2d3748'
}}>
📁 최근 BOM 파일
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{recentFiles.map((file, index) => (
<div key={index} style={{
padding: '16px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div>
<div style={{
fontSize: '16px',
fontWeight: '500',
color: '#2d3748',
marginBottom: '4px'
}}>
{file.original_filename || file.filename}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{file.revision} {file.uploaded_by || '시스템'} {file.parsed_count || 0} 자재
</div>
</div>
<button
onClick={() => handleActionClick('materials')}
style={{
padding: '8px 16px',
backgroundColor: '#667eea',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
자재 보기
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default ProjectWorkspacePage;

View File

@@ -386,3 +386,19 @@ const ProjectsPage = ({ user }) => {
}; };
export default ProjectsPage; export default ProjectsPage;

View File

@@ -1,446 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Box,
Card,
CardContent,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
TextField,
Chip,
Alert,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Divider
} from '@mui/material';
import {
ArrowBack,
Edit,
Check,
Close,
ShoppingCart,
CompareArrows,
Warning
} from '@mui/icons-material';
import { api } from '../api';
const PurchaseConfirmationPage = () => {
const location = useLocation();
const navigate = useNavigate();
const [purchaseItems, setPurchaseItems] = useState([]);
const [revisionComparison, setRevisionComparison] = useState(null);
const [loading, setLoading] = useState(true);
const [editingItem, setEditingItem] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(false);
// URL에서 job_no, revision 정보 가져오기
const searchParams = new URLSearchParams(location.search);
const jobNo = searchParams.get('job_no');
const revision = searchParams.get('revision');
const filename = searchParams.get('filename');
const previousRevision = searchParams.get('prev_revision');
useEffect(() => {
if (jobNo && revision) {
loadPurchaseItems();
if (previousRevision) {
loadRevisionComparison();
}
}
}, [jobNo, revision, previousRevision]);
const loadPurchaseItems = async () => {
try {
setLoading(true);
const response = await api.get('/purchase/items/calculate', {
params: { job_no: jobNo, revision: revision }
});
setPurchaseItems(response.data.items || []);
} catch (error) {
console.error('구매 품목 로딩 실패:', error);
} finally {
setLoading(false);
}
};
const loadRevisionComparison = async () => {
try {
const response = await api.get('/purchase/revision-diff', {
params: {
job_no: jobNo,
current_revision: revision,
previous_revision: previousRevision
}
});
setRevisionComparison(response.data.comparison);
} catch (error) {
console.error('리비전 비교 실패:', error);
}
};
const updateItemQuantity = async (itemId, field, value) => {
try {
await api.patch(`/purchase/items/${itemId}`, {
[field]: parseFloat(value)
});
// 로컬 상태 업데이트
setPurchaseItems(prev =>
prev.map(item =>
item.id === itemId
? { ...item, [field]: parseFloat(value) }
: item
)
);
setEditingItem(null);
} catch (error) {
console.error('수량 업데이트 실패:', error);
}
};
const confirmPurchase = async () => {
try {
const response = await api.post('/purchase/orders/create', {
job_no: jobNo,
revision: revision,
items: purchaseItems.map(item => ({
purchase_item_id: item.id,
ordered_quantity: item.calculated_qty,
required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30일 후
}))
});
alert('구매 주문이 생성되었습니다!');
navigate('/materials', {
state: { message: '구매 주문 생성 완료' }
});
} catch (error) {
console.error('구매 주문 생성 실패:', error);
alert('구매 주문 생성에 실패했습니다.');
}
};
const getCategoryColor = (category) => {
const colors = {
'PIPE': 'primary',
'FITTING': 'secondary',
'VALVE': 'success',
'FLANGE': 'warning',
'BOLT': 'info',
'GASKET': 'error',
'INSTRUMENT': 'purple'
};
return colors[category] || 'default';
};
const formatPipeInfo = (item) => {
if (item.category !== 'PIPE') return null;
return (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="textSecondary">
절단손실: {item.cutting_loss || 0}mm |
구매: {item.pipes_count || 0} |
여유분: {item.waste_length || 0}mm
</Typography>
</Box>
);
};
const formatBoltInfo = (item) => {
if (item.category !== 'BOLT') return null;
// 특수 용도 볼트 정보 (백엔드에서 제공되어야 함)
const specialApplications = item.special_applications || {};
const psvCount = specialApplications.PSV || 0;
const ltCount = specialApplications.LT || 0;
const ckCount = specialApplications.CK || 0;
const oriCount = specialApplications.ORI || 0;
return (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="textSecondary">
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
표면처리: {item.surface_treatment || '없음'}
</Typography>
{/* 특수 용도 볼트 정보 */}
<Box sx={{ mt: 1, p: 1, bgcolor: 'info.50', borderRadius: 1 }}>
<Typography variant="caption" fontWeight="bold" color="info.main">
특수 용도 볼트 현황:
</Typography>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={3}>
<Typography variant="caption" color={psvCount > 0 ? "error.main" : "textSecondary"}>
PSV용: {psvCount}
</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" color={ltCount > 0 ? "warning.main" : "textSecondary"}>
저온용: {ltCount}
</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" color={ckCount > 0 ? "info.main" : "textSecondary"}>
체크밸브용: {ckCount}
</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" color={oriCount > 0 ? "secondary.main" : "textSecondary"}>
오리피스용: {oriCount}
</Typography>
</Grid>
</Grid>
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
<Typography variant="caption" color="success.main" sx={{ fontStyle: 'italic' }}>
특수 용도 볼트 없음 (일반 볼트만 포함)
</Typography>
)}
</Box>
</Box>
);
};
return (
<Box sx={{ p: 3 }}>
{/* 헤더 */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<IconButton onClick={() => navigate(-1)} sx={{ mr: 2 }}>
<ArrowBack />
</IconButton>
<Box sx={{ flex: 1 }}>
<Typography variant="h4" gutterBottom>
🛒 구매 확정
</Typography>
<Typography variant="h6" color="primary">
Job: {jobNo} | {filename} | {revision}
</Typography>
</Box>
<Button
variant="contained"
startIcon={<ShoppingCart />}
onClick={() => setConfirmDialog(true)}
size="large"
disabled={purchaseItems.length === 0}
>
구매 주문 생성
</Button>
</Box>
{/* 리비전 비교 알림 */}
{revisionComparison && (
<Alert
severity={revisionComparison.has_changes ? "warning" : "info"}
sx={{ mb: 3 }}
icon={<CompareArrows />}
>
<Typography variant="body2">
<strong>리비전 변경사항:</strong> {revisionComparison.summary}
</Typography>
{revisionComparison.additional_items && (
<Typography variant="body2" sx={{ mt: 1 }}>
추가 구매 필요: {revisionComparison.additional_items} 품목
</Typography>
)}
</Alert>
)}
{/* 구매 품목 테이블 */}
{purchaseItems.map(item => (
<Card key={item.id} sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Chip
label={item.category}
color={getCategoryColor(item.category)}
sx={{ mr: 2 }}
/>
<Typography variant="h6" sx={{ flex: 1 }}>
{item.specification}
</Typography>
{item.is_additional && (
<Chip
label="추가 구매"
color="warning"
variant="outlined"
/>
)}
</Box>
<Grid container spacing={3}>
{/* BOM 수량 */}
<Grid item xs={12} md={3}>
<Typography variant="body2" color="textSecondary">
BOM 필요량
</Typography>
<Typography variant="h6">
{item.bom_quantity} {item.unit}
</Typography>
{formatPipeInfo(item)}
{formatBoltInfo(item)}
</Grid>
{/* 구매 수량 */}
<Grid item xs={12} md={3}>
<Typography variant="body2" color="textSecondary">
구매 수량
</Typography>
{editingItem === item.id ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
value={item.calculated_qty}
onChange={(e) =>
setPurchaseItems(prev =>
prev.map(i =>
i.id === item.id
? { ...i, calculated_qty: parseFloat(e.target.value) || 0 }
: i
)
)
}
size="small"
type="number"
sx={{ width: 100 }}
/>
<IconButton
size="small"
color="primary"
onClick={() => updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)}
>
<Check />
</IconButton>
<IconButton
size="small"
onClick={() => setEditingItem(null)}
>
<Close />
</IconButton>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" color="primary">
{item.calculated_qty} {item.unit}
</Typography>
<IconButton
size="small"
onClick={() => setEditingItem(item.id)}
>
<Edit />
</IconButton>
</Box>
)}
</Grid>
{/* 이미 구매한 수량 */}
{previousRevision && (
<Grid item xs={12} md={3}>
<Typography variant="body2" color="textSecondary">
기구매 수량
</Typography>
<Typography variant="h6">
{item.purchased_quantity || 0} {item.unit}
</Typography>
</Grid>
)}
{/* 추가 구매 필요량 */}
{previousRevision && (
<Grid item xs={12} md={3}>
<Typography variant="body2" color="textSecondary">
추가 구매 필요
</Typography>
<Typography
variant="h6"
color={item.additional_needed > 0 ? "error" : "success"}
>
{Math.max(item.additional_needed || 0, 0)} {item.unit}
</Typography>
</Grid>
)}
</Grid>
{/* 여유율 및 최소 주문 정보 */}
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
<Grid container spacing={2}>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="textSecondary">
여유율
</Typography>
<Typography variant="body2">
{((item.safety_factor || 1) - 1) * 100}%
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="textSecondary">
최소 주문
</Typography>
<Typography variant="body2">
{item.min_order_qty || 0} {item.unit}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="textSecondary">
예상 여유분
</Typography>
<Typography variant="body2">
{(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="textSecondary">
활용률
</Typography>
<Typography variant="body2">
{((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}%
</Typography>
</Grid>
</Grid>
</Box>
</CardContent>
</Card>
))}
{/* 구매 주문 확인 다이얼로그 */}
<Dialog open={confirmDialog} onClose={() => setConfirmDialog(false)}>
<DialogTitle>구매 주문 생성 확인</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 2 }}>
{purchaseItems.length} 품목에 대한 구매 주문을 생성하시겠습니까?
</Typography>
{revisionComparison && revisionComparison.has_changes && (
<Alert severity="warning" sx={{ mb: 2 }}>
리비전 변경으로 인한 추가 구매가 포함되어 있습니다.
</Alert>
)}
<Typography variant="body2" color="textSecondary">
구매 주문 생성 후에는 수량 변경이 제한됩니다.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog(false)}>
취소
</Button>
<Button onClick={confirmPurchase} variant="contained">
주문 생성
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default PurchaseConfirmationPage;

View File

@@ -1,742 +0,0 @@
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;

View File

@@ -0,0 +1,455 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
const SystemSettingsPage = ({ onNavigate, user }) => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [newUser, setNewUser] = useState({
username: '',
email: '',
password: '',
full_name: '',
role: 'user'
});
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
const response = await api.get('/auth/users');
if (response.data.success) {
setUsers(response.data.users);
}
} catch (err) {
console.error('사용자 목록 로딩 실패:', err);
setError('사용자 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const handleCreateUser = async (e) => {
e.preventDefault();
if (!newUser.username || !newUser.email || !newUser.password) {
setError('모든 필수 필드를 입력해주세요.');
return;
}
try {
setLoading(true);
const response = await api.post('/auth/register', newUser);
if (response.data.success) {
alert('사용자가 성공적으로 생성되었습니다.');
setNewUser({
username: '',
email: '',
password: '',
full_name: '',
role: 'user'
});
setShowCreateForm(false);
loadUsers();
}
} catch (err) {
console.error('사용자 생성 실패:', err);
setError(err.response?.data?.detail || '사용자 생성에 실패했습니다.');
} finally {
setLoading(false);
}
};
const handleDeleteUser = async (userId) => {
if (!confirm('정말로 이 사용자를 삭제하시겠습니까?')) {
return;
}
try {
setLoading(true);
const response = await api.delete(`/auth/users/${userId}`);
if (response.data.success) {
alert('사용자가 삭제되었습니다.');
loadUsers();
}
} catch (err) {
console.error('사용자 삭제 실패:', err);
setError('사용자 삭제에 실패했습니다.');
} finally {
setLoading(false);
}
};
const getRoleDisplay = (role) => {
switch (role) {
case 'admin': return '관리자';
case 'manager': return '매니저';
case 'user': return '사용자';
default: return role;
}
};
const getRoleBadgeColor = (role) => {
switch (role) {
case 'admin': return '#dc2626';
case 'manager': return '#ea580c';
case 'user': return '#059669';
default: return '#6b7280';
}
};
// 관리자 권한 확인
if (user?.role !== 'admin') {
return (
<div style={{ padding: '32px', textAlign: 'center' }}>
<h2 style={{ color: '#dc2626', marginBottom: '16px' }}>접근 권한이 없습니다</h2>
<p style={{ color: '#6b7280', marginBottom: '24px' }}>
시스템 설정은 관리자만 접근할 있습니다.
</p>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '12px 24px',
cursor: 'pointer'
}}
>
대시보드로 돌아가기
</button>
</div>
);
}
return (
<div style={{ padding: '32px', 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', marginBottom: '8px' }}>
시스템 설정
</h1>
<p style={{ color: '#718096', fontSize: '16px' }}>
사용자 계정 관리 시스템 설정
</p>
</div>
<button
onClick={() => onNavigate('dashboard')}
style={{
background: '#e2e8f0',
color: '#4a5568',
border: 'none',
borderRadius: '6px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
대시보드
</button>
</div>
{error && (
<div style={{
background: '#fed7d7',
color: '#c53030',
padding: '12px 16px',
borderRadius: '6px',
marginBottom: '24px'
}}>
{error}
</div>
)}
{/* 사용자 관리 섹션 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.07)',
border: '1px solid #e2e8f0',
marginBottom: '24px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
}}>
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748' }}>
👥 사용자 관리
</h2>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
style={{
background: '#38a169',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
+ 사용자 생성
</button>
</div>
{/* 사용자 생성 폼 */}
{showCreateForm && (
<div style={{
background: '#f7fafc',
padding: '20px',
borderRadius: '8px',
marginBottom: '24px',
border: '1px solid #e2e8f0'
}}>
<h3 style={{ fontSize: '16px', fontWeight: '600', marginBottom: '16px' }}>
사용자 생성
</h3>
<form onSubmit={handleCreateUser}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
사용자명 *
</label>
<input
type="text"
value={newUser.username}
onChange={(e) => setNewUser({...newUser, username: e.target.value})}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
required
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
이메일 *
</label>
<input
type="email"
value={newUser.email}
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
required
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
비밀번호 *
</label>
<input
type="password"
value={newUser.password}
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
required
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
전체 이름
</label>
<input
type="text"
value={newUser.full_name}
onChange={(e) => setNewUser({...newUser, full_name: e.target.value})}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
권한
</label>
<select
value={newUser.role}
onChange={(e) => setNewUser({...newUser, role: e.target.value})}
style={{
width: '200px',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="user">사용자</option>
<option value="manager">매니저</option>
<option value="admin">관리자</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="submit"
disabled={loading}
style={{
background: '#38a169',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '10px 16px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
{loading ? '생성 중...' : '사용자 생성'}
</button>
<button
type="button"
onClick={() => setShowCreateForm(false)}
style={{
background: '#e2e8f0',
color: '#4a5568',
border: 'none',
borderRadius: '6px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
취소
</button>
</div>
</form>
</div>
)}
{/* 사용자 목록 */}
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 ...</div>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
사용자명
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
이메일
</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
전체 이름
</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
권한
</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
상태
</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
작업
</th>
</tr>
</thead>
<tbody>
{users.map((userItem) => (
<tr key={userItem.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontWeight: '500' }}>
{userItem.username}
</td>
<td style={{ padding: '12px', color: '#4a5568' }}>
{userItem.email}
</td>
<td style={{ padding: '12px', color: '#4a5568' }}>
{userItem.full_name || '-'}
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<span style={{
background: getRoleBadgeColor(userItem.role),
color: 'white',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{getRoleDisplay(userItem.role)}
</span>
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<span style={{
background: userItem.is_active ? '#d1fae5' : '#fee2e2',
color: userItem.is_active ? '#065f46' : '#dc2626',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{userItem.is_active ? '활성' : '비활성'}
</span>
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
{userItem.id !== user?.id && (
<button
onClick={() => handleDeleteUser(userItem.id)}
style={{
background: '#dc2626',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '6px 12px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
>
삭제
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default SystemSettingsPage;

View File

@@ -428,3 +428,19 @@
width: 100%; width: 100%;
} }
} }

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import SimpleFileUpload from '../components/SimpleFileUpload'; import SimpleFileUpload from '../components/SimpleFileUpload';
import MaterialList from '../components/MaterialList'; import MaterialList from '../components/MaterialList';
import { fetchMaterials, fetchFiles } from '../api'; import { fetchMaterials, fetchFiles, fetchJobs } from '../api';
const BOMManagementPage = ({ user }) => { const BOMManagementPage = ({ user }) => {
const [activeTab, setActiveTab] = useState('upload'); const [activeTab, setActiveTab] = useState('upload');
@@ -32,10 +32,10 @@ const BOMManagementPage = ({ user }) => {
const loadProjects = async () => { const loadProjects = async () => {
try { try {
const response = await fetch('/api/jobs/'); // API ()
const data = await response.json(); const response = await fetchJobs();
if (data.success) { if (response.data.success) {
setProjects(data.jobs); setProjects(response.data.jobs);
} }
} catch (error) { } catch (error) {
console.error('프로젝트 로딩 실패:', error); console.error('프로젝트 로딩 실패:', error);

View File

@@ -33,7 +33,7 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import MaterialComparisonResult from '../components/MaterialComparisonResult'; import MaterialComparisonResult from '../components/MaterialComparisonResult';
import { compareMaterialRevisions, confirmMaterialPurchase, api } from '../api'; import { compareMaterialRevisions, confirmMaterialPurchase, fetchMaterials, api } from '../api';
import { exportComparisonToExcel } from '../utils/excelExport'; import { exportComparisonToExcel } from '../utils/excelExport';
const MaterialComparisonPage = () => { const MaterialComparisonPage = () => {
@@ -74,8 +74,11 @@ const MaterialComparisonPage = () => {
// 🚨 : MaterialsPage API // 🚨 : MaterialsPage API
try { try {
const testResult = await api.get('/files/materials', { // API -
params: { job_no: jobNo, revision: currentRevision, limit: 10 } const testResult = await fetchMaterials({
job_no: jobNo,
revision: currentRevision,
limit: 10
}); });
const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE'); const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE');
console.log('🧪 MaterialsPage API 테스트 (길이 있는지 확인):', pipeData); console.log('🧪 MaterialsPage API 테스트 (길이 있는지 확인):', pipeData);

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import MaterialList from '../components/MaterialList'; import MaterialList from '../components/MaterialList';
import { fetchMaterials } from '../api'; import { fetchMaterials, fetchJobs } from '../api';
const MaterialsManagementPage = ({ user }) => { const MaterialsManagementPage = ({ user }) => {
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
@@ -31,10 +31,10 @@ const MaterialsManagementPage = ({ user }) => {
const loadProjects = async () => { const loadProjects = async () => {
try { try {
const response = await fetch('/api/jobs/'); // API ()
const data = await response.json(); const response = await fetchJobs();
if (data.success) { if (response.data.success) {
setProjects(data.jobs); setProjects(response.data.jobs);
} }
} catch (error) { } catch (error) {
console.error('프로젝트 로딩 실패:', error); console.error('프로젝트 로딩 실패:', error);

View File

@@ -0,0 +1,736 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api';
const PurchaseConfirmationPage = () => {
const location = useLocation();
const navigate = useNavigate();
const [purchaseItems, setPurchaseItems] = useState([]);
const [revisionComparison, setRevisionComparison] = useState(null);
const [loading, setLoading] = useState(true);
const [editingItem, setEditingItem] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(false);
// URL에서 job_no, revision 정보 가져오기
const searchParams = new URLSearchParams(location.search);
const jobNo = searchParams.get('job_no');
const revision = searchParams.get('revision');
const filename = searchParams.get('filename');
const previousRevision = searchParams.get('prev_revision');
useEffect(() => {
if (jobNo && revision) {
loadPurchaseItems();
if (previousRevision) {
loadRevisionComparison();
}
}
}, [jobNo, revision, previousRevision]);
const loadPurchaseItems = async () => {
try {
setLoading(true);
const response = await api.get('/purchase/items/calculate', {
params: { job_no: jobNo, revision: revision }
});
setPurchaseItems(response.data.items || []);
} catch (error) {
console.error('구매 품목 로딩 실패:', error);
} finally {
setLoading(false);
}
};
const loadRevisionComparison = async () => {
try {
const response = await api.get('/purchase/revision-diff', {
params: {
job_no: jobNo,
current_revision: revision,
previous_revision: previousRevision
}
});
setRevisionComparison(response.data.comparison);
} catch (error) {
console.error('리비전 비교 실패:', error);
}
};
const updateItemQuantity = async (itemId, field, value) => {
try {
await api.patch(`/purchase/items/${itemId}`, {
[field]: parseFloat(value)
});
// 로컬 상태 업데이트
setPurchaseItems(prev =>
prev.map(item =>
item.id === itemId
? { ...item, [field]: parseFloat(value) }
: item
)
);
setEditingItem(null);
} catch (error) {
console.error('수량 업데이트 실패:', error);
}
};
const confirmPurchase = async () => {
try {
// 입력 데이터 검증
if (!jobNo || !revision) {
alert('Job 번호와 리비전 정보가 없습니다.');
return;
}
if (purchaseItems.length === 0) {
alert('구매할 품목이 없습니다.');
return;
}
// 각 품목의 수량 검증
const invalidItems = purchaseItems.filter(item =>
!item.calculated_qty || item.calculated_qty <= 0
);
if (invalidItems.length > 0) {
alert(`다음 품목들의 구매 수량이 유효하지 않습니다:\n${invalidItems.map(item => `- ${item.specification}`).join('\n')}`);
return;
}
setConfirmDialog(false);
const response = await api.post('/purchase/orders/create', {
job_no: jobNo,
revision: revision,
items: purchaseItems.map(item => ({
purchase_item_id: item.id,
ordered_quantity: item.calculated_qty,
required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30일 후
}))
});
const successMessage = `구매 주문이 성공적으로 생성되었습니다!\n\n` +
`- Job: ${jobNo}\n` +
`- Revision: ${revision}\n` +
`- 품목 수: ${purchaseItems.length}\n` +
`- 생성 시간: ${new Date().toLocaleString('ko-KR')}`;
alert(successMessage);
// 자재 목록 페이지로 이동 (상태 기반 라우팅 사용)
// App.jsx의 상태 기반 라우팅을 위해 window 이벤트 발생
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
detail: {
jobNo: jobNo,
revision: revision,
bomName: `${jobNo} ${revision}`,
message: '구매 주문 생성 완료'
}
}));
} catch (error) {
console.error('구매 주문 생성 실패:', error);
let errorMessage = '구매 주문 생성에 실패했습니다.';
if (error.response?.data?.detail) {
errorMessage += `\n\n오류 내용: ${error.response.data.detail}`;
} else if (error.message) {
errorMessage += `\n\n오류 내용: ${error.message}`;
}
if (error.response?.status === 400) {
errorMessage += '\n\n입력 데이터를 확인해주세요.';
} else if (error.response?.status === 404) {
errorMessage += '\n\n해당 Job이나 리비전을 찾을 수 없습니다.';
} else if (error.response?.status >= 500) {
errorMessage += '\n\n서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
alert(errorMessage);
}
};
const getCategoryColor = (category) => {
const colors = {
'PIPE': '#1976d2',
'FITTING': '#9c27b0',
'VALVE': '#2e7d32',
'FLANGE': '#ed6c02',
'BOLT': '#0288d1',
'GASKET': '#d32f2f',
'INSTRUMENT': '#7b1fa2'
};
return colors[category] || '#757575';
};
const formatPipeInfo = (item) => {
if (item.category !== 'PIPE') return null;
return (
<div style={{ marginTop: '8px', fontSize: '12px', color: '#666' }}>
절단손실: {item.cutting_loss || 0}mm |
구매: {item.pipes_count || 0} |
여유분: {item.waste_length || 0}mm
</div>
);
};
const formatBoltInfo = (item) => {
if (item.category !== 'BOLT') return null;
// 특수 용도 볼트 정보 (백엔드에서 제공되어야 함)
const specialApplications = item.special_applications || {};
const psvCount = specialApplications.PSV || 0;
const ltCount = specialApplications.LT || 0;
const ckCount = specialApplications.CK || 0;
const oriCount = specialApplications.ORI || 0;
return (
<div style={{ marginTop: '8px' }}>
<div style={{ fontSize: '12px', color: '#666' }}>
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
표면처리: {item.surface_treatment || '없음'}
</div>
{/* 특수 용도 볼트 정보 */}
<div style={{
marginTop: '8px',
padding: '8px',
backgroundColor: '#e3f2fd',
borderRadius: '4px'
}}>
<div style={{ fontSize: '12px', fontWeight: 'bold', color: '#0288d1' }}>
특수 용도 볼트 현황:
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '8px',
marginTop: '4px'
}}>
<div style={{ fontSize: '12px', color: psvCount > 0 ? '#d32f2f' : '#666' }}>
PSV용: {psvCount}
</div>
<div style={{ fontSize: '12px', color: ltCount > 0 ? '#ed6c02' : '#666' }}>
저온용: {ltCount}
</div>
<div style={{ fontSize: '12px', color: ckCount > 0 ? '#0288d1' : '#666' }}>
체크밸브용: {ckCount}
</div>
<div style={{ fontSize: '12px', color: oriCount > 0 ? '#9c27b0' : '#666' }}>
오리피스용: {oriCount}
</div>
</div>
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
<div style={{ fontSize: '12px', color: '#2e7d32', fontStyle: 'italic' }}>
특수 용도 볼트 없음 (일반 볼트만 포함)
</div>
)}
</div>
</div>
);
};
const exportToExcel = () => {
if (purchaseItems.length === 0) {
alert('내보낼 구매 품목이 없습니다.');
return;
}
// 상세한 구매 확정 데이터 생성
const data = purchaseItems.map((item, index) => {
const baseData = {
'순번': index + 1,
'품목코드': item.item_code || '',
'카테고리': item.category || '',
'사양': item.specification || '',
'재질': item.material_spec || '',
'사이즈': item.size_spec || '',
'단위': item.unit || '',
'BOM수량': item.bom_quantity || 0,
'구매수량': item.calculated_qty || 0,
'여유율': ((item.safety_factor || 1) - 1) * 100 + '%',
'최소주문': item.min_order_qty || 0,
'예상여유분': ((item.calculated_qty || 0) - (item.bom_quantity || 0)).toFixed(1),
'활용률': (((item.bom_quantity || 0) / (item.calculated_qty || 1)) * 100).toFixed(1) + '%'
};
// 파이프 특수 정보 추가
if (item.category === 'PIPE') {
baseData['절단손실'] = item.cutting_loss || 0;
baseData['구매본수'] = item.pipes_count || 0;
baseData['여유길이'] = item.waste_length || 0;
}
// 볼트 특수 정보 추가
if (item.category === 'BOLT') {
const specialApps = item.special_applications || {};
baseData['PSV용'] = specialApps.PSV || 0;
baseData['저온용'] = specialApps.LT || 0;
baseData['체크밸브용'] = specialApps.CK || 0;
baseData['오리피스용'] = specialApps.ORI || 0;
baseData['분수사이즈'] = item.size_fraction || '';
baseData['표면처리'] = item.surface_treatment || '';
}
// 리비전 비교 정보 추가 (있는 경우)
if (previousRevision) {
baseData['기구매수량'] = item.purchased_quantity || 0;
baseData['추가구매필요'] = Math.max(item.additional_needed || 0, 0);
}
return baseData;
});
// 헤더 정보 추가
const headerInfo = [
`구매 확정서`,
`Job No: ${jobNo}`,
`Revision: ${revision}`,
`파일명: ${filename || ''}`,
`생성일: ${new Date().toLocaleString('ko-KR')}`,
`총 품목수: ${purchaseItems.length}`,
''
];
// 요약 정보 계산
const totalBomQty = purchaseItems.reduce((sum, item) => sum + (item.bom_quantity || 0), 0);
const totalPurchaseQty = purchaseItems.reduce((sum, item) => sum + (item.calculated_qty || 0), 0);
const categoryCount = purchaseItems.reduce((acc, item) => {
acc[item.category] = (acc[item.category] || 0) + 1;
return acc;
}, {});
const summaryInfo = [
'=== 요약 정보 ===',
`전체 BOM 수량: ${totalBomQty.toFixed(1)}`,
`전체 구매 수량: ${totalPurchaseQty.toFixed(1)}`,
`카테고리별 품목수: ${Object.entries(categoryCount).map(([cat, count]) => `${cat}(${count})`).join(', ')}`,
''
];
// CSV 형태로 데이터 구성
const csvContent = [
...headerInfo,
...summaryInfo,
'=== 상세 품목 목록 ===',
Object.keys(data[0]).join(','),
...data.map(row => Object.values(row).map(val =>
typeof val === 'string' && val.includes(',') ? `"${val}"` : val
).join(','))
].join('\n');
// 파일 다운로드
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
const timestamp = new Date().toISOString().split('T')[0];
const fileName = `구매확정서_${jobNo}_${revision}_${timestamp}.csv`;
link.setAttribute('download', fileName);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 성공 메시지
alert(`구매 확정서가 다운로드되었습니다.\n파일명: ${fileName}`);
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '200px'
}}>
<div>로딩 ...</div>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
<button
onClick={() => navigate(-1)}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
marginRight: '16px',
padding: '8px'
}}
>
</button>
<div style={{ flex: 1 }}>
<h1 style={{ margin: '0 0 8px 0', fontSize: '28px', fontWeight: 'bold' }}>
🛒 구매 확정
</h1>
<h2 style={{ margin: 0, fontSize: '18px', color: '#1976d2' }}>
Job: {jobNo} | {filename} | {revision}
</h2>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={exportToExcel}
style={{
padding: '12px 24px',
backgroundColor: '#2e7d32',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold'
}}
>
📊 엑셀 내보내기
</button>
<button
onClick={() => setConfirmDialog(true)}
disabled={purchaseItems.length === 0}
style={{
padding: '12px 24px',
backgroundColor: purchaseItems.length === 0 ? '#ccc' : '#1976d2',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: purchaseItems.length === 0 ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 'bold'
}}
>
🛒 구매 주문 생성
</button>
</div>
</div>
{/* 리비전 비교 알림 */}
{revisionComparison && (
<div style={{
padding: '16px',
marginBottom: '24px',
backgroundColor: revisionComparison.has_changes ? '#fff3e0' : '#e3f2fd',
border: `1px solid ${revisionComparison.has_changes ? '#ed6c02' : '#0288d1'}`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center'
}}>
<span style={{ marginRight: '8px', fontSize: '20px' }}>🔄</span>
<div>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
리비전 변경사항: {revisionComparison.summary}
</div>
{revisionComparison.additional_items && (
<div style={{ fontSize: '14px' }}>
추가 구매 필요: {revisionComparison.additional_items} 품목
</div>
)}
</div>
</div>
)}
{/* 구매 품목 목록 */}
{purchaseItems.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '48px',
backgroundColor: '#f5f5f5',
borderRadius: '8px'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
<div style={{ fontSize: '18px', color: '#666' }}>
구매할 품목이 없습니다.
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{purchaseItems.map(item => (
<div key={item.id} style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '20px',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<span style={{
display: 'inline-block',
padding: '4px 12px',
backgroundColor: getCategoryColor(item.category),
color: 'white',
borderRadius: '16px',
fontSize: '12px',
fontWeight: 'bold',
marginRight: '16px'
}}>
{item.category}
</span>
<h3 style={{ margin: 0, flex: 1, fontSize: '18px' }}>
{item.specification}
</h3>
{item.is_additional && (
<span style={{
padding: '4px 12px',
backgroundColor: '#fff3e0',
color: '#ed6c02',
border: '1px solid #ed6c02',
borderRadius: '16px',
fontSize: '12px',
fontWeight: 'bold'
}}>
추가 구매
</span>
)}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '24px'
}}>
{/* BOM 수량 */}
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
BOM 필요량
</div>
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
{item.bom_quantity} {item.unit}
</div>
{formatPipeInfo(item)}
{formatBoltInfo(item)}
</div>
{/* 구매 수량 */}
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
구매 수량
</div>
{editingItem === item.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="number"
value={item.calculated_qty}
onChange={(e) =>
setPurchaseItems(prev =>
prev.map(i =>
i.id === item.id
? { ...i, calculated_qty: parseFloat(e.target.value) || 0 }
: i
)
)
}
style={{
width: '100px',
padding: '4px 8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
/>
<button
onClick={() => updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)}
style={{
background: 'none',
border: 'none',
color: '#1976d2',
cursor: 'pointer',
fontSize: '16px'
}}
>
</button>
<button
onClick={() => setEditingItem(null)}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
fontSize: '16px'
}}
>
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1976d2' }}>
{item.calculated_qty} {item.unit}
</div>
<button
onClick={() => setEditingItem(item.id)}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
fontSize: '14px'
}}
>
</button>
</div>
)}
</div>
{/* 이미 구매한 수량 */}
{previousRevision && (
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
기구매 수량
</div>
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
{item.purchased_quantity || 0} {item.unit}
</div>
</div>
)}
{/* 추가 구매 필요량 */}
{previousRevision && (
<div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
추가 구매 필요
</div>
<div style={{
fontSize: '20px',
fontWeight: 'bold',
color: item.additional_needed > 0 ? '#d32f2f' : '#2e7d32'
}}>
{Math.max(item.additional_needed || 0, 0)} {item.unit}
</div>
</div>
)}
</div>
{/* 여유율 및 최소 주문 정보 */}
<div style={{
marginTop: '16px',
padding: '16px',
backgroundColor: '#f5f5f5',
borderRadius: '4px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '16px'
}}>
<div>
<div style={{ fontSize: '12px', color: '#666' }}>여유율</div>
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
{((item.safety_factor || 1) - 1) * 100}%
</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#666' }}>최소 주문</div>
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
{item.min_order_qty || 0} {item.unit}
</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#666' }}>예상 여유분</div>
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
{(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit}
</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#666' }}>활용률</div>
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
{((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}%
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* 구매 주문 확인 다이얼로그 */}
{confirmDialog && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
padding: '24px',
borderRadius: '8px',
minWidth: '400px',
maxWidth: '500px'
}}>
<h3 style={{ margin: '0 0 16px 0' }}>구매 주문 생성 확인</h3>
<div style={{ marginBottom: '16px' }}>
{purchaseItems.length} 품목에 대한 구매 주문을 생성하시겠습니까?
</div>
{revisionComparison && revisionComparison.has_changes && (
<div style={{
padding: '12px',
marginBottom: '16px',
backgroundColor: '#fff3e0',
border: '1px solid #ed6c02',
borderRadius: '4px'
}}>
리비전 변경으로 인한 추가 구매가 포함되어 있습니다.
</div>
)}
<div style={{ fontSize: '14px', color: '#666', marginBottom: '24px' }}>
구매 주문 생성 후에는 수량 변경이 제한됩니다.
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button
onClick={() => setConfirmDialog(false)}
style={{
padding: '8px 16px',
backgroundColor: 'white',
color: '#666',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer'
}}
>
취소
</button>
<button
onClick={confirmPurchase}
style={{
padding: '8px 16px',
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
주문 생성
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PurchaseConfirmationPage;

View File

@@ -133,6 +133,25 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
.trim(); .trim();
} }
// 니플의 경우 길이 정보 명시적 추가
if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) {
// fitting_details에서 길이 정보 가져오기
if (material.fitting_details && material.fitting_details.length_mm) {
const lengthMm = Math.round(material.fitting_details.length_mm);
// 이미 길이 정보가 있는지 확인
if (!cleanDescription.match(/\d+\s*mm/i)) {
cleanDescription += ` ${lengthMm}mm`;
}
}
// 또는 기존 설명에서 길이 정보 추출
else {
const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i);
if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) {
cleanDescription += ` ${lengthMatch[1]}mm`;
}
}
}
// 구매 수량 계산 // 구매 수량 계산
const purchaseInfo = calculatePurchaseQuantity(material); const purchaseInfo = calculatePurchaseQuantity(material);

View File

@@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3000, port: 13000,
host: true, host: true,
open: true open: true
}, },

139
scripts/docker-run.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/bin/bash
# TK-MP-Project Docker 실행 스크립트
# 환경별로 적절한 Docker Compose 설정을 사용하여 실행
set -e
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 도움말 함수
show_help() {
echo -e "${BLUE}TK-MP-Project Docker 실행 스크립트${NC}"
echo ""
echo "사용법: $0 [환경] [명령]"
echo ""
echo "환경:"
echo " dev 개발 환경 (기본값)"
echo " prod 프로덕션 환경"
echo " synology 시놀로지 NAS 환경"
echo ""
echo "명령:"
echo " up 서비스 시작 (기본값)"
echo " down 서비스 중지"
echo " restart 서비스 재시작"
echo " logs 로그 확인"
echo " build 이미지 다시 빌드"
echo " status 서비스 상태 확인"
echo ""
echo "예시:"
echo " $0 # 개발 환경으로 시작"
echo " $0 dev up # 개발 환경으로 시작"
echo " $0 prod up # 프로덕션 환경으로 시작"
echo " $0 synology up # 시놀로지 환경으로 시작"
echo " $0 dev logs # 개발 환경 로그 확인"
echo " $0 dev down # 개발 환경 중지"
}
# 환경 설정
ENVIRONMENT=${1:-dev}
COMMAND=${2:-up}
# 환경별 Docker Compose 파일 설정
case $ENVIRONMENT in
dev|development)
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.override.yml"
ENV_NAME="개발"
;;
prod|production)
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.prod.yml"
ENV_NAME="프로덕션"
;;
synology|nas)
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.synology.yml"
ENV_NAME="시놀로지"
;;
help|-h|--help)
show_help
exit 0
;;
*)
echo -e "${RED}❌ 알 수 없는 환경: $ENVIRONMENT${NC}"
echo "사용 가능한 환경: dev, prod, synology"
exit 1
;;
esac
# .env 파일 확인
if [ ! -f .env ]; then
echo -e "${YELLOW}⚠️ .env 파일이 없습니다. env.example을 복사하여 .env 파일을 생성합니다.${NC}"
cp env.example .env
echo -e "${GREEN}✅ .env 파일이 생성되었습니다. 필요시 설정을 수정하세요.${NC}"
fi
# 명령 실행
echo -e "${BLUE}🐳 TK-MP-Project ${ENV_NAME} 환경 ${COMMAND} 실행${NC}"
echo "Docker Compose 파일: $COMPOSE_FILES"
echo ""
case $COMMAND in
up|start)
echo -e "${GREEN}🚀 서비스를 시작합니다...${NC}"
docker-compose $COMPOSE_FILES up -d
echo ""
echo -e "${GREEN}✅ 서비스가 시작되었습니다!${NC}"
echo ""
echo "접속 주소:"
case $ENVIRONMENT in
dev|development)
echo " - 프론트엔드: http://localhost:13000"
echo " - 백엔드 API: http://localhost:18000"
echo " - API 문서: http://localhost:18000/docs"
echo " - pgAdmin: http://localhost:5050"
;;
prod|production)
echo " - 웹사이트: http://localhost"
echo " - HTTPS: https://localhost (SSL 설정 시)"
;;
synology|nas)
echo " - 프론트엔드: http://localhost:10173"
echo " - 백엔드 API: http://localhost:10080"
echo " - API 문서: http://localhost:10080/docs"
echo " - pgAdmin: http://localhost:15050"
;;
esac
;;
down|stop)
echo -e "${YELLOW}🛑 서비스를 중지합니다...${NC}"
docker-compose $COMPOSE_FILES down
echo -e "${GREEN}✅ 서비스가 중지되었습니다.${NC}"
;;
restart)
echo -e "${YELLOW}🔄 서비스를 재시작합니다...${NC}"
docker-compose $COMPOSE_FILES restart
echo -e "${GREEN}✅ 서비스가 재시작되었습니다.${NC}"
;;
logs)
echo -e "${BLUE}📋 로그를 확인합니다...${NC}"
docker-compose $COMPOSE_FILES logs -f
;;
build)
echo -e "${BLUE}🔨 이미지를 다시 빌드합니다...${NC}"
docker-compose $COMPOSE_FILES build --no-cache
echo -e "${GREEN}✅ 빌드가 완료되었습니다.${NC}"
;;
status|ps)
echo -e "${BLUE}📊 서비스 상태를 확인합니다...${NC}"
docker-compose $COMPOSE_FILES ps
;;
*)
echo -e "${RED}❌ 알 수 없는 명령: $COMMAND${NC}"
echo "사용 가능한 명령: up, down, restart, logs, build, status"
exit 1
;;
esac

View File

@@ -1,4 +0,0 @@
description,qty,main_nom
"0.5, 70.0000 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",76,3/4"
"HEX BOLT M16 X 100MM, ASTM A193 B7",10,M16
"STUD BOLT 1/2"" X 120MM, ASTM A193 GR B7",25,1/2"
Can't render this file because it contains an unexpected character in line 2 and column 68.

View File

@@ -1,5 +0,0 @@
DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,LENGTH,QUANTITY,UNIT
BOLT_HEX,0.625 HEX BOLT 100.0000 LG PSV ASTM A193/A194 GR B7/2H ELEC.GALV,0.625,,100,10,EA
BOLT_STUD,0.5 STUD BOLT 75.0000 LG LT ASTM A320 GR L7,0.5,,75,8,EA
BOLT_HEX,0.75 HEX BOLT 120.0000 LG CK ASTM A193 GR B8,0.75,,120,6,EA
BOLT_HEX,0.625 HEX BOLT 100.0000 LG ASTM A193/A194 GR B7/2H ELEC.GALV,0.625,,100,12,EA
1 DAT_FILE DESCRIPTION MAIN_NOM RED_NOM LENGTH QUANTITY UNIT
2 BOLT_HEX 0.625 HEX BOLT 100.0000 LG PSV ASTM A193/A194 GR B7/2H ELEC.GALV 0.625 100 10 EA
3 BOLT_STUD 0.5 STUD BOLT 75.0000 LG LT ASTM A320 GR L7 0.5 75 8 EA
4 BOLT_HEX 0.75 HEX BOLT 120.0000 LG CK ASTM A193 GR B8 0.75 120 6 EA
5 BOLT_HEX 0.625 HEX BOLT 100.0000 LG ASTM A193/A194 GR B7/2H ELEC.GALV 0.625 100 12 EA

View File

@@ -1,6 +0,0 @@
DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,QUANTITY,UNIT,DRAWING_NAME,AREA_CODE,LINE_NO
BLT_150_TK,"STUD BOLT, 0.5, 70.0000 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",0.5,70.0000,8.0,EA,P&ID-001,#01,LINE-001-A
BLT_300_TK,"FLANGE BOLT, 3/4, 80.0000 LG, 300LB, ASTM A193/A194 GR B7/2H",3/4,80.0000,12.0,EA,P&ID-002,#02,LINE-002-B
BOLT_HEX_M16,"HEX BOLT, M16 X 60MM, GRADE 8.8, ZINC PLATED",M16,60.0000,10.0,EA,P&ID-003,#03,LINE-003-C
STUD_M20,"STUD BOLT, M20 X 100MM, ASTM A193 B7, 600LB",M20,100.0000,6.0,EA,P&ID-004,#04,LINE-004-D
NUT_HEX_M16,"HEX NUT, M16, ASTM A194 2H",M16,,16.0,EA,P&ID-003,#03,LINE-003-C
1 DAT_FILE DESCRIPTION MAIN_NOM RED_NOM QUANTITY UNIT DRAWING_NAME AREA_CODE LINE_NO
2 BLT_150_TK STUD BOLT, 0.5, 70.0000 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV 0.5 70.0000 8.0 EA P&ID-001 #01 LINE-001-A
3 BLT_300_TK FLANGE BOLT, 3/4, 80.0000 LG, 300LB, ASTM A193/A194 GR B7/2H 3/4 80.0000 12.0 EA P&ID-002 #02 LINE-002-B
4 BOLT_HEX_M16 HEX BOLT, M16 X 60MM, GRADE 8.8, ZINC PLATED M16 60.0000 10.0 EA P&ID-003 #03 LINE-003-C
5 STUD_M20 STUD BOLT, M20 X 100MM, ASTM A193 B7, 600LB M20 100.0000 6.0 EA P&ID-004 #04 LINE-004-D
6 NUT_HEX_M16 HEX NUT, M16, ASTM A194 2H M16 16.0 EA P&ID-003 #03 LINE-003-C

View File

@@ -1,6 +0,0 @@
DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,LENGTH,QUANTITY,UNIT
FLANGE_BOLT,FLANGE BOLT 6" 300LB M16 X 80MM ASTM A193 B7 ELECTRO GALVANIZED,6,,80,20,EA
FLANGE_BOLT,FLANGE BOLT 1-1/2" 150LB 5/8" X 100MM PSV ASTM A193 B7 ELECTRO GALVANIZED,1.5,,100,8,EA
FLANGE_BOLT,FLANGE BOLT 8" 150LB 3/4" X 130MM ASTM A193 B7 ELECTRO GALVANIZED,8,,130,12,EA
FLANGE_BOLT,FLANGE BOLT 4" 150LB 0.625" X 120MM ASTM A193 B7 ELECTRO GALVANIZED,4,,120,15,EA
FLANGE_BOLT,FLANGE BOLT 2" 300LB 1-1/2" X 180MM ASTM A193 B7 ELECTRO GALVANIZED,2,,180,5,EA
Can't render this file because it contains an unexpected character in line 2 and column 26.

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env python3
"""
구매 수량 계산기 테스트
특히 파이프 절단 손실 + 6M 단위 계산 테스트
"""
import sys
import os
sys.path.append('backend')
def test_pipe_calculation():
"""파이프 절단 손실 + 6M 단위 계산 테스트"""
from app.services.purchase_calculator import calculate_pipe_purchase_quantity
print("🔧 PIPE 구매 수량 계산 테스트\n")
# 테스트 케이스들
test_cases = [
{
"name": "25,000mm 필요 (10회 절단)",
"materials": [
{"length_mm": 3000, "original_description": "PIPE 4\" SCH40 - 3M", "quantity": 1},
{"length_mm": 2500, "original_description": "PIPE 4\" SCH40 - 2.5M", "quantity": 1},
{"length_mm": 1800, "original_description": "PIPE 4\" SCH40 - 1.8M", "quantity": 1},
{"length_mm": 4200, "original_description": "PIPE 4\" SCH40 - 4.2M", "quantity": 1},
{"length_mm": 2100, "original_description": "PIPE 4\" SCH40 - 2.1M", "quantity": 1},
{"length_mm": 1500, "original_description": "PIPE 4\" SCH40 - 1.5M", "quantity": 1},
{"length_mm": 3800, "original_description": "PIPE 4\" SCH40 - 3.8M", "quantity": 1},
{"length_mm": 2200, "original_description": "PIPE 4\" SCH40 - 2.2M", "quantity": 1},
{"length_mm": 1900, "original_description": "PIPE 4\" SCH40 - 1.9M", "quantity": 1},
{"length_mm": 2000, "original_description": "PIPE 4\" SCH40 - 2M", "quantity": 1}
],
"expected_pipes": 5
},
{
"name": "5,900mm 필요 (3회 절단)",
"materials": [
{"length_mm": 2000, "original_description": "PIPE 6\" SCH40 - 2M", "quantity": 1},
{"length_mm": 1900, "original_description": "PIPE 6\" SCH40 - 1.9M", "quantity": 1},
{"length_mm": 2000, "original_description": "PIPE 6\" SCH40 - 2M", "quantity": 1}
],
"expected_pipes": 1
},
{
"name": "12,000mm 정확히 (4회 절단)",
"materials": [
{"length_mm": 3000, "original_description": "PIPE 8\" SCH40 - 3M", "quantity": 4}
],
"expected_pipes": 2
}
]
for i, test_case in enumerate(test_cases, 1):
print(f"📋 테스트 {i}: {test_case['name']}")
result = calculate_pipe_purchase_quantity(test_case["materials"])
print(f" 🎯 BOM 총 길이: {result['bom_quantity']:,}mm")
print(f" ✂️ 절단 횟수: {result['cutting_count']}")
print(f" 📏 절단 손실: {result['cutting_loss']}mm (각 절단마다 3mm)")
print(f" 🔢 총 필요량: {result['required_length']:,}mm")
print(f" 📦 구매 파이프: {result['pipes_count']}본 (각 6M)")
print(f" 💰 구매 총량: {result['calculated_qty']:,}mm")
print(f" ♻️ 여유분: {result['waste_length']:,}mm")
print(f" 📊 활용률: {result['utilization_rate']:.1f}%")
# 결과 확인
if result['pipes_count'] == test_case['expected_pipes']:
print(f" ✅ 성공: 예상 {test_case['expected_pipes']}본 = 결과 {result['pipes_count']}")
else:
print(f" ❌ 실패: 예상 {test_case['expected_pipes']}본 ≠ 결과 {result['pipes_count']}")
print()
def test_standard_calculation():
"""일반 자재 구매 수량 계산 테스트"""
from app.services.purchase_calculator import calculate_standard_purchase_quantity
print("🔧 일반 자재 구매 수량 계산 테스트\n")
test_cases = [
{"category": "VALVE", "bom_qty": 2, "expected_factor": 1.5, "desc": "밸브 2개 (50% 예비품)"},
{"category": "BOLT", "bom_qty": 24, "expected_min": 50, "desc": "볼트 24개 (박스 단위 50개)"},
{"category": "FITTING", "bom_qty": 5, "expected_factor": 1.1, "desc": "피팅 5개 (10% 여유)"},
{"category": "GASKET", "bom_qty": 3, "expected_factor": 1.25, "desc": "가스켓 3개 (25% 교체 주기)"},
{"category": "INSTRUMENT", "bom_qty": 1, "expected_factor": 1.0, "desc": "계기 1개 (정확한 수량)"}
]
for i, test_case in enumerate(test_cases, 1):
print(f"📋 테스트 {i}: {test_case['desc']}")
result = calculate_standard_purchase_quantity(
test_case["category"],
test_case["bom_qty"]
)
print(f" 🎯 BOM 수량: {result['bom_quantity']}")
print(f" 📈 여유율: {result['safety_factor']:.2f} ({(result['safety_factor']-1)*100:.0f}%)")
print(f" 🔢 여유 적용: {result['safety_qty']:.1f}")
print(f" 📦 최소 주문: {result['min_order_qty']}")
print(f" 💰 최종 구매: {result['calculated_qty']}")
print(f" ♻️ 여유분: {result['waste_quantity']}")
print(f" 📊 활용률: {result['utilization_rate']:.1f}%")
print()
if __name__ == "__main__":
test_pipe_calculation()
test_standard_calculation()

View File

@@ -1,3 +0,0 @@
description,qty,main_nom
PIPE A,10,4
FITTING B,5,2
1 description qty main_nom
2 PIPE A 10 4
3 FITTING B 5 2

View File

@@ -1,11 +0,0 @@
DESCRIPTION,QTY,MAIN_NOM
"GATE VALVE, 150LB, FL, 4"", ASTM A216 WCB, RF",2,4"
"BALL VALVE, 300LB, THREADED, 2"", SS316, FULL PORT",3,2"
"GLOBE VALVE, 600LB, SW, 1"", A105, Y-TYPE",1,1"
"CHECK VALVE, 150LB, WAFER, 6"", DCI, DUAL PLATE",4,6"
"BUTTERFLY VALVE, 150LB, WAFER, 12"", DCI, GEAR OPERATED",1,12"
"NEEDLE VALVE, 6000LB, SW, 1/2"", A182 F316, FINE ADJUST",5,1/2"
"RELIEF VALVE, PSV, 150LB, FL, 3"", A216 WCB, SET 150 PSI",2,3"
"SOLENOID VALVE, 24VDC, 150LB, THD, 1/4"", SS316, 2-WAY NC",8,1/4"
"GATE VALVE, 300LB, BW, 8"", A216 WCB, RTJ",1,8"
"BALL VALVE, 600LB, SW, 1-1/2"", A182 F316, FIRE SAFE",2,1-1/2"
Can't render this file because it contains an unexpected character in line 2 and column 52.

Some files were not shown because too many files have changed in this diff Show More