프론트엔드 작성중
This commit is contained in:
@@ -236,35 +236,86 @@ async def upload_file(
|
|||||||
async def get_materials(
|
async def get_materials(
|
||||||
project_id: Optional[int] = None,
|
project_id: Optional[int] = None,
|
||||||
file_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,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""저장된 자재 목록 조회"""
|
"""
|
||||||
|
저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
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.size_spec, m.material_grade, m.line_number, m.row_number,
|
||||||
m.created_at,
|
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
|
p.official_project_code, p.project_name
|
||||||
FROM materials m
|
FROM materials m
|
||||||
LEFT JOIN files f ON m.file_id = f.id
|
LEFT JOIN files f ON m.file_id = f.id
|
||||||
LEFT JOIN projects p ON f.project_id = p.id
|
LEFT JOIN projects p ON f.project_id = p.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
query += " AND f.project_id = :project_id"
|
query += " AND f.project_id = :project_id"
|
||||||
params["project_id"] = project_id
|
params["project_id"] = project_id
|
||||||
|
|
||||||
if file_id:
|
if file_id:
|
||||||
query += " AND m.file_id = :file_id"
|
query += " AND m.file_id = :file_id"
|
||||||
params["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}%"
|
||||||
|
|
||||||
query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip"
|
# 정렬 처리
|
||||||
|
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 += " LIMIT :limit OFFSET :skip"
|
||||||
params["limit"] = limit
|
params["limit"] = limit
|
||||||
params["skip"] = skip
|
params["skip"] = skip
|
||||||
|
|
||||||
@@ -288,6 +339,26 @@ async def get_materials(
|
|||||||
count_query += " AND m.file_id = :file_id"
|
count_query += " AND m.file_id = :file_id"
|
||||||
count_params["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)
|
count_result = db.execute(text(count_query), count_params)
|
||||||
total_count = count_result.fetchone()[0]
|
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 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 앱 생성
|
# FastAPI 앱 생성
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -30,14 +37,514 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
print("jobs 라우터를 찾을 수 없습니다")
|
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("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {
|
return {
|
||||||
"message": "TK-MP BOM Management API",
|
"message": "TK-MP BOM Management API",
|
||||||
"version": "1.0.0",
|
"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")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "healthy", "timestamp": "2024-07-15"}
|
return {"status": "healthy", "timestamp": "2024-07-15"}
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from app.services.material_classifier import classify_material
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
UPLOAD_DIR = Path("uploads")
|
UPLOAD_DIR = Path("uploads")
|
||||||
@@ -153,16 +161,21 @@ async def upload_file(
|
|||||||
file_path = UPLOAD_DIR / unique_filename
|
file_path = UPLOAD_DIR / unique_filename
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
print("파일 저장 시작")
|
||||||
with open(file_path, "wb") as buffer:
|
with open(file_path, "wb") as buffer:
|
||||||
shutil.copyfileobj(file.file, buffer)
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
print(f"파일 저장 완료: {file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
print("파일 저장 시작")
|
||||||
materials_data = parse_file_data(str(file_path))
|
materials_data = parse_file_data(str(file_path))
|
||||||
parsed_count = len(materials_data)
|
parsed_count = len(materials_data)
|
||||||
|
print(f"파싱 완료: {parsed_count}개 자재")
|
||||||
|
|
||||||
# 파일 정보 저장
|
# 파일 정보 저장 (project_id 제거)
|
||||||
|
print("DB 저장 시작")
|
||||||
file_insert_query = text("""
|
file_insert_query = text("""
|
||||||
INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
|
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)
|
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]
|
file_id = file_result.fetchone()[0]
|
||||||
|
print(f"파일 저장 완료: file_id = {file_id}")
|
||||||
|
|
||||||
# 자재 데이터 저장
|
# 자재 데이터 저장 (분류 포함)
|
||||||
|
print("자재 분류 및 저장 시작")
|
||||||
materials_inserted = 0
|
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:
|
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("""
|
material_insert_query = text("""
|
||||||
INSERT INTO materials (
|
INSERT INTO materials (
|
||||||
file_id, original_description, quantity, unit, size_spec,
|
file_id, original_description, quantity, unit, size_spec,
|
||||||
material_grade, line_number, row_number, classified_category,
|
material_grade, line_number, row_number, classified_category,
|
||||||
classification_confidence, is_verified, created_at
|
classification_confidence, is_verified, created_at,
|
||||||
|
subcategory, standard, grade
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
:file_id, :original_description, :quantity, :unit, :size_spec,
|
:file_id, :original_description, :quantity, :unit, :size_spec,
|
||||||
:material_grade, :line_number, :row_number, :classified_category,
|
: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"],
|
"material_grade": material_data["material_grade"],
|
||||||
"line_number": material_data["line_number"],
|
"line_number": material_data["line_number"],
|
||||||
"row_number": material_data["row_number"],
|
"row_number": material_data["row_number"],
|
||||||
"classified_category": get_major_category(material_data["original_description"]),
|
"classified_category": classification_result.get('category', 'OTHER'),
|
||||||
"classification_confidence": 0.9,
|
"classification_confidence": classification_result.get('confidence', 0.0),
|
||||||
"is_verified": False,
|
"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
|
materials_inserted += 1
|
||||||
|
|
||||||
|
print(f"자재 저장 완료: {materials_inserted}개")
|
||||||
|
print("커밋 직전")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
print("커밋 완료")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -224,6 +263,7 @@ async def upload_file(
|
|||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"parsed_materials_count": parsed_count,
|
"parsed_materials_count": parsed_count,
|
||||||
"saved_materials_count": materials_inserted,
|
"saved_materials_count": materials_inserted,
|
||||||
|
"classification_stats": classification_stats,
|
||||||
"sample_materials": materials_data[:3] if materials_data else []
|
"sample_materials": materials_data[:3] if materials_data else []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,93 +271,260 @@ async def upload_file(
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
import traceback
|
||||||
|
print(traceback.format_exc()) # 에러 전체 로그 출력
|
||||||
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
|
||||||
@router.get("/materials")
|
@router.get("/materials")
|
||||||
async def get_materials(
|
async def get_materials(
|
||||||
job_no: Optional[str] = None,
|
project_id: Optional[int] = None,
|
||||||
file_id: Optional[str] = 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,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""저장된 자재 목록 조회"""
|
"""자재 목록 조회 (개선된 버전)"""
|
||||||
try:
|
try:
|
||||||
query = """
|
# 기본 쿼리 구성
|
||||||
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
base_query = """
|
||||||
m.size_spec, m.material_grade, m.line_number, m.row_number,
|
SELECT
|
||||||
m.created_at,
|
m.id,
|
||||||
f.original_filename, f.job_no,
|
m.original_description,
|
||||||
j.job_no, j.job_name, m.classified_category, m.classification_confidence
|
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
|
FROM materials m
|
||||||
LEFT JOIN files f ON m.file_id = f.id
|
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
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
|
conditions = []
|
||||||
|
|
||||||
if job_no:
|
# 프로젝트 필터
|
||||||
query += " AND f.job_no = :job_no"
|
if project_id:
|
||||||
params["job_no"] = job_no
|
conditions.append("f.project_id = :project_id")
|
||||||
|
params["project_id"] = project_id
|
||||||
|
|
||||||
if file_id:
|
# Job ID 필터
|
||||||
query += " AND m.file_id = :file_id"
|
if job_id:
|
||||||
params["file_id"] = file_id
|
conditions.append("f.job_no = (SELECT job_no FROM jobs WHERE id = :job_id)")
|
||||||
|
params["job_id"] = job_id
|
||||||
|
|
||||||
query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip"
|
# 리비전 필터
|
||||||
|
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["limit"] = limit
|
||||||
params["skip"] = skip
|
params["skip"] = skip
|
||||||
|
|
||||||
result = db.execute(text(query), params)
|
result = db.execute(text(base_query), params)
|
||||||
materials = result.fetchall()
|
materials = result.fetchall()
|
||||||
|
|
||||||
# 전체 개수 조회
|
# 리비전 비교 데이터 생성
|
||||||
count_query = """
|
revision_comparison = None
|
||||||
SELECT COUNT(*) as total
|
if revision and revision != "Rev.0":
|
||||||
FROM materials m
|
comparison_query = """
|
||||||
LEFT JOIN files f ON m.file_id = f.id
|
SELECT
|
||||||
WHERE 1=1
|
m.original_description,
|
||||||
"""
|
m.size_spec,
|
||||||
count_params = {}
|
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)
|
||||||
|
"""
|
||||||
|
|
||||||
if job_no:
|
comparison_params = {
|
||||||
count_query += " AND f.job_no = :job_no"
|
"project_id": project_id,
|
||||||
count_params["job_no"] = job_no
|
"current_revision": revision,
|
||||||
|
"prev_revision": f"Rev.{int(revision.split('.')[-1]) - 1}"
|
||||||
|
}
|
||||||
|
|
||||||
if file_id:
|
comparison_result = db.execute(text(comparison_query), comparison_params)
|
||||||
count_query += " AND m.file_id = :file_id"
|
comparison_data = comparison_result.fetchall()
|
||||||
count_params["file_id"] = file_id
|
|
||||||
|
|
||||||
count_result = db.execute(text(count_query), count_params)
|
if comparison_data:
|
||||||
total_count = count_result.fetchone()[0]
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 결과 포맷팅
|
||||||
|
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)
|
||||||
|
|
||||||
|
total_count = materials[0].total_count if materials else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"materials": formatted_materials,
|
||||||
"total_count": total_count,
|
"total_count": total_count,
|
||||||
"returned_count": len(materials),
|
"revision_comparison": revision_comparison
|
||||||
"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
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -419,3 +626,123 @@ def get_major_category(description):
|
|||||||
return 'bolt'
|
return 'bolt'
|
||||||
else:
|
else:
|
||||||
return 'other'
|
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),
|
search: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Job 목록 조회"""
|
"""Job 목록 조회 (job_name을 프로젝트명으로 사용)"""
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT job_no, job_name, client_name, end_user, epc_company,
|
SELECT job_no, job_name, client_name, end_user, epc_company,
|
||||||
@@ -68,7 +68,8 @@ async def get_jobs(
|
|||||||
"delivery_terms": job.delivery_terms,
|
"delivery_terms": job.delivery_terms,
|
||||||
"status": job.status,
|
"status": job.status,
|
||||||
"description": job.description,
|
"description": job.description,
|
||||||
"created_at": job.created_at
|
"created_at": job.created_at,
|
||||||
|
"project_name": job.job_name # job_name을 프로젝트명으로 사용
|
||||||
}
|
}
|
||||||
for job in jobs
|
for job in jobs
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ alembic==1.13.1
|
|||||||
# 파일 처리
|
# 파일 처리
|
||||||
pandas==2.1.4
|
pandas==2.1.4
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
|
xlrd>=2.0.1
|
||||||
python-multipart==0.0.6
|
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 React from 'react';
|
||||||
import {
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
AppBar,
|
import JobSelectionPage from './pages/JobSelectionPage';
|
||||||
Toolbar,
|
import BOMManagerPage from './pages/BOMManagerPage';
|
||||||
Typography,
|
import MaterialsPage from './pages/MaterialsPage';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
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 (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<Router>
|
||||||
<CssBaseline />
|
<Routes>
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Route path="/" element={<JobSelectionPage />} />
|
||||||
{/* 상단 앱바 */}
|
<Route path="/bom-manager" element={<BOMManagerPage />} />
|
||||||
<AppBar position="static" elevation={1}>
|
<Route path="/materials" element={<MaterialsPage />} />
|
||||||
<Toolbar>
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
</Routes>
|
||||||
TK-MP BOM 관리 시스템
|
</Router>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 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 }) {
|
function Dashboard({ selectedProject, projects }) {
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
fetchMaterialStats();
|
fetchMaterialStats();
|
||||||
|
fetchMaterialList();
|
||||||
}
|
}
|
||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
const fetchMaterialStats = async () => {
|
const fetchMaterialStats = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setStats(data.summary);
|
setStats(data.summary);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('통계 로드 실패:', error);
|
setToast({
|
||||||
|
open: true,
|
||||||
|
message: '자재 통계 로드 실패',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
📊 대시보드
|
📊 대시보드
|
||||||
</Typography>
|
</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 container spacing={3}>
|
||||||
|
{/* 프로젝트 현황 */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -46,81 +221,218 @@ function Dashboard({ selectedProject, projects }) {
|
|||||||
총 프로젝트 수
|
총 프로젝트 수
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ mt: 2 }}>
|
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||||
선택된 프로젝트: {selectedProject ? selectedProject.project_name : '없음'}
|
선택된 프로젝트: {selectedProject.project_name}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
{/* 자재 현황 */}
|
||||||
<Card>
|
<Grid item xs={12} md={6}>
|
||||||
<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}>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" color="secondary" gutterBottom>
|
||||||
📋 프로젝트 상세 정보
|
자재 현황
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
{loading ? (
|
||||||
<Grid item xs={12} sm={6}>
|
<Box display="flex" justifyContent="center" py={3}>
|
||||||
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
|
<CircularProgress />
|
||||||
<Typography variant="body1">{selectedProject.official_project_code}</Typography>
|
</Box>
|
||||||
</Grid>
|
) : stats ? (
|
||||||
<Grid item xs={12} sm={6}>
|
<Box>
|
||||||
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
|
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
|
||||||
<Typography variant="body1">{selectedProject.project_name}</Typography>
|
{stats.total_items.toLocaleString()}
|
||||||
</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()}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
</Grid>
|
총 자재 수
|
||||||
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</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>
|
</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 React, { useState, useCallback } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
@@ -6,38 +7,74 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
Button,
|
Button,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Alert,
|
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
Chip,
|
Chip,
|
||||||
Paper,
|
Paper,
|
||||||
Divider
|
Divider,
|
||||||
|
Stepper,
|
||||||
|
Step,
|
||||||
|
StepLabel,
|
||||||
|
StepContent,
|
||||||
|
Alert,
|
||||||
|
Grid
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
CloudUpload,
|
CloudUpload,
|
||||||
AttachFile,
|
AttachFile,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
Description
|
Description,
|
||||||
|
AutoAwesome,
|
||||||
|
Category,
|
||||||
|
Science
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
|
||||||
|
import Toast from './Toast';
|
||||||
|
|
||||||
function FileUpload({ selectedProject, onUploadSuccess }) {
|
function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||||
|
console.log('=== FileUpload 컴포넌트 렌더링 ===');
|
||||||
|
console.log('selectedProject:', selectedProject);
|
||||||
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadResult, setUploadResult] = useState(null);
|
const [uploadResult, setUploadResult] = useState(null);
|
||||||
const [error, setError] = useState('');
|
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) => {
|
const onDrop = useCallback((acceptedFiles) => {
|
||||||
|
console.log('=== FileUpload: onDrop 함수 호출됨 ===');
|
||||||
|
console.log('받은 파일들:', acceptedFiles);
|
||||||
|
console.log('선택된 프로젝트:', selectedProject);
|
||||||
|
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
setError('프로젝트를 먼저 선택해주세요.');
|
console.log('프로젝트가 선택되지 않음');
|
||||||
|
setToast({
|
||||||
|
open: true,
|
||||||
|
message: '프로젝트를 먼저 선택해주세요.',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (acceptedFiles.length > 0) {
|
if (acceptedFiles.length > 0) {
|
||||||
|
console.log('파일 업로드 시작');
|
||||||
uploadFile(acceptedFiles[0]);
|
uploadFile(acceptedFiles[0]);
|
||||||
|
} else {
|
||||||
|
console.log('선택된 파일이 없음');
|
||||||
}
|
}
|
||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
@@ -52,57 +89,185 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
maxSize: 10 * 1024 * 1024 // 10MB
|
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) => {
|
const uploadFile = async (file) => {
|
||||||
|
console.log('=== FileUpload: uploadFile 함수 시작 ===');
|
||||||
|
console.log('파일 정보:', {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type
|
||||||
|
});
|
||||||
|
console.log('선택된 프로젝트:', selectedProject);
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setError('');
|
setError('');
|
||||||
setUploadResult(null);
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('project_id', selectedProject.id);
|
formData.append('job_no', selectedProject.job_no);
|
||||||
formData.append('revision', 'Rev.0');
|
formData.append('revision', 'Rev.0');
|
||||||
|
|
||||||
try {
|
console.log('FormData 내용:', {
|
||||||
const xhr = new XMLHttpRequest();
|
fileName: file.name,
|
||||||
|
jobNo: selectedProject.job_no,
|
||||||
|
revision: 'Rev.0'
|
||||||
|
});
|
||||||
|
|
||||||
// 업로드 진행률 추적
|
try {
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
// 1단계: 파일 업로드
|
||||||
if (event.lengthComputable) {
|
updateUploadStep(0, true, false);
|
||||||
const progress = Math.round((event.loaded / event.total) * 100);
|
updateUploadStep(1, false, true);
|
||||||
setUploadProgress(progress);
|
|
||||||
|
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 래핑
|
console.log('API 응답:', response.data);
|
||||||
const uploadPromise = new Promise((resolve, reject) => {
|
const result = response.data;
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status === 200) {
|
console.log('응답 데이터 구조:', {
|
||||||
resolve(JSON.parse(xhr.responseText));
|
success: result.success,
|
||||||
} else {
|
file_id: result.file_id,
|
||||||
reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText}`));
|
message: result.message,
|
||||||
}
|
hasFileId: 'file_id' in result
|
||||||
};
|
|
||||||
xhr.onerror = () => reject(new Error('Network error'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.open('POST', 'http://localhost:8000/api/files/upload');
|
// 2단계: 데이터 파싱 완료
|
||||||
xhr.send(formData);
|
updateUploadStep(1, true, false);
|
||||||
|
updateUploadStep(2, false, true);
|
||||||
const result = await uploadPromise;
|
|
||||||
|
|
||||||
if (result.success) {
|
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);
|
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) {
|
if (onUploadSuccess) {
|
||||||
|
console.log('onUploadSuccess 콜백 호출');
|
||||||
onUploadSuccess(result);
|
onUploadSuccess(result);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setError(result.message || '업로드에 실패했습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 파일 목록 갱신을 위한 이벤트 발생
|
||||||
|
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 {
|
||||||
|
setToast({
|
||||||
|
open: true,
|
||||||
|
message: result.message || '업로드에 실패했습니다.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('업로드 실패:', 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 {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
@@ -118,30 +283,32 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
|
|
||||||
const resetUpload = () => {
|
const resetUpload = () => {
|
||||||
setUploadResult(null);
|
setUploadResult(null);
|
||||||
setError('');
|
|
||||||
setUploadProgress(0);
|
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) {
|
const getClassificationStats = () => {
|
||||||
return (
|
if (!uploadResult?.classification_stats) return null;
|
||||||
<Box>
|
|
||||||
<Typography variant="h4" gutterBottom>
|
const stats = uploadResult.classification_stats;
|
||||||
📁 파일 업로드
|
const total = Object.values(stats).reduce((sum, count) => sum + count, 0);
|
||||||
</Typography>
|
|
||||||
<Card>
|
return Object.entries(stats)
|
||||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
.filter(([category, count]) => count > 0)
|
||||||
<CloudUpload sx={{ fontSize: 64, color: 'grey.400', mb: 2 }} />
|
.map(([category, count]) => ({
|
||||||
<Typography variant="h6" gutterBottom>
|
category,
|
||||||
프로젝트를 선택해주세요
|
count,
|
||||||
</Typography>
|
percentage: total > 0 ? Math.round((count / total) * 100) : 0
|
||||||
<Typography variant="body2" color="textSecondary">
|
}))
|
||||||
프로젝트 관리 탭에서 프로젝트를 선택한 후 파일을 업로드할 수 있습니다.
|
.sort((a, b) => b.count - a.count);
|
||||||
</Typography>
|
};
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -153,10 +320,56 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{error && (
|
{/* 전역 Toast */}
|
||||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
<Toast
|
||||||
{error}
|
open={toast.open}
|
||||||
</Alert>
|
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 ? (
|
{uploadResult ? (
|
||||||
@@ -165,149 +378,171 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
<CheckCircle color="success" sx={{ mr: 1 }} />
|
<CheckCircle color="success" sx={{ mr: 1 }} />
|
||||||
<Typography variant="h6" color="success.main">
|
<Typography variant="h6" color="success.main">
|
||||||
업로드 성공!
|
업로드 및 분류 성공!
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<List>
|
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||||
<ListItem>
|
<Grid item xs={12} md={6}>
|
||||||
<ListItemIcon>
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
<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개):
|
|
||||||
</Typography>
|
</Typography>
|
||||||
{uploadResult.sample_materials.map((material, index) => (
|
<List dense>
|
||||||
<Typography key={index} variant="body2" sx={{
|
<ListItem>
|
||||||
bgcolor: 'grey.50',
|
<ListItemIcon>
|
||||||
p: 1,
|
<Description />
|
||||||
mb: 0.5,
|
</ListItemIcon>
|
||||||
borderRadius: 1,
|
<ListItemText
|
||||||
fontSize: '0.8rem'
|
primary="파일명"
|
||||||
}}>
|
secondary={uploadResult.original_filename}
|
||||||
{index + 1}. {material.original_description} - {material.quantity} {material.unit}
|
/>
|
||||||
{material.size_spec && ` (${material.size_spec})`}
|
</ListItem>
|
||||||
</Typography>
|
<ListItem>
|
||||||
))}
|
<ListItemIcon>
|
||||||
</Box>
|
<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 }}>
|
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
|
||||||
<Button variant="outlined" onClick={resetUpload}>
|
<Button
|
||||||
다른 파일 업로드
|
variant="contained"
|
||||||
|
onClick={() => window.location.href = '/materials'}
|
||||||
|
startIcon={<Description />}
|
||||||
|
>
|
||||||
|
자재 목록 보기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={resetUpload}
|
||||||
|
>
|
||||||
|
새로 업로드
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<>
|
||||||
<CardContent>
|
<Paper
|
||||||
{uploading ? (
|
{...getRootProps()}
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
onClick={() => console.log('드래그 앤 드롭 영역 클릭됨')}
|
||||||
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
sx={{
|
||||||
<Typography variant="h6" gutterBottom>
|
p: 4,
|
||||||
파일 업로드 중...
|
textAlign: 'center',
|
||||||
</Typography>
|
border: 2,
|
||||||
<Box sx={{ width: '100%', maxWidth: 400, mx: 'auto', mt: 2 }}>
|
borderStyle: 'dashed',
|
||||||
<LinearProgress
|
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||||
variant="determinate"
|
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
|
||||||
value={uploadProgress}
|
cursor: 'pointer',
|
||||||
sx={{ height: 8, borderRadius: 4 }}
|
transition: 'all 0.2s ease',
|
||||||
/>
|
'&:hover': {
|
||||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
borderColor: 'primary.main',
|
||||||
{uploadProgress}% 완료
|
bgcolor: 'primary.50'
|
||||||
</Typography>
|
}
|
||||||
</Box>
|
}}
|
||||||
</Box>
|
>
|
||||||
) : (
|
<input {...getInputProps()} />
|
||||||
<>
|
<CloudUpload sx={{
|
||||||
<Paper
|
fontSize: 64,
|
||||||
{...getRootProps()}
|
color: isDragActive ? 'primary.main' : 'grey.400',
|
||||||
sx={{
|
mb: 2
|
||||||
p: 4,
|
}} />
|
||||||
textAlign: 'center',
|
<Typography variant="h6" gutterBottom>
|
||||||
border: 2,
|
{isDragActive
|
||||||
borderStyle: 'dashed',
|
? "파일을 여기에 놓으세요!"
|
||||||
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
: "Excel 파일을 드래그하거나 클릭하여 선택"
|
||||||
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
|
}
|
||||||
cursor: 'pointer',
|
</Typography>
|
||||||
transition: 'all 0.2s ease',
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
'&:hover': {
|
지원 형식: .xlsx, .xls, .csv (최대 10MB)
|
||||||
borderColor: 'primary.main',
|
</Typography>
|
||||||
bgcolor: 'primary.50'
|
<Button
|
||||||
}
|
variant="contained"
|
||||||
}}
|
startIcon={<AttachFile />}
|
||||||
>
|
component="span"
|
||||||
<input {...getInputProps()} />
|
disabled={uploading}
|
||||||
<CloudUpload sx={{
|
onClick={() => console.log('파일 선택 버튼 클릭됨')}
|
||||||
fontSize: 64,
|
>
|
||||||
color: isDragActive ? 'primary.main' : 'grey.400',
|
{uploading ? '업로드 중...' : '파일 선택'}
|
||||||
mb: 2
|
</Button>
|
||||||
}} />
|
</Paper>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Typography variant="body2" color="textSecondary">
|
<Typography variant="body2" color="textSecondary">
|
||||||
💡 <strong>업로드 팁:</strong>
|
💡 <strong>업로드 및 분류 프로세스:</strong>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="textSecondary">
|
<Typography variant="body2" color="textSecondary">
|
||||||
• BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
|
• BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="textSecondary">
|
<Typography variant="body2" color="textSecondary">
|
||||||
• 자재명, 수량, 사이즈, 재질 등이 자동으로 분류됩니다
|
• 각 자재는 8가지 분류기(볼트, 플랜지, 피팅, 가스켓, 계기, 파이프, 밸브, 재질)로 자동 분류됩니다
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
<Typography variant="body2" color="textSecondary">
|
||||||
</>
|
• 분류 결과는 신뢰도 점수와 함께 저장되며, 필요시 수동 검증이 가능합니다
|
||||||
)}
|
</Typography>
|
||||||
</CardContent>
|
</Box>
|
||||||
</Card>
|
</>
|
||||||
)}
|
)}
|
||||||
</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;
|
export default FileUpload;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
@@ -13,48 +14,211 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
TablePagination,
|
TablePagination,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Alert,
|
Chip,
|
||||||
Chip
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
InputLabel,
|
||||||
|
FormControl,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Alert
|
||||||
} from '@mui/material';
|
} 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 }) {
|
function MaterialList({ selectedProject }) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [materials, setMaterials] = useState([]);
|
const [materials, setMaterials] = useState([]);
|
||||||
|
const [jobs, setJobs] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
|
fetchJobsData();
|
||||||
fetchMaterials();
|
fetchMaterials();
|
||||||
} else {
|
} else {
|
||||||
setMaterials([]);
|
setMaterials([]);
|
||||||
setTotalCount(0);
|
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 () => {
|
const fetchMaterials = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const skip = page * rowsPerPage;
|
const skip = page * rowsPerPage;
|
||||||
const response = await fetch(
|
const params = {
|
||||||
`http://localhost:8000/api/files/materials?project_id=${selectedProject.id}&skip=${skip}&limit=${rowsPerPage}`
|
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) {
|
// selectedProject가 없으면 API 호출하지 않음
|
||||||
const data = await response.json();
|
if (!selectedProject?.job_no) {
|
||||||
setMaterials(data.materials || []);
|
setMaterials([]);
|
||||||
setTotalCount(data.total_count || 0);
|
setTotalCount(0);
|
||||||
} else {
|
setLoading(false);
|
||||||
setError('자재 데이터를 불러오는데 실패했습니다.');
|
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) {
|
} catch (error) {
|
||||||
console.error('자재 조회 실패:', 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -69,6 +233,20 @@ function MaterialList({ selectedProject }) {
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch('');
|
||||||
|
setSearchValue('');
|
||||||
|
setItemType('');
|
||||||
|
setMaterialGrade('');
|
||||||
|
setSizeSpec('');
|
||||||
|
setFileFilter('');
|
||||||
|
setSelectedJob('');
|
||||||
|
setSelectedRevision('');
|
||||||
|
setGroupingType('item');
|
||||||
|
setSortBy('');
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
const getItemTypeColor = (itemType) => {
|
const getItemTypeColor = (itemType) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
'PIPE': 'primary',
|
'PIPE': 'primary',
|
||||||
@@ -81,6 +259,18 @@ function MaterialList({ selectedProject }) {
|
|||||||
return colors[itemType] || 'default';
|
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) {
|
if (!selectedProject) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -112,12 +302,378 @@ function MaterialList({ selectedProject }) {
|
|||||||
{selectedProject.project_name} ({selectedProject.official_project_code})
|
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{error && (
|
{/* 필터/검색/정렬 UI */}
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
||||||
{error}
|
<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>
|
</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 ? (
|
{loading ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
@@ -132,11 +688,19 @@ function MaterialList({ selectedProject }) {
|
|||||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
자재 데이터가 없습니다
|
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision ? '검색 결과가 없습니다' : '자재 데이터가 없습니다'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="textSecondary">
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
파일 업로드 탭에서 BOM 파일을 업로드해주세요.
|
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision
|
||||||
|
? '다른 검색 조건을 시도해보세요.'
|
||||||
|
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
|
||||||
|
}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision) && (
|
||||||
|
<Button variant="outlined" onClick={clearFilters}>
|
||||||
|
필터 초기화
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -164,13 +728,16 @@ function MaterialList({ selectedProject }) {
|
|||||||
<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><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>
|
<TableCell align="center"><strong>라인 수</strong></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{materials.map((material, index) => (
|
{materials.map((material, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={material.id}
|
key={`${material.id}-${index}`}
|
||||||
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -208,6 +775,30 @@ function MaterialList({ selectedProject }) {
|
|||||||
{material.material_grade || '-'}
|
{material.material_grade || '-'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</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">
|
<TableCell align="center">
|
||||||
<Chip
|
<Chip
|
||||||
label={`${material.line_count || 1}개 라인`}
|
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;
|
export default MaterialList;
|
||||||
|
|||||||
@@ -11,67 +11,228 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
TextField,
|
TextField,
|
||||||
Alert,
|
Alert,
|
||||||
CircularProgress
|
CircularProgress,
|
||||||
|
Snackbar,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
Divider,
|
||||||
|
Menu,
|
||||||
|
MenuItem
|
||||||
} from '@mui/material';
|
} 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 }) {
|
function ProjectManager({ projects, selectedProject, setSelectedProject, onProjectsChange }) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
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 [projectCode, setProjectCode] = useState('');
|
||||||
const [projectName, setProjectName] = useState('');
|
const [projectName, setProjectName] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
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 () => {
|
const handleCreateProject = async () => {
|
||||||
if (!projectCode.trim() || !projectName.trim()) {
|
if (!projectCode.trim() || !projectName.trim()) {
|
||||||
setError('프로젝트 코드와 이름을 모두 입력해주세요.');
|
setToast({
|
||||||
|
open: true,
|
||||||
|
message: '프로젝트 코드와 이름을 모두 입력해주세요.',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:8000/api/projects', {
|
const data = {
|
||||||
method: 'POST',
|
official_project_code: projectCode.trim(),
|
||||||
headers: {
|
project_name: projectName.trim(),
|
||||||
'Content-Type': 'application/json',
|
design_project_code: projectCode.trim(),
|
||||||
'Accept': 'application/json'
|
is_code_matched: true,
|
||||||
},
|
status: 'active'
|
||||||
body: JSON.stringify({
|
};
|
||||||
official_project_code: projectCode.trim(),
|
const response = await createJob(data);
|
||||||
project_name: projectName.trim(),
|
const result = response.data;
|
||||||
design_project_code: projectCode.trim(),
|
if (result && result.job) {
|
||||||
is_code_matched: true,
|
|
||||||
status: 'active'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const newProject = await response.json();
|
|
||||||
onProjectsChange();
|
onProjectsChange();
|
||||||
setSelectedProject(newProject);
|
setSelectedProject(result.job);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setProjectCode('');
|
setProjectCode('');
|
||||||
setProjectName('');
|
setProjectName('');
|
||||||
setError('');
|
setToast({
|
||||||
|
open: true,
|
||||||
|
message: '프로젝트가 성공적으로 생성되었습니다.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
setToast({
|
||||||
setError(errorData.detail || '프로젝트 생성에 실패했습니다.');
|
open: true,
|
||||||
|
message: result.message || '프로젝트 생성에 실패했습니다.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('프로젝트 생성 실패:', error);
|
console.error('프로젝트 생성 실패:', error);
|
||||||
setError('네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.');
|
setToast({
|
||||||
|
open: true,
|
||||||
|
message: '네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 = () => {
|
const handleCloseDialog = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setProjectCode('');
|
setProjectCode('');
|
||||||
setProjectName('');
|
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 (
|
return (
|
||||||
@@ -89,6 +250,14 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* 전역 Toast */}
|
||||||
|
<Toast
|
||||||
|
open={toast.open}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={() => setToast({ open: false, message: '', type: 'info' })}
|
||||||
|
/>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
@@ -109,42 +278,89 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Box>
|
<Grid container spacing={2}>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Card
|
<Grid item xs={12} md={6} lg={4} key={project.id}>
|
||||||
key={project.id}
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
mb: 2,
|
cursor: 'pointer',
|
||||||
cursor: 'pointer',
|
border: selectedProject?.id === project.id ? 2 : 1,
|
||||||
border: selectedProject?.id === project.id ? 2 : 1,
|
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider',
|
||||||
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider'
|
'&:hover': {
|
||||||
}}
|
boxShadow: 3,
|
||||||
onClick={() => setSelectedProject(project)}
|
borderColor: 'primary.main'
|
||||||
>
|
}
|
||||||
<CardContent>
|
}}
|
||||||
<Typography variant="h6">
|
onClick={() => setSelectedProject(project)}
|
||||||
{project.project_name || project.official_project_code}
|
>
|
||||||
</Typography>
|
<CardContent>
|
||||||
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
|
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
|
||||||
코드: {project.official_project_code}
|
<Box sx={{ flex: 1 }}>
|
||||||
</Typography>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
<Typography variant="body2" color="textSecondary">
|
{project.project_name || project.official_project_code}
|
||||||
상태: {project.status} | 생성일: {new Date(project.created_at).toLocaleDateString()}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
|
||||||
</CardContent>
|
코드: {project.official_project_code}
|
||||||
</Card>
|
</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>
|
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>새 프로젝트 생성</DialogTitle>
|
<DialogTitle>새 프로젝트 생성</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
margin="dense"
|
margin="dense"
|
||||||
@@ -180,6 +396,121 @@ function ProjectManager({ projects, selectedProject, setSelectedProject, onProje
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</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