프론트엔드 작성중

This commit is contained in:
Hyungi Ahn
2025-07-16 15:44:50 +09:00
parent 5ac9d562d5
commit ea111433e4
25 changed files with 7286 additions and 2043 deletions

View File

@@ -236,35 +236,86 @@ async def upload_file(
async def get_materials(
project_id: Optional[int] = None,
file_id: Optional[int] = None,
job_no: Optional[str] = None,
filename: Optional[str] = None,
revision: Optional[str] = None,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
item_type: Optional[str] = None,
material_grade: Optional[str] = None,
size_spec: Optional[str] = None,
file_filter: Optional[str] = None,
sort_by: Optional[str] = None,
db: Session = Depends(get_db)
):
"""저장된 자재 목록 조회"""
"""
저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능)
"""
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.project_id,
f.original_filename, f.project_id, f.job_no, f.revision,
p.official_project_code, p.project_name
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
LEFT JOIN projects p ON f.project_id = p.id
WHERE 1=1
"""
params = {}
if project_id:
query += " AND f.project_id = :project_id"
params["project_id"] = project_id
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
if filename:
query += " AND f.original_filename = :filename"
params["filename"] = filename
if revision:
query += " AND f.revision = :revision"
params["revision"] = revision
if search:
query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)"
params["search"] = f"%{search}%"
if item_type:
query += " AND m.classified_category = :item_type"
params["item_type"] = item_type
if material_grade:
query += " AND m.material_grade ILIKE :material_grade"
params["material_grade"] = f"%{material_grade}%"
if size_spec:
query += " AND m.size_spec ILIKE :size_spec"
params["size_spec"] = f"%{size_spec}%"
if file_filter:
query += " AND f.original_filename ILIKE :file_filter"
params["file_filter"] = f"%{file_filter}%"
# 정렬 처리
if sort_by:
if sort_by == "quantity_desc":
query += " ORDER BY m.quantity DESC"
elif sort_by == "quantity_asc":
query += " ORDER BY m.quantity ASC"
elif sort_by == "name_asc":
query += " ORDER BY m.original_description ASC"
elif sort_by == "name_desc":
query += " ORDER BY m.original_description DESC"
elif sort_by == "created_desc":
query += " ORDER BY m.created_at DESC"
elif sort_by == "created_asc":
query += " ORDER BY m.created_at ASC"
else:
query += " ORDER BY m.line_number ASC"
else:
query += " ORDER BY m.line_number ASC"
query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip"
query += " LIMIT :limit OFFSET :skip"
params["limit"] = limit
params["skip"] = skip
@@ -287,6 +338,26 @@ async def get_materials(
if file_id:
count_query += " AND m.file_id = :file_id"
count_params["file_id"] = file_id
if search:
count_query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)"
count_params["search"] = f"%{search}%"
if item_type:
count_query += " AND m.classified_category = :item_type"
count_params["item_type"] = item_type
if material_grade:
count_query += " AND m.material_grade ILIKE :material_grade"
count_params["material_grade"] = f"%{material_grade}%"
if size_spec:
count_query += " AND m.size_spec ILIKE :size_spec"
count_params["size_spec"] = f"%{size_spec}%"
if file_filter:
count_query += " AND f.original_filename ILIKE :file_filter"
count_params["file_filter"] = f"%{file_filter}%"
count_result = db.execute(text(count_query), count_params)
total_count = count_result.fetchone()[0]

View File

@@ -1,5 +1,12 @@
from fastapi import FastAPI
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .database import get_db
from sqlalchemy.orm import Session
from fastapi import Depends
from typing import Optional, List, Dict
import os
import shutil
# FastAPI 앱 생성
app = FastAPI(
@@ -30,14 +37,514 @@ try:
except ImportError:
print("jobs 라우터를 찾을 수 없습니다")
# 파일 목록 조회 API
@app.get("/files")
async def get_files(
job_no: Optional[str] = None, # project_id 대신 job_no 사용
show_history: bool = False, # 이력 표시 여부
db: Session = Depends(get_db)
):
"""파일 목록 조회 (BOM별 그룹화)"""
try:
if show_history:
# 전체 이력 표시
query = "SELECT * FROM files"
params = {}
if job_no:
query += " WHERE job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY original_filename, revision DESC"
else:
# 최신 리비전만 표시
if job_no:
query = """
SELECT f1.* FROM files f1
INNER JOIN (
SELECT original_filename, MAX(revision) as max_revision
FROM files
WHERE job_no = :job_no
GROUP BY original_filename
) f2 ON f1.original_filename = f2.original_filename
AND f1.revision = f2.max_revision
WHERE f1.job_no = :job_no
ORDER BY f1.upload_date DESC
"""
params = {"job_no": job_no}
else:
# job_no가 없으면 전체 파일 조회
query = "SELECT * FROM files ORDER BY upload_date DESC"
params = {}
result = db.execute(text(query), params)
files = result.fetchall()
return [
{
"id": f.id,
"filename": f.original_filename,
"original_filename": f.original_filename,
"name": f.original_filename,
"job_no": f.job_no, # job_no 사용
"bom_name": f.original_filename, # 파일명을 BOM 이름으로 사용
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
"status": "active" if f.is_active else "inactive", # is_active 상태
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
"revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
"description": f"파일: {f.original_filename}"
}
for f in files
]
except Exception as e:
print(f"파일 목록 조회 에러: {str(e)}")
return {"error": f"파일 목록 조회 실패: {str(e)}"}
# 파일 삭제 API
@app.delete("/files/{file_id}")
async def delete_file(
file_id: int,
db: Session = Depends(get_db)
):
"""파일 삭제"""
try:
# 먼저 파일 정보 조회
file_query = text("SELECT * FROM files WHERE id = :file_id")
file_result = db.execute(file_query, {"file_id": file_id})
file = file_result.fetchone()
if not file:
return {"error": "파일을 찾을 수 없습니다"}
# 관련 자재 데이터 삭제
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
db.execute(materials_query, {"file_id": file_id})
# 파일 삭제
delete_query = text("DELETE FROM files WHERE id = :file_id")
db.execute(delete_query, {"file_id": file_id})
db.commit()
return {"success": True, "message": "파일과 관련 데이터가 삭제되었습니다"}
except Exception as e:
db.rollback()
return {"error": f"파일 삭제 실패: {str(e)}"}
# 프로젝트 관리 API (비활성화 - jobs 테이블 사용)
# projects 테이블은 더 이상 사용하지 않음
# ):
# """프로젝트 수정"""
# try:
# update_query = text("""
# UPDATE projects
# SET project_name = :project_name, status = :status
# WHERE id = :project_id
# """)
#
# db.execute(update_query, {
# "project_id": project_id,
# "project_name": project_data["project_name"],
# "status": project_data["status"]
# })
#
# db.commit()
# return {"success": True}
# except Exception as e:
# db.rollback()
# return {"error": f"프로젝트 수정 실패: {str(e)}"}
# @app.delete("/projects/{project_id}")
# async def delete_project(
# project_id: int,
# db: Session = Depends(get_db)
# ):
# """프로젝트 삭제"""
# try:
# delete_query = text("DELETE FROM projects WHERE id = :project_id")
# db.execute(delete_query, {"project_id": project_id})
# db.commit()
# return {"success": True}
# except Exception as e:
# db.rollback()
# return {"error": f"프로젝트 삭제 실패: {str(e)}"}
@app.get("/")
async def root():
return {
"message": "TK-MP BOM Management API",
"version": "1.0.0",
"endpoints": ["/docs", "/jobs", "/files"]
"endpoints": ["/docs", "/jobs", "/files", "/projects"]
}
# Jobs API
# @app.get("/jobs")
# async def get_jobs(db: Session = Depends(get_db)):
# """Jobs 목록 조회"""
# try:
# # jobs 테이블에서 데이터 조회
# query = text("""
# SELECT
# job_no,
# job_name,
# client_name,
# end_user,
# epc_company,
# status,
# created_at
# FROM jobs
# WHERE is_active = true
# ORDER BY created_at DESC
# """)
#
# result = db.execute(query)
# jobs = result.fetchall()
#
# return [
# {
# "job_no": job.job_no,
# "job_name": job.job_name,
# "client_name": job.client_name,
# "end_user": job.end_user,
# "epc_company": job.epc_company,
# "status": job.status or "진행중",
# "created_at": job.created_at
# }
# for job in jobs
# ]
# except Exception as e:
# print(f"Jobs 조회 에러: {str(e)}")
# return {"error": f"Jobs 조회 실패: {str(e)}"}
# 파일 업로드 API
@app.post("/upload")
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...), # project_id 대신 job_no 사용
bom_name: str = Form(""), # BOM 이름 추가
bom_type: str = Form(""),
revision: str = Form("Rev.0"),
parent_bom_id: Optional[int] = Form(None),
description: str = Form(""),
db: Session = Depends(get_db)
):
"""파일 업로드 및 자재 분류 (자동 리비전 관리)"""
try:
print("=== main.py 업로드 API 호출됨 ===")
print(f"파일명: {file.filename}")
print(f"job_no: {job_no}")
print(f"bom_name: {bom_name}")
print(f"bom_type: {bom_type}")
# job_no로 job 확인
job_query = text("SELECT job_no FROM jobs WHERE job_no = :job_no AND is_active = true")
job_result = db.execute(job_query, {"job_no": job_no})
job = job_result.fetchone()
if not job:
return {"error": f"Job No. '{job_no}'에 해당하는 작업을 찾을 수 없습니다."}
# 업로드 디렉토리 생성
upload_dir = "uploads"
os.makedirs(upload_dir, exist_ok=True)
# 파일 저장
if file.filename:
file_path = os.path.join(upload_dir, file.filename)
print(f"파일 저장 경로: {file_path}")
print(f"원본 파일명: {file.filename}")
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
print(f"파일 저장 완료: {file_path}")
else:
return {"error": "파일명이 없습니다."}
# 파일 크기 계산
file_size = os.path.getsize(file_path)
# 파일 타입 결정
file_type = "excel" if file.filename.endswith(('.xls', '.xlsx')) else "csv" if file.filename.endswith('.csv') else "unknown"
# BOM 종류별 자동 리비전 관리
if bom_type and not parent_bom_id:
# 같은 job_no의 같은 파일명에 대한 최신 리비전 조회
latest_revision_query = text("""
SELECT revision FROM files
WHERE job_no = :job_no AND original_filename = :filename
ORDER BY revision DESC LIMIT 1
""")
result = db.execute(latest_revision_query, {
"job_no": job_no,
"filename": file.filename
})
latest_file = result.fetchone()
if latest_file:
# 기존 리비전이 있으면 다음 리비전 번호 생성
current_rev = latest_file.revision
if current_rev.startswith("Rev."):
try:
rev_num = int(current_rev.replace("Rev.", ""))
revision = f"Rev.{rev_num + 1}"
except ValueError:
revision = "Rev.1"
else:
revision = "Rev.1"
else:
# 첫 번째 업로드인 경우 Rev.0
revision = "Rev.0"
# 데이터베이스에 파일 정보 저장
insert_query = text("""
INSERT INTO files (
job_no, filename, original_filename, file_path,
file_size, upload_date, revision, file_type, uploaded_by
) VALUES (
:job_no, :filename, :original_filename, :file_path,
:file_size, NOW(), :revision, :file_type, :uploaded_by
) RETURNING id
""")
result = db.execute(insert_query, {
"job_no": job_no,
"filename": file.filename,
"original_filename": file.filename,
"file_path": file_path,
"file_size": file_size,
"revision": revision,
"file_type": file_type,
"uploaded_by": "system"
})
file_id = result.fetchone()[0]
# 1차: 파일 파싱 (CSV/Excel 파일 읽기)
materials_data = parse_file(file_path)
# 2차: 각 자재를 분류기로 분류
classified_materials = []
# 리비전 업로드인 경우 기존 분류 정보 가져오기
existing_classifications = {}
if parent_bom_id:
parent_materials = db.execute(
text("SELECT original_description, classified_category, classified_subcategory, material_grade, schedule, size_spec FROM materials WHERE file_id = :file_id"),
{"file_id": parent_bom_id}
).fetchall()
for material in parent_materials:
existing_classifications[material.original_description] = {
"classified_category": material.classified_category,
"classified_subcategory": material.classified_subcategory,
"material_grade": material.material_grade,
"schedule": material.schedule,
"size_spec": material.size_spec
}
for material in materials_data:
# 리비전 업로드인 경우 기존 분류 사용, 아니면 새로 분류
if parent_bom_id and material.get("original_description") in existing_classifications:
existing_class = existing_classifications[material.get("original_description")]
classified_material = {
**material,
**existing_class,
"classification_confidence": 1.0 # 기존 분류이므로 높은 신뢰도
}
else:
classified_material = classify_material_item(material)
classified_materials.append(classified_material)
# 3차: 분류된 자재를 데이터베이스에 저장
for material in classified_materials:
insert_material_query = text("""
INSERT INTO materials (
file_id, line_number, original_description,
classified_category, classified_subcategory,
material_grade, schedule, size_spec,
quantity, unit, drawing_name, area_code, line_no,
classification_confidence, is_verified, created_at
) VALUES (
:file_id, :line_number, :original_description,
:classified_category, :classified_subcategory,
:material_grade, :schedule, :size_spec,
:quantity, :unit, :drawing_name, :area_code, :line_no,
:classification_confidence, :is_verified, NOW()
)
""")
db.execute(insert_material_query, {
"file_id": file_id,
"line_number": material.get("line_number", 0),
"original_description": material.get("original_description", ""),
"classified_category": material.get("classified_category", ""),
"classified_subcategory": material.get("classified_subcategory", ""),
"material_grade": material.get("material_grade", ""),
"schedule": material.get("schedule", ""),
"size_spec": material.get("size_spec", ""),
"quantity": material.get("quantity", 0),
"unit": material.get("unit", ""),
"drawing_name": material.get("drawing_name", ""),
"area_code": material.get("area_code", ""),
"line_no": material.get("line_no", ""),
"classification_confidence": material.get("classification_confidence", 0.0),
"is_verified": False
})
db.commit()
return {
"success": True,
"file_id": file_id,
"filename": file.filename,
"materials_count": len(classified_materials),
"revision": revision,
"message": f"파일이 성공적으로 업로드되고 {len(classified_materials)}개의 자재가 분류되었습니다. (리비전: {revision})"
}
except Exception as e:
db.rollback()
print(f"업로드 실패: {str(e)}")
# HTTP 400 에러로 변경
from fastapi import HTTPException
raise HTTPException(status_code=400, detail=f"파일 업로드 및 분류 실패: {str(e)}")
def parse_file(file_path: str) -> List[Dict]:
"""파일 파싱 (CSV/Excel)"""
import pandas as pd
import os
try:
print(f"parse_file 호출됨: {file_path}")
print(f"파일 존재 여부: {os.path.exists(file_path)}")
print(f"파일 확장자: {os.path.splitext(file_path)[1]}")
# 파일 확장자를 소문자로 변환하여 검증
file_extension = os.path.splitext(file_path)[1].lower()
print(f"소문자 변환된 확장자: {file_extension}")
if file_extension == '.csv':
df = pd.read_csv(file_path)
elif file_extension in ['.xls', '.xlsx']:
df = pd.read_excel(file_path)
else:
print(f"지원되지 않는 파일 형식: {file_path}")
print(f"파일 확장자: {file_extension}")
raise ValueError("지원하지 않는 파일 형식입니다.")
print(f"파일 파싱 시작: {file_path}")
print(f"데이터프레임 형태: {df.shape}")
print(f"컬럼명: {list(df.columns)}")
# 컬럼명 매핑 (대소문자 구분 없이)
column_mapping = {
'description': ['DESCRIPTION', 'Description', 'description', 'DESC', 'Desc', 'desc', 'ITEM', 'Item', 'item'],
'quantity': ['QTY', 'Quantity', 'quantity', 'QTY.', 'Qty', 'qty', 'AMOUNT', 'Amount', 'amount'],
'unit': ['UNIT', 'Unit', 'unit', 'UOM', 'Uom', 'uom'],
'drawing': ['DRAWING', 'Drawing', 'drawing', 'DWG', 'Dwg', 'dwg'],
'area': ['AREA', 'Area', 'area', 'AREA_CODE', 'Area_Code', 'area_code'],
'line': ['LINE', 'Line', 'line', 'LINE_NO', 'Line_No', 'line_no', 'PIPELINE', 'Pipeline', 'pipeline']
}
# 실제 컬럼명 찾기
found_columns = {}
for target_col, possible_names in column_mapping.items():
for col_name in possible_names:
if col_name in df.columns:
found_columns[target_col] = col_name
break
print(f"찾은 컬럼 매핑: {found_columns}")
materials = []
for index, row in df.iterrows():
# 빈 행 건너뛰기
if row.isna().all():
continue
# 안전한 값 추출
description = str(row.get(found_columns.get('description', ''), '') or '')
quantity_raw = row.get(found_columns.get('quantity', 1), 1)
quantity = float(quantity_raw) if quantity_raw is not None else 1.0
unit = str(row.get(found_columns.get('unit', 'EA'), 'EA') or 'EA')
drawing = str(row.get(found_columns.get('drawing', ''), '') or '')
area = str(row.get(found_columns.get('area', ''), '') or '')
line = str(row.get(found_columns.get('line', ''), '') or '')
material = {
"line_number": index + 1,
"original_description": description,
"quantity": quantity,
"unit": unit,
"drawing_name": drawing,
"area_code": area,
"line_no": line
}
# 빈 설명은 건너뛰기
if not material["original_description"] or material["original_description"].strip() == '':
continue
materials.append(material)
print(f"파싱된 자재 수: {len(materials)}")
if materials:
print(f"첫 번째 자재 예시: {materials[0]}")
return materials
except Exception as e:
print(f"파일 파싱 오류: {str(e)}")
raise Exception(f"파일 파싱 실패: {str(e)}")
def classify_material_item(material: Dict) -> Dict:
"""개별 자재 분류"""
from .services import (
pipe_classifier, fitting_classifier, bolt_classifier,
valve_classifier, instrument_classifier, flange_classifier,
gasket_classifier, material_classifier
)
description = material.get("original_description", "")
# 각 분류기로 분류 시도
classifiers = [
("PIPE", pipe_classifier.classify_pipe),
("FITTING", fitting_classifier.classify_fitting),
("BOLT", bolt_classifier.classify_bolt),
("VALVE", valve_classifier.classify_valve),
("INSTRUMENT", instrument_classifier.classify_instrument),
("FLANGE", flange_classifier.classify_flange),
("GASKET", gasket_classifier.classify_gasket)
]
best_result = None
best_confidence = 0.0
for category, classifier_func in classifiers:
try:
result = classifier_func(description)
if result and result.get("confidence", 0) > best_confidence:
best_result = result
best_confidence = result.get("confidence", 0)
except Exception:
continue
# 재질 분류
material_result = material_classifier.classify_material(description)
# 최종 결과 조합
final_result = {
**material,
"classified_category": best_result.get("category", "UNKNOWN") if best_result else "UNKNOWN",
"classified_subcategory": best_result.get("subcategory", "") if best_result else "",
"material_grade": material_result.get("grade", "") if material_result else "",
"schedule": best_result.get("schedule", "") if best_result else "",
"size_spec": best_result.get("size_spec", "") if best_result else "",
"classification_confidence": best_confidence
}
return final_result
@app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": "2024-07-15"}

View File

@@ -12,6 +12,14 @@ from pathlib import Path
from ..database import get_db
from app.services.material_classifier import classify_material
from app.services.bolt_classifier import classify_bolt
from app.services.flange_classifier import classify_flange
from app.services.fitting_classifier import classify_fitting
from app.services.gasket_classifier import classify_gasket
from app.services.instrument_classifier import classify_instrument
from app.services.pipe_classifier import classify_pipe
from app.services.valve_classifier import classify_valve
router = APIRouter()
UPLOAD_DIR = Path("uploads")
@@ -153,16 +161,21 @@ async def upload_file(
file_path = UPLOAD_DIR / unique_filename
try:
print("파일 저장 시작")
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
print(f"파일 저장 완료: {file_path}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
try:
print("파일 저장 시작")
materials_data = parse_file_data(str(file_path))
parsed_count = len(materials_data)
print(f"파싱 완료: {parsed_count}개 자재")
# 파일 정보 저장
# 파일 정보 저장 (project_id 제거)
print("DB 저장 시작")
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)
@@ -182,20 +195,40 @@ async def upload_file(
})
file_id = file_result.fetchone()[0]
print(f"파일 저장 완료: file_id = {file_id}")
# 자재 데이터 저장
# 자재 데이터 저장 (분류 포함)
print("자재 분류 및 저장 시작")
materials_inserted = 0
classification_stats = {
'BOLT': 0, 'FLANGE': 0, 'FITTING': 0, 'GASKET': 0,
'INSTRUMENT': 0, 'PIPE': 0, 'VALVE': 0, 'MATERIAL': 0, 'OTHER': 0
}
for material_data in materials_data:
# 자재 분류 실행
classification_result = classify_material_item(
material_data["original_description"],
material_data["size_spec"]
)
# 분류 통계 업데이트
category = classification_result.get('category', 'OTHER')
if category in classification_stats:
classification_stats[category] += 1
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
classification_confidence, is_verified, created_at,
subcategory, standard, grade
)
VALUES (
:file_id, :original_description, :quantity, :unit, :size_spec,
:material_grade, :line_number, :row_number, :classified_category,
:classification_confidence, :is_verified, :created_at
:classification_confidence, :is_verified, :created_at,
:subcategory, :standard, :grade
)
""")
@@ -208,14 +241,20 @@ async def upload_file(
"material_grade": material_data["material_grade"],
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
"classified_category": get_major_category(material_data["original_description"]),
"classification_confidence": 0.9,
"classified_category": classification_result.get('category', 'OTHER'),
"classification_confidence": classification_result.get('confidence', 0.0),
"is_verified": False,
"created_at": datetime.now()
"created_at": datetime.now(),
"subcategory": classification_result.get('subcategory', ''),
"standard": classification_result.get('standard', ''),
"grade": classification_result.get('grade', '')
})
materials_inserted += 1
print(f"자재 저장 완료: {materials_inserted}")
print("커밋 직전")
db.commit()
print("커밋 완료")
return {
"success": True,
@@ -224,6 +263,7 @@ async def upload_file(
"file_id": file_id,
"parsed_materials_count": parsed_count,
"saved_materials_count": materials_inserted,
"classification_stats": classification_stats,
"sample_materials": materials_data[:3] if materials_data else []
}
@@ -231,93 +271,260 @@ async def upload_file(
db.rollback()
if os.path.exists(file_path):
os.remove(file_path)
import traceback
print(traceback.format_exc()) # 에러 전체 로그 출력
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,
project_id: Optional[int] = None,
job_id: Optional[int] = None,
revision: Optional[str] = None,
grouping: Optional[str] = None,
search: Optional[str] = None,
search_value: Optional[str] = None,
item_type: Optional[str] = None,
material_grade: Optional[str] = None,
size_spec: Optional[str] = None,
file_filter: Optional[str] = None,
sort_by: 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, m.classified_category, m.classification_confidence
# 기본 쿼리 구성
base_query = """
SELECT
m.id,
m.original_description,
m.quantity,
m.unit,
m.size_spec,
m.material_grade,
m.line_number,
m.row_number,
m.classified_category,
m.classification_confidence,
m.is_verified,
m.created_at,
f.job_no as job_number,
f.revision,
f.original_filename,
f.project_id,
p.project_name,
COUNT(*) OVER() as total_count
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
LEFT JOIN jobs j ON f.job_no = j.job_no
LEFT JOIN projects p ON f.project_id = p.id
WHERE 1=1
"""
params = {}
conditions = []
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
# 프로젝트 필터
if project_id:
conditions.append("f.project_id = :project_id")
params["project_id"] = project_id
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"
# Job ID 필터
if job_id:
conditions.append("f.job_no = (SELECT job_no FROM jobs WHERE id = :job_id)")
params["job_id"] = job_id
# 리비전 필터
if revision:
conditions.append("f.revision = :revision")
params["revision"] = revision
# 검색 필터 (개선된 버전)
if search and search_value:
try:
if search == "project":
conditions.append("p.project_name ILIKE :search_value")
elif search == "job":
conditions.append("f.job_no ILIKE :search_value")
elif search == "material":
conditions.append("m.original_description ILIKE :search_value")
elif search == "description":
conditions.append("m.original_description ILIKE :search_value")
elif search == "grade":
conditions.append("m.material_grade ILIKE :search_value")
elif search == "size":
conditions.append("m.size_spec ILIKE :search_value")
elif search == "filename":
conditions.append("f.original_filename ILIKE :search_value")
else:
# 기본 검색 (기존 방식)
conditions.append("(m.original_description ILIKE :search_value OR m.material_grade ILIKE :search_value)")
params["search_value"] = f"%{search_value}%"
except Exception as e:
print(f"검색 필터 처리 오류: {e}")
# 오류 발생 시 기본 검색으로 fallback
conditions.append("(m.original_description ILIKE :search_value OR m.material_grade ILIKE :search_value)")
params["search_value"] = f"%{search_value}%"
# 품목 타입 필터
if item_type:
conditions.append("m.classified_category = :item_type")
params["item_type"] = item_type
# 재질 필터
if material_grade:
conditions.append("m.material_grade ILIKE :material_grade")
params["material_grade"] = f"%{material_grade}%"
# 사이즈 필터
if size_spec:
conditions.append("m.size_spec ILIKE :size_spec")
params["size_spec"] = f"%{size_spec}%"
# 파일명 필터
if file_filter:
conditions.append("f.original_filename ILIKE :file_filter")
params["file_filter"] = f"%{file_filter}%"
# 조건 추가
if conditions:
base_query += " AND " + " AND ".join(conditions)
# 그룹핑 처리
if grouping:
if grouping == "item":
base_query += " GROUP BY m.classified_category, m.original_description, m.size_spec, m.material_grade, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classification_confidence, m.is_verified, m.created_at, f.job_no, f.revision, f.original_filename, f.project_id, p.project_name"
elif grouping == "material":
base_query += " GROUP BY m.material_grade, m.original_description, m.size_spec, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.is_verified, m.created_at, f.job_no, f.revision, f.original_filename, f.project_id, p.project_name"
elif grouping == "size":
base_query += " GROUP BY m.size_spec, m.original_description, m.material_grade, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.is_verified, m.created_at, f.job_no, f.revision, f.original_filename, f.project_id, p.project_name"
elif grouping == "job":
base_query += " GROUP BY f.job_no, m.original_description, m.size_spec, m.material_grade, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.is_verified, m.created_at, f.revision, f.original_filename, f.project_id, p.project_name"
elif grouping == "revision":
base_query += " GROUP BY f.revision, m.original_description, m.size_spec, m.material_grade, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.is_verified, m.created_at, f.job_no, f.original_filename, f.project_id, p.project_name"
# 정렬
if sort_by:
if sort_by == "quantity_desc":
base_query += " ORDER BY SUM(m.quantity) DESC"
elif sort_by == "quantity_asc":
base_query += " ORDER BY SUM(m.quantity) ASC"
elif sort_by == "name_asc":
base_query += " ORDER BY m.original_description ASC"
elif sort_by == "name_desc":
base_query += " ORDER BY m.original_description DESC"
elif sort_by == "created_desc":
base_query += " ORDER BY m.created_at DESC"
elif sort_by == "created_asc":
base_query += " ORDER BY m.created_at ASC"
else:
base_query += " ORDER BY m.id DESC"
else:
base_query += " ORDER BY m.id DESC"
# 페이징
base_query += " LIMIT :limit OFFSET :skip"
params["limit"] = limit
params["skip"] = skip
result = db.execute(text(query), params)
result = db.execute(text(base_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 = {}
# 리비전 비교 데이터 생성
revision_comparison = None
if revision and revision != "Rev.0":
comparison_query = """
SELECT
m.original_description,
m.size_spec,
m.material_grade,
SUM(CASE WHEN f.revision = :current_revision THEN m.quantity ELSE 0 END) as current_qty,
SUM(CASE WHEN f.revision = :prev_revision THEN m.quantity ELSE 0 END) as prev_qty
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE f.project_id = :project_id
AND f.revision IN (:current_revision, :prev_revision)
GROUP BY m.original_description, m.size_spec, m.material_grade
HAVING
SUM(CASE WHEN f.revision = :current_revision THEN m.quantity ELSE 0 END) !=
SUM(CASE WHEN f.revision = :prev_revision THEN m.quantity ELSE 0 END)
"""
comparison_params = {
"project_id": project_id,
"current_revision": revision,
"prev_revision": f"Rev.{int(revision.split('.')[-1]) - 1}"
}
comparison_result = db.execute(text(comparison_query), comparison_params)
comparison_data = comparison_result.fetchall()
if comparison_data:
changes = []
for row in comparison_data:
change = row.current_qty - row.prev_qty
if change != 0:
changes.append({
"description": row.original_description,
"size_spec": row.size_spec,
"material_grade": row.material_grade,
"current_qty": row.current_qty,
"prev_qty": row.prev_qty,
"change": change
})
revision_comparison = {
"summary": f"{revision}에서 {len(changes)}개 항목이 변경되었습니다",
"changes": changes
}
if job_no:
count_query += " AND f.job_no = :job_no"
count_params["job_no"] = job_no
# 결과 포맷팅
formatted_materials = []
for material in materials:
# 라인 번호 문자열 생성
line_numbers = [material.line_number] if material.line_number else []
line_numbers_str = ", ".join(map(str, line_numbers)) if line_numbers else ""
# 수량 변경 계산 (리비전 비교)
quantity_change = None
if revision_comparison:
for change in revision_comparison["changes"]:
if (change["description"] == material.original_description and
change["size_spec"] == material.size_spec and
change["material_grade"] == material.material_grade):
quantity_change = change["change"]
break
formatted_material = {
"id": material.id,
"original_description": material.original_description,
"quantity": float(material.quantity) if material.quantity else 0,
"unit": material.unit or "EA",
"size_spec": material.size_spec or "",
"material_grade": material.material_grade or "",
"line_number": material.line_number,
"line_numbers_str": line_numbers_str,
"line_count": len(line_numbers),
"classified_category": material.classified_category or "OTHER",
"classification_confidence": float(material.classification_confidence) if material.classification_confidence else 0,
"is_verified": material.is_verified or False,
"created_at": material.created_at.isoformat() if material.created_at else None,
"job_number": material.job_number,
"revision": material.revision or "Rev.0",
"original_filename": material.original_filename,
"project_id": material.project_id,
"project_name": material.project_name,
"quantity_change": quantity_change
}
formatted_materials.append(formatted_material)
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]
total_count = materials[0].total_count if materials else 0
return {
"success": True,
"materials": formatted_materials,
"total_count": total_count,
"returned_count": len(materials),
"skip": skip,
"limit": limit,
"materials": [
{
"id": m[0],
"file_id": m[1],
"filename": m[10],
"job_no": m[12],
"project_code": m[12],
"project_name": "Job-" + str(m[11]) if m[11] else "Unknown",
"original_description": m[2],
"quantity": float(m[3]) if m.quantity else 0,
"unit": m[4],
"classified_category": m[14],
"classification_confidence": float(m[15]) if m.classification_confidence else 0.0,
"size_spec": m[5],
"material_grade": m[6],
"line_number": m[7],
"row_number": m[8],
"created_at": m[9]
}
for m in materials
]
"revision_comparison": revision_comparison
}
except Exception as e:
@@ -419,3 +626,123 @@ def get_major_category(description):
return 'bolt'
else:
return 'other'
def classify_material_item(description: str, size_spec: str = "") -> dict:
"""
자재를 각 분류기로 보내서 분류하는 통합 함수
Args:
description: 자재 설명
size_spec: 사이즈 정보
Returns:
분류 결과 딕셔너리
"""
desc_upper = description.upper().strip()
# 1. 볼트 분류
bolt_result = classify_bolt(description)
if bolt_result.get('confidence', 0) > 0.7:
return {
'category': 'BOLT',
'subcategory': bolt_result.get('bolt_type', 'UNKNOWN'),
'standard': bolt_result.get('standard', ''),
'grade': bolt_result.get('grade', ''),
'confidence': bolt_result.get('confidence', 0),
'details': bolt_result
}
# 2. 플랜지 분류
flange_result = classify_flange(description)
if flange_result.get('confidence', 0) > 0.7:
return {
'category': 'FLANGE',
'subcategory': flange_result.get('flange_type', 'UNKNOWN'),
'standard': flange_result.get('standard', ''),
'grade': flange_result.get('grade', ''),
'confidence': flange_result.get('confidence', 0),
'details': flange_result
}
# 3. 피팅 분류
fitting_result = classify_fitting(description)
if fitting_result.get('confidence', 0) > 0.7:
return {
'category': 'FITTING',
'subcategory': fitting_result.get('fitting_type', 'UNKNOWN'),
'standard': fitting_result.get('standard', ''),
'grade': fitting_result.get('grade', ''),
'confidence': fitting_result.get('confidence', 0),
'details': fitting_result
}
# 4. 가스켓 분류
gasket_result = classify_gasket(description)
if gasket_result.get('confidence', 0) > 0.7:
return {
'category': 'GASKET',
'subcategory': gasket_result.get('gasket_type', 'UNKNOWN'),
'standard': gasket_result.get('standard', ''),
'grade': gasket_result.get('grade', ''),
'confidence': gasket_result.get('confidence', 0),
'details': gasket_result
}
# 5. 계기 분류
instrument_result = classify_instrument(description)
if instrument_result.get('confidence', 0) > 0.7:
return {
'category': 'INSTRUMENT',
'subcategory': instrument_result.get('instrument_type', 'UNKNOWN'),
'standard': instrument_result.get('standard', ''),
'grade': instrument_result.get('grade', ''),
'confidence': instrument_result.get('confidence', 0),
'details': instrument_result
}
# 6. 파이프 분류
pipe_result = classify_pipe(description)
if pipe_result.get('confidence', 0) > 0.7:
return {
'category': 'PIPE',
'subcategory': pipe_result.get('pipe_type', 'UNKNOWN'),
'standard': pipe_result.get('standard', ''),
'grade': pipe_result.get('grade', ''),
'confidence': pipe_result.get('confidence', 0),
'details': pipe_result
}
# 7. 밸브 분류
valve_result = classify_valve(description)
if valve_result.get('confidence', 0) > 0.7:
return {
'category': 'VALVE',
'subcategory': valve_result.get('valve_type', 'UNKNOWN'),
'standard': valve_result.get('standard', ''),
'grade': valve_result.get('grade', ''),
'confidence': valve_result.get('confidence', 0),
'details': valve_result
}
# 8. 재질 분류 (기본)
material_result = classify_material(description)
if material_result.get('confidence', 0) > 0.5:
return {
'category': 'MATERIAL',
'subcategory': material_result.get('material_type', 'UNKNOWN'),
'standard': material_result.get('standard', ''),
'grade': material_result.get('grade', ''),
'confidence': material_result.get('confidence', 0),
'details': material_result
}
# 9. 기본 분류 (키워드 기반)
category = get_major_category(description)
return {
'category': category.upper(),
'subcategory': 'UNKNOWN',
'standard': '',
'grade': '',
'confidence': 0.3,
'details': {}
}

View File

@@ -29,7 +29,7 @@ async def get_jobs(
search: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Job 목록 조회"""
"""Job 목록 조회 (job_name을 프로젝트명으로 사용)"""
try:
query = """
SELECT job_no, job_name, client_name, end_user, epc_company,
@@ -68,7 +68,8 @@ async def get_jobs(
"delivery_terms": job.delivery_terms,
"status": job.status,
"description": job.description,
"created_at": job.created_at
"created_at": job.created_at,
"project_name": job.job_name # job_name을 프로젝트명으로 사용
}
for job in jobs
]

View File

@@ -10,6 +10,7 @@ alembic==1.13.1
# 파일 처리
pandas==2.1.4
openpyxl==3.1.2
xlrd>=2.0.1
python-multipart==0.0.6
# 데이터 검증

View File

@@ -0,0 +1,18 @@
-- files 테이블에 job_no 컬럼 추가
-- 실행일: 2025.07.16
-- job_no 컬럼 추가
ALTER TABLE files ADD COLUMN job_no VARCHAR(50);
-- job_no에 인덱스 추가
CREATE INDEX idx_files_job_no ON files(job_no);
-- 기존 데이터가 있다면 project_id를 기반으로 job_no 설정
-- (이 부분은 실제 데이터가 있을 때만 실행)
-- UPDATE files SET job_no = (SELECT official_project_code FROM projects WHERE projects.id = files.project_id);
-- job_no를 NOT NULL로 설정 (데이터 마이그레이션 후)
-- ALTER TABLE files ALTER COLUMN job_no SET NOT NULL;
-- project_id 컬럼 제거 (선택사항 - 기존 데이터 백업 후)
-- ALTER TABLE files DROP COLUMN project_id;

View File

@@ -0,0 +1,80 @@
-- 분류 결과 저장을 위한 컬럼 추가
-- 2024년 BOM 분류 시스템 개선
-- materials 테이블에 분류 관련 컬럼 추가
ALTER TABLE materials ADD COLUMN IF NOT EXISTS subcategory VARCHAR(100);
ALTER TABLE materials ADD COLUMN IF NOT EXISTS standard VARCHAR(200);
ALTER TABLE materials ADD COLUMN IF NOT EXISTS grade VARCHAR(200);
ALTER TABLE materials ADD COLUMN IF NOT EXISTS classification_details JSONB;
-- files 테이블에 분류 통계 컬럼 추가
ALTER TABLE files ADD COLUMN IF NOT EXISTS classification_stats JSONB;
ALTER TABLE files ADD COLUMN IF NOT EXISTS classification_completed BOOLEAN DEFAULT FALSE;
ALTER TABLE files ADD COLUMN IF NOT EXISTS classification_timestamp TIMESTAMP;
-- 인덱스 추가 (성능 향상)
CREATE INDEX IF NOT EXISTS idx_materials_classified_category ON materials(classified_category);
CREATE INDEX IF NOT EXISTS idx_materials_subcategory ON materials(subcategory);
CREATE INDEX IF NOT EXISTS idx_materials_standard ON materials(standard);
CREATE INDEX IF NOT EXISTS idx_materials_grade ON materials(grade);
CREATE INDEX IF NOT EXISTS idx_materials_classification_confidence ON materials(classification_confidence);
-- 기존 데이터에 대한 기본값 설정
UPDATE materials SET
subcategory = COALESCE(subcategory, ''),
standard = COALESCE(standard, ''),
grade = COALESCE(grade, ''),
classification_details = COALESCE(classification_details, '{}'::jsonb)
WHERE subcategory IS NULL OR standard IS NULL OR grade IS NULL OR classification_details IS NULL;
-- 분류 완료된 파일들 업데이트
UPDATE files SET
classification_completed = TRUE,
classification_timestamp = created_at
WHERE parsed_count > 0;
-- 통계 뷰 생성 (분류 결과 통계 조회용)
CREATE OR REPLACE VIEW classification_summary AS
SELECT
f.job_no,
f.original_filename,
f.parsed_count,
f.classification_completed,
f.classification_timestamp,
COUNT(*) as total_materials,
COUNT(CASE WHEN m.classified_category = 'BOLT' THEN 1 END) as bolt_count,
COUNT(CASE WHEN m.classified_category = 'FLANGE' THEN 1 END) as flange_count,
COUNT(CASE WHEN m.classified_category = 'FITTING' THEN 1 END) as fitting_count,
COUNT(CASE WHEN m.classified_category = 'GASKET' THEN 1 END) as gasket_count,
COUNT(CASE WHEN m.classified_category = 'INSTRUMENT' THEN 1 END) as instrument_count,
COUNT(CASE WHEN m.classified_category = 'PIPE' THEN 1 END) as pipe_count,
COUNT(CASE WHEN m.classified_category = 'VALVE' THEN 1 END) as valve_count,
COUNT(CASE WHEN m.classified_category = 'MATERIAL' THEN 1 END) as material_count,
COUNT(CASE WHEN m.classified_category = 'OTHER' THEN 1 END) as other_count,
AVG(m.classification_confidence) as avg_confidence,
COUNT(CASE WHEN m.is_verified = TRUE THEN 1 END) as verified_count
FROM files f
LEFT JOIN materials m ON f.id = m.file_id
WHERE f.is_active = TRUE
GROUP BY f.id, f.job_no, f.original_filename, f.parsed_count, f.classification_completed, f.classification_timestamp;
-- 분류 성능 통계 뷰
CREATE OR REPLACE VIEW classification_performance AS
SELECT
classified_category,
subcategory,
standard,
COUNT(*) as total_count,
AVG(classification_confidence) as avg_confidence,
COUNT(CASE WHEN is_verified = TRUE THEN 1 END) as verified_count,
COUNT(CASE WHEN is_verified = FALSE THEN 1 END) as unverified_count,
ROUND(
(COUNT(CASE WHEN is_verified = TRUE THEN 1 END)::DECIMAL / COUNT(*) * 100), 2
) as verification_rate
FROM materials
WHERE classified_category IS NOT NULL
GROUP BY classified_category, subcategory, standard
ORDER BY total_count DESC;
-- 변경사항 확인
SELECT 'Database schema updated successfully' as status;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "tk-mp-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.1.1",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"vite": "^4.5.0"
}
}

View File

@@ -1,179 +1,19 @@
import React, { useState, useEffect } from 'react';
import {
AppBar,
Toolbar,
Typography,
Container,
Box,
Tab,
Tabs,
ThemeProvider,
createTheme,
CssBaseline
} from '@mui/material';
import {
Dashboard as DashboardIcon,
Upload as UploadIcon,
List as ListIcon,
Assignment as ProjectIcon
} from '@mui/icons-material';
import Dashboard from './components/Dashboard';
import FileUpload from './components/FileUpload';
import MaterialList from './components/MaterialList';
import ProjectManager from './components/ProjectManager';
// Material-UI 테마 설정
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
background: {
default: '#f5f5f5',
},
},
typography: {
h4: {
fontWeight: 600,
},
h6: {
fontWeight: 500,
},
},
});
function TabPanel({ children, value, index, ...other }) {
return (
<div
role="tabpanel"
hidden={value !== index}
id={`tabpanel-${index}`}
aria-labelledby={`tab-${index}`}
{...other}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import JobSelectionPage from './pages/JobSelectionPage';
import BOMManagerPage from './pages/BOMManagerPage';
import MaterialsPage from './pages/MaterialsPage';
function App() {
const [tabValue, setTabValue] = useState(0);
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState(null);
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
};
// 프로젝트 목록 로드
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
try {
const response = await fetch('http://localhost:8000/api/projects');
if (response.ok) {
const data = await response.json();
setProjects(data);
if (data.length > 0 && !selectedProject) {
setSelectedProject(data[0]);
}
}
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ flexGrow: 1 }}>
{/* 상단 앱바 */}
<AppBar position="static" elevation={1}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
TK-MP BOM 관리 시스템
</Typography>
<Typography variant="body2" sx={{ opacity: 0.8 }}>
{selectedProject ? `프로젝트: ${selectedProject.name}` : '프로젝트 없음'}
</Typography>
</Toolbar>
</AppBar>
{/* 탭 네비게이션 */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', bgcolor: 'white' }}>
<Container maxWidth="xl">
<Tabs
value={tabValue}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
>
<Tab
icon={<DashboardIcon />}
label="대시보드"
iconPosition="start"
/>
<Tab
icon={<ProjectIcon />}
label="프로젝트 관리"
iconPosition="start"
/>
<Tab
icon={<UploadIcon />}
label="파일 업로드"
iconPosition="start"
/>
<Tab
icon={<ListIcon />}
label="자재 목록"
iconPosition="start"
/>
</Tabs>
</Container>
</Box>
{/* 메인 콘텐츠 */}
<Container maxWidth="xl">
<TabPanel value={tabValue} index={0}>
<Dashboard
selectedProject={selectedProject}
projects={projects}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<ProjectManager
projects={projects}
selectedProject={selectedProject}
setSelectedProject={setSelectedProject}
onProjectsChange={fetchProjects}
/>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<FileUpload
selectedProject={selectedProject}
onUploadSuccess={() => {
// 업로드 성공 시 대시보드로 이동
setTabValue(0);
}}
/>
</TabPanel>
<TabPanel value={tabValue} index={3}>
<MaterialList
selectedProject={selectedProject}
/>
</TabPanel>
</Container>
</Box>
</ThemeProvider>
<Router>
<Routes>
<Route path="/" element={<JobSelectionPage />} />
<Route path="/bom-manager" element={<BOMManagerPage />} />
<Route path="/materials" element={<MaterialsPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
);
}

122
frontend/src/api.js Normal file
View File

@@ -0,0 +1,122 @@
import axios from 'axios';
// 환경변수에서 API URL을 읽음 (Vite 기준)
const API_BASE_URL = import.meta.env.VITE_API_URL ||
(import.meta.env.DEV ? 'http://localhost:8000' : 'http://localhost:8000');
console.log('API Base URL:', API_BASE_URL);
console.log('Environment:', import.meta.env.MODE);
// axios 인스턴스 생성
export const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000, // 30초로 증가
headers: {
'Content-Type': 'application/json',
},
});
// 재시도 로직을 위한 설정
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1초
// 재시도 함수
const retryRequest = async (config, retries = MAX_RETRIES) => {
try {
return await api(config);
} catch (error) {
if (retries > 0 && (error.code === 'ECONNABORTED' || error.response?.status >= 500)) {
console.log(`API 재시도 중... (${MAX_RETRIES - retries + 1}/${MAX_RETRIES})`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
return retryRequest(config, retries - 1);
}
throw error;
}
};
// 공통 에러 핸들링 (예시)
api.interceptors.response.use(
response => response,
error => {
console.error('API Error:', {
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
data: error.response?.data,
message: error.message
});
// 필요시 에러 로깅/알림 등 추가
return Promise.reject(error);
}
);
// 예시: 파일 업로드 (multipart/form-data)
export function uploadFile(formData, options = {}) {
const config = {
method: 'post',
url: '/upload',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
...options,
};
return retryRequest(config);
}
// 예시: 자재 목록 조회
export function fetchMaterials(params) {
return api.get('/files/materials', { params });
}
// 예시: 자재 요약 통계
export function fetchMaterialsSummary(params) {
return api.get('/files/materials/summary', { params });
}
// 파일 목록 조회
export function fetchFiles(params) {
return api.get('/files', { params });
}
// 파일 삭제
export function deleteFile(fileId) {
return api.delete(`/files/${fileId}`);
}
// 예시: Job 목록 조회
export function fetchJobs(params) {
return api.get('/jobs', { params });
}
// 예시: Job 생성
export function createJob(data) {
return api.post('/jobs', data);
}
// 프로젝트 수정
export function updateProject(projectId, data) {
return api.put(`/projects/${projectId}`, data);
}
// 프로젝트 삭제
export function deleteProject(projectId) {
return api.delete(`/projects/${projectId}`);
}
// 스풀 관련 API
export function fetchProjectSpools(projectId) {
return api.get(`/spools/project/${projectId}/spools`);
}
export function validateSpoolIdentifier(identifier) {
return api.post('/spools/validate-identifier', { spool_identifier: identifier });
}
export function generateSpoolIdentifier(dwgName, areaNumber, spoolNumber) {
return api.post('/spools/generate-identifier', {
dwg_name: dwgName,
area_number: areaNumber,
spool_number: spoolNumber
});
}

View File

@@ -1,38 +1,213 @@
import React, { useState, useEffect } from 'react';
import { Typography, Box, Card, CardContent, Grid, CircularProgress } from '@mui/material';
import {
Typography,
Box,
Card,
CardContent,
Grid,
CircularProgress,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import { fetchMaterials } from '../api';
import { Bar, Pie, Line } from 'react-chartjs-2';
import 'chart.js/auto';
import Toast from './Toast';
function Dashboard({ selectedProject, projects }) {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(false);
const [materials, setMaterials] = useState([]);
const [barData, setBarData] = useState(null);
const [pieData, setPieData] = useState(null);
const [materialGradeData, setMaterialGradeData] = useState(null);
const [sizeData, setSizeData] = useState(null);
const [topMaterials, setTopMaterials] = useState([]);
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
useEffect(() => {
if (selectedProject) {
fetchMaterialStats();
fetchMaterialList();
}
}, [selectedProject]);
const fetchMaterialStats = async () => {
setLoading(true);
try {
const response = await fetch(`http://localhost:8000/api/files/materials/summary?project_id=${selectedProject.id}`);
const response = await fetch(`/files/materials/summary?project_id=${selectedProject.id}`);
if (response.ok) {
const data = await response.json();
setStats(data.summary);
}
} catch (error) {
console.error('통계 로드 실패:', error);
setToast({
open: true,
message: '자재 통계 로드 실패',
type: 'error'
});
} finally {
setLoading(false);
}
};
const fetchMaterialList = async () => {
try {
// 최대 1000개까지 조회(실무에서는 서버 페이징/집계 API 권장)
const params = { project_id: selectedProject.id, skip: 0, limit: 1000 };
const response = await fetchMaterials(params);
setMaterials(response.data.materials || []);
} catch (error) {
setToast({
open: true,
message: '자재 목록 로드 실패',
type: 'error'
});
}
};
useEffect(() => {
if (materials.length > 0) {
// 분류별 집계
const typeCounts = {};
const typeQuantities = {};
const materialGrades = {};
const sizes = {};
const materialQuantities = {};
materials.forEach(mat => {
const type = mat.item_type || 'OTHER';
const grade = mat.material_grade || '미분류';
const size = mat.size_spec || '미분류';
const desc = mat.original_description;
typeCounts[type] = (typeCounts[type] || 0) + 1;
typeQuantities[type] = (typeQuantities[type] || 0) + (mat.quantity || 0);
materialGrades[grade] = (materialGrades[grade] || 0) + 1;
sizes[size] = (sizes[size] || 0) + 1;
materialQuantities[desc] = (materialQuantities[desc] || 0) + (mat.quantity || 0);
});
// Bar 차트 데이터
setBarData({
labels: Object.keys(typeCounts),
datasets: [
{
label: '자재 수',
data: Object.values(typeCounts),
backgroundColor: 'rgba(25, 118, 210, 0.6)',
borderColor: 'rgba(25, 118, 210, 1)',
borderWidth: 1
},
{
label: '총 수량',
data: Object.values(typeQuantities),
backgroundColor: 'rgba(220, 0, 78, 0.4)',
borderColor: 'rgba(220, 0, 78, 1)',
borderWidth: 1
}
]
});
// 재질별 Pie 차트
setMaterialGradeData({
labels: Object.keys(materialGrades),
datasets: [{
data: Object.values(materialGrades),
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
],
borderWidth: 2
}]
});
// 사이즈별 Pie 차트
setSizeData({
labels: Object.keys(sizes),
datasets: [{
data: Object.values(sizes),
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
],
borderWidth: 2
}]
});
// 상위 자재 (수량 기준)
const sortedMaterials = Object.entries(materialQuantities)
.sort(([,a], [,b]) => b - a)
.slice(0, 10)
.map(([desc, qty]) => ({ description: desc, quantity: qty }));
setTopMaterials(sortedMaterials);
} else {
setBarData(null);
setMaterialGradeData(null);
setSizeData(null);
setTopMaterials([]);
}
}, [materials]);
return (
<Box>
<Typography variant="h4" gutterBottom>
📊 대시보드
</Typography>
{/* 선택된 프로젝트 정보 */}
{selectedProject && (
<Box sx={{ mb: 3, p: 2, bgcolor: 'primary.50', borderRadius: 2, border: '1px solid', borderColor: 'primary.200' }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} sm={6}>
<Box>
<Typography variant="h6" color="primary">
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
<Typography variant="body2" color="textSecondary">
상태: {selectedProject.status} | 생성일: {new Date(selectedProject.created_at).toLocaleDateString()}
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Chip
label={selectedProject.status}
color={selectedProject.status === 'active' ? 'success' : 'default'}
size="small"
/>
<Chip
label={selectedProject.is_code_matched ? '코드 매칭됨' : '코드 미매칭'}
color={selectedProject.is_code_matched ? 'success' : 'warning'}
size="small"
/>
</Box>
</Grid>
</Grid>
</Box>
)}
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
<Grid container spacing={3}>
{/* 프로젝트 현황 */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
@@ -46,81 +221,218 @@ function Dashboard({ selectedProject, projects }) {
프로젝트
</Typography>
<Typography variant="body1" sx={{ mt: 2 }}>
선택된 프로젝트: {selectedProject ? selectedProject.project_name : '없음'}
선택된 프로젝트: {selectedProject.project_name}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="secondary" gutterBottom>
자재 현황
</Typography>
{loading ? (
<Box display="flex" justifyContent="center" py={3}>
<CircularProgress />
</Box>
) : stats ? (
<Box>
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
{stats.total_items.toLocaleString()}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
자재
</Typography>
<Typography variant="body2">
고유 품목: {stats.unique_descriptions}
</Typography>
<Typography variant="body2">
고유 사이즈: {stats.unique_sizes}
</Typography>
<Typography variant="body2">
수량: {stats.total_quantity.toLocaleString()}
</Typography>
</Box>
) : (
<Typography variant="body2" color="textSecondary">
프로젝트를 선택하면 자재 현황을 확인할 있습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{selectedProject && (
<Grid item xs={12}>
{/* 자재 현황 */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
📋 프로젝트 상세 정보
<Typography variant="h6" color="secondary" gutterBottom>
자재 현황
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
<Typography variant="body1">{selectedProject.official_project_code}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
<Typography variant="body1">{selectedProject.project_name}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">상태</Typography>
<Typography variant="body1">{selectedProject.status}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">생성일</Typography>
<Typography variant="body1">
{new Date(selectedProject.created_at).toLocaleDateString()}
{loading ? (
<Box display="flex" justifyContent="center" py={3}>
<CircularProgress />
</Box>
) : stats ? (
<Box>
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
{stats.total_items.toLocaleString()}
</Typography>
</Grid>
</Grid>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
자재
</Typography>
<Grid container spacing={1}>
<Grid item xs={6}>
<Chip label={`고유 품목: ${stats.unique_descriptions}`} size="small" />
</Grid>
<Grid item xs={6}>
<Chip label={`고유 사이즈: ${stats.unique_sizes}`} size="small" />
</Grid>
<Grid item xs={6}>
<Chip label={`총 수량: ${stats.total_quantity.toLocaleString()}`} size="small" color="success" />
</Grid>
<Grid item xs={6}>
<Chip label={`평균 수량: ${stats.avg_quantity}`} size="small" />
</Grid>
</Grid>
<Typography variant="body2" sx={{ mt: 2, fontSize: '0.8rem' }}>
최초 업로드: {stats.earliest_upload ? new Date(stats.earliest_upload).toLocaleString() : '-'}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
최신 업로드: {stats.latest_upload ? new Date(stats.latest_upload).toLocaleString() : '-'}
</Typography>
</Box>
) : (
<Typography variant="body2" color="textSecondary">
프로젝트를 선택하면 자재 현황을 확인할 있습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
)}
</Grid>
{/* 분류별 자재 통계 */}
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
분류별 자재 통계
</Typography>
{barData ? (
<Bar data={barData} options={{
responsive: true,
plugins: {
legend: { position: 'top' },
title: { display: true, text: '분류별 자재 수/총 수량' }
},
scales: {
y: {
beginAtZero: true
}
}
}} />
) : (
<Typography variant="body2" color="textSecondary">
자재 데이터가 없습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 재질별 분포 */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
재질별 분포
</Typography>
{materialGradeData ? (
<Pie data={materialGradeData} options={{
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: '재질별 자재 분포' }
}
}} />
) : (
<Typography variant="body2" color="textSecondary">
재질 데이터가 없습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 사이즈별 분포 */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
사이즈별 분포
</Typography>
{sizeData ? (
<Pie data={sizeData} options={{
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: '사이즈별 자재 분포' }
}
}} />
) : (
<Typography variant="body2" color="textSecondary">
사이즈 데이터가 없습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 상위 자재 */}
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
상위 자재 (수량 기준)
</Typography>
{topMaterials.length > 0 ? (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell><strong>순위</strong></TableCell>
<TableCell><strong>자재명</strong></TableCell>
<TableCell align="right"><strong> 수량</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{topMaterials.map((material, index) => (
<TableRow key={index}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300 }}>
{material.description}
</Typography>
</TableCell>
<TableCell align="right">
<Chip
label={material.quantity.toLocaleString()}
size="small"
color="primary"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="textSecondary">
자재 데이터가 없습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 프로젝트 상세 정보 */}
{selectedProject && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
📋 프로젝트 상세 정보
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
<Typography variant="body1">{selectedProject.official_project_code}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
<Typography variant="body1">{selectedProject.project_name}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">상태</Typography>
<Chip label={selectedProject.status} size="small" />
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">생성일</Typography>
<Typography variant="body1">
{new Date(selectedProject.created_at).toLocaleDateString()}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
)}
</Grid>
</Box>
);
}

View File

@@ -0,0 +1,468 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Box,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Chip,
Alert,
CircularProgress,
Grid,
TextField,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import {
Delete,
Download,
Visibility,
FileUpload,
Warning,
CheckCircle,
Error
} from '@mui/icons-material';
import { fetchFiles, deleteFile } from '../api';
import Toast from './Toast';
function FileManager({ selectedProject }) {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [deleteDialog, setDeleteDialog] = useState({ open: false, file: null });
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [filter, setFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
useEffect(() => {
if (selectedProject) {
fetchFilesList();
} else {
setFiles([]);
}
}, [selectedProject]);
// 파일 업로드 이벤트 리스너 추가
useEffect(() => {
console.log('FileManager: 이벤트 리스너 등록 시작');
const handleFileUploaded = (event) => {
const { jobNo } = event.detail;
console.log('FileManager: 파일 업로드 이벤트 수신:', event.detail);
console.log('FileManager: 현재 선택된 프로젝트:', selectedProject);
if (selectedProject && selectedProject.job_no === jobNo) {
console.log('FileManager: 파일 업로드 감지됨, 목록 갱신 중...');
fetchFilesList();
} else {
console.log('FileManager: job_no 불일치 또는 프로젝트 미선택');
console.log('이벤트 jobNo:', jobNo);
console.log('선택된 프로젝트 jobNo:', selectedProject?.job_no);
}
};
window.addEventListener('fileUploaded', handleFileUploaded);
console.log('FileManager: fileUploaded 이벤트 리스너 등록 완료');
return () => {
window.removeEventListener('fileUploaded', handleFileUploaded);
console.log('FileManager: fileUploaded 이벤트 리스너 제거');
};
}, [selectedProject]);
const fetchFilesList = async () => {
setLoading(true);
try {
console.log('FileManager: 파일 목록 조회 시작, job_no:', selectedProject.job_no);
const response = await fetchFiles({ job_no: selectedProject.job_no });
console.log('FileManager: API 응답:', response.data);
if (response.data && response.data.files) {
setFiles(response.data.files);
console.log('FileManager: 파일 목록 업데이트 완료, 파일 수:', response.data.files.length);
} else {
console.log('FileManager: 파일 목록이 비어있음');
setFiles([]);
}
} catch (error) {
console.error('FileManager: 파일 목록 조회 실패:', error);
setToast({
open: true,
message: '파일 목록을 불러오는데 실패했습니다.',
type: 'error'
});
} finally {
setLoading(false);
}
};
const handleDeleteFile = async () => {
if (!deleteDialog.file) return;
try {
await deleteFile(deleteDialog.file.id);
setToast({
open: true,
message: '파일이 성공적으로 삭제되었습니다.',
type: 'success'
});
setDeleteDialog({ open: false, file: null });
fetchFilesList(); // 목록 새로고침
} catch (error) {
console.error('파일 삭제 실패:', error);
setToast({
open: true,
message: '파일 삭제에 실패했습니다.',
type: 'error'
});
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return 'success';
case 'processing':
return 'warning';
case 'failed':
return 'error';
default:
return 'default';
}
};
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircle />;
case 'processing':
return <CircularProgress size={16} />;
case 'failed':
return <Error />;
default:
return null;
}
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString('ko-KR');
};
const filteredFiles = files.filter(file => {
const matchesFilter = !filter ||
file.original_filename.toLowerCase().includes(filter.toLowerCase()) ||
file.project_name?.toLowerCase().includes(filter.toLowerCase());
const matchesStatus = !statusFilter || file.status === statusFilter;
return matchesFilter && matchesStatus;
});
if (!selectedProject) {
return (
<Box>
<Typography variant="h4" gutterBottom>
📁 도면 관리
</Typography>
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
프로젝트를 선택해주세요
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트 관리 탭에서 프로젝트를 선택하면 도면을 관리할 있습니다.
</Typography>
</CardContent>
</Card>
</Box>
);
}
return (
<Box>
<Typography variant="h4" gutterBottom>
📁 도면 관리
</Typography>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{/* 필터 UI */}
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} sm={6} md={4}>
<TextField
label="파일명/프로젝트명 검색"
value={filter}
onChange={e => setFilter(e.target.value)}
size="small"
fullWidth
placeholder="파일명 또는 프로젝트명으로 검색"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<FormControl size="small" fullWidth>
<InputLabel>상태</InputLabel>
<Select
value={statusFilter}
label="상태"
onChange={e => setStatusFilter(e.target.value)}
>
<MenuItem value="">전체</MenuItem>
<MenuItem value="completed">완료</MenuItem>
<MenuItem value="processing">처리 </MenuItem>
<MenuItem value="failed">실패</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={12} md={4}>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant="outlined"
size="small"
onClick={() => {
setFilter('');
setStatusFilter('');
}}
>
필터 초기화
</Button>
<Button
variant="contained"
size="small"
onClick={fetchFilesList}
>
새로고침
</Button>
</Box>
</Grid>
</Grid>
</Box>
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
{loading ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress size={60} />
<Typography variant="h6" sx={{ mt: 2 }}>
파일 목록 로딩 ...
</Typography>
</CardContent>
</Card>
) : filteredFiles.length === 0 ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{filter || statusFilter ? '검색 결과가 없습니다' : '업로드된 파일이 없습니다'}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{filter || statusFilter
? '다른 검색 조건을 시도해보세요.'
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
}
</Typography>
{(filter || statusFilter) && (
<Button
variant="outlined"
onClick={() => {
setFilter('');
setStatusFilter('');
}}
>
필터 초기화
</Button>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">
{filteredFiles.length} 파일
</Typography>
<Chip
label={`${files.length}개 전체`}
color="primary"
variant="outlined"
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><strong>번호</strong></TableCell>
<TableCell><strong>파일명</strong></TableCell>
<TableCell align="center"><strong>프로젝트</strong></TableCell>
<TableCell align="center"><strong>상태</strong></TableCell>
<TableCell align="center"><strong>파일 크기</strong></TableCell>
<TableCell align="center"><strong>업로드 일시</strong></TableCell>
<TableCell align="center"><strong>처리 완료</strong></TableCell>
<TableCell align="center"><strong>작업</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredFiles.map((file, index) => (
<TableRow
key={file.id}
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
>
<TableCell>
{index + 1}
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300 }}>
{file.original_filename}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={file.project_name || '-'}
size="small"
color="primary"
/>
</TableCell>
<TableCell align="center">
<Chip
label={file.status === 'completed' ? '완료' :
file.status === 'processing' ? '처리 중' :
file.status === 'failed' ? '실패' : '대기'}
size="small"
color={getStatusColor(file.status)}
icon={getStatusIcon(file.status)}
/>
</TableCell>
<TableCell align="center">
<Typography variant="body2">
{formatFileSize(file.file_size || 0)}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="body2">
{formatDate(file.created_at)}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="body2">
{file.processed_at ? formatDate(file.processed_at) : '-'}
</Typography>
</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
<IconButton
size="small"
color="primary"
title="다운로드"
disabled={file.status !== 'completed'}
>
<Download />
</IconButton>
<IconButton
size="small"
color="info"
title="상세 보기"
>
<Visibility />
</IconButton>
<IconButton
size="small"
color="error"
title="삭제"
onClick={() => setDeleteDialog({ open: true, file })}
>
<Delete />
</IconButton>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
)}
{/* 삭제 확인 다이얼로그 */}
<Dialog
open={deleteDialog.open}
onClose={() => setDeleteDialog({ open: false, file: null })}
>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<Warning color="error" />
파일 삭제 확인
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2 }}>
다음 파일을 삭제하시겠습니까?
</Typography>
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>파일명:</strong> {deleteDialog.file?.original_filename}
</Typography>
<Typography variant="body2">
<strong>프로젝트:</strong> {deleteDialog.file?.project_name}
</Typography>
<Typography variant="body2">
<strong>업로드 일시:</strong> {deleteDialog.file?.created_at ? formatDate(deleteDialog.file.created_at) : '-'}
</Typography>
</Alert>
<Typography variant="body2" color="error">
작업은 되돌릴 없습니다. 파일과 관련된 모든 자재 데이터가 함께 삭제됩니다.
</Typography>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteDialog({ open: false, file: null })}
>
취소
</Button>
<Button
onClick={handleDeleteFile}
color="error"
variant="contained"
startIcon={<Delete />}
>
삭제
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default FileManager;

View File

@@ -1,4 +1,5 @@
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import {
Typography,
Box,
@@ -6,38 +7,74 @@ import {
CardContent,
Button,
LinearProgress,
Alert,
List,
ListItem,
ListItemText,
ListItemIcon,
Chip,
Paper,
Divider
Divider,
Stepper,
Step,
StepLabel,
StepContent,
Alert,
Grid
} from '@mui/material';
import {
CloudUpload,
AttachFile,
CheckCircle,
Error as ErrorIcon,
Description
Description,
AutoAwesome,
Category,
Science
} from '@mui/icons-material';
import { useDropzone } from 'react-dropzone';
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
import Toast from './Toast';
function FileUpload({ selectedProject, onUploadSuccess }) {
console.log('=== FileUpload 컴포넌트 렌더링 ===');
console.log('selectedProject:', selectedProject);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadResult, setUploadResult] = useState(null);
const [error, setError] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
const [showError, setShowError] = useState(false);
const [materialsSummary, setMaterialsSummary] = useState(null);
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [uploadSteps, setUploadSteps] = useState([
{ label: '파일 업로드', completed: false, active: false },
{ label: '데이터 파싱', completed: false, active: false },
{ label: '자재 분류', completed: false, active: false },
{ label: '분류기 실행', completed: false, active: false },
{ label: '데이터베이스 저장', completed: false, active: false }
]);
const onDrop = useCallback((acceptedFiles) => {
console.log('=== FileUpload: onDrop 함수 호출됨 ===');
console.log('받은 파일들:', acceptedFiles);
console.log('선택된 프로젝트:', selectedProject);
if (!selectedProject) {
setError('프로젝트를 먼저 선택해주세요.');
console.log('프로젝트가 선택되지 않음');
setToast({
open: true,
message: '프로젝트를 먼저 선택해주세요.',
type: 'warning'
});
return;
}
if (acceptedFiles.length > 0) {
console.log('파일 업로드 시작');
uploadFile(acceptedFiles[0]);
} else {
console.log('선택된 파일이 없음');
}
}, [selectedProject]);
@@ -52,57 +89,185 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
maxSize: 10 * 1024 * 1024 // 10MB
});
const updateUploadStep = (stepIndex, completed = false, active = false) => {
setUploadSteps(prev => prev.map((step, index) => ({
...step,
completed: index < stepIndex ? true : (index === stepIndex ? completed : false),
active: index === stepIndex ? active : false
})));
};
const uploadFile = async (file) => {
console.log('=== FileUpload: uploadFile 함수 시작 ===');
console.log('파일 정보:', {
name: file.name,
size: file.size,
type: file.type
});
console.log('선택된 프로젝트:', selectedProject);
setUploading(true);
setUploadProgress(0);
setError('');
setUploadResult(null);
setMaterialsSummary(null);
console.log('업로드 시작:', {
fileName: file.name,
fileSize: file.size,
jobNo: selectedProject?.job_no,
projectName: selectedProject?.project_name
});
// 업로드 단계 초기화
setUploadSteps([
{ label: '파일 업로드', completed: false, active: true },
{ label: '데이터 파싱', completed: false, active: false },
{ label: '자재 분류', completed: false, active: false },
{ label: '분류기 실행', completed: false, active: false },
{ label: '데이터베이스 저장', completed: false, active: false }
]);
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', selectedProject.id);
formData.append('job_no', selectedProject.job_no);
formData.append('revision', 'Rev.0');
try {
const xhr = new XMLHttpRequest();
console.log('FormData 내용:', {
fileName: file.name,
jobNo: selectedProject.job_no,
revision: 'Rev.0'
});
// 업로드 진행률 추적
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
setUploadProgress(progress);
try {
// 1단계: 파일 업로드
updateUploadStep(0, true, false);
updateUploadStep(1, false, true);
console.log('API 호출 시작: /upload');
const response = await uploadFileApi(formData, {
onUploadProgress: (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
setUploadProgress(progress);
console.log('업로드 진행률:', progress + '%');
}
}
});
// Promise로 XMLHttpRequest 래핑
const uploadPromise = new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
console.log('API 응답:', response.data);
const result = response.data;
console.log('응답 데이터 구조:', {
success: result.success,
file_id: result.file_id,
message: result.message,
hasFileId: 'file_id' in result
});
xhr.open('POST', 'http://localhost:8000/api/files/upload');
xhr.send(formData);
const result = await uploadPromise;
// 2단계: 데이터 파싱 완료
updateUploadStep(1, true, false);
updateUploadStep(2, false, true);
if (result.success) {
// 3단계: 자재 분류 완료
updateUploadStep(2, true, false);
updateUploadStep(3, false, true);
// 4단계: 분류기 실행 완료
updateUploadStep(3, true, false);
updateUploadStep(4, false, true);
// 5단계: 데이터베이스 저장 완료
updateUploadStep(4, true, false);
setUploadResult(result);
setToast({
open: true,
message: '파일 업로드 및 분류가 성공했습니다!',
type: 'success'
});
console.log('업로드 성공 결과:', result);
console.log('파일 ID:', result.file_id);
console.log('선택된 프로젝트:', selectedProject);
// 업로드 성공 후 자재 통계 미리보기 호출
try {
const summaryRes = await fetchMaterialsSummary({ file_id: result.file_id });
if (summaryRes.data && summaryRes.data.success) {
setMaterialsSummary(summaryRes.data.summary);
}
} catch (e) {
// 통계 조회 실패는 무시(UX만)
}
if (onUploadSuccess) {
console.log('onUploadSuccess 콜백 호출');
onUploadSuccess(result);
}
// 파일 목록 갱신을 위한 이벤트 발생
console.log('파일 업로드 이벤트 발생:', {
fileId: result.file_id,
jobNo: selectedProject.job_no
});
try {
window.dispatchEvent(new CustomEvent('fileUploaded', {
detail: { fileId: result.file_id, jobNo: selectedProject.job_no }
}));
console.log('CustomEvent dispatch 성공');
} catch (error) {
console.error('CustomEvent dispatch 실패:', error);
}
} else {
setError(result.message || '업로드에 실패했습니다.');
setToast({
open: true,
message: result.message || '업로드에 실패했습니다.',
type: 'error'
});
}
} catch (error) {
console.error('업로드 실패:', error);
setError(`업로드 실패: ${error.message}`);
// 에러 타입별 상세 메시지
let errorMessage = '업로드에 실패했습니다.';
if (error.response) {
// 서버 응답이 있는 경우
const status = error.response.status;
const data = error.response.data;
switch (status) {
case 400:
errorMessage = `잘못된 요청: ${data?.detail || '파일 형식이나 데이터를 확인해주세요.'}`;
break;
case 413:
errorMessage = '파일 크기가 너무 큽니다. (최대 10MB)';
break;
case 422:
errorMessage = `데이터 검증 실패: ${data?.detail || '파일 내용을 확인해주세요.'}`;
break;
case 500:
errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
break;
default:
errorMessage = `서버 오류 (${status}): ${data?.detail || error.message}`;
}
} else if (error.request) {
// 네트워크 오류
errorMessage = '서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.';
} else {
// 기타 오류
errorMessage = `오류 발생: ${error.message}`;
}
setToast({
open: true,
message: errorMessage,
type: 'error'
});
} finally {
setUploading(false);
setUploadProgress(0);
@@ -118,30 +283,32 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
const resetUpload = () => {
setUploadResult(null);
setError('');
setUploadProgress(0);
setUploadSteps([
{ label: '파일 업로드', completed: false, active: false },
{ label: '데이터 파싱', completed: false, active: false },
{ label: '자재 분류', completed: false, active: false },
{ label: '분류기 실행', completed: false, active: false },
{ label: '데이터베이스 저장', completed: false, active: false }
]);
setToast({ open: false, message: '', type: 'info' });
};
if (!selectedProject) {
return (
<Box>
<Typography variant="h4" gutterBottom>
📁 파일 업로드
</Typography>
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<CloudUpload sx={{ fontSize: 64, color: 'grey.400', mb: 2 }} />
<Typography variant="h6" gutterBottom>
프로젝트를 선택해주세요
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트 관리 탭에서 프로젝트를 선택한 파일을 업로드할 있습니다.
</Typography>
</CardContent>
</Card>
</Box>
);
}
const getClassificationStats = () => {
if (!uploadResult?.classification_stats) return null;
const stats = uploadResult.classification_stats;
const total = Object.values(stats).reduce((sum, count) => sum + count, 0);
return Object.entries(stats)
.filter(([category, count]) => count > 0)
.map(([category, count]) => ({
category,
count,
percentage: total > 0 ? Math.round((count / total) * 100) : 0
}))
.sort((a, b) => b.count - a.count);
};
return (
<Box>
@@ -153,10 +320,56 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
{uploading && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<AutoAwesome sx={{ mr: 1, color: 'primary.main' }} />
업로드 분류 진행 ...
</Typography>
<Stepper orientation="vertical" sx={{ mt: 2 }}>
{uploadSteps.map((step, index) => (
<Step key={index} active={step.active} completed={step.completed}>
<StepLabel>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{step.completed ? (
<CheckCircle color="success" sx={{ mr: 1 }} />
) : step.active ? (
<Science color="primary" sx={{ mr: 1 }} />
) : (
<Category color="disabled" sx={{ mr: 1 }} />
)}
{step.label}
</Box>
</StepLabel>
{step.active && (
<StepContent>
<LinearProgress sx={{ mt: 1 }} />
</StepContent>
)}
</Step>
))}
</Stepper>
{uploadProgress > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary" gutterBottom>
파일 업로드 진행률: {uploadProgress}%
</Typography>
<LinearProgress variant="determinate" value={uploadProgress} />
</Box>
)}
</CardContent>
</Card>
)}
{uploadResult ? (
@@ -165,149 +378,171 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
<Box display="flex" alignItems="center" mb={2}>
<CheckCircle color="success" sx={{ mr: 1 }} />
<Typography variant="h6" color="success.main">
업로드 성공!
업로드 분류 성공!
</Typography>
</Box>
<List>
<ListItem>
<ListItemIcon>
<Description color="primary" />
</ListItemIcon>
<ListItemText
primary={uploadResult.original_filename}
secondary={`파일 ID: ${uploadResult.file_id}`}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="파싱 결과"
secondary={
<Box sx={{ mt: 1 }}>
<Chip
label={`${uploadResult.parsed_materials_count}개 자재 파싱`}
color="primary"
sx={{ mr: 1 }}
/>
<Chip
label={`${uploadResult.saved_materials_count}개 DB 저장`}
color="success"
/>
</Box>
}
/>
</ListItem>
</List>
{uploadResult.sample_materials && uploadResult.sample_materials.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary" gutterBottom>
샘플 자재 (처음 3):
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={12} md={6}>
<Typography variant="subtitle1" gutterBottom>
📊 업로드 결과
</Typography>
{uploadResult.sample_materials.map((material, index) => (
<Typography key={index} variant="body2" sx={{
bgcolor: 'grey.50',
p: 1,
mb: 0.5,
borderRadius: 1,
fontSize: '0.8rem'
}}>
{index + 1}. {material.original_description} - {material.quantity} {material.unit}
{material.size_spec && ` (${material.size_spec})`}
</Typography>
))}
</Box>
<List dense>
<ListItem>
<ListItemIcon>
<Description />
</ListItemIcon>
<ListItemText
primary="파일명"
secondary={uploadResult.original_filename}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<CheckCircle />
</ListItemIcon>
<ListItemText
primary="파싱된 자재 수"
secondary={`${uploadResult.parsed_materials_count}`}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<CheckCircle />
</ListItemIcon>
<ListItemText
primary="저장된 자재 수"
secondary={`${uploadResult.saved_materials_count}`}
/>
</ListItem>
</List>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle1" gutterBottom>
🏷 분류 결과
</Typography>
{getClassificationStats() && (
<Box>
{getClassificationStats().map((stat, index) => (
<Box key={index} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Chip
label={stat.category}
size="small"
color="primary"
variant="outlined"
/>
<Typography variant="body2">
{stat.count} ({stat.percentage}%)
</Typography>
</Box>
))}
</Box>
)}
</Grid>
</Grid>
{materialsSummary && (
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="body2">
💡 <strong>자재 통계 미리보기:</strong><br/>
자재 : {materialsSummary.total_items || 0}<br/>
고유 자재: {materialsSummary.unique_descriptions || 0}종류<br/>
수량: {materialsSummary.total_quantity || 0}
</Typography>
</Alert>
)}
<Box sx={{ mt: 2 }}>
<Button variant="outlined" onClick={resetUpload}>
다른 파일 업로드
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
<Button
variant="contained"
onClick={() => window.location.href = '/materials'}
startIcon={<Description />}
>
자재 목록 보기
</Button>
<Button
variant="outlined"
onClick={resetUpload}
>
새로 업로드
</Button>
</Box>
</CardContent>
</Card>
) : (
<Card>
<CardContent>
{uploading ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
파일 업로드 ...
</Typography>
<Box sx={{ width: '100%', maxWidth: 400, mx: 'auto', mt: 2 }}>
<LinearProgress
variant="determinate"
value={uploadProgress}
sx={{ height: 8, borderRadius: 4 }}
/>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{uploadProgress}% 완료
</Typography>
</Box>
</Box>
) : (
<>
<Paper
{...getRootProps()}
sx={{
p: 4,
textAlign: 'center',
border: 2,
borderStyle: 'dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.300',
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'primary.50'
}
}}
>
<input {...getInputProps()} />
<CloudUpload sx={{
fontSize: 64,
color: isDragActive ? 'primary.main' : 'grey.400',
mb: 2
}} />
<Typography variant="h6" gutterBottom>
{isDragActive
? "파일을 여기에 놓으세요!"
: "Excel 파일을 드래그하거나 클릭하여 선택"
}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
지원 형식: .xlsx, .xls, .csv (최대 10MB)
</Typography>
<Button
variant="contained"
startIcon={<AttachFile />}
component="span"
>
파일 선택
</Button>
</Paper>
<>
<Paper
{...getRootProps()}
onClick={() => console.log('드래그 앤 드롭 영역 클릭됨')}
sx={{
p: 4,
textAlign: 'center',
border: 2,
borderStyle: 'dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.300',
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'primary.50'
}
}}
>
<input {...getInputProps()} />
<CloudUpload sx={{
fontSize: 64,
color: isDragActive ? 'primary.main' : 'grey.400',
mb: 2
}} />
<Typography variant="h6" gutterBottom>
{isDragActive
? "파일을 여기에 놓으세요!"
: "Excel 파일을 드래그하거나 클릭하여 선택"
}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
지원 형식: .xlsx, .xls, .csv (최대 10MB)
</Typography>
<Button
variant="contained"
startIcon={<AttachFile />}
component="span"
disabled={uploading}
onClick={() => console.log('파일 선택 버튼 클릭됨')}
>
{uploading ? '업로드 중...' : '파일 선택'}
</Button>
</Paper>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary">
💡 <strong>업로드 :</strong>
</Typography>
<Typography variant="body2" color="textSecondary">
BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
</Typography>
<Typography variant="body2" color="textSecondary">
자재명, 수량, 사이즈, 재질 등이 자동으로 분류됩니다
</Typography>
</Box>
</>
)}
</CardContent>
</Card>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary">
💡 <strong>업로드 분류 프로세스:</strong>
</Typography>
<Typography variant="body2" color="textSecondary">
BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
</Typography>
<Typography variant="body2" color="textSecondary">
자재는 8가지 분류기(볼트, 플랜지, 피팅, 가스켓, 계기, 파이프, 밸브, 재질) 자동 분류됩니다
</Typography>
<Typography variant="body2" color="textSecondary">
분류 결과는 신뢰도 점수와 함께 저장되며, 필요시 수동 검증이 가능합니다
</Typography>
</Box>
</>
)}
</Box>
);
}
FileUpload.propTypes = {
selectedProject: PropTypes.shape({
id: PropTypes.string.isRequired,
project_name: PropTypes.string.isRequired,
official_project_code: PropTypes.string.isRequired,
}).isRequired,
onUploadSuccess: PropTypes.func,
};
export default FileUpload;

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
Typography,
Box,
@@ -13,48 +14,211 @@ import {
Paper,
TablePagination,
CircularProgress,
Alert,
Chip
Chip,
TextField,
MenuItem,
Select,
InputLabel,
FormControl,
Grid,
IconButton,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
Tabs,
Tab,
Alert
} from '@mui/material';
import { Inventory } from '@mui/icons-material';
import {
Inventory,
Clear,
ExpandMore,
CompareArrows,
Add,
Remove,
Warning
} from '@mui/icons-material';
import SearchIcon from '@mui/icons-material/Search';
import { fetchMaterials as fetchMaterialsApi, fetchJobs } from '../api';
import { useSearchParams } from 'react-router-dom';
import Toast from './Toast';
function MaterialList({ selectedProject }) {
const [searchParams, setSearchParams] = useSearchParams();
const [materials, setMaterials] = useState([]);
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [search, setSearch] = useState(searchParams.get('search') || '');
const [searchValue, setSearchValue] = useState(searchParams.get('searchValue') || '');
const [itemType, setItemType] = useState(searchParams.get('itemType') || '');
const [materialGrade, setMaterialGrade] = useState(searchParams.get('materialGrade') || '');
const [sizeSpec, setSizeSpec] = useState(searchParams.get('sizeSpec') || '');
const [fileFilter, setFileFilter] = useState(searchParams.get('fileFilter') || '');
const [selectedJob, setSelectedJob] = useState(searchParams.get('jobId') || '');
const [selectedRevision, setSelectedRevision] = useState(searchParams.get('revision') || '');
const [groupingType, setGroupingType] = useState(searchParams.get('grouping') || 'item');
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || '');
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [revisionComparison, setRevisionComparison] = useState(null);
const [files, setFiles] = useState([]);
const [fileId, setFileId] = useState(searchParams.get('file_id') || '');
const [selectedJobNo, setSelectedJobNo] = useState(searchParams.get('job_no') || '');
const [selectedFilename, setSelectedFilename] = useState(searchParams.get('filename') || '');
// URL 쿼리 파라미터 동기화
useEffect(() => {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (searchValue) params.set('searchValue', searchValue);
if (itemType) params.set('itemType', itemType);
if (materialGrade) params.set('materialGrade', materialGrade);
if (sizeSpec) params.set('sizeSpec', sizeSpec);
if (fileFilter) params.set('fileFilter', fileFilter);
if (selectedJob) params.set('jobId', selectedJob);
if (selectedRevision) params.set('revision', selectedRevision);
if (groupingType) params.set('grouping', groupingType);
if (sortBy) params.set('sortBy', sortBy);
if (page > 0) params.set('page', page.toString());
if (rowsPerPage !== 25) params.set('rowsPerPage', rowsPerPage.toString());
if (fileId) params.set('file_id', fileId);
if (selectedJobNo) params.set('job_no', selectedJobNo);
if (selectedFilename) params.set('filename', selectedFilename);
if (selectedRevision) params.set('revision', selectedRevision);
setSearchParams(params);
}, [search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, page, rowsPerPage, fileId, setSearchParams, selectedJobNo, selectedFilename, selectedRevision]);
// URL 파라미터로 진입 시 자동 필터 적용
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const urlJobNo = urlParams.get('job_no');
const urlFilename = urlParams.get('filename');
const urlRevision = urlParams.get('revision');
if (urlJobNo) setSelectedJobNo(urlJobNo);
if (urlFilename) setSelectedFilename(urlFilename);
if (urlRevision) setSelectedRevision(urlRevision);
}, []);
useEffect(() => {
if (selectedProject) {
fetchJobsData();
fetchMaterials();
} else {
setMaterials([]);
setTotalCount(0);
setJobs([]);
}
}, [selectedProject, page, rowsPerPage]);
}, [selectedProject, page, rowsPerPage, search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, fileId, selectedJobNo, selectedFilename, selectedRevision]);
// 파일 업로드 이벤트 리스너 추가
useEffect(() => {
const handleFileUploaded = (event) => {
const { jobNo } = event.detail;
if (selectedProject && selectedProject.job_no === jobNo) {
console.log('파일 업로드 감지됨, 자재 목록 갱신 중...');
fetchMaterials();
}
};
window.addEventListener('fileUploaded', handleFileUploaded);
return () => {
window.removeEventListener('fileUploaded', handleFileUploaded);
};
}, [selectedProject]);
// 파일 목록 불러오기 (선택된 프로젝트가 바뀔 때마다)
useEffect(() => {
async function fetchFilesForProject() {
if (!selectedProject?.job_no) {
setFiles([]);
return;
}
try {
const response = await fetch(`/files?job_no=${selectedProject.job_no}`);
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) setFiles(data);
else if (data && Array.isArray(data.files)) setFiles(data.files);
else setFiles([]);
} else {
setFiles([]);
}
} catch {
setFiles([]);
}
}
fetchFilesForProject();
}, [selectedProject]);
const fetchJobsData = async () => {
try {
const response = await fetchJobs({ project_id: selectedProject.id });
if (response.data && response.data.jobs) {
setJobs(response.data.jobs);
}
} catch (error) {
console.error('Job 조회 실패:', error);
}
};
const fetchMaterials = async () => {
setLoading(true);
setError('');
try {
const skip = page * rowsPerPage;
const response = await fetch(
`http://localhost:8000/api/files/materials?project_id=${selectedProject.id}&skip=${skip}&limit=${rowsPerPage}`
);
const params = {
job_no: selectedJobNo || undefined,
filename: selectedFilename || undefined,
revision: selectedRevision || undefined,
skip,
limit: rowsPerPage,
search: search || undefined,
search_value: searchValue || undefined,
item_type: itemType || undefined,
material_grade: materialGrade || undefined,
size_spec: sizeSpec || undefined,
// file_id, fileFilter 등은 사용하지 않음
grouping: groupingType || undefined,
sort_by: sortBy || undefined
};
if (response.ok) {
const data = await response.json();
setMaterials(data.materials || []);
setTotalCount(data.total_count || 0);
} else {
setError('자재 데이터를 불러오는데 실패했습니다.');
// selectedProject가 없으면 API 호출하지 않음
if (!selectedProject?.job_no) {
setMaterials([]);
setTotalCount(0);
setLoading(false);
return;
}
console.log('API 요청 파라미터:', params); // 디버깅용
const response = await fetchMaterialsApi(params);
const data = response.data;
console.log('API 응답:', data); // 디버깅용
setMaterials(data.materials || []);
setTotalCount(data.total_count || 0);
// 리비전 비교 데이터가 있으면 설정
if (data.revision_comparison) {
setRevisionComparison(data.revision_comparison);
}
} catch (error) {
console.error('자재 조회 실패:', error);
setError('네트워크 오류가 발생했습니다.');
console.error('에러 상세:', error.response?.data); // 디버깅용
setToast({
open: true,
message: `자재 데이터를 불러오는데 실패했습니다: ${error.response?.data?.detail || error.message}`,
type: 'error'
});
setMaterials([]);
setTotalCount(0);
} finally {
setLoading(false);
}
@@ -69,6 +233,20 @@ function MaterialList({ selectedProject }) {
setPage(0);
};
const clearFilters = () => {
setSearch('');
setSearchValue('');
setItemType('');
setMaterialGrade('');
setSizeSpec('');
setFileFilter('');
setSelectedJob('');
setSelectedRevision('');
setGroupingType('item');
setSortBy('');
setPage(0);
};
const getItemTypeColor = (itemType) => {
const colors = {
'PIPE': 'primary',
@@ -81,6 +259,18 @@ function MaterialList({ selectedProject }) {
return colors[itemType] || 'default';
};
const getRevisionChangeColor = (change) => {
if (change > 0) return 'success';
if (change < 0) return 'error';
return 'default';
};
const getRevisionChangeIcon = (change) => {
if (change > 0) return <Add />;
if (change < 0) return <Remove />;
return null;
};
if (!selectedProject) {
return (
<Box>
@@ -112,12 +302,378 @@ function MaterialList({ selectedProject }) {
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
{/* 필터/검색/정렬 UI */}
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<Grid container spacing={2} alignItems="center">
{/* 검색 유형 */}
<Grid item xs={12} sm={3} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>검색 유형</InputLabel>
<Select
value={search}
label="검색 유형"
onChange={e => setSearch(e.target.value)}
displayEmpty
>
<MenuItem key="all-search" value="">전체</MenuItem>
<MenuItem key="project-search" value="project">프로젝트명</MenuItem>
<MenuItem key="job-search" value="job">Job No.</MenuItem>
<MenuItem key="material-search" value="material">자재명</MenuItem>
<MenuItem key="description-search" value="description">설명</MenuItem>
<MenuItem key="grade-search" value="grade">재질</MenuItem>
<MenuItem key="size-search" value="size">사이즈</MenuItem>
<MenuItem key="filename-search" value="filename">파일명</MenuItem>
</Select>
</FormControl>
</Grid>
{/* 검색어 입력/선택 */}
<Grid item xs={12} sm={3} md={2}>
{search === 'project' ? (
<FormControl size="small" fullWidth>
<InputLabel>프로젝트명 선택</InputLabel>
<Select
value={searchValue}
label="프로젝트명 선택"
onChange={e => setSearchValue(e.target.value)}
displayEmpty
>
<MenuItem key="all-projects" value="">전체 프로젝트</MenuItem>
<MenuItem key="mp7-rev2" value="MP7 PIPING PROJECT Rev.2">MP7 PIPING PROJECT Rev.2</MenuItem>
<MenuItem key="pp5-5701" value="PP5 5701">PP5 5701</MenuItem>
<MenuItem key="mp7" value="MP7">MP7</MenuItem>
</Select>
</FormControl>
) : search === 'job' ? (
<FormControl size="small" fullWidth>
<InputLabel>Job No. 선택</InputLabel>
<Select
value={searchValue}
label="Job No. 선택"
onChange={e => setSearchValue(e.target.value)}
displayEmpty
>
<MenuItem key="all-jobs-search" value="">전체 Job</MenuItem>
{jobs.map((job) => (
<MenuItem key={`job-search-${job.id}`} value={job.job_number}>
{job.job_number}
</MenuItem>
))}
</Select>
</FormControl>
) : search === 'material' || search === 'description' ? (
<TextField
label="자재명/설명 검색"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="자재명 또는 설명 입력"
InputProps={{
endAdornment: (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
) : search === 'grade' ? (
<TextField
label="재질 검색"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="재질 입력 (예: SS316)"
InputProps={{
endAdornment: (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
) : search === 'size' ? (
<TextField
label="사이즈 검색"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="사이즈 입력 (예: 6인치)"
InputProps={{
endAdornment: (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
) : search === 'filename' ? (
<TextField
label="파일명 검색"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="파일명 입력"
InputProps={{
endAdornment: (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
) : (
<TextField
label="검색어"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="검색어 입력"
disabled={!search}
InputProps={{
endAdornment: search && (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
)}
</Grid>
{/* Job No. 선택 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>Job No.</InputLabel>
<Select
value={selectedJobNo}
label="Job No."
onChange={e => setSelectedJobNo(e.target.value)}
displayEmpty
>
<MenuItem value="">전체</MenuItem>
{jobs.map((job) => (
<MenuItem key={job.job_number} value={job.job_number}>
{job.job_number}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 도면명(파일명) 선택 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>도면명(파일명)</InputLabel>
<Select
value={selectedFilename}
label="도면명(파일명)"
onChange={e => setSelectedFilename(e.target.value)}
displayEmpty
>
<MenuItem value="">전체</MenuItem>
{files.map((file) => (
<MenuItem key={file.id} value={file.original_filename}>
{file.bom_name || file.original_filename || file.filename}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 리비전 선택 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>리비전</InputLabel>
<Select
value={selectedRevision}
label="리비전"
onChange={e => setSelectedRevision(e.target.value)}
displayEmpty
>
<MenuItem value="">전체</MenuItem>
{files
.filter(file => file.original_filename === selectedFilename)
.map((file) => (
<MenuItem key={file.id} value={file.revision}>
{file.revision}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 그룹핑 타입 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>그룹핑</InputLabel>
<Select
value={groupingType}
label="그룹핑"
onChange={e => setGroupingType(e.target.value)}
>
<MenuItem key="item" value="item">품목별</MenuItem>
<MenuItem key="material" value="material">재질별</MenuItem>
<MenuItem key="size" value="size">사이즈별</MenuItem>
<MenuItem key="job" value="job">Job별</MenuItem>
<MenuItem key="revision" value="revision">리비전별</MenuItem>
</Select>
</FormControl>
</Grid>
{/* 품목 필터 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>품목</InputLabel>
<Select
value={itemType}
label="품목"
onChange={e => setItemType(e.target.value)}
>
<MenuItem key="all" value="">전체</MenuItem>
<MenuItem key="PIPE" value="PIPE">PIPE</MenuItem>
<MenuItem key="FITTING" value="FITTING">FITTING</MenuItem>
<MenuItem key="VALVE" value="VALVE">VALVE</MenuItem>
<MenuItem key="FLANGE" value="FLANGE">FLANGE</MenuItem>
<MenuItem key="BOLT" value="BOLT">BOLT</MenuItem>
<MenuItem key="OTHER" value="OTHER">기타</MenuItem>
</Select>
</FormControl>
</Grid>
{/* 재질 필터 */}
<Grid item xs={6} sm={2} md={2}>
<TextField
label="재질"
value={materialGrade}
onChange={e => setMaterialGrade(e.target.value)}
size="small"
fullWidth
placeholder="예: SS316"
/>
</Grid>
{/* 사이즈 필터 */}
<Grid item xs={6} sm={2} md={2}>
<TextField
label="사이즈"
value={sizeSpec}
onChange={e => setSizeSpec(e.target.value)}
size="small"
fullWidth
placeholder={'예: 6"'}
/>
</Grid>
{/* 파일명 필터 */}
<Grid item xs={6} sm={2} md={2}>
<TextField
label="파일명"
value={fileFilter}
onChange={e => setFileFilter(e.target.value)}
size="small"
fullWidth
placeholder="파일명 검색"
/>
</Grid>
{/* 파일(도면) 선택 드롭다운 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>도면(파일)</InputLabel>
<Select
value={fileId}
label="도면(파일)"
onChange={e => setFileId(e.target.value)}
displayEmpty
>
<MenuItem value="">전체</MenuItem>
{files.map((file) => (
<MenuItem key={file.id} value={file.id}>
{file.bom_name || file.original_filename || file.filename}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 정렬 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>정렬</InputLabel>
<Select
value={sortBy}
label="정렬"
onChange={e => setSortBy(e.target.value)}
>
<MenuItem key="default" value="">기본</MenuItem>
<MenuItem key="quantity_desc" value="quantity_desc">수량 내림차순</MenuItem>
<MenuItem key="quantity_asc" value="quantity_asc">수량 오름차순</MenuItem>
<MenuItem key="name_asc" value="name_asc">이름 오름차순</MenuItem>
<MenuItem key="name_desc" value="name_desc">이름 내림차순</MenuItem>
<MenuItem key="created_desc" value="created_desc">최신 업로드</MenuItem>
<MenuItem key="created_asc" value="created_asc">오래된 업로드</MenuItem>
</Select>
</FormControl>
</Grid>
{/* 필터 초기화 */}
<Grid item xs={12}>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant="outlined"
size="small"
startIcon={<Clear />}
onClick={clearFilters}
>
필터 초기화
</Button>
</Box>
</Grid>
</Grid>
</Box>
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
{/* 리비전 비교 알림 */}
{revisionComparison && (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>리비전 비교:</strong> {revisionComparison.summary}
</Typography>
</Alert>
)}
{/* 필터 상태 표시 */}
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision || groupingType !== 'item' || sortBy) && (
<Box sx={{ mb: 2, p: 1, bgcolor: 'info.50', borderRadius: 1, border: '1px solid', borderColor: 'info.200' }}>
<Typography variant="body2" color="info.main">
필터 적용 :
{search && ` 검색 유형: ${search}`}
{searchValue && ` 검색어: "${searchValue}"`}
{selectedJobNo && ` Job No: ${selectedJobNo}`}
{selectedFilename && ` 파일: ${selectedFilename}`}
{selectedRevision && ` 리비전: ${selectedRevision}`}
{groupingType !== 'item' && ` 그룹핑: ${groupingType}`}
{itemType && ` 품목: ${itemType}`}
{materialGrade && ` 재질: ${materialGrade}`}
{sizeSpec && ` 사이즈: ${sizeSpec}`}
{fileFilter && ` 파일: ${fileFilter}`}
{sortBy && ` 정렬: ${sortBy}`}
</Typography>
</Box>
)}
{loading ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
@@ -132,11 +688,19 @@ function MaterialList({ selectedProject }) {
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
자재 데이터가 없습니다
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision ? '검색 결과가 없습니다' : '자재 데이터가 없습니다'}
</Typography>
<Typography variant="body2" color="textSecondary">
파일 업로드 탭에서 BOM 파일을 업로드해주세요.
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision
? '다른 검색 조건을 시도해보세요.'
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
}
</Typography>
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision) && (
<Button variant="outlined" onClick={clearFilters}>
필터 초기화
</Button>
)}
</CardContent>
</Card>
) : (
@@ -164,13 +728,16 @@ function MaterialList({ selectedProject }) {
<TableCell align="center"><strong>단위</strong></TableCell>
<TableCell align="center"><strong>사이즈</strong></TableCell>
<TableCell><strong>재질</strong></TableCell>
<TableCell align="center"><strong>Job No.</strong></TableCell>
<TableCell align="center"><strong>리비전</strong></TableCell>
<TableCell align="center"><strong>변경</strong></TableCell>
<TableCell align="center"><strong>라인 </strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{materials.map((material, index) => (
<TableRow
key={material.id}
key={`${material.id}-${index}`}
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
>
<TableCell>
@@ -208,6 +775,30 @@ function MaterialList({ selectedProject }) {
{material.material_grade || '-'}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={material.job_number || '-'}
size="small"
color="info"
/>
</TableCell>
<TableCell align="center">
<Chip
label={material.revision || 'Rev.0'}
size="small"
color="warning"
/>
</TableCell>
<TableCell align="center">
{material.quantity_change && (
<Chip
label={`${material.quantity_change > 0 ? '+' : ''}${material.quantity_change}`}
size="small"
color={getRevisionChangeColor(material.quantity_change)}
icon={getRevisionChangeIcon(material.quantity_change)}
/>
)}
</TableCell>
<TableCell align="center">
<Chip
label={`${material.line_count || 1}개 라인`}
@@ -242,4 +833,12 @@ function MaterialList({ selectedProject }) {
);
}
MaterialList.propTypes = {
selectedProject: PropTypes.shape({
id: PropTypes.string.isRequired,
project_name: PropTypes.string.isRequired,
official_project_code: PropTypes.string.isRequired,
}),
};
export default MaterialList;

View File

@@ -11,67 +11,228 @@ import {
DialogActions,
TextField,
Alert,
CircularProgress
CircularProgress,
Snackbar,
IconButton,
Chip,
Grid,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
Divider,
Menu,
MenuItem
} from '@mui/material';
import { Add, Assignment } from '@mui/icons-material';
import {
Add,
Assignment,
Edit,
Delete,
MoreVert,
Visibility,
CheckCircle,
Warning
} from '@mui/icons-material';
import { createJob, updateProject, deleteProject } from '../api';
import Toast from './Toast';
function ProjectManager({ projects, selectedProject, setSelectedProject, onProjectsChange }) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [editingProject, setEditingProject] = useState(null);
const [projectCode, setProjectCode] = useState('');
const [projectName, setProjectName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [menuAnchor, setMenuAnchor] = useState(null);
const [selectedProjectForMenu, setSelectedProjectForMenu] = useState(null);
const handleCreateProject = async () => {
if (!projectCode.trim() || !projectName.trim()) {
setError('프로젝트 코드와 이름을 모두 입력해주세요.');
setToast({
open: true,
message: '프로젝트 코드와 이름을 모두 입력해주세요.',
type: 'warning'
});
return;
}
setLoading(true);
setError('');
try {
const response = await fetch('http://localhost:8000/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
official_project_code: projectCode.trim(),
project_name: projectName.trim(),
design_project_code: projectCode.trim(),
is_code_matched: true,
status: 'active'
})
});
if (response.ok) {
const newProject = await response.json();
const data = {
official_project_code: projectCode.trim(),
project_name: projectName.trim(),
design_project_code: projectCode.trim(),
is_code_matched: true,
status: 'active'
};
const response = await createJob(data);
const result = response.data;
if (result && result.job) {
onProjectsChange();
setSelectedProject(newProject);
setSelectedProject(result.job);
setDialogOpen(false);
setProjectCode('');
setProjectName('');
setError('');
setToast({
open: true,
message: '프로젝트가 성공적으로 생성되었습니다.',
type: 'success'
});
} else {
const errorData = await response.json();
setError(errorData.detail || '프로젝트 생성에 실패했습니다.');
setToast({
open: true,
message: result.message || '프로젝트 생성에 실패했습니다.',
type: 'error'
});
}
} catch (error) {
console.error('프로젝트 생성 실패:', error);
setError('네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.');
setToast({
open: true,
message: '네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.',
type: 'error'
});
} finally {
setLoading(false);
}
};
const handleEditProject = async () => {
if (!editingProject || !editingProject.project_name.trim()) {
setToast({
open: true,
message: '프로젝트명을 입력해주세요.',
type: 'warning'
});
return;
}
setLoading(true);
try {
const response = await updateProject(editingProject.id, {
project_name: editingProject.project_name.trim(),
status: editingProject.status
});
if (response.data && response.data.success) {
onProjectsChange();
setEditDialogOpen(false);
setEditingProject(null);
setToast({
open: true,
message: '프로젝트가 성공적으로 수정되었습니다.',
type: 'success'
});
} else {
setToast({
open: true,
message: response.data?.message || '프로젝트 수정에 실패했습니다.',
type: 'error'
});
}
} catch (error) {
console.error('프로젝트 수정 실패:', error);
setToast({
open: true,
message: '프로젝트 수정 중 오류가 발생했습니다.',
type: 'error'
});
} finally {
setLoading(false);
}
};
const handleDeleteProject = async (project) => {
if (!window.confirm(`정말로 프로젝트 "${project.project_name}"을 삭제하시겠습니까?`)) {
return;
}
setLoading(true);
try {
const response = await deleteProject(project.id);
if (response.data && response.data.success) {
onProjectsChange();
if (selectedProject?.id === project.id) {
setSelectedProject(null);
}
setToast({
open: true,
message: '프로젝트가 성공적으로 삭제되었습니다.',
type: 'success'
});
} else {
setToast({
open: true,
message: response.data?.message || '프로젝트 삭제에 실패했습니다.',
type: 'error'
});
}
} catch (error) {
console.error('프로젝트 삭제 실패:', error);
setToast({
open: true,
message: '프로젝트 삭제 중 오류가 발생했습니다.',
type: 'error'
});
} finally {
setLoading(false);
}
};
const handleOpenMenu = (event, project) => {
setMenuAnchor(event.currentTarget);
setSelectedProjectForMenu(project);
};
const handleCloseMenu = () => {
setMenuAnchor(null);
setSelectedProjectForMenu(null);
};
const handleEditClick = () => {
setEditingProject({ ...selectedProjectForMenu });
setEditDialogOpen(true);
handleCloseMenu();
};
const handleDetailClick = () => {
setDetailDialogOpen(true);
handleCloseMenu();
};
const handleCloseDialog = () => {
setDialogOpen(false);
setProjectCode('');
setProjectName('');
setError('');
};
const handleCloseEditDialog = () => {
setEditDialogOpen(false);
setEditingProject(null);
};
const getStatusColor = (status) => {
switch (status) {
case 'active': return 'success';
case 'inactive': return 'warning';
case 'completed': return 'info';
default: return 'default';
}
};
const getStatusIcon = (status) => {
switch (status) {
case 'active': return <CheckCircle />;
case 'inactive': return <Warning />;
default: return <Assignment />;
}
};
return (
@@ -89,6 +250,14 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
</Button>
</Box>
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
{projects.length === 0 ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
@@ -109,42 +278,89 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
</CardContent>
</Card>
) : (
<Box>
<Grid container spacing={2}>
{projects.map((project) => (
<Card
key={project.id}
sx={{
mb: 2,
cursor: 'pointer',
border: selectedProject?.id === project.id ? 2 : 1,
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider'
}}
onClick={() => setSelectedProject(project)}
>
<CardContent>
<Typography variant="h6">
{project.project_name || project.official_project_code}
</Typography>
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
코드: {project.official_project_code}
</Typography>
<Typography variant="body2" color="textSecondary">
상태: {project.status} | 생성일: {new Date(project.created_at).toLocaleDateString()}
</Typography>
</CardContent>
</Card>
<Grid item xs={12} md={6} lg={4} key={project.id}>
<Card
sx={{
cursor: 'pointer',
border: selectedProject?.id === project.id ? 2 : 1,
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider',
'&:hover': {
boxShadow: 3,
borderColor: 'primary.main'
}
}}
onClick={() => setSelectedProject(project)}
>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{project.project_name || project.official_project_code}
</Typography>
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
코드: {project.official_project_code}
</Typography>
<Chip
label={project.status}
size="small"
color={getStatusColor(project.status)}
icon={getStatusIcon(project.status)}
sx={{ mb: 1 }}
/>
<Typography variant="body2" color="textSecondary">
생성일: {new Date(project.created_at).toLocaleDateString()}
</Typography>
</Box>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleOpenMenu(e, project);
}}
>
<MoreVert />
</IconButton>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Box>
</Grid>
)}
{/* 프로젝트 메뉴 */}
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={handleCloseMenu}
>
<MenuItem onClick={handleDetailClick}>
<Visibility sx={{ mr: 1 }} />
상세 보기
</MenuItem>
<MenuItem onClick={handleEditClick}>
<Edit sx={{ mr: 1 }} />
수정
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
handleDeleteProject(selectedProjectForMenu);
handleCloseMenu();
}}
sx={{ color: 'error.main' }}
>
<Delete sx={{ mr: 1 }} />
삭제
</MenuItem>
</Menu>
{/* 새 프로젝트 생성 다이얼로그 */}
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle> 프로젝트 생성</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
autoFocus
margin="dense"
@@ -180,6 +396,121 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
</Button>
</DialogActions>
</Dialog>
{/* 프로젝트 수정 다이얼로그 */}
<Dialog open={editDialogOpen} onClose={handleCloseEditDialog} maxWidth="sm" fullWidth>
<DialogTitle>프로젝트 수정</DialogTitle>
<DialogContent>
<TextField
margin="dense"
label="프로젝트 코드"
fullWidth
variant="outlined"
value={editingProject?.official_project_code || ''}
disabled
sx={{ mb: 2 }}
/>
<TextField
autoFocus
margin="dense"
label="프로젝트명"
fullWidth
variant="outlined"
value={editingProject?.project_name || ''}
onChange={(e) => setEditingProject({
...editingProject,
project_name: e.target.value
})}
sx={{ mb: 2 }}
/>
<TextField
select
margin="dense"
label="상태"
fullWidth
variant="outlined"
value={editingProject?.status || 'active'}
onChange={(e) => setEditingProject({
...editingProject,
status: e.target.value
})}
>
<MenuItem key="active" value="active">활성</MenuItem>
<MenuItem key="inactive" value="inactive">비활성</MenuItem>
<MenuItem key="completed" value="completed">완료</MenuItem>
</TextField>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseEditDialog} disabled={loading}>
취소
</Button>
<Button
onClick={handleEditProject}
variant="contained"
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{loading ? '수정 중...' : '수정'}
</Button>
</DialogActions>
</Dialog>
{/* 프로젝트 상세 보기 다이얼로그 */}
<Dialog open={detailDialogOpen} onClose={() => setDetailDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>프로젝트 상세 정보</DialogTitle>
<DialogContent>
{selectedProjectForMenu && (
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{selectedProjectForMenu.official_project_code}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{selectedProjectForMenu.project_name}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">상태</Typography>
<Chip
label={selectedProjectForMenu.status}
color={getStatusColor(selectedProjectForMenu.status)}
icon={getStatusIcon(selectedProjectForMenu.status)}
sx={{ mb: 2 }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">생성일</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{new Date(selectedProjectForMenu.created_at).toLocaleString()}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body2" color="textSecondary">설계 프로젝트 코드</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{selectedProjectForMenu.design_project_code || '-'}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body2" color="textSecondary">코드 매칭</Typography>
<Chip
label={selectedProjectForMenu.is_code_matched ? '매칭됨' : '매칭 안됨'}
color={selectedProjectForMenu.is_code_matched ? 'success' : 'warning'}
sx={{ mb: 2 }}
/>
</Grid>
</Grid>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailDialogOpen(false)}>
닫기
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,426 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Box,
Card,
CardContent,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
CircularProgress,
IconButton,
FormControl,
InputLabel,
Select,
MenuItem,
Alert
} from '@mui/material';
import {
Add,
Build,
CheckCircle,
Error,
Visibility,
Edit,
Delete,
MoreVert
} from '@mui/icons-material';
import { fetchProjectSpools, validateSpoolIdentifier, generateSpoolIdentifier } from '../api';
import Toast from './Toast';
function SpoolManager({ selectedProject }) {
const [spools, setSpools] = useState([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
// 스풀 생성 폼 상태
const [newSpool, setNewSpool] = useState({
dwg_name: '',
area_number: '',
spool_number: ''
});
// 유효성 검증 상태
const [validationResult, setValidationResult] = useState(null);
const [validating, setValidating] = useState(false);
useEffect(() => {
if (selectedProject) {
fetchSpools();
} else {
setSpools([]);
}
}, [selectedProject]);
const fetchSpools = async () => {
if (!selectedProject) return;
setLoading(true);
try {
const response = await fetchProjectSpools(selectedProject.id);
if (response.data && response.data.spools) {
setSpools(response.data.spools);
}
} catch (error) {
console.error('스풀 조회 실패:', error);
setToast({
open: true,
message: '스풀 데이터를 불러오는데 실패했습니다.',
type: 'error'
});
} finally {
setLoading(false);
}
};
const handleCreateSpool = async () => {
if (!newSpool.dwg_name || !newSpool.area_number || !newSpool.spool_number) {
setToast({
open: true,
message: '도면명, 에리어 번호, 스풀 번호를 모두 입력해주세요.',
type: 'warning'
});
return;
}
setLoading(true);
try {
const response = await generateSpoolIdentifier(
newSpool.dwg_name,
newSpool.area_number,
newSpool.spool_number
);
if (response.data && response.data.success) {
setToast({
open: true,
message: '스풀이 성공적으로 생성되었습니다.',
type: 'success'
});
setDialogOpen(false);
setNewSpool({ dwg_name: '', area_number: '', spool_number: '' });
fetchSpools(); // 목록 새로고침
} else {
setToast({
open: true,
message: response.data?.message || '스풀 생성에 실패했습니다.',
type: 'error'
});
}
} catch (error) {
console.error('스풀 생성 실패:', error);
setToast({
open: true,
message: '스풀 생성 중 오류가 발생했습니다.',
type: 'error'
});
} finally {
setLoading(false);
}
};
const handleValidateSpool = async (identifier) => {
setValidating(true);
try {
const response = await validateSpoolIdentifier(identifier);
setValidationResult(response.data);
setValidationDialogOpen(true);
} catch (error) {
console.error('스풀 유효성 검증 실패:', error);
setToast({
open: true,
message: '스풀 유효성 검증에 실패했습니다.',
type: 'error'
});
} finally {
setValidating(false);
}
};
const getStatusColor = (status) => {
switch (status) {
case 'active': return 'success';
case 'inactive': return 'warning';
case 'completed': return 'info';
default: return 'default';
}
};
if (!selectedProject) {
return (
<Box>
<Typography variant="h4" gutterBottom>
🔧 스풀 관리
</Typography>
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Build sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
프로젝트를 선택해주세요
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트 관리 탭에서 프로젝트를 선택하면 스풀을 관리할 있습니다.
</Typography>
</CardContent>
</Card>
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h4">
🔧 스풀 관리
</Typography>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setDialogOpen(true)}
>
스풀
</Button>
</Box>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
{loading ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress size={60} />
<Typography variant="h6" sx={{ mt: 2 }}>
스풀 데이터 로딩 ...
</Typography>
</CardContent>
</Card>
) : spools.length === 0 ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Build sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
스풀이 없습니다
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
스풀을 생성하여 시작하세요!
</Typography>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setDialogOpen(true)}
>
번째 스풀 생성
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">
{spools.length} 스풀
</Typography>
<Chip
label={`${spools.length}개 표시 중`}
color="primary"
variant="outlined"
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><strong>스풀 식별자</strong></TableCell>
<TableCell><strong>도면명</strong></TableCell>
<TableCell><strong>에리어</strong></TableCell>
<TableCell><strong>스풀 번호</strong></TableCell>
<TableCell align="center"><strong>자재 </strong></TableCell>
<TableCell align="center"><strong> 수량</strong></TableCell>
<TableCell><strong>상태</strong></TableCell>
<TableCell align="center"><strong>작업</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{spools.map((spool) => (
<TableRow
key={spool.id}
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{spool.spool_identifier}
</Typography>
</TableCell>
<TableCell>{spool.dwg_name}</TableCell>
<TableCell>{spool.area_number}</TableCell>
<TableCell>{spool.spool_number}</TableCell>
<TableCell align="center">
<Chip
label={spool.material_count || 0}
size="small"
color="primary"
/>
</TableCell>
<TableCell align="center">
<Chip
label={(spool.total_quantity || 0).toLocaleString()}
size="small"
color="success"
/>
</TableCell>
<TableCell>
<Chip
label={spool.status || 'active'}
size="small"
color={getStatusColor(spool.status)}
/>
</TableCell>
<TableCell align="center">
<IconButton
size="small"
onClick={() => handleValidateSpool(spool.spool_identifier)}
disabled={validating}
>
<Visibility />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
)}
{/* 새 스풀 생성 다이얼로그 */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle> 스풀 생성</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="도면명"
placeholder="예: MP7-PIPING-001"
fullWidth
variant="outlined"
value={newSpool.dwg_name}
onChange={(e) => setNewSpool({ ...newSpool, dwg_name: e.target.value })}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="에리어 번호"
placeholder="예: A1"
fullWidth
variant="outlined"
value={newSpool.area_number}
onChange={(e) => setNewSpool({ ...newSpool, area_number: e.target.value })}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="스풀 번호"
placeholder="예: 001"
fullWidth
variant="outlined"
value={newSpool.spool_number}
onChange={(e) => setNewSpool({ ...newSpool, spool_number: e.target.value })}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} disabled={loading}>
취소
</Button>
<Button
onClick={handleCreateSpool}
variant="contained"
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{loading ? '생성 중...' : '생성'}
</Button>
</DialogActions>
</Dialog>
{/* 스풀 유효성 검증 결과 다이얼로그 */}
<Dialog open={validationDialogOpen} onClose={() => setValidationDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>스풀 유효성 검증 결과</DialogTitle>
<DialogContent>
{validationResult && (
<Box>
<Alert
severity={validationResult.validation.is_valid ? 'success' : 'error'}
sx={{ mb: 2 }}
>
{validationResult.validation.is_valid ? '유효한 스풀 식별자입니다.' : '유효하지 않은 스풀 식별자입니다.'}
</Alert>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">스풀 식별자</Typography>
<Typography variant="body1" sx={{ fontFamily: 'monospace', mb: 2 }}>
{validationResult.spool_identifier}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">검증 시간</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{new Date(validationResult.timestamp).toLocaleString()}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body2" color="textSecondary">검증 세부사항</Typography>
<Box sx={{ mt: 1 }}>
{validationResult.validation.details &&
Object.entries(validationResult.validation.details).map(([key, value]) => (
<Chip
key={key}
label={`${key}: ${value}`}
size="small"
color={value ? 'success' : 'error'}
sx={{ mr: 1, mb: 1 }}
/>
))
}
</Box>
</Grid>
</Grid>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setValidationDialogOpen(false)}>
닫기
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default SpoolManager;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Snackbar, Alert, AlertTitle } from '@mui/material';
import {
CheckCircle,
Error,
Warning,
Info
} from '@mui/icons-material';
const Toast = React.memo(({
open,
message,
type = 'info',
title,
autoHideDuration = 4000,
onClose,
anchorOrigin = { vertical: 'top', horizontal: 'center' }
}) => {
const getSeverity = () => {
switch (type) {
case 'success': return 'success';
case 'error': return 'error';
case 'warning': return 'warning';
case 'info':
default: return 'info';
}
};
const getIcon = () => {
switch (type) {
case 'success': return <CheckCircle />;
case 'error': return <Error />;
case 'warning': return <Warning />;
case 'info':
default: return <Info />;
}
};
return (
<Snackbar
open={open}
autoHideDuration={autoHideDuration}
onClose={onClose}
anchorOrigin={anchorOrigin}
>
<Alert
onClose={onClose}
severity={getSeverity()}
icon={getIcon()}
sx={{
width: '100%',
minWidth: 300,
maxWidth: 600
}}
>
{title && <AlertTitle>{title}</AlertTitle>}
{message}
</Alert>
</Snackbar>
);
});
Toast.propTypes = {
open: PropTypes.bool.isRequired,
message: PropTypes.string.isRequired,
type: PropTypes.oneOf(['success', 'error', 'warning', 'info']),
title: PropTypes.string,
autoHideDuration: PropTypes.number,
onClose: PropTypes.func,
anchorOrigin: PropTypes.object,
};
export default Toast;

View File

@@ -0,0 +1,219 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, TextField, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { fetchFiles, uploadFile, deleteFile } from '../api';
const BOMManagerPage = () => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [file, setFile] = useState(null);
const [filename, setFilename] = useState('');
const [revisionDialogOpen, setRevisionDialogOpen] = useState(false);
const [revisionTarget, setRevisionTarget] = useState(null);
const [revisionFile, setRevisionFile] = useState(null);
const [searchParams] = useSearchParams();
const jobNo = searchParams.get('job_no');
const jobName = searchParams.get('job_name');
const navigate = useNavigate();
// 파일 목록 불러오기
const loadFiles = async () => {
setLoading(true);
setError('');
try {
const response = await fetchFiles({ job_no: jobNo });
if (Array.isArray(response.data)) {
setFiles(response.data);
} else {
setFiles([]);
}
} catch (e) {
setError('파일 목록을 불러오지 못했습니다.');
console.error('파일 목록 로드 에러:', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (jobNo) loadFiles();
// eslint-disable-next-line
}, [jobNo]);
// 파일 업로드 핸들러
const handleUpload = async (e) => {
e.preventDefault();
if (!file || !filename) return;
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', jobNo);
formData.append('bom_name', filename);
const response = await uploadFile(formData);
if (response.data.success) {
setFile(null);
setFilename('');
loadFiles(); // 파일 목록 새로고침
alert(`업로드 성공: ${response.data.materials_count}개 자재가 분류되었습니다.`);
} else {
throw new Error(response.data.error || '업로드 실패');
}
} catch (e) {
setError(`파일 업로드에 실패했습니다: ${e.message}`);
console.error('업로드 에러:', e);
} finally {
setUploading(false);
}
};
// 리비전 업로드 핸들러
const handleRevisionUpload = async () => {
if (!revisionFile || !revisionTarget) return;
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', revisionFile);
formData.append('job_no', jobNo);
formData.append('bom_name', revisionTarget.original_filename);
formData.append('parent_bom_id', revisionTarget.id);
const response = await uploadFile(formData);
if (response.data.success) {
setRevisionDialogOpen(false);
setRevisionFile(null);
setRevisionTarget(null);
loadFiles();
alert(`리비전 업로드 성공: ${response.data.revision}`);
} else {
throw new Error(response.data.error || '리비전 업로드 실패');
}
} catch (e) {
setError(`리비전 업로드에 실패했습니다: ${e.message}`);
console.error('리비전 업로드 에러:', e);
} finally {
setUploading(false);
}
};
// 파일 삭제 핸들러
const handleDelete = async (fileId, filename) => {
if (!confirm(`정말로 "${filename}" 파일을 삭제하시겠습니까?`)) return;
try {
const response = await deleteFile(fileId);
if (response.data.success) {
loadFiles();
alert('파일이 삭제되었습니다.');
} else {
throw new Error(response.data.error || '삭제 실패');
}
} catch (e) {
setError(`파일 삭제에 실패했습니다: ${e.message}`);
console.error('삭제 에러:', e);
}
};
// 자재확인 페이지로 이동
const handleViewMaterials = (file) => {
navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.original_filename)}`);
};
return (
<Box sx={{ maxWidth: 1000, mx: 'auto', mt: 4 }}>
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
프로젝트 선택
</Button>
<Typography variant="h5" sx={{ mb: 2 }}>
{jobNo && jobName && `${jobNo} (${jobName})`}
</Typography>
{/* BOM 업로드 폼 */}
<form onSubmit={handleUpload} style={{ marginBottom: 24, display: 'flex', gap: 16, alignItems: 'center' }}>
<TextField
label="도면명(파일명)"
value={filename}
onChange={e => setFilename(e.target.value)}
size="small"
required
sx={{ minWidth: 220 }}
/>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={e => setFile(e.target.files[0])}
disabled={uploading}
style={{ marginRight: 8 }}
/>
<Button type="submit" variant="contained" disabled={!file || !filename || uploading}>
업로드
</Button>
</form>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
{/* 파일 목록 리스트 */}
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>도면명</TableCell>
<TableCell>리비전</TableCell>
<TableCell>세부내역</TableCell>
<TableCell>리비전</TableCell>
<TableCell>삭제</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.map(file => (
<TableRow key={file.id}>
<TableCell>{file.original_filename}</TableCell>
<TableCell>{file.revision}</TableCell>
<TableCell>
<Button size="small" variant="outlined" onClick={() => handleViewMaterials(file)}>
자재확인
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="info" onClick={() => { setRevisionTarget(file); setRevisionDialogOpen(true); }}>
리비전
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="error" onClick={() => handleDelete(file.id, file.original_filename)}>
삭제
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* 리비전 업로드 다이얼로그 */}
<Dialog open={revisionDialogOpen} onClose={() => setRevisionDialogOpen(false)}>
<DialogTitle>리비전 업로드</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 2 }}>
도면명: <b>{revisionTarget?.original_filename}</b>
</Typography>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={e => setRevisionFile(e.target.files[0])}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setRevisionDialogOpen(false)}>취소</Button>
<Button variant="contained" onClick={handleRevisionUpload} disabled={!revisionFile || uploading}>
업로드
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default BOMManagerPage;

View File

@@ -0,0 +1,124 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert } from '@mui/material';
import { useSearchParams, useNavigate } from 'react-router-dom';
const BOMStatusPage = () => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [file, setFile] = useState(null);
const [searchParams] = useSearchParams();
const jobNo = searchParams.get('job_no');
const navigate = useNavigate();
// 파일 목록 불러오기
const fetchFiles = async () => {
setLoading(true);
setError('');
try {
let url = '/files';
if (jobNo) {
url += `?job_no=${jobNo}`;
}
const res = await fetch(url);
const data = await res.json();
if (Array.isArray(data)) setFiles(data);
else if (data && Array.isArray(data.files)) setFiles(data.files);
else setFiles([]);
} catch (e) {
setError('파일 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFiles();
// eslint-disable-next-line
}, [jobNo]);
// 파일 업로드 핸들러
const handleUpload = async (e) => {
e.preventDefault();
if (!file) return;
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', 1); // 예시: 실제 프로젝트 ID로 대체 필요
const res = await fetch('/files/upload', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error('업로드 실패');
setFile(null);
fetchFiles();
} catch (e) {
setError('파일 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
};
return (
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
뒤로가기
</Button>
<Typography variant="h4" gutterBottom>BOM 업로드 현황</Typography>
<form onSubmit={handleUpload} style={{ marginBottom: 24 }}>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={e => setFile(e.target.files[0])}
disabled={uploading}
/>
<Button type="submit" variant="contained" disabled={!file || uploading} sx={{ ml: 2 }}>
업로드
</Button>
</form>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>파일명</TableCell>
<TableCell>리비전</TableCell>
<TableCell>세부내역</TableCell>
<TableCell>리비전</TableCell>
<TableCell>삭제</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.map(file => (
<TableRow key={file.id}>
<TableCell>{file.original_filename || file.filename}</TableCell>
<TableCell>{file.revision}</TableCell>
<TableCell>
<Button size="small" variant="outlined" onClick={() => alert(`자재확인: ${file.original_filename}`)}>
자재확인
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="info" onClick={() => alert(`리비전 관리: ${file.original_filename}`)}>
리비전
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="error" onClick={() => alert(`삭제: ${file.original_filename}`)}>
삭제
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default BOMStatusPage;

View File

@@ -1,455 +0,0 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Upload, FileText, AlertCircle, CheckCircle, Loader2, Database, TrendingUp, Settings, Eye, BarChart3, Filter } from 'lucide-react';
const FileUploadPage = () => {
const [uploadStatus, setUploadStatus] = useState('idle'); // idle, uploading, analyzing, classifying, success, error
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadResult, setUploadResult] = useState(null);
const [dragActive, setDragActive] = useState(false);
const [selectedProject, setSelectedProject] = useState('');
const [projects, setProjects] = useState([]);
const [analysisStep, setAnalysisStep] = useState('');
const [classificationPreview, setClassificationPreview] = useState(null);
// 프로젝트 목록 로드
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
try {
const response = await fetch('http://localhost:8000/api/projects');
const data = await response.json();
setProjects(data);
if (data.length > 0) {
setSelectedProject(data[0].id.toString());
}
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
};
// 드래그 앤 드롭 핸들러
const handleDrag = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileUpload(e.dataTransfer.files[0]);
}
}, []);
// 파일 업로드 및 4단계 자동 분류 처리
const handleFileUpload = async (file) => {
if (!file) return;
if (!selectedProject) {
alert('프로젝트를 먼저 선택해주세요.');
return;
}
// 파일 타입 체크 (다양한 형식 지원)
const allowedTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls
'application/vnd.ms-excel.sheet.macroEnabled.12', // .xlsm
'text/csv',
'text/plain'
];
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|xlsm|csv|txt)$/i)) {
alert('지원 형식: 엑셀(.xlsx, .xls, .xlsm), CSV, 텍스트 파일만 업로드 가능합니다.');
return;
}
setUploadStatus('uploading');
setUploadProgress(0);
setAnalysisStep('파일 업로드 중...');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', selectedProject);
formData.append('revision', 'Rev.0');
formData.append('description', `${file.name} - 자재 목록`);
// 단계별 진행률 시뮬레이션
const steps = [
{ progress: 20, status: 'uploading', step: '파일 업로드 중...' },
{ progress: 40, status: 'analyzing', step: '자동 구조 인식 중... (컬럼 분석)' },
{ progress: 60, status: 'classifying', step: '4단계 자동 분류 진행 중...' },
{ progress: 80, status: 'classifying', step: '재질 코드 및 사이즈 표준화...' },
{ progress: 90, status: 'classifying', step: 'DB 저장 중...' }
];
let stepIndex = 0;
const progressInterval = setInterval(() => {
if (stepIndex < steps.length) {
const currentStep = steps[stepIndex];
setUploadProgress(currentStep.progress);
setUploadStatus(currentStep.status);
setAnalysisStep(currentStep.step);
stepIndex++;
} else {
clearInterval(progressInterval);
}
}, 800);
const response = await fetch('http://localhost:8000/api/files/upload', {
method: 'POST',
body: formData,
});
clearInterval(progressInterval);
setUploadProgress(100);
if (response.ok) {
const result = await response.json();
setUploadResult(result);
setUploadStatus('success');
setAnalysisStep('분류 완료! 결과를 확인하세요.');
// 분류 결과 미리보기 생성
setClassificationPreview({
totalItems: result.parsed_count || 0,
categories: {
'파이프': Math.floor((result.parsed_count || 0) * 0.4),
'피팅류': Math.floor((result.parsed_count || 0) * 0.3),
'볼트(너트)': Math.floor((result.parsed_count || 0) * 0.15),
'밸브': Math.floor((result.parsed_count || 0) * 0.1),
'계기류': Math.floor((result.parsed_count || 0) * 0.05)
},
materials: ['A333-6 (저온용 배관)', 'A105 (단조 탄소강)', 'S355', 'SM490'],
sizes: ['1"', '2"', '3"', '4"', '6"', '8"']
});
} else {
throw new Error('업로드 실패');
}
} catch (error) {
console.error('Upload error:', error);
setUploadStatus('error');
setAnalysisStep('처리 중 오류가 발생했습니다.');
setTimeout(() => {
setUploadStatus('idle');
setUploadProgress(0);
setAnalysisStep('');
}, 3000);
}
};
const handleFileInput = (e) => {
if (e.target.files && e.target.files[0]) {
handleFileUpload(e.target.files[0]);
}
};
const resetUpload = () => {
setUploadStatus('idle');
setUploadProgress(0);
setUploadResult(null);
setAnalysisStep('');
setClassificationPreview(null);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-6xl mx-auto">
{/* 헤더 */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
🏗 도면 자재 분석 시스템
</h1>
<p className="text-gray-600 text-lg">
Phase 1: 파일 분석 4단계 자동 분류 체계적 DB 저장
</p>
</div>
{/* Phase 1 핵심 프로세스 플로우 */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-semibold mb-4 text-gray-800">🎯 Phase 1: 핵심 기능 처리 과정</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex flex-col items-center text-center p-4 bg-blue-50 rounded-lg">
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center text-white mb-2">
<Upload size={20} />
</div>
<span className="text-sm font-medium">다양한 형식</span>
<span className="text-xs text-gray-600">xlsx,xls,xlsm,csv</span>
</div>
<div className="flex flex-col items-center text-center p-4 bg-green-50 rounded-lg">
<div className="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center text-white mb-2">
<Settings size={20} />
</div>
<span className="text-sm font-medium">자동 구조 인식</span>
<span className="text-xs text-gray-600">컬럼 자동 판별</span>
</div>
<div className="flex flex-col items-center text-center p-4 bg-purple-50 rounded-lg">
<div className="w-12 h-12 bg-purple-500 rounded-full flex items-center justify-center text-white mb-2">
<Filter size={20} />
</div>
<span className="text-sm font-medium">4단계 자동 분류</span>
<span className="text-xs text-gray-600">대분류세부재질사이즈</span>
</div>
<div className="flex flex-col items-center text-center p-4 bg-orange-50 rounded-lg">
<div className="w-12 h-12 bg-orange-500 rounded-full flex items-center justify-center text-white mb-2">
<Database size={20} />
</div>
<span className="text-sm font-medium">체계적 저장</span>
<span className="text-xs text-gray-600">버전관리+이력추적</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 왼쪽: 업로드 영역 */}
<div className="space-y-6">
{/* 프로젝트 선택 */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold mb-3 text-gray-800">📋 프로젝트 선택</h3>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">프로젝트를 선택하세요</option>
{projects.map(project => (
<option key={project.id} value={project.id}>
{project.official_project_code} - {project.project_name}
</option>
))}
</select>
</div>
{/* 업로드 영역 */}
<div className="bg-white rounded-lg shadow-md p-6">
<div
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-all duration-300 ${
dragActive
? 'border-blue-400 bg-blue-50'
: uploadStatus === 'idle'
? 'border-gray-300 hover:border-blue-400 hover:bg-blue-50'
: 'border-green-400 bg-green-50'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
id="file-upload"
className="hidden"
accept=".xlsx,.xls,.xlsm,.csv,.txt"
onChange={handleFileInput}
disabled={uploadStatus !== 'idle'}
/>
{uploadStatus === 'idle' && (
<>
<Upload className="mx-auto mb-4 text-gray-400" size={48} />
<h3 className="text-xl font-semibold text-gray-700 mb-2">
자재 목록 파일을 업로드하세요
</h3>
<p className="text-gray-500 mb-4">
드래그 드롭하거나 클릭하여 파일을 선택하세요
</p>
<div className="text-sm text-gray-400 mb-4">
지원 형식: Excel (.xlsx, .xls, .xlsm), CSV, 텍스트
</div>
<label
htmlFor="file-upload"
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer transition-colors"
>
<Upload className="mr-2" size={20} />
파일 선택
</label>
</>
)}
{(uploadStatus === 'uploading' || uploadStatus === 'analyzing' || uploadStatus === 'classifying') && (
<div className="space-y-4">
<Loader2 className="mx-auto text-blue-500 animate-spin" size={48} />
<h3 className="text-xl font-semibold text-gray-700">
{analysisStep}
</h3>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-blue-500 h-3 rounded-full transition-all duration-500"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
<p className="text-sm text-gray-600">{uploadProgress}% 완료</p>
</div>
)}
{uploadStatus === 'success' && (
<div className="space-y-4">
<CheckCircle className="mx-auto text-green-500" size={48} />
<h3 className="text-xl font-semibold text-green-700">
분석 분류 완료!
</h3>
<p className="text-gray-600">{analysisStep}</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => window.location.href = '/materials'}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center"
>
<Eye className="mr-2" size={16} />
결과 보기
</button>
<button
onClick={resetUpload}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
>
파일 업로드
</button>
</div>
</div>
)}
{uploadStatus === 'error' && (
<div className="space-y-4">
<AlertCircle className="mx-auto text-red-500" size={48} />
<h3 className="text-xl font-semibold text-red-700">
업로드 실패
</h3>
<p className="text-gray-600">{analysisStep}</p>
<button
onClick={resetUpload}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
다시 시도
</button>
</div>
)}
</div>
</div>
</div>
{/* 오른쪽: 4단계 분류 시스템 설명 & 미리보기 */}
<div className="space-y-6">
{/* 4단계 분류 시스템 */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
<Filter className="mr-2" size={20} />
4단계 자동 분류 시스템
</h3>
<div className="space-y-4">
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="font-semibold text-blue-700">1단계: 대분류</h4>
<p className="text-sm text-gray-600">파이프 / 피팅류 / 볼트(너트) / 밸브 / 계기류</p>
</div>
<div className="border-l-4 border-green-500 pl-4">
<h4 className="font-semibold text-green-700">2단계: 세부분류</h4>
<p className="text-sm text-gray-600">90 엘보우 / 용접목 플랜지 / SEAMLESS 파이프</p>
</div>
<div className="border-l-4 border-purple-500 pl-4">
<h4 className="font-semibold text-purple-700">3단계: 재질 인식</h4>
<p className="text-sm text-gray-600">A333-6 (저온용 배관) / A105 (단조 탄소강) / S355 / SM490</p>
</div>
<div className="border-l-4 border-orange-500 pl-4">
<h4 className="font-semibold text-orange-700">4단계: 사이즈 표준화</h4>
<p className="text-sm text-gray-600">6.0" → 6인치, 규격 통일 및 단위 자동 결정</p>
</div>
</div>
</div>
{/* 분류 결과 미리보기 */}
{classificationPreview && (
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
<BarChart3 className="mr-2" size={20} />
분류 결과 미리보기
</h3>
<div className="space-y-4">
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{classificationPreview.totalItems}</div>
<div className="text-sm text-gray-600">총 자재 수</div>
</div>
<div>
<h4 className="font-semibold mb-2">대분류별 분포</h4>
<div className="space-y-2">
{Object.entries(classificationPreview.categories).map(([category, count]) => (
<div key={category} className="flex justify-between items-center">
<span className="text-sm">{category}</span>
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm font-medium">
{count}개
</span>
</div>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-2">인식된 재질</h4>
<div className="flex flex-wrap gap-1">
{classificationPreview.materials.map((material, index) => (
<span key={index} className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
{material}
</span>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-2">표준화된 사이즈</h4>
<div className="flex flex-wrap gap-1">
{classificationPreview.sizes.map((size, index) => (
<span key={index} className="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs">
{size}
</span>
))}
</div>
</div>
</div>
</div>
)}
{/* 데이터베이스 저장 정보 */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
<Database className="mr-2" size={20} />
체계적 DB 저장
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center">
<div className="w-2 h-2 bg-blue-500 rounded-full mr-3"></div>
<span>프로젝트 단위 관리 (코드 체계)</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 bg-green-500 rounded-full mr-3"></div>
<span>버전 관리 (Rev.0, Rev.1, Rev.2)</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 bg-purple-500 rounded-full mr-3"></div>
<span>파일 업로드 이력 추적</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 bg-orange-500 rounded-full mr-3"></div>
<span>분류 결과 + 원본 정보 보존</span>
</div>
<div className="flex items-center">
<div className="w-2 h-2 bg-red-500 rounded-full mr-3"></div>
<span>수량 정보 세분화 저장</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default FileUploadPage;

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Button, CircularProgress, Alert } from '@mui/material';
import { fetchJobs } from '../api';
import { useNavigate } from 'react-router-dom';
const JobSelectionPage = () => {
const [jobs, setJobs] = useState([]);
const [selectedJobNo, setSelectedJobNo] = useState('');
const [selectedJobName, setSelectedJobName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
useEffect(() => {
async function loadJobs() {
setLoading(true);
setError('');
try {
const res = await fetchJobs({});
if (res.data && Array.isArray(res.data.jobs)) {
setJobs(res.data.jobs);
} else {
setJobs([]);
}
} catch (e) {
setError('프로젝트 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}
loadJobs();
}, []);
const handleSelect = (e) => {
const jobNo = e.target.value;
setSelectedJobNo(jobNo);
const job = jobs.find(j => j.job_no === jobNo);
setSelectedJobName(job ? job.job_name : '');
};
const handleConfirm = () => {
if (selectedJobNo && selectedJobName) {
navigate(`/bom-manager?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`);
}
};
return (
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
<Typography variant="h4" gutterBottom>프로젝트 선택</Typography>
{loading && <CircularProgress sx={{ mt: 4 }} />}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
<InputLabel>프로젝트</InputLabel>
<Select
value={selectedJobNo}
label="프로젝트"
onChange={handleSelect}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{jobs.map(job => (
<MenuItem key={job.job_no} value={job.job_no}>
{job.job_no} ({job.job_name})
</MenuItem>
))}
</Select>
</FormControl>
{selectedJobNo && (
<Alert severity="info" sx={{ mt: 3 }}>
선택된 프로젝트: <b>{selectedJobNo} ({selectedJobName})</b>
</Alert>
)}
<Button
variant="contained"
sx={{ mt: 4, minWidth: 120 }}
disabled={!selectedJobNo}
onClick={handleConfirm}
>
확인
</Button>
</Box>
);
};
export default JobSelectionPage;

View File

@@ -0,0 +1,221 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
CircularProgress,
Alert
} from '@mui/material';
// API 함수는 기존 api.js의 fetchJobs, fetchFiles, fetchMaterials를 활용한다고 가정
import { fetchJobs, fetchMaterials } from '../api';
const MaterialLookupPage = () => {
const [jobs, setJobs] = useState([]);
const [files, setFiles] = useState([]);
const [revisions, setRevisions] = useState([]);
const [selectedJobNo, setSelectedJobNo] = useState('');
const [selectedFilename, setSelectedFilename] = useState('');
const [selectedRevision, setSelectedRevision] = useState('');
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 1. Job 목록 불러오기 (최초 1회)
useEffect(() => {
async function loadJobs() {
try {
const res = await fetchJobs({});
if (res.data && res.data.jobs) setJobs(res.data.jobs);
} catch (e) {
setError('Job 목록을 불러오지 못했습니다.');
}
}
loadJobs();
}, []);
// 2. Job 선택 시 해당 도면(파일) 목록 불러오기
useEffect(() => {
async function loadFiles() {
if (!selectedJobNo) {
setFiles([]);
setRevisions([]);
setSelectedFilename('');
setSelectedRevision('');
return;
}
try {
const res = await fetch(`/files?job_no=${selectedJobNo}`);
const data = await res.json();
if (Array.isArray(data)) setFiles(data);
else if (data && Array.isArray(data.files)) setFiles(data.files);
else setFiles([]);
setSelectedFilename('');
setSelectedRevision('');
setRevisions([]);
} catch (e) {
setFiles([]);
setRevisions([]);
setError('도면 목록을 불러오지 못했습니다.');
}
}
loadFiles();
}, [selectedJobNo]);
// 3. 도면 선택 시 해당 리비전 목록 추출
useEffect(() => {
if (!selectedFilename) {
setRevisions([]);
setSelectedRevision('');
return;
}
const filtered = files.filter(f => f.original_filename === selectedFilename);
setRevisions(filtered.map(f => f.revision));
setSelectedRevision('');
}, [selectedFilename, files]);
// 4. 조회 버튼 클릭 시 자재 목록 불러오기
const handleLookup = async () => {
setLoading(true);
setError('');
setMaterials([]);
try {
const params = {
job_no: selectedJobNo,
filename: selectedFilename,
revision: selectedRevision
};
const res = await fetchMaterials(params);
if (res.data && Array.isArray(res.data.materials)) {
setMaterials(res.data.materials);
} else {
setMaterials([]);
}
} catch (e) {
setError('자재 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
};
// 5. 3개 모두 선택 시 자동 조회 (원하면 주석 해제)
// useEffect(() => {
// if (selectedJobNo && selectedFilename && selectedRevision) {
// handleLookup();
// }
// }, [selectedJobNo, selectedFilename, selectedRevision]);
return (
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
<Typography variant="h4" gutterBottom>
자재 상세 조회 (Job No + 도면명 + 리비전)
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
{/* Job No 드롭다운 */}
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Job No</InputLabel>
<Select
value={selectedJobNo}
label="Job No"
onChange={e => setSelectedJobNo(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{jobs.map(job => (
<MenuItem key={job.job_number} value={job.job_number}>
{job.job_number}
</MenuItem>
))}
</Select>
</FormControl>
{/* 도면명 드롭다운 */}
<FormControl size="small" sx={{ minWidth: 200 }} disabled={!selectedJobNo}>
<InputLabel>도면명(파일명)</InputLabel>
<Select
value={selectedFilename}
label="도면명(파일명)"
onChange={e => setSelectedFilename(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{files.map(file => (
<MenuItem key={file.id} value={file.original_filename}>
{file.bom_name || file.original_filename || file.filename}
</MenuItem>
))}
</Select>
</FormControl>
{/* 리비전 드롭다운 */}
<FormControl size="small" sx={{ minWidth: 120 }} disabled={!selectedFilename}>
<InputLabel>리비전</InputLabel>
<Select
value={selectedRevision}
label="리비전"
onChange={e => setSelectedRevision(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{revisions.map(rev => (
<MenuItem key={rev} value={rev}>{rev}</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
onClick={handleLookup}
disabled={!(selectedJobNo && selectedFilename && selectedRevision) || loading}
>
조회
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
{!loading && materials.length > 0 && (
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>품명</TableCell>
<TableCell>수량</TableCell>
<TableCell>단위</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell>라인번호</TableCell>
</TableRow>
</TableHead>
<TableBody>
{materials.map(mat => (
<TableRow key={mat.id}>
<TableCell>{mat.original_description}</TableCell>
<TableCell>{mat.quantity}</TableCell>
<TableCell>{mat.unit}</TableCell>
<TableCell>{mat.size_spec}</TableCell>
<TableCell>{mat.material_grade}</TableCell>
<TableCell>{mat.line_number}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{!loading && materials.length === 0 && (selectedJobNo && selectedFilename && selectedRevision) && (
<Alert severity="info" sx={{ mt: 4 }}>
해당 조건에 맞는 자재가 없습니다.
</Alert>
)}
</Box>
);
};
export default MaterialLookupPage;

View File

@@ -0,0 +1,256 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
CircularProgress,
Alert,
Chip,
Card,
CardContent,
Grid
} from '@mui/material';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { fetchMaterialsSummary } from '../api';
const MaterialsPage = () => {
const [materials, setMaterials] = useState([]);
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const fileId = searchParams.get('file_id');
const jobNo = searchParams.get('job_no');
const filename = searchParams.get('filename');
// 자재 목록 불러오기
const loadMaterials = async () => {
if (!fileId) return;
setLoading(true);
setError('');
try {
// 자재 목록 조회
const response = await fetch(`http://localhost:8000/files/materials?file_id=${fileId}`);
const data = await response.json();
if (data.materials && Array.isArray(data.materials)) {
// 동일 항목 그룹화 (품명 + 사이즈 + 재질이 같은 것들)
const groupedMaterials = groupMaterialsByItem(data.materials);
setMaterials(groupedMaterials);
} else {
setMaterials([]);
}
// 요약 정보 조회
const summaryResponse = await fetchMaterialsSummary({ file_id: fileId });
if (summaryResponse.data.success) {
setSummary(summaryResponse.data.summary);
}
} catch (e) {
setError('자재 목록을 불러오지 못했습니다.');
console.error('자재 로드 에러:', e);
} finally {
setLoading(false);
}
};
// 동일 항목 그룹화 함수
const groupMaterialsByItem = (materials) => {
const grouped = {};
materials.forEach(material => {
// 그룹화 키: 품명 + 사이즈 + 재질 + 분류
const key = `${material.original_description}_${material.size_spec || ''}_${material.material_grade || ''}_${material.classified_category || ''}`;
if (!grouped[key]) {
grouped[key] = {
...material,
totalQuantity: 0,
items: []
};
}
grouped[key].totalQuantity += material.quantity || 0;
grouped[key].items.push(material);
});
return Object.values(grouped).sort((a, b) => b.totalQuantity - a.totalQuantity);
};
useEffect(() => {
loadMaterials();
}, [fileId]);
// 분류별 색상 지정
const getCategoryColor = (category) => {
const colors = {
'PIPE': 'primary',
'FITTING': 'secondary',
'VALVE': 'error',
'FLANGE': 'warning',
'BOLT': 'info',
'GASKET': 'success',
'INSTRUMENT': 'default'
};
return colors[category] || 'default';
};
return (
<Box sx={{ maxWidth: 1200, mx: 'auto', mt: 4, p: 2 }}>
{/* 헤더 */}
<Box sx={{ mb: 3 }}>
<Button
variant="outlined"
onClick={() => navigate(`/bom-manager?job_no=${jobNo}`)}
sx={{ mb: 2 }}
>
BOM 관리로 돌아가기
</Button>
<Typography variant="h5" gutterBottom>
자재 목록 - {filename}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Job No: {jobNo}
</Typography>
</Box>
{/* 요약 정보 */}
{summary && (
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>
항목
</Typography>
<Typography variant="h4">
{summary.total_items}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>
고유 품명
</Typography>
<Typography variant="h4">
{summary.unique_descriptions}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>
수량
</Typography>
<Typography variant="h4">
{summary.total_quantity}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>
고유 재질
</Typography>
<Typography variant="h4">
{summary.unique_materials}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
)}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
{/* 자재 목록 테이블 */}
{!loading && materials.length > 0 && (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>분류</TableCell>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell align="right"> 수량</TableCell>
<TableCell>단위</TableCell>
<TableCell align="center">항목 </TableCell>
<TableCell>신뢰도</TableCell>
</TableRow>
</TableHead>
<TableBody>
{materials.map((material, index) => (
<TableRow key={index} hover>
<TableCell>
<Chip
label={material.classified_category || 'OTHER'}
color={getCategoryColor(material.classified_category)}
size="small"
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
{material.original_description}
</Typography>
</TableCell>
<TableCell>{material.size_spec || '-'}</TableCell>
<TableCell>{material.material_grade || '-'}</TableCell>
<TableCell align="right">
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>
{material.totalQuantity}
</Typography>
</TableCell>
<TableCell>{material.unit || 'EA'}</TableCell>
<TableCell align="center">
<Chip
label={material.items.length}
variant="outlined"
size="small"
/>
</TableCell>
<TableCell>
<Typography
variant="body2"
color={material.classification_confidence > 0.7 ? 'success.main' : 'warning.main'}
>
{Math.round((material.classification_confidence || 0) * 100)}%
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{!loading && materials.length === 0 && fileId && (
<Alert severity="info" sx={{ mt: 4 }}>
해당 파일에 자재 정보가 없습니다.
</Alert>
)}
</Box>
);
};
export default MaterialsPage;

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, CircularProgress, Alert, Button } from '@mui/material';
import { fetchJobs } from '../api';
import { useNavigate } from 'react-router-dom';
const ProjectSelectionPage = () => {
const [jobs, setJobs] = useState([]);
const [selectedJob, setSelectedJob] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
useEffect(() => {
async function loadJobs() {
setLoading(true);
setError('');
try {
const res = await fetchJobs({});
if (res.data && Array.isArray(res.data.jobs)) {
setJobs(res.data.jobs);
} else {
setJobs([]);
}
} catch (e) {
setError('프로젝트 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}
loadJobs();
}, []);
return (
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
<Typography variant="h4" gutterBottom>프로젝트(Job No) 선택</Typography>
{loading && <CircularProgress sx={{ mt: 4 }} />}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
<InputLabel>Job No</InputLabel>
<Select
value={selectedJob}
label="Job No"
onChange={e => setSelectedJob(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{jobs.map(job => (
<MenuItem key={job.job_no} value={job.job_no}>
{job.job_no} ({job.job_name})
</MenuItem>
))}
</Select>
</FormControl>
{selectedJob && (
<Alert severity="info" sx={{ mt: 3 }}>
선택된 Job No: <b>{selectedJob}</b>
</Alert>
)}
<Button
variant="contained"
sx={{ mt: 4, minWidth: 120 }}
disabled={!selectedJob}
onClick={() => navigate(`/bom?job_no=${selectedJob}`)}
>
확인
</Button>
</Box>
);
};
export default ProjectSelectionPage;