프론트엔드 작성중
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
# 데이터 검증
|
||||
|
||||
18
backend/scripts/04_add_job_no_to_files.sql
Normal file
18
backend/scripts/04_add_job_no_to_files.sql
Normal 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;
|
||||
80
backend/scripts/05_add_classification_columns.sql
Normal file
80
backend/scripts/05_add_classification_columns.sql
Normal 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;
|
||||
3286
frontend/package-lock.json
generated
3286
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
122
frontend/src/api.js
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
468
frontend/src/components/FileManager.jsx
Normal file
468
frontend/src/components/FileManager.jsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
426
frontend/src/components/SpoolManager.jsx
Normal file
426
frontend/src/components/SpoolManager.jsx
Normal 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;
|
||||
74
frontend/src/components/Toast.jsx
Normal file
74
frontend/src/components/Toast.jsx
Normal 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;
|
||||
219
frontend/src/pages/BOMManagerPage.jsx
Normal file
219
frontend/src/pages/BOMManagerPage.jsx
Normal 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;
|
||||
124
frontend/src/pages/BOMStatusPage.jsx
Normal file
124
frontend/src/pages/BOMStatusPage.jsx
Normal 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;
|
||||
@@ -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;
|
||||
85
frontend/src/pages/JobSelectionPage.jsx
Normal file
85
frontend/src/pages/JobSelectionPage.jsx
Normal 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;
|
||||
221
frontend/src/pages/MaterialLookupPage.jsx
Normal file
221
frontend/src/pages/MaterialLookupPage.jsx
Normal 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;
|
||||
256
frontend/src/pages/MaterialsPage.jsx
Normal file
256
frontend/src/pages/MaterialsPage.jsx
Normal 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;
|
||||
71
frontend/src/pages/ProjectSelectionPage.jsx
Normal file
71
frontend/src/pages/ProjectSelectionPage.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user