feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
193
DOCKER-GUIDE.md
Normal file
193
DOCKER-GUIDE.md
Normal 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/` 폴더에 보관됨**
|
||||||
|
|
||||||
166
backend/app.py
166
backend/app.py
@@ -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)}
|
|
||||||
|
|
||||||
@@ -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
@@ -61,3 +61,19 @@ __all__ = [
|
|||||||
'RolePermission',
|
'RolePermission',
|
||||||
'UserRepository'
|
'UserRepository'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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="사용자 삭제 중 오류가 발생했습니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -249,3 +249,19 @@ class JWTService:
|
|||||||
|
|
||||||
# JWT 서비스 인스턴스
|
# JWT 서비스 인스턴스
|
||||||
jwt_service = JWTService()
|
jwt_service = JWTService()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
427
backend/app/routers/dashboard.py
Normal file
427
backend/app/routers/dashboard.py
Normal 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
@@ -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)}"}
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
362
backend/app/services/activity_logger.py
Normal file
362
backend/app/services/activity_logger.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
289
backend/app/services/revision_comparator.py
Normal file
289
backend/app/services/revision_comparator.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 키워드 없음 - 재질 기반 분류로 이동")
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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 $$;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
142
backend/scripts/19_add_user_tracking_fields.sql
Normal file
142
backend/scripts/19_add_user_tracking_fields.sql
Normal 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;
|
||||||
49
backend/scripts/20_add_pipe_end_preparation_table.sql
Normal file
49
backend/scripts/20_add_pipe_end_preparation_table.sql
Normal 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();
|
||||||
@@ -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()
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# main.py에 추가할 import
|
|
||||||
from .api import spools
|
|
||||||
|
|
||||||
# app.include_router 추가
|
|
||||||
app.include_router(spools.router, prefix="/api/spools", tags=["스풀 관리"])
|
|
||||||
@@ -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)}")
|
|
||||||
@@ -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']}")
|
|
||||||
@@ -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.
|
@@ -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()
|
|
||||||
@@ -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.
|
@@ -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.
|
72
database/init/20_purchase_confirmations.sql
Normal file
72
database/init/20_purchase_confirmations.sql
Normal 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 '구매 수량 확정자';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
33
docker-backup/docker-compose.override.yml
Normal file
33
docker-backup/docker-compose.override.yml
Normal 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"
|
||||||
55
docker-backup/docker-compose.prod.yml
Normal file
55
docker-backup/docker-compose.prod.yml
Normal 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
|
||||||
57
docker-backup/docker-compose.synology.yml
Normal file
57
docker-backup/docker-compose.synology.yml
Normal 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
|
||||||
124
docker-backup/docker-compose.yml
Normal file
124
docker-backup/docker-compose.yml
Normal 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
|
||||||
@@ -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"
|
|
||||||
34
docker-compose.override.yml
Normal file
34
docker-compose.override.yml
Normal 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"
|
||||||
|
|
||||||
@@ -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 터널링)
|
|
||||||
@@ -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
160
docker-compose.unified.yml
Normal 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
|
||||||
|
|
||||||
@@ -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
104
docker-run.sh
Executable 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
43
env.development
Normal 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
36
env.example
Normal 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
43
env.production
Normal 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
43
env.synology
Normal 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
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 정적 파일 캐싱
|
# 정적 파일 캐싱
|
||||||
|
|||||||
BIN
frontend/public/img/login-bg.jpeg
Normal file
BIN
frontend/public/img/login-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
frontend/public/img/logo.png
Normal file
BIN
frontend/public/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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;
|
||||||
@@ -218,3 +218,19 @@ const SimpleDashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default SimpleDashboard;
|
export default SimpleDashboard;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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)}
|
||||||
300
frontend/src/_deprecated/BOMStatusPage.jsx
Normal file
300
frontend/src/_deprecated/BOMStatusPage.jsx
Normal 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;
|
||||||
416
frontend/src/_deprecated/BOMUploadPage.jsx
Normal file
416
frontend/src/_deprecated/BOMUploadPage.jsx
Normal 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;
|
||||||
@@ -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 목록 조회
|
||||||
|
|||||||
@@ -116,3 +116,19 @@ const BOMFileUpload = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default BOMFileUpload;
|
export default BOMFileUpload;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 />}
|
||||||
>
|
>
|
||||||
자재 목록 보기
|
자재 목록 보기
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -535,3 +535,19 @@
|
|||||||
right: 12px;
|
right: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,19 @@ const NavigationBar = ({ currentPage, onNavigate }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default NavigationBar;
|
export default NavigationBar;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -248,3 +248,19 @@
|
|||||||
.menu-section::-webkit-scrollbar-thumb:hover {
|
.menu-section::-webkit-scrollbar-thumb:hover {
|
||||||
background: #a1a1a1;
|
background: #a1a1a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -172,3 +172,19 @@ const NavigationMenu = ({ user, currentPage, onPageChange }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default NavigationMenu;
|
export default NavigationMenu;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
482
frontend/src/components/PersonalizedDashboard.jsx
Normal file
482
frontend/src/components/PersonalizedDashboard.jsx
Normal 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;
|
||||||
319
frontend/src/components/ProjectSelector.jsx
Normal file
319
frontend/src/components/ProjectSelector.jsx
Normal 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;
|
||||||
@@ -80,3 +80,19 @@ const RevisionUploadDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default RevisionUploadDialog;
|
export default RevisionUploadDialog;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -299,3 +299,19 @@ const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default SimpleFileUpload;
|
export default SimpleFileUpload;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
720
frontend/src/pages/BOMWorkspacePage.jsx
Normal file
720
frontend/src/pages/BOMWorkspacePage.jsx
Normal 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;
|
||||||
@@ -262,3 +262,19 @@ const DashboardPage = ({ user }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default DashboardPage;
|
export default DashboardPage;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -217,3 +217,19 @@
|
|||||||
border-color: #667eea;
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -114,3 +114,19 @@ const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default LoginPage;
|
export default LoginPage;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
464
frontend/src/pages/NewMaterialsPage.css
Normal file
464
frontend/src/pages/NewMaterialsPage.css
Normal 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;
|
||||||
|
}
|
||||||
971
frontend/src/pages/NewMaterialsPage.jsx
Normal file
971
frontend/src/pages/NewMaterialsPage.jsx
Normal 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;
|
||||||
358
frontend/src/pages/ProjectWorkspacePage.jsx
Normal file
358
frontend/src/pages/ProjectWorkspacePage.jsx
Normal 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;
|
||||||
@@ -386,3 +386,19 @@ const ProjectsPage = ({ user }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectsPage;
|
export default ProjectsPage;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
455
frontend/src/pages/SystemSettingsPage.jsx
Normal file
455
frontend/src/pages/SystemSettingsPage.jsx
Normal 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;
|
||||||
@@ -428,3 +428,19 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
736
frontend/src/pages/_backup/PurchaseConfirmationPage.jsx
Normal file
736
frontend/src/pages/_backup/PurchaseConfirmationPage.jsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
139
scripts/docker-run.sh
Executable 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
|
||||||
@@ -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.
|
@@ -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,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,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.
|
@@ -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()
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
description,qty,main_nom
|
|
||||||
PIPE A,10,4
|
|
||||||
FITTING B,5,2
|
|
||||||
|
@@ -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
Reference in New Issue
Block a user