diff --git a/PIPE_DATABASE_TABLES.md b/PIPE_DATABASE_TABLES.md
new file mode 100644
index 0000000..06e35ae
--- /dev/null
+++ b/PIPE_DATABASE_TABLES.md
@@ -0,0 +1,255 @@
+# ๐๏ธ PIPE ๊ด๋ฆฌ ์์คํ
๋ฐ์ดํฐ๋ฒ ์ด์ค ํ
์ด๋ธ ๊ฐ์ด๋
+
+## ๐ ํ
์ด๋ธ ๊ฐ์
+
+PIPE ๊ด๋ฆฌ ์์คํ
์ **7๊ฐ์ ์ ์ฉ ํ
์ด๋ธ**๋ก ๊ตฌ์ฑ๋์ด ์์ผ๋ฉฐ, ๊ฐ๊ฐ ๊ณ ์ ํ ์ญํ ์ ๋ด๋นํฉ๋๋ค.
+
+---
+
+## ๐ง **1. pipe_cutting_plans**
+**์ฉ๋**: PIPE Cutting Plan์ ๋จ๊ด ์ ๋ณด ์ ์ฅ
+
+### **์ฃผ์ ์ปฌ๋ผ**
+- `job_no`: ์์
๋ฒํธ
+- `area`: ๊ตฌ์ญ ์ ๋ณด (#01, #02 ๋ฑ)
+- `drawing_name`: ๋๋ฉด๋ช
(P&ID-001)
+- `line_no`: ๋ผ์ธ๋ฒํธ (LINE-A-001)
+- `material_grade`: ์ฌ์ง (A106 GR.B)
+- `schedule_spec`: ์ค์ผ์ค (SCH40, SCH80)
+- `nominal_size`: ํธ์นญ ํฌ๊ธฐ (4", 6")
+- `length_mm`: ๊ธธ์ด (mm ๋จ์)
+- `end_preparation`: ๋๋จ ๊ฐ๊ณต (๋ฌด๊ฐ์ , ํ๊ฐ์ , ์๊ฐ์ )
+
+### **์ฌ์ฉ ์์ **
+- Cutting Plan ์์ฑ ์ ๋จ๊ด ์ ๋ณด ์ ์ฅ
+- ๊ตฌ์ญ๋ณ ๋๋ฉด ํ ๋น ์๋ฃ ํ
+- ๋ผ์ธ๋ฒํธ ์
๋ ฅ ์๋ฃ ํ
+
+---
+
+## ๐ **2. pipe_revision_comparisons**
+**์ฉ๋**: PIPE ๋ฆฌ๋น์ ๋น๊ต ๊ฒฐ๊ณผ ๋ฐ ํต๊ณ ์ ์ฅ
+
+### **์ฃผ์ ์ปฌ๋ผ**
+- `job_no`: ์์
๋ฒํธ
+- `current_file_id`: ํ์ฌ ํ์ผ ID
+- `previous_cutting_plan_id`: ์ด์ Cutting Plan ID
+- `total_drawings`: ์ ์ฒด ๋๋ฉด ์
+- `changed_drawings`: ๋ณ๊ฒฝ๋ ๋๋ฉด ์
+- `total_segments`: ์ ์ฒด ๋จ๊ด ์
+- `added_segments`: ์ถ๊ฐ๋ ๋จ๊ด ์
+- `removed_segments`: ์ญ์ ๋ ๋จ๊ด ์
+- `modified_segments`: ์์ ๋ ๋จ๊ด ์
+
+### **์ฌ์ฉ ์์ **
+- ์๋ก์ด BOM ์
๋ก๋ ์ (Cutting Plan ์์ฑ ํ)
+- ๊ธฐ์กด Cutting Plan๊ณผ ์ ๊ท BOM ๋น๊ต ์
+- ๋ฆฌ๋น์ ๋ณ๊ฒฝ์ฌํญ ๋ถ์ ์
+
+---
+
+## ๐ **3. pipe_revision_changes**
+**์ฉ๋**: PIPE ๋ฆฌ๋น์ ๋ณ๊ฒฝ์ฌํญ ์์ธ ์ ๋ณด ์ ์ฅ
+
+### **์ฃผ์ ์ปฌ๋ผ**
+- `comparison_id`: ๋น๊ต ๊ฒฐ๊ณผ ID (pipe_revision_comparisons ์ฐธ์กฐ)
+- `drawing_name`: ๋๋ฉด๋ช
+- `change_type`: ๋ณ๊ฒฝ ์ ํ (added, removed, modified, unchanged)
+- `old_*`: ์ด์ ๋ฐ์ดํฐ (๋ผ์ธ๋ฒํธ, ์ฌ์ง, ๊ธธ์ด ๋ฑ)
+- `new_*`: ์๋ก์ด ๋ฐ์ดํฐ (๋ผ์ธ๋ฒํธ, ์ฌ์ง, ๊ธธ์ด ๋ฑ)
+- `change_reason`: ๋ณ๊ฒฝ ์ฌ์
+
+### **์ฌ์ฉ ์์ **
+- ๋ฆฌ๋น์ ๋น๊ต ์ํ ์ ๊ฐ ๋จ๊ด๋ณ ๋ณ๊ฒฝ์ฌํญ ๊ธฐ๋ก
+- ๋ณ๊ฒฝ์ฌํญ ์์ธ ๋ถ์ ์
+- ๋ฆฌ๋น์ ์ด๋ ฅ ์ถ์ ์
+
+---
+
+## ๐ธ **4. pipe_issue_snapshots**
+**์ฉ๋**: ์ด์ ๊ด๋ฆฌ์ฉ ์ค๋
์ท ๋ฉํ๋ฐ์ดํฐ ์ ์ฅ
+
+### **์ฃผ์ ์ปฌ๋ผ**
+- `job_no`: ์์
๋ฒํธ
+- `snapshot_name`: ์ค๋
์ท ์ด๋ฆ
+- `is_active`: ํ์ฑ ์ํ
+- `is_locked`: ์ ๊ธ ์ํ (์ด์ ๊ด๋ฆฌ ์์ ์ true)
+- `total_segments`: ์ด ๋จ๊ด ์
+- `total_drawings`: ์ด ๋๋ฉด ์
+- `created_at`: ์์ฑ ์๊ฐ
+- `locked_at`: ์ ๊ธ ์๊ฐ
+
+### **์ฌ์ฉ ์์ **
+- Cutting Plan ํ์ ์ ์๋ ์์ฑ
+- ์ด์ ๊ด๋ฆฌ ์์ ์ ์ ๊ธ
+- ๋ฆฌ๋น์ ๋ณดํธ ํ์ฑํ ์
+
+---
+
+## ๐ **5. pipe_issue_segments**
+**์ฉ๋**: ์ค๋
์ท๋ ๋จ๊ด ์ ๋ณด ์ ์ฅ (๊ณ ์ ๋ฐ์ดํฐ)
+
+### **์ฃผ์ ์ปฌ๋ผ**
+- `snapshot_id`: ์ค๋
์ท ID (pipe_issue_snapshots ์ฐธ์กฐ)
+- `area`: ๊ตฌ์ญ ์ ๋ณด
+- `drawing_name`: ๋๋ฉด๋ช
+- `line_no`: ๋ผ์ธ๋ฒํธ
+- `material_grade`: ์ฌ์ง
+- `length_mm`: ๊ธธ์ด
+- `end_preparation`: ๋๋จ ๊ฐ๊ณต
+- `original_cutting_plan_id`: ์๋ณธ Cutting Plan ID
+
+### **์ฌ์ฉ ์์ **
+- Cutting Plan ํ์ ์ ํ์ฌ ๋ฐ์ดํฐ ๋ณต์ฌ
+- ์ด์ ๊ด๋ฆฌ ํ์ด์ง์์ ๊ธฐ์ค ๋ฐ์ดํฐ๋ก ์ฌ์ฉ
+- ๋ฆฌ๋น์ ๊ณผ ๋ฌด๊ดํ๊ฒ ๊ณ ์ ๋ ๋ฐ์ดํฐ ์ ๊ณต
+
+---
+
+## ๐ **6. pipe_drawing_issues**
+**์ฉ๋**: ๋๋ฉด ์ ๋ฐ์ ์ธ ์ด์ ์ ์ฅ
+
+### **์ฃผ์ ์ปฌ๋ผ**
+- `snapshot_id`: ์ค๋
์ท ID (pipe_issue_snapshots ์ฐธ์กฐ)
+- `area`: ๊ตฌ์ญ ์ ๋ณด
+- `drawing_name`: ๋๋ฉด๋ช
+- `issue_description`: ์ด์ ์ค๋ช
(์์ ํ
์คํธ)
+- `severity`: ์ฌ๊ฐ๋ (low, medium, high, critical)
+- `status`: ์ํ (open, in_progress, resolved)
+- `resolution_notes`: ํด๊ฒฐ ๋ฐฉ๋ฒ
+- `reported_by`: ๋ณด๊ณ ์
+
+### **์ฌ์ฉ ์์ **
+- ํ์ฅ์์ ๋๋ฉด ์ ์ฒด์ ๋ํ ๋ฌธ์ ๋ฐ๊ฒฌ ์
+- ๋ฐฐ๊ด ๊ฐ์ญ, ๋ผ์ฐํ
๋ณ๊ฒฝ ๋ฑ ์ ๋ฐ์ ์ด์ ๊ธฐ๋ก
+- ์ค๊ณ ๋ณ๊ฒฝ ์์ฒญ ์
+
+### **์์**
+```
+๊ตฌ์ญ: #01
+๋๋ฉด: P&ID-001
+์ด์: "๋๋ฉด A ์ ์ฒด์ ์ผ๋ก ๋ฐฐ๊ด ๊ฐ์ญ์ด ์ฌํจ. ํ์ฅ ์ฌ๊ฑด์ ์ผ๋ถ ๋ฃจํธ ๋ณ๊ฒฝ ํ์"
+```
+
+---
+
+## ๐ง **7. pipe_segment_issues**
+**์ฉ๋**: ๊ฐ๋ณ ๋จ๊ด๋ณ ์ด์ ์ ์ฅ
+
+### **์ฃผ์ ์ปฌ๋ผ**
+- `snapshot_id`: ์ค๋
์ท ID (pipe_issue_snapshots ์ฐธ์กฐ)
+- `segment_id`: ๋จ๊ด ID (pipe_issue_segments ์ฐธ์กฐ)
+- `issue_description`: ์ด์ ์ค๋ช
+- `issue_type`: ์ด์ ์ ํ (cutting, installation, material, routing, other)
+- `length_change`: ๊ธธ์ด ๋ณ๊ฒฝ๋ (+/- mm)
+- `new_length`: ์ต์ข
๊ธธ์ด
+- `material_change`: ์ฌ์ง ๋ณ๊ฒฝ ์ ๋ณด
+- `severity`: ์ฌ๊ฐ๋
+- `status`: ์ํ
+
+### **์ฌ์ฉ ์์ **
+- ๊ฐ๋ณ ๋จ๊ด์์ ๋ฌธ์ ๋ฐ๊ฒฌ ์
+- ํ์ฅ ์ ๋จ, ์ค์น ๋ฌธ์ ๋ฑ ๊ตฌ์ฒด์ ์ด์ ๊ธฐ๋ก
+- ๋จ๊ด๋ณ ์์ ์ฌํญ ์ถ์
+
+### **์์**
+```
+๊ตฌ์ญ: #01
+๋๋ฉด: P&ID-001
+๋ผ์ธ๋ฒํธ: LINE-A-001
+์ด์: "์ค์น๊ฐ ํ๋ค์ด 30mm ์ ๋จํจ"
+๊ธธ์ด ๋ณ๊ฒฝ: -30mm
+์ต์ข
๊ธธ์ด: 1470mm
+```
+
+---
+
+## ๐ **ํ
์ด๋ธ ๊ด๊ณ๋**
+
+```
+pipe_cutting_plans (๋จ๊ด ์ ๋ณด)
+ โ
+pipe_revision_comparisons (๋ฆฌ๋น์ ๋น๊ต)
+ โ
+pipe_revision_changes (๋ณ๊ฒฝ์ฌํญ ์์ธ)
+
+pipe_cutting_plans (๋จ๊ด ์ ๋ณด)
+ โ (ํ์ ์ ์ค๋
์ท)
+pipe_issue_snapshots (์ค๋
์ท ๋ฉํ)
+ โ
+pipe_issue_segments (๊ณ ์ ๋ ๋จ๊ด ์ ๋ณด)
+ โ
+pipe_segment_issues (๋จ๊ด๋ณ ์ด์)
+
+pipe_issue_snapshots (์ค๋
์ท ๋ฉํ)
+ โ
+pipe_drawing_issues (๋๋ฉด๋ณ ์ด์)
+```
+
+---
+
+## ๐ฏ **๋ฐ์ดํฐ ํ๋ฆ**
+
+### **1. Cutting Plan ์์ฑ**
+```
+BOM ์
๋ก๋ โ PIPE ๋ฐ์ดํฐ ์ถ์ถ โ pipe_cutting_plans ์ ์ฅ
+```
+
+### **2. ๋ฆฌ๋น์ ๋ฐ์**
+```
+์ BOM ์
๋ก๋ โ ๊ธฐ์กด ๋ฐ์ดํฐ ๋น๊ต โ pipe_revision_comparisons + pipe_revision_changes ์ ์ฅ
+```
+
+### **3. Cutting Plan ํ์ **
+```
+ํ์ ๋ฒํผ ํด๋ฆญ โ pipe_issue_snapshots ์์ฑ โ pipe_issue_segments ๋ณต์ฌ (๊ณ ์ )
+```
+
+### **4. ์ด์ ๊ด๋ฆฌ**
+```
+ํ์ฅ ์ด์ ๋ฐ์ โ pipe_drawing_issues (๋๋ฉด๋ณ) ๋๋ pipe_segment_issues (๋จ๊ด๋ณ) ์ ์ฅ
+```
+
+---
+
+## ๐ **๋ฆฌ๋น์ ๋ณดํธ ๋ฉ์ปค๋์ฆ**
+
+### **ํ์ ์ **
+- `pipe_cutting_plans`: ๋ฆฌ๋น์ ์ ๋ณ๊ฒฝ๋จ โ
+- Excel ๋ด๋ณด๋ด๊ธฐ: ํ์ฌ ๋ฐ์ดํฐ ๊ธฐ์ค (๋ณ๋ ๊ฐ๋ฅ)
+
+### **ํ์ ํ**
+- `pipe_issue_snapshots`: ์ ๊ธ ์ํ ๐
+- `pipe_issue_segments`: ๊ณ ์ ๋ ๋ฐ์ดํฐ โ
+- Excel ๋ด๋ณด๋ด๊ธฐ: ์ค๋
์ท ๋ฐ์ดํฐ ๊ธฐ์ค (๊ณ ์ )
+- ์ด์ ๊ด๋ฆฌ: ๊ณ ์ ๋ ๋ฐ์ดํฐ ๊ธฐ์ค์ผ๋ก ์งํ
+
+---
+
+## ๐ **์๋ ๋ง์ด๊ทธ๋ ์ด์
**
+
+๋ชจ๋ PIPE ๊ด๋ จ ํ
์ด๋ธ์ `backend/scripts/analyze_and_fix_schema.py`์ ํฌํจ๋์ด ์์ด **์๋์ผ๋ก ์์ฑ**๋ฉ๋๋ค.
+
+### **๋ง์ด๊ทธ๋ ์ด์
ํฌํจ ์ฌํญ**
+- โ
7๊ฐ ํ
์ด๋ธ ์๋ ์์ฑ
+- โ
๋ชจ๋ ์ธ๋ฑ์ค ์๋ ์์ฑ
+- โ
์ธ๋ํค ์ ์ฝ์กฐ๊ฑด ์ค์
+- โ
๊ธฐ๋ณธ๊ฐ ๋ฐ ์ ์ฝ์กฐ๊ฑด ์ค์
+
+### **๋ฐฐํฌ ์ ์๋ ์คํ**
+```bash
+# Docker ์ปจํ
์ด๋ ์์ ์ ์๋ ์คํ
+./start.sh โ analyze_and_fix_schema.py โ PIPE ํ
์ด๋ธ ์์ฑ
+```
+
+---
+
+## ๐ **ํต์ฌ ์ฅ์ **
+
+1. **์์ ํ ๋ฆฌ๋น์ ๋ณดํธ**: ํ์ ํ ๋ฐ์ดํฐ ๋ณ๊ฒฝ ๋ถ๊ฐ
+2. **์ฒด๊ณ์ ์ด์ ๊ด๋ฆฌ**: ๋๋ฉด๋ณ/๋จ๊ด๋ณ ๊ตฌ๋ถ ๊ด๋ฆฌ
+3. **์๋ ๋ง์ด๊ทธ๋ ์ด์
**: ๋ฐฐํฌ ์ ์๋ ํ
์ด๋ธ ์์ฑ
+4. **์ฑ๋ฅ ์ต์ ํ**: ๋ชจ๋ ์ฃผ์ ์ปฌ๋ผ์ ์ธ๋ฑ์ค ์ค์
+5. **๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ**: ์ธ๋ํค ์ ์ฝ์กฐ๊ฑด์ผ๋ก ๊ด๊ณ ๋ณด์ฅ
+
+์ด์ **์๋ฒฝํ PIPE ๊ด๋ฆฌ ์์คํ
**์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ธฐ๋ฐ์ด ๊ตฌ์ถ๋์์ต๋๋ค! ๐
diff --git a/PIPE_DEVELOPMENT_GUIDE.md b/PIPE_DEVELOPMENT_GUIDE.md
new file mode 100644
index 0000000..7a3acad
--- /dev/null
+++ b/PIPE_DEVELOPMENT_GUIDE.md
@@ -0,0 +1,1123 @@
+# ๐ง PIPE Cutting Plan & ์ด์ ๊ด๋ฆฌ ์์คํ
๊ฐ๋ฐ ๊ฐ์ด๋
+
+## ๐ ๋ชฉ์ฐจ
+1. [์์คํ
๊ฐ์](#์์คํ
-๊ฐ์)
+2. [์ ์ฒด ์ํฌํ๋ก์ฐ](#์ ์ฒด-์ํฌํ๋ก์ฐ)
+3. [๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค๊ณ](#๋ฐ์ดํฐ๋ฒ ์ด์ค-์ค๊ณ)
+4. [ํ์ด์ง๋ณ ์์ธ ์ค๊ณ](#ํ์ด์ง๋ณ-์์ธ-์ค๊ณ)
+5. [API ์๋ํฌ์ธํธ](#api-์๋ํฌ์ธํธ)
+6. [๋ฆฌ๋น์ ๊ด๋ฆฌ ๋ก์ง](#๋ฆฌ๋น์ -๊ด๋ฆฌ-๋ก์ง)
+7. [๊ฐ๋ฐ ์ฐ์ ์์](#๊ฐ๋ฐ-์ฐ์ ์์)
+8. [๊ธฐ์ ์คํ](#๊ธฐ์ -์คํ)
+
+---
+
+## ๐ฏ ์์คํ
๊ฐ์
+
+### **ํต์ฌ ๋ชฉ์ **
+- PIPE ์์ฌ์ ์ฒด๊ณ์ ์ธ Cutting Plan ๊ด๋ฆฌ
+- ๊ตฌ์ญ๋ณ/๋๋ฉด๋ณ ๋จ๊ด ์ ๋ณด ๊ด๋ฆฌ
+- ๋ฆฌ๋น์ ์ ๋ณ๊ฒฝ์ฌํญ ์ถ์ ๋ฐ ๋น๊ต
+- ํ์ฅ ์ด์ ๋ฐ ๋ฌธ์ ์ ์ฒด๊ณ์ ๊ธฐ๋ก
+
+### **์ฃผ์ ํน์ง**
+- **2๋จ๊ณ ์ํฌํ๋ก์ฐ**: ๊ตฌ์ญ ํ ๋น โ ๋ผ์ธ๋ฒํธ ์
๋ ฅ
+- **์ค๋งํธ ๋ฆฌ๋น์ **: ๊ธฐ์กด ๋ฐ์ดํฐ์ ์ ๊ท BOM ์๋ ๋น๊ต
+- **ํ์ฅ ์ด์ ๊ด๋ฆฌ**: ๋๋ฉด๋ณ/๋จ๊ด๋ณ ๋ฌธ์ ์ ์ถ์
+- **Excel ์ฐ๋**: ์์ ํ ๋ฐ์ดํฐ ๋ด๋ณด๋ด๊ธฐ ์ง์
+
+---
+
+## ๐ ์ ์ฒด ์ํฌํ๋ก์ฐ
+
+```mermaid
+graph TD
+ A[BOM ์
๋ก๋] --> B{PIPE ๋ฐ์ดํฐ ์ถ์ถ}
+ B --> C[1๋จ๊ณ: ๊ตฌ์ญ๋ณ ๋๋ฉด ํ ๋น]
+ C --> D[2๋จ๊ณ: ๋ผ์ธ๋ฒํธ ์
๋ ฅ]
+ D --> E[3๋จ๊ณ: ๋จ๊ด ์ ๋ณด ํ์ธ]
+ E --> F[Cutting Plan ์ ์ฅ]
+
+ F --> G{์๋ก์ด ๋ฆฌ๋น์ ?}
+ G -->|Yes| H[4๋จ๊ณ: ๋ฆฌ๋น์ ๋น๊ต]
+ G -->|No| I[์ด์ ๊ด๋ฆฌ ํ์ด์ง]
+
+ H --> J[๋ณ๊ฒฝ์ฌํญ ํ์ธ/์์ ]
+ J --> K[5๋จ๊ณ: ๋ณ๊ฒฝ ๋๋ฉด ์์ฝ]
+ K --> L[ํ์ดํ ๊ตฌ๋งค๋ ์ฌ๊ณ์ฐ]
+ L --> I
+
+ I --> M[ํ์ฅ ์ด์ ์
๋ ฅ]
+ M --> N[Excel ๋ด๋ณด๋ด๊ธฐ]
+```
+
+---
+
+## ๐๏ธ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค๊ณ
+
+### **1. pipe_cutting_plans** (๋จ๊ด ์ ๋ณด)
+```sql
+CREATE TABLE pipe_cutting_plans (
+ id SERIAL PRIMARY KEY,
+ job_no VARCHAR(50) NOT NULL,
+ file_id INTEGER REFERENCES files(id),
+
+ -- ๊ธฐ๋ณธ ์ ๋ณด
+ area VARCHAR(10), -- #01, #02 ๋ฑ
+ drawing_name VARCHAR(100) NOT NULL, -- P&ID-001
+ line_no VARCHAR(50) NOT NULL, -- LINE-A-001
+
+ -- ํ์ดํ ์ ๋ณด
+ material_grade VARCHAR(50), -- A106 GR.B
+ schedule_spec VARCHAR(20), -- SCH40, SCH80
+ nominal_size VARCHAR(50), -- 4", 6", 8"
+ length_mm DECIMAL(10,3) NOT NULL, -- 1500.0
+
+ -- ๋๋จ ์ ๋ณด
+ end_preparation VARCHAR(20), -- ๋ฌด๊ฐ์ , ํ๊ฐ์ , ์๊ฐ์
+
+ -- ๋ฉํ๋ฐ์ดํฐ
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ created_by VARCHAR(100),
+
+ -- ์ธ๋ฑ์ค
+ INDEX idx_cutting_plans_job (job_no),
+ INDEX idx_cutting_plans_area_drawing (area, drawing_name),
+ INDEX idx_cutting_plans_material (material_grade, nominal_size)
+);
+```
+
+### **2. pipe_revision_comparisons** (๋ฆฌ๋น์ ๋น๊ต)
+```sql
+CREATE TABLE pipe_revision_comparisons (
+ id SERIAL PRIMARY KEY,
+ job_no VARCHAR(50) NOT NULL,
+ current_file_id INTEGER REFERENCES files(id),
+ previous_cutting_plan_id INTEGER REFERENCES pipe_cutting_plans(id),
+
+ -- ๋น๊ต ๊ฒฐ๊ณผ
+ comparison_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ total_drawings INTEGER, -- ์ ์ฒด ๋๋ฉด ์
+ changed_drawings INTEGER, -- ๋ณ๊ฒฝ๋ ๋๋ฉด ์
+ unchanged_drawings INTEGER, -- ๋ณ๊ฒฝ๋์ง ์์ ๋๋ฉด ์
+
+ -- ๋จ๊ด ๋ณ๊ฒฝ ํต๊ณ
+ total_segments INTEGER, -- ์ ์ฒด ๋จ๊ด ์
+ added_segments INTEGER, -- ์ถ๊ฐ๋ ๋จ๊ด
+ removed_segments INTEGER, -- ์ญ์ ๋ ๋จ๊ด
+ modified_segments INTEGER, -- ์์ ๋ ๋จ๊ด
+ unchanged_segments INTEGER, -- ๋ณ๊ฒฝ๋์ง ์์ ๋จ๊ด
+
+ -- ๋ฉํ๋ฐ์ดํฐ
+ created_by VARCHAR(100),
+ is_applied BOOLEAN DEFAULT FALSE,
+ applied_at TIMESTAMP,
+ applied_by VARCHAR(100)
+);
+```
+
+### **3. pipe_revision_changes** (๋ฆฌ๋น์ ๋ณ๊ฒฝ ์์ธ)
+```sql
+CREATE TABLE pipe_revision_changes (
+ id SERIAL PRIMARY KEY,
+ comparison_id INTEGER REFERENCES pipe_revision_comparisons(id),
+
+ -- ๋ณ๊ฒฝ ์ ๋ณด
+ drawing_name VARCHAR(100) NOT NULL,
+ change_type VARCHAR(20) NOT NULL, -- 'added', 'removed', 'modified', 'unchanged'
+
+ -- ์ด์ ๋ฐ์ดํฐ (์์ /์ญ์ ์ ๊ฒฝ์ฐ)
+ old_line_no VARCHAR(50),
+ old_material_grade VARCHAR(50),
+ old_schedule_spec VARCHAR(20),
+ old_nominal_size VARCHAR(50),
+ old_length_mm DECIMAL(10,3),
+ old_end_preparation VARCHAR(20),
+
+ -- ์๋ก์ด ๋ฐ์ดํฐ (์ถ๊ฐ/์์ ์ ๊ฒฝ์ฐ)
+ new_line_no VARCHAR(50),
+ new_material_grade VARCHAR(50),
+ new_schedule_spec VARCHAR(20),
+ new_nominal_size VARCHAR(50),
+ new_length_mm DECIMAL(10,3),
+ new_end_preparation VARCHAR(20),
+
+ -- ๋ณ๊ฒฝ ์ฌ์
+ change_reason TEXT,
+
+ -- ๋ฉํ๋ฐ์ดํฐ
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+### **4. pipe_drawing_issues** (๋๋ฉด ์ ๋ฐ ์ด์)
+```sql
+CREATE TABLE pipe_drawing_issues (
+ id SERIAL PRIMARY KEY,
+ job_no VARCHAR(50) NOT NULL,
+ area VARCHAR(10) NOT NULL,
+ drawing_name VARCHAR(100) NOT NULL,
+
+ -- ์ด์ ์ ๋ณด
+ issue_description TEXT NOT NULL,
+ severity VARCHAR(20) DEFAULT 'medium', -- 'low', 'medium', 'high', 'critical'
+ status VARCHAR(20) DEFAULT 'open', -- 'open', 'in_progress', 'resolved'
+
+ -- ํด๊ฒฐ ์ ๋ณด
+ resolution_notes TEXT,
+ resolved_by VARCHAR(100),
+ resolved_at TIMESTAMP,
+
+ -- ๋ฉํ๋ฐ์ดํฐ
+ reported_by VARCHAR(100),
+ reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+### **5. pipe_segment_issues** (๋จ๊ด๋ณ ์ด์)
+```sql
+CREATE TABLE pipe_segment_issues (
+ id SERIAL PRIMARY KEY,
+ job_no VARCHAR(50) NOT NULL,
+ area VARCHAR(10) NOT NULL,
+ drawing_name VARCHAR(100) NOT NULL,
+ line_no VARCHAR(50) NOT NULL,
+
+ -- ์๋ณธ ์ ๋ณด
+ original_material_info VARCHAR(100),
+ original_length DECIMAL(10,3),
+
+ -- ์ด์ ์ ๋ณด
+ issue_description TEXT NOT NULL,
+ issue_type VARCHAR(50), -- 'cutting', 'installation', 'material', 'routing', 'other'
+
+ -- ๋ณ๊ฒฝ์ฌํญ (์๋ ๊ฒฝ์ฐ)
+ length_change DECIMAL(10,3), -- +/- ๋ณ๊ฒฝ๋
+ new_length DECIMAL(10,3), -- ์ต์ข
๊ธธ์ด
+ material_change VARCHAR(100), -- ์ฌ์ง ๋ณ๊ฒฝ ์ ๋ณด
+
+ -- ์ด์ ๊ด๋ฆฌ
+ severity VARCHAR(20) DEFAULT 'medium',
+ status VARCHAR(20) DEFAULT 'open',
+
+ -- ํด๊ฒฐ ์ ๋ณด
+ resolution_notes TEXT,
+ resolved_by VARCHAR(100),
+ resolved_at TIMESTAMP,
+
+ -- ๋ฉํ๋ฐ์ดํฐ
+ cutting_plan_id INTEGER REFERENCES pipe_cutting_plans(id),
+ reported_by VARCHAR(100),
+ reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+---
+
+## ๐ ํ์ด์ง๋ณ ์์ธ ์ค๊ณ
+
+### **1. PIPE Cutting Plan ๋ฉ์ธ ํ์ด์ง**
+**ํ์ผ**: `frontend/src/pages/revision/PipeCuttingPlanPage.jsx`
+
+#### **1๋จ๊ณ: ๊ตฌ์ญ๋ณ ๋๋ฉด ํ ๋น**
+```javascript
+// UI ๊ตฌ์กฐ
+
+
+
์นดํ
๊ณ ๋ฆฌ๋ณ ์ฒ๋ฆฌ ํํฉ
+
+
+
+
+ {Object.entries(categoryStats).map(([category, stats]) => (
+
+
+ {category}
+
+ {stats.processed}/{stats.total}
+
+
+
+
0 ? (stats.processed / stats.total) * 100 : 0}%`
+ }}
+ />
+
+
+ ))}
+
+
+ );
+};
+
+/**
+ * ๋ฆฌ๋น์ ๋ณ๊ฒฝ์ฌํญ ์์ฝ ์นด๋
+ */
+export const RevisionChangeSummary = ({ changes, className = '' }) => {
+ if (!changes) return null;
+
+ const changeTypes = [
+ { key: 'added', label: '์ถ๊ฐ', icon: 'โ', color: '#10b981' },
+ { key: 'modified', label: '์์ ', icon: '๐', color: '#3b82f6' },
+ { key: 'removed', label: '์ ๊ฑฐ', icon: 'โ', color: '#ef4444' },
+ { key: 'unchanged', label: '๋ณ๊ฒฝ์์', icon: 'โ
', color: '#6b7280' }
+ ];
+
+ const totalChanges = changeTypes.reduce(
+ (sum, type) => sum + (changes[type.key]?.length || 0), 0
+ );
+
+ return (
+
+
+
๋ณ๊ฒฝ์ฌํญ ์์ฝ
+ ์ด {totalChanges}๊ฐ
+
+
+
+ {changeTypes.map(type => {
+ const count = changes[type.key]?.length || 0;
+ const percentage = totalChanges > 0 ? (count / totalChanges) * 100 : 0;
+
+ return (
+
+
+ {type.icon}
+ {type.label}
+
+
+ {count}
+
+
+ {percentage.toFixed(1)}%
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default RevisionStatusIndicator;
diff --git a/frontend/src/hooks/usePipeIssue.js b/frontend/src/hooks/usePipeIssue.js
new file mode 100644
index 0000000..e6d12b0
--- /dev/null
+++ b/frontend/src/hooks/usePipeIssue.js
@@ -0,0 +1,333 @@
+/**
+ * PIPE ์ด์ ๊ด๋ฆฌ ํ
+ *
+ * ์ค๋
์ท ๊ธฐ๋ฐ ๋๋ฉด๋ณ/๋จ๊ด๋ณ ์ด์ ๊ด๋ฆฌ ๊ธฐ๋ฅ ์ ๊ณต
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import api from '../api';
+
+export const usePipeIssue = (jobNo = null, snapshotId = null) => {
+ // ์ํ ๊ด๋ฆฌ
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ // ๋ฐ์ดํฐ ์ํ
+ const [snapshots, setSnapshots] = useState([]);
+ const [currentSnapshot, setCurrentSnapshot] = useState(null);
+ const [drawingIssues, setDrawingIssues] = useState([]);
+ const [segmentIssues, setSegmentIssues] = useState([]);
+ const [issueReport, setIssueReport] = useState(null);
+
+ // ํํฐ ์ํ
+ const [selectedArea, setSelectedArea] = useState('');
+ const [selectedDrawing, setSelectedDrawing] = useState('');
+ const [statusFilter, setStatusFilter] = useState('');
+
+ // ์ค๋
์ท ๋ชฉ๋ก ์กฐํ
+ const fetchSnapshots = useCallback(async () => {
+ if (!jobNo) return;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.get(`/pipe-issue/snapshots/${jobNo}`);
+ setSnapshots(response.data.snapshots || []);
+
+ // ์ฒซ ๋ฒ์งธ ํ์ฑ ์ค๋
์ท์ ๊ธฐ๋ณธ ์ ํ
+ const activeSnapshot = response.data.snapshots?.find(s => s.is_locked);
+ if (activeSnapshot && !currentSnapshot) {
+ setCurrentSnapshot(activeSnapshot);
+ }
+
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '์ค๋
์ท ์กฐํ ์คํจ';
+ setError(errorMessage);
+ console.error('์ค๋
์ท ์กฐํ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo, currentSnapshot]);
+
+ // ๋๋ฉด ์ด์ ๋ชฉ๋ก ์กฐํ
+ const fetchDrawingIssues = useCallback(async (filters = {}) => {
+ const targetSnapshotId = filters.snapshotId || snapshotId || currentSnapshot?.snapshot_id;
+ if (!targetSnapshotId) return;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const params = new URLSearchParams();
+ if (filters.area || selectedArea) params.append('area', filters.area || selectedArea);
+ if (filters.drawing_name || selectedDrawing) params.append('drawing_name', filters.drawing_name || selectedDrawing);
+ if (filters.status_filter || statusFilter) params.append('status_filter', filters.status_filter || statusFilter);
+
+ const response = await api.get(`/pipe-issue/drawing-issues/${targetSnapshotId}?${params}`);
+ setDrawingIssues(response.data.issues || []);
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋๋ฉด ์ด์ ์กฐํ ์คํจ';
+ setError(errorMessage);
+ console.error('๋๋ฉด ์ด์ ์กฐํ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [snapshotId, currentSnapshot, selectedArea, selectedDrawing, statusFilter]);
+
+ // ๋จ๊ด ์ด์ ๋ชฉ๋ก ์กฐํ
+ const fetchSegmentIssues = useCallback(async (filters = {}) => {
+ const targetSnapshotId = filters.snapshotId || snapshotId || currentSnapshot?.snapshot_id;
+ if (!targetSnapshotId) return;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const params = new URLSearchParams();
+ if (filters.segment_id) params.append('segment_id', filters.segment_id);
+ if (filters.status_filter || statusFilter) params.append('status_filter', filters.status_filter || statusFilter);
+
+ const response = await api.get(`/pipe-issue/segment-issues/${targetSnapshotId}?${params}`);
+ setSegmentIssues(response.data.issues || []);
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋จ๊ด ์ด์ ์กฐํ ์คํจ';
+ setError(errorMessage);
+ console.error('๋จ๊ด ์ด์ ์กฐํ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [snapshotId, currentSnapshot, statusFilter]);
+
+ // ๋๋ฉด ์ด์ ์์ฑ
+ const createDrawingIssue = useCallback(async (issueData) => {
+ const targetSnapshotId = issueData.snapshot_id || snapshotId || currentSnapshot?.snapshot_id;
+ if (!targetSnapshotId) {
+ setError('์ค๋
์ท ID๊ฐ ํ์ํฉ๋๋ค.');
+ return null;
+ }
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.post('/pipe-issue/drawing-issues', {
+ ...issueData,
+ snapshot_id: targetSnapshotId
+ });
+
+ // ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ await fetchDrawingIssues();
+
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋๋ฉด ์ด์ ์์ฑ ์คํจ';
+ setError(errorMessage);
+ console.error('๋๋ฉด ์ด์ ์์ฑ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [snapshotId, currentSnapshot, fetchDrawingIssues]);
+
+ // ๋จ๊ด ์ด์ ์์ฑ
+ const createSegmentIssue = useCallback(async (issueData) => {
+ const targetSnapshotId = issueData.snapshot_id || snapshotId || currentSnapshot?.snapshot_id;
+ if (!targetSnapshotId) {
+ setError('์ค๋
์ท ID๊ฐ ํ์ํฉ๋๋ค.');
+ return null;
+ }
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.post('/pipe-issue/segment-issues', {
+ ...issueData,
+ snapshot_id: targetSnapshotId
+ });
+
+ // ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ await fetchSegmentIssues();
+
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋จ๊ด ์ด์ ์์ฑ ์คํจ';
+ setError(errorMessage);
+ console.error('๋จ๊ด ์ด์ ์์ฑ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [snapshotId, currentSnapshot, fetchSegmentIssues]);
+
+ // ๋๋ฉด ์ด์ ์ํ ์
๋ฐ์ดํธ
+ const updateDrawingIssueStatus = useCallback(async (issueId, statusData) => {
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.put(`/pipe-issue/drawing-issues/${issueId}/status`, statusData);
+
+ // ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ await fetchDrawingIssues();
+
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋๋ฉด ์ด์ ์ํ ์
๋ฐ์ดํธ ์คํจ';
+ setError(errorMessage);
+ console.error('๋๋ฉด ์ด์ ์ํ ์
๋ฐ์ดํธ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [fetchDrawingIssues]);
+
+ // ๋จ๊ด ์ด์ ์ํ ์
๋ฐ์ดํธ
+ const updateSegmentIssueStatus = useCallback(async (issueId, statusData) => {
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.put(`/pipe-issue/segment-issues/${issueId}/status`, statusData);
+
+ // ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ await fetchSegmentIssues();
+
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋จ๊ด ์ด์ ์ํ ์
๋ฐ์ดํธ ์คํจ';
+ setError(errorMessage);
+ console.error('๋จ๊ด ์ด์ ์ํ ์
๋ฐ์ดํธ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [fetchSegmentIssues]);
+
+ // ์ด์ ๋ฆฌํฌํธ ์์ฑ
+ const generateIssueReport = useCallback(async () => {
+ const targetSnapshotId = snapshotId || currentSnapshot?.snapshot_id;
+ if (!targetSnapshotId) return;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.get(`/pipe-issue/report/${targetSnapshotId}`);
+ setIssueReport(response.data);
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '์ด์ ๋ฆฌํฌํธ ์์ฑ ์คํจ';
+ setError(errorMessage);
+ console.error('์ด์ ๋ฆฌํฌํธ ์์ฑ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [snapshotId, currentSnapshot]);
+
+ // ์ด๊ธฐ ๋ฐ์ดํฐ ๋ก๋
+ useEffect(() => {
+ if (jobNo) {
+ fetchSnapshots();
+ }
+ }, [jobNo, fetchSnapshots]);
+
+ // ์ค๋
์ท ๋ณ๊ฒฝ ์ ์ด์ ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ useEffect(() => {
+ if (currentSnapshot?.snapshot_id) {
+ fetchDrawingIssues();
+ fetchSegmentIssues();
+ }
+ }, [currentSnapshot, fetchDrawingIssues, fetchSegmentIssues]);
+
+ // ํํฐ ๋ณ๊ฒฝ ์ ์ด์ ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ useEffect(() => {
+ if (currentSnapshot?.snapshot_id) {
+ fetchDrawingIssues();
+ }
+ }, [selectedArea, selectedDrawing, statusFilter, fetchDrawingIssues]);
+
+ // ํธ์ ํจ์๋ค
+ const getIssueStats = useCallback(() => {
+ const totalDrawingIssues = drawingIssues.length;
+ const totalSegmentIssues = segmentIssues.length;
+
+ const drawingStats = {
+ total: totalDrawingIssues,
+ open: drawingIssues.filter(i => i.status === 'open').length,
+ in_progress: drawingIssues.filter(i => i.status === 'in_progress').length,
+ resolved: drawingIssues.filter(i => i.status === 'resolved').length,
+ critical: drawingIssues.filter(i => i.severity === 'critical').length,
+ high: drawingIssues.filter(i => i.severity === 'high').length
+ };
+
+ const segmentStats = {
+ total: totalSegmentIssues,
+ open: segmentIssues.filter(i => i.status === 'open').length,
+ in_progress: segmentIssues.filter(i => i.status === 'in_progress').length,
+ resolved: segmentIssues.filter(i => i.status === 'resolved').length,
+ critical: segmentIssues.filter(i => i.severity === 'critical').length,
+ high: segmentIssues.filter(i => i.severity === 'high').length
+ };
+
+ return {
+ drawing: drawingStats,
+ segment: segmentStats,
+ total: totalDrawingIssues + totalSegmentIssues
+ };
+ }, [drawingIssues, segmentIssues]);
+
+ const hasActiveSnapshot = currentSnapshot && currentSnapshot.is_locked;
+ const canManageIssues = hasActiveSnapshot;
+
+ return {
+ // ์ํ
+ loading,
+ error,
+ snapshots,
+ currentSnapshot,
+ drawingIssues,
+ segmentIssues,
+ issueReport,
+
+ // ํํฐ
+ selectedArea,
+ selectedDrawing,
+ statusFilter,
+ setSelectedArea,
+ setSelectedDrawing,
+ setStatusFilter,
+
+ // ์ก์
+ fetchSnapshots,
+ fetchDrawingIssues,
+ fetchSegmentIssues,
+ createDrawingIssue,
+ createSegmentIssue,
+ updateDrawingIssueStatus,
+ updateSegmentIssueStatus,
+ generateIssueReport,
+ setCurrentSnapshot,
+
+ // ํธ์ ํจ์
+ getIssueStats,
+ clearError: () => setError(''),
+
+ // ์ํ ํ์ธ
+ hasActiveSnapshot,
+ canManageIssues,
+
+ // ํต๊ณ
+ stats: getIssueStats()
+ };
+};
+
+export default usePipeIssue;
diff --git a/frontend/src/hooks/usePipeRevision.js b/frontend/src/hooks/usePipeRevision.js
new file mode 100644
index 0000000..b1dd5ff
--- /dev/null
+++ b/frontend/src/hooks/usePipeRevision.js
@@ -0,0 +1,386 @@
+/**
+ * PIPE ์ ์ฉ ๋ฆฌ๋น์ ๊ด๋ฆฌ ํ
+ *
+ * Cutting Plan ์์ฑ ์ /ํ์ ๋ฐ๋ฅธ ์ฐจ๋ณํ๋ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import api from '../api';
+import { PipeLogger, PIPE_CONSTANTS } from '../utils/pipeUtils';
+
+export const usePipeRevision = (jobNo, fileId) => {
+ const [revisionStatus, setRevisionStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [comparisonResult, setComparisonResult] = useState(null);
+
+ // ๋ฆฌ๋น์ ์ํ ํ์ธ
+ const checkRevisionStatus = useCallback(async () => {
+ if (!jobNo || !fileId) return;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ PipeLogger.logPipeOperation('๋ฆฌ๋น์ ์ํ ํ์ธ', jobNo, { fileId });
+
+ const response = await api.post('/pipe-revision/check-status', {
+ job_no: jobNo,
+ new_file_id: parseInt(fileId)
+ });
+
+ setRevisionStatus(response.data);
+
+ PipeLogger.logPipeOperation('๋ฆฌ๋น์ ์ํ ํ์ธ ์๋ฃ', jobNo, {
+ revisionType: response.data.revision_type,
+ requiresAction: response.data.requires_action
+ });
+
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || 'PIPE ๋ฆฌ๋น์ ์ํ ํ์ธ ์คํจ';
+ setError(errorMessage);
+
+ PipeLogger.logPipeError('๋ฆฌ๋น์ ์ํ ํ์ธ', jobNo, err, { fileId });
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo, fileId]);
+
+ // Cutting Plan ์์ฑ ์ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+ const handlePreCuttingPlanRevision = useCallback(async () => {
+ if (!jobNo || !fileId) return null;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.post('/pipe-revision/handle-pre-cutting-plan', {
+ job_no: jobNo,
+ new_file_id: parseInt(fileId)
+ });
+
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || 'Cutting Plan ์์ฑ ์ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์คํจ';
+ setError(errorMessage);
+ console.error('Pre-cutting-plan ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo, fileId]);
+
+ // Cutting Plan ์์ฑ ํ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+ const handlePostCuttingPlanRevision = useCallback(async () => {
+ if (!jobNo || !fileId) return null;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.post('/pipe-revision/handle-post-cutting-plan', {
+ job_no: jobNo,
+ new_file_id: parseInt(fileId)
+ });
+
+ setComparisonResult(response.data);
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || 'Cutting Plan ์์ฑ ํ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์คํจ';
+ setError(errorMessage);
+ console.error('Post-cutting-plan ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo, fileId]);
+
+ // ๋ฆฌ๋น์ ๋น๊ต ๊ฒฐ๊ณผ ์์ธ ์กฐํ
+ const getComparisonDetails = useCallback(async (comparisonId) => {
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.get(`/pipe-revision/comparison/${comparisonId}`);
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋ฆฌ๋น์ ๋น๊ต ๊ฒฐ๊ณผ ์กฐํ ์คํจ';
+ setError(errorMessage);
+ console.error('๋ฆฌ๋น์ ๋น๊ต ๊ฒฐ๊ณผ ์กฐํ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // ๋ฆฌ๋น์ ๋ณ๊ฒฝ์ฌํญ ์ ์ฉ
+ const applyRevisionChanges = useCallback(async (comparisonId) => {
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.post(`/pipe-revision/comparison/${comparisonId}/apply`);
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋ฆฌ๋น์ ๋ณ๊ฒฝ์ฌํญ ์ ์ฉ ์คํจ';
+ setError(errorMessage);
+ console.error('๋ฆฌ๋น์ ๋ณ๊ฒฝ์ฌํญ ์ ์ฉ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // ๊ตฌ๋งค ์ํฅ ๋ถ์
+ const getPurchaseImpact = useCallback(async (comparisonId) => {
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.get(`/pipe-revision/comparison/${comparisonId}/purchase-impact`);
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๊ตฌ๋งค ์ํฅ ๋ถ์ ์คํจ';
+ setError(errorMessage);
+ console.error('๊ตฌ๋งค ์ํฅ ๋ถ์ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // ๋ฆฌ๋น์ ์ด๋ ฅ ์กฐํ
+ const getRevisionHistory = useCallback(async () => {
+ if (!jobNo) return null;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.get(`/pipe-revision/job/${jobNo}/history`);
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || '๋ฆฌ๋น์ ์ด๋ ฅ ์กฐํ ์คํจ';
+ setError(errorMessage);
+ console.error('๋ฆฌ๋น์ ์ด๋ ฅ ์กฐํ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo]);
+
+ // ์๋ ๋ฆฌ๋น์ ์ฒ๋ฆฌ (์ํ์ ๋ฐ๋ผ ์ ์ ํ ์ฒ๋ฆฌ ์ํ)
+ const processRevisionAutomatically = useCallback(async () => {
+ try {
+ // 1. ๋ฆฌ๋น์ ์ํ ํ์ธ
+ const status = await checkRevisionStatus();
+ if (!status || !status.requires_action) {
+ return {
+ success: true,
+ type: 'no_action_needed',
+ message: status?.message || '๋ฆฌ๋น์ ์ฒ๋ฆฌ๊ฐ ํ์ํ์ง ์์ต๋๋ค.'
+ };
+ }
+
+ // 2. ๋ฆฌ๋น์ ํ์
์ ๋ฐ๋ฅธ ์ฒ๋ฆฌ
+ if (status.revision_type === 'pre_cutting_plan') {
+ const result = await handlePreCuttingPlanRevision();
+ return {
+ success: result !== null,
+ type: 'pre_cutting_plan',
+ data: result,
+ message: result?.message || 'Cutting Plan ์์ฑ ์ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์๋ฃ'
+ };
+ } else if (status.revision_type === 'post_cutting_plan') {
+ const result = await handlePostCuttingPlanRevision();
+ return {
+ success: result !== null,
+ type: 'post_cutting_plan',
+ data: result,
+ message: result?.message || 'Cutting Plan ์์ฑ ํ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์๋ฃ'
+ };
+ }
+
+ return {
+ success: false,
+ type: 'unknown',
+ message: '์ ์ ์๋ ๋ฆฌ๋น์ ํ์
์
๋๋ค.'
+ };
+
+ } catch (err) {
+ console.error('์๋ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์คํจ:', err);
+ return {
+ success: false,
+ type: 'error',
+ message: '์๋ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.'
+ };
+ }
+ }, [checkRevisionStatus, handlePreCuttingPlanRevision, handlePostCuttingPlanRevision]);
+
+ // ์ปดํฌ๋ํธ ๋ง์ดํธ ์ ๋ฆฌ๋น์ ์ํ ํ์ธ
+ useEffect(() => {
+ if (jobNo && fileId) {
+ checkRevisionStatus();
+ }
+ }, [jobNo, fileId, checkRevisionStatus]);
+
+ return {
+ // ์ํ
+ revisionStatus,
+ comparisonResult,
+ loading,
+ error,
+
+ // ์ก์
+ checkRevisionStatus,
+ handlePreCuttingPlanRevision,
+ handlePostCuttingPlanRevision,
+ getComparisonDetails,
+ applyRevisionChanges,
+ getPurchaseImpact,
+ getRevisionHistory,
+ processRevisionAutomatically,
+
+ // Cutting Plan ํ์ (์ค๋
์ท ์์ฑ)
+ finalizeCuttingPlan: useCallback(async () => {
+ if (!jobNo) return null;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.post('/pipe-snapshot/finalize-cutting-plan', {
+ job_no: jobNo,
+ created_by: 'user' // ์ถํ ์ค์ ์ฌ์ฉ์ ์ ๋ณด๋ก ๋ณ๊ฒฝ
+ });
+
+ return response.data;
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || 'Cutting Plan ํ์ ์คํจ';
+ setError(errorMessage);
+ console.error('Cutting Plan ํ์ ์คํจ:', err);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo]),
+
+ // ์ค๋
์ท ์ํ ํ์ธ
+ getSnapshotStatus: useCallback(async () => {
+ if (!jobNo) return null;
+
+ try {
+ const response = await api.get(`/pipe-snapshot/status/${jobNo}`);
+ return response.data;
+ } catch (err) {
+ console.error('์ค๋
์ท ์ํ ํ์ธ ์คํจ:', err);
+ return null;
+ }
+ }, [jobNo]),
+
+ // ์ค๋
์ท ๋จ๊ด ์ ๋ณด ์กฐํ
+ getSnapshotSegments: useCallback(async (snapshotId, area = null, drawingName = null) => {
+ try {
+ const params = new URLSearchParams();
+ if (area) params.append('area', area);
+ if (drawingName) params.append('drawing_name', drawingName);
+
+ const response = await api.get(`/pipe-snapshot/segments/${snapshotId}?${params}`);
+ return response.data;
+ } catch (err) {
+ console.error('์ค๋
์ท ๋จ๊ด ์กฐํ ์คํจ:', err);
+ return null;
+ }
+ }, []),
+
+ // ์ฌ์ฉ ๊ฐ๋ฅํ ๊ตฌ์ญ ๋ชฉ๋ก ์กฐํ
+ getAvailableAreas: useCallback(async (snapshotId) => {
+ try {
+ const response = await api.get(`/pipe-snapshot/areas/${snapshotId}`);
+ return response.data;
+ } catch (err) {
+ console.error('๊ตฌ์ญ ๋ชฉ๋ก ์กฐํ ์คํจ:', err);
+ return null;
+ }
+ }, []),
+
+ // ์ฌ์ฉ ๊ฐ๋ฅํ ๋๋ฉด ๋ชฉ๋ก ์กฐํ
+ getAvailableDrawings: useCallback(async (snapshotId, area = null) => {
+ try {
+ const params = area ? `?area=${encodeURIComponent(area)}` : '';
+ const response = await api.get(`/pipe-snapshot/drawings/${snapshotId}${params}`);
+ return response.data;
+ } catch (err) {
+ console.error('๋๋ฉด ๋ชฉ๋ก ์กฐํ ์คํจ:', err);
+ return null;
+ }
+ }, []),
+
+ // ๋ฆฌ๋น์ ๋ณดํธ ์ํ ํ์ธ
+ checkRevisionProtection: useCallback(async () => {
+ if (!jobNo) return null;
+
+ try {
+ const response = await api.get(`/pipe-snapshot/revision-protection/${jobNo}`);
+ return response.data;
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ๋ณดํธ ์ํ ํ์ธ ์คํจ:', err);
+ return null;
+ }
+ }, [jobNo]),
+
+ // ํ์ ๋ Excel ๋ด๋ณด๋ด๊ธฐ
+ exportFinalizedExcel: useCallback(async () => {
+ if (!jobNo) return null;
+
+ try {
+ const response = await api.get(`/pipe-excel/export-finalized/${jobNo}`, {
+ responseType: 'blob'
+ });
+
+ // ํ์ผ ๋ค์ด๋ก๋
+ const blob = new Blob([response.data], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `PIPE_Cutting_Plan_${jobNo}_FINALIZED.xlsx`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+
+ return { success: true, message: 'Excel ํ์ผ์ด ๋ค์ด๋ก๋๋์์ต๋๋ค.' };
+ } catch (err) {
+ const errorMessage = err.response?.data?.detail || 'ํ์ ๋ Excel ๋ด๋ณด๋ด๊ธฐ ์คํจ';
+ setError(errorMessage);
+ console.error('ํ์ ๋ Excel ๋ด๋ณด๋ด๊ธฐ ์คํจ:', err);
+ return { success: false, message: errorMessage };
+ }
+ }, [jobNo]),
+
+ // ํ์ ์ํ ํ์ธ
+ checkFinalizationStatus: useCallback(async () => {
+ if (!jobNo) return null;
+
+ try {
+ const response = await api.get(`/pipe-excel/check-finalization/${jobNo}`);
+ return response.data;
+ } catch (err) {
+ console.error('ํ์ ์ํ ํ์ธ ์คํจ:', err);
+ return null;
+ }
+ }, [jobNo]),
+
+ // ์ ํธ๋ฆฌํฐ
+ clearError: () => setError(''),
+ isPreCuttingPlan: revisionStatus?.revision_type === 'pre_cutting_plan',
+ isPostCuttingPlan: revisionStatus?.revision_type === 'post_cutting_plan',
+ requiresAction: revisionStatus?.requires_action || false
+ };
+};
+
+export default usePipeRevision;
diff --git a/frontend/src/hooks/useRevisionComparison.js b/frontend/src/hooks/useRevisionComparison.js
new file mode 100644
index 0000000..0d1108f
--- /dev/null
+++ b/frontend/src/hooks/useRevisionComparison.js
@@ -0,0 +1,389 @@
+import { useState, useCallback } from 'react';
+import { api } from '../api';
+
+/**
+ * ๋ฆฌ๋น์ ๋น๊ต ํ
+ * ๋ ๋ฆฌ๋น์ ๊ฐ์ ์์ฌ ๋น๊ต ๋ฐ ์ฐจ์ด์ ๋ถ์
+ */
+export const useRevisionComparison = () => {
+ const [comparisonResult, setComparisonResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const compareRevisions = useCallback(async (currentFileId, previousFileId, categoryFilter = null) => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const params = new URLSearchParams({
+ current_file_id: currentFileId,
+ previous_file_id: previousFileId
+ });
+
+ if (categoryFilter) {
+ params.append('category_filter', categoryFilter);
+ }
+
+ const response = await api.post(`/revision-comparison/compare?${params}`);
+
+ if (response.data.success) {
+ setComparisonResult(response.data.data);
+ return response.data.data;
+ } else {
+ throw new Error(response.data.message || '๋ฆฌ๋น์ ๋น๊ต ์คํจ');
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ๋น๊ต ์คํจ:', err);
+ setError(err.message);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const getCategoryComparison = useCallback(async (currentFileId, previousFileId, category) => {
+ if (category === 'PIPE') {
+ console.warn('PIPE ์นดํ
๊ณ ๋ฆฌ๋ ๋ณ๋ ์ฒ๋ฆฌ๊ฐ ํ์ํฉ๋๋ค.');
+ return null;
+ }
+
+ return await compareRevisions(currentFileId, previousFileId, category);
+ }, [compareRevisions]);
+
+ const getComparisonSummary = useCallback((comparison) => {
+ if (!comparison || !comparison.summary) return null;
+
+ const { summary } = comparison;
+
+ return {
+ totalChanges: summary.modified + summary.added + summary.removed,
+ unchanged: summary.unchanged,
+ modified: summary.modified,
+ added: summary.added,
+ removed: summary.removed,
+ changePercentage: summary.previous_count > 0
+ ? ((summary.modified + summary.added + summary.removed) / summary.previous_count * 100)
+ : 0,
+ hasSignificantChanges: (summary.modified + summary.added + summary.removed) > 0
+ };
+ }, []);
+
+ const getChangesByType = useCallback((comparison, changeType) => {
+ if (!comparison || !comparison.changes) return [];
+ return comparison.changes[changeType] || [];
+ }, []);
+
+ const getMaterialChanges = useCallback((comparison, materialId) => {
+ if (!comparison || !comparison.changes) return null;
+
+ // ๋ชจ๋ ๋ณ๊ฒฝ ํ์
์์ ํด๋น ์์ฌ ์ฐพ๊ธฐ
+ for (const [changeType, changes] of Object.entries(comparison.changes)) {
+ const materialChange = changes.find(change => {
+ const material = change.material || change.current || change.previous;
+ return material && material.id === materialId;
+ });
+
+ if (materialChange) {
+ return {
+ changeType,
+ ...materialChange
+ };
+ }
+ }
+
+ return null;
+ }, []);
+
+ const filterChangesByCategory = useCallback((comparison, category) => {
+ if (!comparison || !comparison.changes) return null;
+
+ const filteredChanges = {};
+
+ for (const [changeType, changes] of Object.entries(comparison.changes)) {
+ filteredChanges[changeType] = changes.filter(change => {
+ const material = change.material || change.current || change.previous;
+ return material && material.classified_category === category;
+ });
+ }
+
+ // ํํฐ๋ง๋ ์์ฝ ํต๊ณ ๊ณ์ฐ
+ const filteredSummary = {
+ unchanged: filteredChanges.unchanged?.length || 0,
+ modified: filteredChanges.modified?.length || 0,
+ added: filteredChanges.added?.length || 0,
+ removed: filteredChanges.removed?.length || 0
+ };
+
+ return {
+ ...comparison,
+ changes: filteredChanges,
+ summary: {
+ ...comparison.summary,
+ ...filteredSummary,
+ category_filter: category
+ }
+ };
+ }, []);
+
+ const exportComparisonReport = useCallback(async (comparison, format = 'excel') => {
+ try {
+ setLoading(true);
+
+ const response = await api.post('/revision-comparison/export', {
+ comparison_data: comparison,
+ format
+ }, {
+ responseType: 'blob'
+ });
+
+ // ํ์ผ ๋ค์ด๋ก๋
+ const blob = new Blob([response.data], {
+ type: format === 'excel'
+ ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ : 'application/pdf'
+ });
+
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `revision_comparison_${new Date().toISOString().split('T')[0]}.${format === 'excel' ? 'xlsx' : 'pdf'}`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+
+ return true;
+ } catch (err) {
+ console.error('๋น๊ต ๋ณด๊ณ ์ ๋ด๋ณด๋ด๊ธฐ ์คํจ:', err);
+ setError(err.message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const clearComparison = useCallback(() => {
+ setComparisonResult(null);
+ setError(null);
+ }, []);
+
+ return {
+ comparisonResult,
+ loading,
+ error,
+ compareRevisions,
+ getCategoryComparison,
+ getComparisonSummary,
+ getChangesByType,
+ getMaterialChanges,
+ filterChangesByCategory,
+ exportComparisonReport,
+ clearComparison,
+ setError
+ };
+};
+
+/**
+ * ๋ฆฌ๋น์ ์ฐจ์ด์ ์๊ฐํ ํ
+ */
+export const useRevisionVisualization = (comparison) => {
+ const getChangeVisualizationData = useCallback(() => {
+ if (!comparison || !comparison.summary) return null;
+
+ const { summary } = comparison;
+
+ return {
+ pieChart: [
+ { name: '๋ณ๊ฒฝ์์', value: summary.unchanged, color: '#10b981' },
+ { name: '์์ ๋จ', value: summary.modified, color: '#f59e0b' },
+ { name: '์ถ๊ฐ๋จ', value: summary.added, color: '#3b82f6' },
+ { name: '์ ๊ฑฐ๋จ', value: summary.removed, color: '#ef4444' }
+ ].filter(item => item.value > 0),
+
+ barChart: {
+ categories: ['๋ณ๊ฒฝ์์', '์์ ๋จ', '์ถ๊ฐ๋จ', '์ ๊ฑฐ๋จ'],
+ data: [summary.unchanged, summary.modified, summary.added, summary.removed],
+ colors: ['#10b981', '#f59e0b', '#3b82f6', '#ef4444']
+ },
+
+ summary: {
+ totalItems: summary.previous_count + summary.added,
+ changeRate: summary.previous_count > 0
+ ? ((summary.modified + summary.added + summary.removed) / summary.previous_count * 100)
+ : 0,
+ stabilityRate: summary.previous_count > 0
+ ? (summary.unchanged / summary.previous_count * 100)
+ : 0
+ }
+ };
+ }, [comparison]);
+
+ const getCategoryBreakdown = useCallback(() => {
+ if (!comparison || !comparison.changes) return null;
+
+ const categoryStats = {};
+
+ // ๋ชจ๋ ๋ณ๊ฒฝ์ฌํญ์ ์นดํ
๊ณ ๋ฆฌ๋ณ๋ก ๋ถ๋ฅ
+ Object.entries(comparison.changes).forEach(([changeType, changes]) => {
+ changes.forEach(change => {
+ const material = change.material || change.current || change.previous;
+ const category = material?.classified_category || 'UNKNOWN';
+
+ if (!categoryStats[category]) {
+ categoryStats[category] = {
+ unchanged: 0,
+ modified: 0,
+ added: 0,
+ removed: 0,
+ total: 0
+ };
+ }
+
+ categoryStats[category][changeType]++;
+ categoryStats[category].total++;
+ });
+ });
+
+ return Object.entries(categoryStats).map(([category, stats]) => ({
+ category,
+ ...stats,
+ changeRate: stats.total > 0 ? ((stats.modified + stats.added + stats.removed) / stats.total * 100) : 0
+ }));
+ }, [comparison]);
+
+ const getTimelineData = useCallback(() => {
+ if (!comparison) return null;
+
+ return {
+ comparisonDate: comparison.comparison_date,
+ previousVersion: {
+ fileId: comparison.previous_file_id,
+ materialCount: comparison.summary?.previous_count || 0
+ },
+ currentVersion: {
+ fileId: comparison.current_file_id,
+ materialCount: comparison.summary?.current_count || 0
+ },
+ changes: {
+ added: comparison.summary?.added || 0,
+ removed: comparison.summary?.removed || 0,
+ modified: comparison.summary?.modified || 0
+ }
+ };
+ }, [comparison]);
+
+ return {
+ getChangeVisualizationData,
+ getCategoryBreakdown,
+ getTimelineData
+ };
+};
+
+/**
+ * ๋ฆฌ๋น์ ๋น๊ต ํํฐ๋ง ํ
+ */
+export const useRevisionFiltering = (comparison) => {
+ const [filters, setFilters] = useState({
+ category: 'all',
+ changeType: 'all',
+ searchTerm: '',
+ showOnlySignificant: false
+ });
+
+ const updateFilter = useCallback((filterName, value) => {
+ setFilters(prev => ({
+ ...prev,
+ [filterName]: value
+ }));
+ }, []);
+
+ const getFilteredChanges = useCallback(() => {
+ if (!comparison || !comparison.changes) return null;
+
+ let filteredChanges = { ...comparison.changes };
+
+ // ์นดํ
๊ณ ๋ฆฌ ํํฐ
+ if (filters.category !== 'all') {
+ Object.keys(filteredChanges).forEach(changeType => {
+ filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
+ const material = change.material || change.current || change.previous;
+ return material?.classified_category === filters.category;
+ });
+ });
+ }
+
+ // ๋ณ๊ฒฝ ํ์
ํํฐ
+ if (filters.changeType !== 'all') {
+ const selectedChanges = filteredChanges[filters.changeType] || [];
+ filteredChanges = {
+ unchanged: filters.changeType === 'unchanged' ? selectedChanges : [],
+ modified: filters.changeType === 'modified' ? selectedChanges : [],
+ added: filters.changeType === 'added' ? selectedChanges : [],
+ removed: filters.changeType === 'removed' ? selectedChanges : []
+ };
+ }
+
+ // ๊ฒ์์ด ํํฐ
+ if (filters.searchTerm) {
+ const searchLower = filters.searchTerm.toLowerCase();
+ Object.keys(filteredChanges).forEach(changeType => {
+ filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
+ const material = change.material || change.current || change.previous;
+ const description = material?.original_description || '';
+ return description.toLowerCase().includes(searchLower);
+ });
+ });
+ }
+
+ // ์ค์ํ ๋ณ๊ฒฝ์ฌํญ๋ง ํ์
+ if (filters.showOnlySignificant) {
+ Object.keys(filteredChanges).forEach(changeType => {
+ if (changeType === 'unchanged') {
+ filteredChanges[changeType] = []; // ๋ณ๊ฒฝ์๋ ํญ๋ชฉ ์ ์ธ
+ } else {
+ filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
+ // ์๋ ๋ณํ๊ฐ ํฐ ๊ฒฝ์ฐ๋ง ํ์
+ const changes = change.changes || {};
+ const quantityChange = changes.quantity?.change || 0;
+ return Math.abs(quantityChange) > 1; // 1๊ฐ ์ด์ ๋ณ๊ฒฝ๋ ๊ฒฝ์ฐ๋ง
+ });
+ }
+ });
+ }
+
+ return filteredChanges;
+ }, [comparison, filters]);
+
+ const getFilterSummary = useCallback(() => {
+ const filteredChanges = getFilteredChanges();
+ if (!filteredChanges) return null;
+
+ const summary = {
+ unchanged: filteredChanges.unchanged?.length || 0,
+ modified: filteredChanges.modified?.length || 0,
+ added: filteredChanges.added?.length || 0,
+ removed: filteredChanges.removed?.length || 0
+ };
+
+ summary.total = summary.unchanged + summary.modified + summary.added + summary.removed;
+
+ return summary;
+ }, [getFilteredChanges]);
+
+ const resetFilters = useCallback(() => {
+ setFilters({
+ category: 'all',
+ changeType: 'all',
+ searchTerm: '',
+ showOnlySignificant: false
+ });
+ }, []);
+
+ return {
+ filters,
+ updateFilter,
+ getFilteredChanges,
+ getFilterSummary,
+ resetFilters
+ };
+};
diff --git a/frontend/src/hooks/useRevisionLogic.js b/frontend/src/hooks/useRevisionLogic.js
new file mode 100644
index 0000000..eb3b166
--- /dev/null
+++ b/frontend/src/hooks/useRevisionLogic.js
@@ -0,0 +1,314 @@
+import { useState, useEffect, useCallback } from 'react';
+import { api } from '../api';
+
+/**
+ * ๋ฆฌ๋น์ ๋ก์ง ์ฒ๋ฆฌ ํ
+ * ๊ตฌ๋งค ์ํ๋ณ ์์ฌ ์ฒ๋ฆฌ ๋ก์ง
+ */
+export const useRevisionLogic = (jobNo, currentFileId, previousFileId = null) => {
+ const [processingResult, setProcessingResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const processRevision = useCallback(async () => {
+ if (!jobNo || !currentFileId) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const params = new URLSearchParams({
+ job_no: jobNo,
+ file_id: currentFileId
+ });
+
+ if (previousFileId) {
+ params.append('previous_file_id', previousFileId);
+ }
+
+ const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${currentFileId}?${params}`);
+
+ if (response.data.success) {
+ setProcessingResult(response.data.data);
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ์ฒ๋ฆฌ ์คํจ:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo, currentFileId, previousFileId]);
+
+ const applyProcessingResults = useCallback(async (results) => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const response = await api.post('/revision-material/apply-results', {
+ processing_results: results
+ });
+
+ if (response.data.success) {
+ return response.data;
+ } else {
+ throw new Error(response.data.message || '์ฒ๋ฆฌ ๊ฒฐ๊ณผ ์ ์ฉ ์คํจ');
+ }
+ } catch (err) {
+ console.error('์ฒ๋ฆฌ ๊ฒฐ๊ณผ ์ ์ฉ ์คํจ:', err);
+ setError(err.message);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ return {
+ processingResult,
+ loading,
+ error,
+ processRevision,
+ applyProcessingResults,
+ setError
+ };
+};
+
+/**
+ * ์นดํ
๊ณ ๋ฆฌ๋ณ ์์ฌ ์ฒ๋ฆฌ ํ
+ */
+export const useCategoryMaterialProcessing = (fileId, category) => {
+ const [materials, setMaterials] = useState([]);
+ const [processingInfo, setProcessingInfo] = useState({});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadCategoryMaterials = useCallback(async () => {
+ if (!fileId || !category || category === 'PIPE') return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const response = await api.get(`/revision-material/category/${fileId}/${category}`);
+
+ if (response.data.success) {
+ setMaterials(response.data.data.materials || []);
+ setProcessingInfo(response.data.data.processing_info || {});
+ }
+ } catch (err) {
+ console.error('์นดํ
๊ณ ๋ฆฌ ์์ฌ ์กฐํ ์คํจ:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [fileId, category]);
+
+ const processMaterial = useCallback(async (materialId, action, additionalData = {}) => {
+ try {
+ const response = await api.post(`/revision-material/process/${materialId}`, {
+ action,
+ ...additionalData
+ });
+
+ if (response.data.success) {
+ // ์์ฌ ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ await loadCategoryMaterials();
+ return response.data;
+ } else {
+ throw new Error(response.data.message || '์์ฌ ์ฒ๋ฆฌ ์คํจ');
+ }
+ } catch (err) {
+ console.error('์์ฌ ์ฒ๋ฆฌ ์คํจ:', err);
+ setError(err.message);
+ throw err;
+ }
+ }, [loadCategoryMaterials]);
+
+ const updateMaterialStatus = useCallback((materialId, newStatus, additionalInfo = {}) => {
+ setMaterials(prev =>
+ prev.map(material =>
+ material.id === materialId
+ ? {
+ ...material,
+ revision_status: newStatus,
+ processing_info: {
+ ...material.processing_info,
+ ...additionalInfo
+ }
+ }
+ : material
+ )
+ );
+ }, []);
+
+ useEffect(() => {
+ loadCategoryMaterials();
+ }, [loadCategoryMaterials]);
+
+ return {
+ materials,
+ processingInfo,
+ loading,
+ error,
+ loadCategoryMaterials,
+ processMaterial,
+ updateMaterialStatus,
+ setError
+ };
+};
+
+/**
+ * ์์ฌ ์ ํ ๋ฐ ์ผ๊ด ์ฒ๋ฆฌ ํ
+ */
+export const useMaterialSelection = (materials = []) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [selectAll, setSelectAll] = useState(false);
+
+ const toggleMaterial = useCallback((materialId) => {
+ setSelectedMaterials(prev => {
+ const newSet = new Set(prev);
+ if (newSet.has(materialId)) {
+ newSet.delete(materialId);
+ } else {
+ newSet.add(materialId);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const toggleSelectAll = useCallback(() => {
+ if (selectAll) {
+ setSelectedMaterials(new Set());
+ } else {
+ const selectableMaterials = materials
+ .filter(material => material.processing_info?.action !== '์๋ฃ')
+ .map(material => material.id);
+ setSelectedMaterials(new Set(selectableMaterials));
+ }
+ setSelectAll(!selectAll);
+ }, [selectAll, materials]);
+
+ const clearSelection = useCallback(() => {
+ setSelectedMaterials(new Set());
+ setSelectAll(false);
+ }, []);
+
+ const getSelectedMaterials = useCallback(() => {
+ return materials.filter(material => selectedMaterials.has(material.id));
+ }, [materials, selectedMaterials]);
+
+ const getSelectionSummary = useCallback(() => {
+ const selected = getSelectedMaterials();
+ const byStatus = selected.reduce((acc, material) => {
+ const status = material.processing_info?.display_status || 'UNKNOWN';
+ acc[status] = (acc[status] || 0) + 1;
+ return acc;
+ }, {});
+
+ return {
+ total: selected.length,
+ byStatus,
+ canProcess: selected.length > 0
+ };
+ }, [getSelectedMaterials]);
+
+ // materials ๋ณ๊ฒฝ ์ selectAll ์ํ ์
๋ฐ์ดํธ
+ useEffect(() => {
+ const selectableMaterials = materials
+ .filter(material => material.processing_info?.action !== '์๋ฃ');
+
+ if (selectableMaterials.length === 0) {
+ setSelectAll(false);
+ } else {
+ const allSelected = selectableMaterials.every(material =>
+ selectedMaterials.has(material.id)
+ );
+ setSelectAll(allSelected);
+ }
+ }, [materials, selectedMaterials]);
+
+ return {
+ selectedMaterials,
+ selectAll,
+ toggleMaterial,
+ toggleSelectAll,
+ clearSelection,
+ getSelectedMaterials,
+ getSelectionSummary
+ };
+};
+
+/**
+ * ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์ํ ์ถ์ ํ
+ */
+export const useRevisionProcessingStatus = (jobNo, fileId) => {
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadStatus = useCallback(async () => {
+ if (!jobNo || !fileId) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const response = await api.get(`/revision-status/${jobNo}/${fileId}`);
+
+ if (response.data.success) {
+ setStatus(response.data.data);
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ์ํ ์กฐํ ์คํจ:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo, fileId]);
+
+ const updateProcessingProgress = useCallback((category, processed, total) => {
+ setStatus(prev => {
+ if (!prev) return prev;
+
+ const newCategoryStatus = {
+ ...prev.processing_status.category_breakdown[category],
+ processed,
+ pending: total - processed
+ };
+
+ const newCategoryBreakdown = {
+ ...prev.processing_status.category_breakdown,
+ [category]: newCategoryStatus
+ };
+
+ // ์ ์ฒด ํต๊ณ ์ฌ๊ณ์ฐ
+ const totalProcessed = Object.values(newCategoryBreakdown)
+ .reduce((sum, cat) => sum + cat.processed, 0);
+ const totalMaterials = Object.values(newCategoryBreakdown)
+ .reduce((sum, cat) => sum + cat.total, 0);
+
+ return {
+ ...prev,
+ processing_status: {
+ ...prev.processing_status,
+ total_processed: totalProcessed,
+ pending_processing: totalMaterials - totalProcessed,
+ completion_percentage: totalMaterials > 0 ? (totalProcessed / totalMaterials * 100) : 0,
+ category_breakdown: newCategoryBreakdown
+ }
+ };
+ });
+ }, []);
+
+ useEffect(() => {
+ loadStatus();
+ }, [loadStatus]);
+
+ return {
+ status,
+ loading,
+ error,
+ loadStatus,
+ updateProcessingProgress,
+ setError
+ };
+};
diff --git a/frontend/src/hooks/useRevisionRedirect.js b/frontend/src/hooks/useRevisionRedirect.js
new file mode 100644
index 0000000..7138aa2
--- /dev/null
+++ b/frontend/src/hooks/useRevisionRedirect.js
@@ -0,0 +1,108 @@
+import { useState, useEffect } from 'react';
+import { api } from '../api';
+
+/**
+ * ๋ฆฌ๋น์ ๋ฆฌ๋ค์ด๋ ํธ ํ
+ * BOM ํ์ด์ง ์ ๊ทผ ์ ๋ฆฌ๋น์ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ ํ์์ฑ ํ์ธ
+ */
+export const useRevisionRedirect = (jobNo, fileId, previousFileId = null) => {
+ const [redirectInfo, setRedirectInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!jobNo || !fileId) {
+ setLoading(false);
+ return;
+ }
+
+ checkRevisionRedirect();
+ }, [jobNo, fileId, previousFileId]);
+
+ const checkRevisionRedirect = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const params = new URLSearchParams({
+ job_no: jobNo,
+ file_id: fileId
+ });
+
+ if (previousFileId) {
+ params.append('previous_file_id', previousFileId);
+ }
+
+ const response = await api.get(`/revision-redirect/check/${jobNo}/${fileId}?${params}`);
+
+ if (response.data.success) {
+ setRedirectInfo(response.data.data);
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ๋ฆฌ๋ค์ด๋ ํธ ํ์ธ ์คํจ:', err);
+ setError(err.message);
+ // ์๋ฌ ๋ฐ์ ์ ๊ธฐ์กด BOM ํ์ด์ง ์ฌ์ฉ
+ setRedirectInfo({
+ should_redirect: false,
+ reason: '๋ฆฌ๋น์ ์ํ ํ์ธ ์คํจ - ๊ธฐ์กด ํ์ด์ง ์ฌ์ฉ',
+ redirect_url: null,
+ processing_summary: null
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return {
+ redirectInfo,
+ loading,
+ error,
+ refetch: checkRevisionRedirect
+ };
+};
+
+/**
+ * ๋ฆฌ๋น์ ์ฒ๋ฆฌ ๋ก์ง ํ
+ * ๋ฆฌ๋น์ ํ์ด์ง์์ ์ฌ์ฉํ ์์ธ ์ฒ๋ฆฌ ๊ฒฐ๊ณผ ์กฐํ
+ */
+export const useRevisionProcessing = (jobNo, fileId, previousFileId = null) => {
+ const [processingResult, setProcessingResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const processRevision = async () => {
+ if (!jobNo || !fileId) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const params = new URLSearchParams({
+ job_no: jobNo,
+ file_id: fileId
+ });
+
+ if (previousFileId) {
+ params.append('previous_file_id', previousFileId);
+ }
+
+ const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${fileId}?${params}`);
+
+ if (response.data.success) {
+ setProcessingResult(response.data.data);
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ์ฒ๋ฆฌ ์คํจ:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return {
+ processingResult,
+ loading,
+ error,
+ processRevision
+ };
+};
diff --git a/frontend/src/hooks/useRevisionStatus.js b/frontend/src/hooks/useRevisionStatus.js
new file mode 100644
index 0000000..2593b1c
--- /dev/null
+++ b/frontend/src/hooks/useRevisionStatus.js
@@ -0,0 +1,399 @@
+import { useState, useEffect, useCallback } from 'react';
+import { api } from '../api';
+
+/**
+ * ๋ฆฌ๋น์ ์ํ ๊ด๋ฆฌ ํ
+ * ๋ฆฌ๋น์ ์งํ ์ํ, ํ์คํ ๋ฆฌ, ํ์ ๋ฑ ๊ด๋ฆฌ
+ */
+export const useRevisionStatus = (jobNo, fileId) => {
+ const [revisionStatus, setRevisionStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadRevisionStatus = useCallback(async () => {
+ if (!jobNo || !fileId) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const response = await api.get(`/revision-status/${jobNo}/${fileId}`);
+
+ if (response.data.success) {
+ setRevisionStatus(response.data.data);
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ์ํ ์กฐํ ์คํจ:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo, fileId]);
+
+ const createComparisonRecord = useCallback(async (previousFileId, comparisonResult) => {
+ try {
+ const response = await api.post('/revision-status/create-comparison', {
+ job_no: jobNo,
+ current_file_id: fileId,
+ previous_file_id: previousFileId,
+ comparison_result: comparisonResult
+ });
+
+ if (response.data.success) {
+ // ์ํ ์๋ก๊ณ ์นจ
+ await loadRevisionStatus();
+ return response.data.data.comparison_id;
+ } else {
+ throw new Error(response.data.message || '๋น๊ต ๊ธฐ๋ก ์์ฑ ์คํจ');
+ }
+ } catch (err) {
+ console.error('๋น๊ต ๊ธฐ๋ก ์์ฑ ์คํจ:', err);
+ setError(err.message);
+ throw err;
+ }
+ }, [jobNo, fileId, loadRevisionStatus]);
+
+ const applyComparison = useCallback(async (comparisonId) => {
+ try {
+ setLoading(true);
+
+ const response = await api.post(`/revision-status/apply-comparison/${comparisonId}`);
+
+ if (response.data.success) {
+ // ์ํ ์๋ก๊ณ ์นจ
+ await loadRevisionStatus();
+ return response.data.data;
+ } else {
+ throw new Error(response.data.message || '๋น๊ต ๊ฒฐ๊ณผ ์ ์ฉ ์คํจ');
+ }
+ } catch (err) {
+ console.error('๋น๊ต ๊ฒฐ๊ณผ ์ ์ฉ ์คํจ:', err);
+ setError(err.message);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ }, [loadRevisionStatus]);
+
+ useEffect(() => {
+ loadRevisionStatus();
+ }, [loadRevisionStatus]);
+
+ return {
+ revisionStatus,
+ loading,
+ error,
+ loadRevisionStatus,
+ createComparisonRecord,
+ applyComparison,
+ setError
+ };
+};
+
+/**
+ * ๋ฆฌ๋น์ ํ์คํ ๋ฆฌ ํ
+ */
+export const useRevisionHistory = (jobNo) => {
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadHistory = useCallback(async () => {
+ if (!jobNo) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const response = await api.get(`/revision-status/history/${jobNo}`);
+
+ if (response.data.success) {
+ setHistory(response.data.data || []);
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ํ์คํ ๋ฆฌ ์กฐํ ์คํจ:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo]);
+
+ const getRevisionByFileId = useCallback((fileId) => {
+ return history.find(revision => revision.file_id === fileId);
+ }, [history]);
+
+ const getLatestRevision = useCallback(() => {
+ return history.find(revision => revision.is_latest);
+ }, [history]);
+
+ const getPreviousRevision = useCallback((currentFileId) => {
+ const currentIndex = history.findIndex(revision => revision.file_id === currentFileId);
+ return currentIndex > -1 && currentIndex < history.length - 1
+ ? history[currentIndex + 1]
+ : null;
+ }, [history]);
+
+ const getNextRevision = useCallback((currentFileId) => {
+ const currentIndex = history.findIndex(revision => revision.file_id === currentFileId);
+ return currentIndex > 0
+ ? history[currentIndex - 1]
+ : null;
+ }, [history]);
+
+ useEffect(() => {
+ loadHistory();
+ }, [loadHistory]);
+
+ return {
+ history,
+ loading,
+ error,
+ loadHistory,
+ getRevisionByFileId,
+ getLatestRevision,
+ getPreviousRevision,
+ getNextRevision,
+ setError
+ };
+};
+
+/**
+ * ๋๊ธฐ ์ค์ธ ๋ฆฌ๋น์ ๊ด๋ฆฌ ํ
+ */
+export const usePendingRevisions = (jobNo = null) => {
+ const [pendingRevisions, setPendingRevisions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadPendingRevisions = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const url = jobNo
+ ? `/revision-status/pending?job_no=${jobNo}`
+ : '/revision-status/pending';
+
+ const response = await api.get(url);
+
+ if (response.data.success) {
+ setPendingRevisions(response.data.data || []);
+ }
+ } catch (err) {
+ console.error('๋๊ธฐ ์ค์ธ ๋ฆฌ๋น์ ์กฐํ ์คํจ:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [jobNo]);
+
+ const approvePendingRevision = useCallback(async (comparisonId) => {
+ try {
+ const response = await api.post(`/revision-status/apply-comparison/${comparisonId}`);
+
+ if (response.data.success) {
+ // ๋๊ธฐ ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ await loadPendingRevisions();
+ return response.data.data;
+ } else {
+ throw new Error(response.data.message || '๋ฆฌ๋น์ ์น์ธ ์คํจ');
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ์น์ธ ์คํจ:', err);
+ setError(err.message);
+ throw err;
+ }
+ }, [loadPendingRevisions]);
+
+ const rejectPendingRevision = useCallback(async (comparisonId, reason = '') => {
+ try {
+ const response = await api.post(`/revision-status/reject-comparison/${comparisonId}`, {
+ reason
+ });
+
+ if (response.data.success) {
+ // ๋๊ธฐ ๋ชฉ๋ก ์๋ก๊ณ ์นจ
+ await loadPendingRevisions();
+ return response.data.data;
+ } else {
+ throw new Error(response.data.message || '๋ฆฌ๋น์ ๊ฑฐ๋ถ ์คํจ');
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ๊ฑฐ๋ถ ์คํจ:', err);
+ setError(err.message);
+ throw err;
+ }
+ }, [loadPendingRevisions]);
+
+ useEffect(() => {
+ loadPendingRevisions();
+ }, [loadPendingRevisions]);
+
+ return {
+ pendingRevisions,
+ loading,
+ error,
+ loadPendingRevisions,
+ approvePendingRevision,
+ rejectPendingRevision,
+ setError
+ };
+};
+
+/**
+ * ๋ฆฌ๋น์ ์
๋ก๋ ํ
+ */
+export const useRevisionUpload = (jobNo, currentFileId) => {
+ const [uploading, setUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [error, setError] = useState(null);
+
+ const uploadNewRevision = useCallback(async (file, revisionInfo = {}) => {
+ if (!file || !jobNo || !currentFileId) {
+ throw new Error('ํ์ ์ ๋ณด๊ฐ ๋๋ฝ๋์์ต๋๋ค.');
+ }
+
+ try {
+ setUploading(true);
+ setUploadProgress(0);
+ setError(null);
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('job_no', jobNo);
+ formData.append('parent_file_id', currentFileId);
+
+ // ๋ฆฌ๋น์ ์ ๋ณด ์ถ๊ฐ
+ Object.entries(revisionInfo).forEach(([key, value]) => {
+ formData.append(key, value);
+ });
+
+ const response = await api.post('/files/upload-revision', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ },
+ onUploadProgress: (progressEvent) => {
+ const progress = Math.round(
+ (progressEvent.loaded * 100) / progressEvent.total
+ );
+ setUploadProgress(progress);
+ }
+ });
+
+ if (response.data.success) {
+ setUploadProgress(100);
+ return response.data.data;
+ } else {
+ throw new Error(response.data.message || '๋ฆฌ๋น์ ์
๋ก๋ ์คํจ');
+ }
+ } catch (err) {
+ console.error('๋ฆฌ๋น์ ์
๋ก๋ ์คํจ:', err);
+ setError(err.message);
+ throw err;
+ } finally {
+ setUploading(false);
+ }
+ }, [jobNo, currentFileId]);
+
+ const validateRevisionFile = useCallback((file) => {
+ const errors = [];
+
+ // ํ์ผ ํฌ๊ธฐ ๊ฒ์ฆ (100MB ์ ํ)
+ if (file.size > 100 * 1024 * 1024) {
+ errors.push('ํ์ผ ํฌ๊ธฐ๋ 100MB๋ฅผ ์ด๊ณผํ ์ ์์ต๋๋ค.');
+ }
+
+ // ํ์ผ ํ์ ๊ฒ์ฆ
+ const allowedTypes = [
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'text/csv'
+ ];
+
+ if (!allowedTypes.includes(file.type)) {
+ errors.push('Excel ํ์ผ(.xlsx, .xls) ๋๋ CSV ํ์ผ๋ง ์
๋ก๋ ๊ฐ๋ฅํฉ๋๋ค.');
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ };
+ }, []);
+
+ const resetUpload = useCallback(() => {
+ setUploading(false);
+ setUploadProgress(0);
+ setError(null);
+ }, []);
+
+ return {
+ uploading,
+ uploadProgress,
+ error,
+ uploadNewRevision,
+ validateRevisionFile,
+ resetUpload,
+ setError
+ };
+};
+
+/**
+ * ๋ฆฌ๋น์ ๋ค๋น๊ฒ์ด์
ํ
+ */
+export const useRevisionNavigation = (jobNo, currentFileId) => {
+ const { history } = useRevisionHistory(jobNo);
+
+ const getCurrentRevisionIndex = useCallback(() => {
+ return history.findIndex(revision => revision.file_id === currentFileId);
+ }, [history, currentFileId]);
+
+ const canNavigateToPrevious = useCallback(() => {
+ const currentIndex = getCurrentRevisionIndex();
+ return currentIndex > -1 && currentIndex < history.length - 1;
+ }, [getCurrentRevisionIndex, history.length]);
+
+ const canNavigateToNext = useCallback(() => {
+ const currentIndex = getCurrentRevisionIndex();
+ return currentIndex > 0;
+ }, [getCurrentRevisionIndex]);
+
+ const getPreviousRevision = useCallback(() => {
+ const currentIndex = getCurrentRevisionIndex();
+ return canNavigateToPrevious() ? history[currentIndex + 1] : null;
+ }, [getCurrentRevisionIndex, canNavigateToPrevious, history]);
+
+ const getNextRevision = useCallback(() => {
+ const currentIndex = getCurrentRevisionIndex();
+ return canNavigateToNext() ? history[currentIndex - 1] : null;
+ }, [getCurrentRevisionIndex, canNavigateToNext, history]);
+
+ const isLatestRevision = useCallback(() => {
+ const currentIndex = getCurrentRevisionIndex();
+ return currentIndex === 0;
+ }, [getCurrentRevisionIndex]);
+
+ const isFirstRevision = useCallback(() => {
+ const currentIndex = getCurrentRevisionIndex();
+ return currentIndex === history.length - 1;
+ }, [getCurrentRevisionIndex, history.length]);
+
+ const getRevisionPosition = useCallback(() => {
+ const currentIndex = getCurrentRevisionIndex();
+ return {
+ current: currentIndex + 1,
+ total: history.length,
+ isLatest: isLatestRevision(),
+ isFirst: isFirstRevision()
+ };
+ }, [getCurrentRevisionIndex, history.length, isLatestRevision, isFirstRevision]);
+
+ return {
+ canNavigateToPrevious,
+ canNavigateToNext,
+ getPreviousRevision,
+ getNextRevision,
+ isLatestRevision,
+ isFirstRevision,
+ getRevisionPosition
+ };
+};
diff --git a/frontend/src/pages/EnhancedRevisionPage.css b/frontend/src/pages/EnhancedRevisionPage.css
new file mode 100644
index 0000000..54beaca
--- /dev/null
+++ b/frontend/src/pages/EnhancedRevisionPage.css
@@ -0,0 +1,815 @@
+/* Enhanced Revision Page - ๊ธฐ์กด ์คํ์ผ ํต์ผ */
+
+* {
+ box-sizing: border-box;
+}
+
+.materials-page {
+ background: #f8f9fa;
+ min-height: 100vh;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
+ overflow-x: auto;
+ min-width: 1400px;
+}
+
+/* ํค๋ */
+.materials-header {
+ background: white;
+ border-bottom: 1px solid #e5e7eb;
+ padding: 16px 24px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.back-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ background: #6366f1;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.back-button:hover {
+ background: #5558e3;
+ transform: translateY(-1px);
+}
+
+.header-center {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+/* ๋ฉ์ธ ์ฝํ
์ธ */
+.materials-content {
+ padding: 24px;
+}
+
+/* ์ปจํธ๋กค ์น์
*/
+.control-section {
+ background: white;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ border: 1px solid #e5e7eb;
+}
+
+.section-header h3 {
+ margin: 0 0 16px 0;
+ color: #1f2937;
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.control-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 20px;
+ align-items: end;
+}
+
+.control-group {
+ display: flex;
+ flex-direction: column;
+}
+
+.control-group label {
+ font-weight: 600;
+ color: #34495e;
+ margin-bottom: 8px;
+ font-size: 0.95em;
+}
+
+.control-group select {
+ padding: 8px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 14px;
+ background: white;
+ color: #374151;
+ transition: all 0.2s ease;
+}
+
+.control-group select:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+.control-group select:disabled {
+ background-color: #f9fafb;
+ color: #9ca3af;
+ cursor: not-allowed;
+}
+
+.btn-compare {
+ padding: 8px 16px;
+ background: #6366f1;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+}
+
+.btn-compare:hover:not(:disabled) {
+ background: #5558e3;
+ transform: translateY(-1px);
+}
+
+.btn-compare:disabled {
+ background: #9ca3af;
+ cursor: not-allowed;
+ transform: none;
+}
+
+/* ๋ฉ์ธ ์ฝํ
์ธ ๋ ์ด์์ */
+.revision-content {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 25px;
+}
+
+.content-left, .content-right {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+}
+
+/* ๋น๊ต ๊ฒฐ๊ณผ */
+.comparison-result {
+ background: white;
+ border-radius: 8px;
+ padding: 20px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ border: 1px solid #e5e7eb;
+}
+
+.result-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.result-header h3 {
+ margin: 0;
+ color: #1f2937;
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.btn-apply {
+ padding: 8px 16px;
+ background: #10b981;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.btn-apply:hover:not(:disabled) {
+ background: #059669;
+ transform: translateY(-1px);
+}
+
+/* ๋น๊ต ์์ฝ */
+.comparison-summary {
+ margin-bottom: 30px;
+}
+
+.comparison-summary h3 {
+ margin: 0 0 20px 0;
+ color: #2c3e50;
+ font-size: 1.2em;
+}
+
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 20px;
+}
+
+.summary-card {
+ padding: 20px;
+ border-radius: 10px;
+ border-left: 4px solid;
+}
+
+.summary-card.purchased {
+ background: #e8f5e8;
+ border-left-color: #27ae60;
+}
+
+.summary-card.unpurchased {
+ background: #fff3cd;
+ border-left-color: #ffc107;
+}
+
+.summary-card.changes {
+ background: #e3f2fd;
+ border-left-color: #2196f3;
+}
+
+.summary-card h4 {
+ margin: 0 0 15px 0;
+ font-size: 1.1em;
+ color: #2c3e50;
+}
+
+.summary-stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.stat-item {
+ padding: 6px 12px;
+ border-radius: 20px;
+ font-size: 0.9em;
+ font-weight: 600;
+ background: white;
+ color: #2c3e50;
+ border: 1px solid #e1e8ed;
+}
+
+.stat-item.increase {
+ background: #ffebee;
+ color: #c62828;
+ border-color: #ffcdd2;
+}
+
+.stat-item.decrease {
+ background: #e8f5e8;
+ color: #2e7d32;
+ border-color: #c8e6c9;
+}
+
+.stat-item.new {
+ background: #e3f2fd;
+ color: #1565c0;
+ border-color: #bbdefb;
+}
+
+.stat-item.deleted {
+ background: #fce4ec;
+ color: #ad1457;
+ border-color: #f8bbd9;
+}
+
+/* ๋ณ๊ฒฝ์ฌํญ ์์ธ */
+.change-details {
+ margin-top: 20px;
+}
+
+.change-details h3 {
+ margin: 0 0 20px 0;
+ color: #2c3e50;
+ font-size: 1.2em;
+}
+
+.change-section {
+ margin-bottom: 25px;
+ padding: 20px;
+ background: #f8f9fa;
+ border-radius: 10px;
+}
+
+.change-section h4 {
+ margin: 0 0 15px 0;
+ color: #2c3e50;
+ font-size: 1.1em;
+}
+
+.change-category {
+ margin-bottom: 20px;
+}
+
+.change-category h5 {
+ margin: 0 0 12px 0;
+ color: #34495e;
+ font-size: 1em;
+}
+
+.material-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.material-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ background: white;
+ border-radius: 8px;
+ border-left: 3px solid #e1e8ed;
+}
+
+.change-category.additional-purchase .material-item {
+ border-left-color: #e74c3c;
+}
+
+.change-category.excess-inventory .material-item {
+ border-left-color: #f39c12;
+}
+
+.change-category.quantity-updated .material-item {
+ border-left-color: #3498db;
+}
+
+.change-category.quantity-reduced .material-item {
+ border-left-color: #95a5a6;
+}
+
+.change-category.new-materials .material-item {
+ border-left-color: #27ae60;
+}
+
+.change-category.deleted-materials .material-item {
+ border-left-color: #e74c3c;
+}
+
+.material-desc {
+ flex: 1;
+ font-weight: 500;
+ color: #2c3e50;
+}
+
+.quantity-change, .quantity-info {
+ font-weight: 600;
+ color: #7f8c8d;
+ font-size: 0.9em;
+}
+
+.reason {
+ font-style: italic;
+ color: #95a5a6;
+ font-size: 0.85em;
+}
+
+/* PIPE ๊ธธ์ด ์์ฝ */
+.pipe-length-summary {
+ background: white;
+ border-radius: 12px;
+ padding: 25px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+}
+
+.summary-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 15px;
+ border-bottom: 2px solid #ecf0f1;
+}
+
+.summary-header h3 {
+ margin: 0;
+ color: #2c3e50;
+ font-size: 1.3em;
+}
+
+.btn-recalculate {
+ padding: 8px 16px;
+ background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9em;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.btn-recalculate:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 3px 10px rgba(243, 156, 18, 0.3);
+}
+
+.pipe-stats {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 20px;
+ padding: 15px;
+ background: #f8f9fa;
+ border-radius: 8px;
+}
+
+.pipe-stats span {
+ font-weight: 600;
+ color: #2c3e50;
+}
+
+.pipe-lines {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.pipe-line {
+ padding: 15px;
+ border-radius: 8px;
+ border-left: 4px solid;
+ background: white;
+ transition: all 0.2s ease;
+}
+
+.pipe-line:hover {
+ transform: translateX(5px);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+}
+
+.pipe-line.purchased {
+ border-left-color: #27ae60;
+ background: #e8f5e8;
+}
+
+.pipe-line.pending {
+ border-left-color: #f39c12;
+ background: #fff3cd;
+}
+
+.pipe-line.mixed {
+ border-left-color: #e74c3c;
+ background: #ffebee;
+}
+
+.line-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.drawing-line {
+ font-weight: 600;
+ color: #2c3e50;
+ font-size: 1.05em;
+}
+
+.material-spec {
+ font-size: 0.9em;
+ color: #7f8c8d;
+}
+
+.line-stats {
+ display: flex;
+ gap: 15px;
+ align-items: center;
+}
+
+.line-stats span {
+ font-size: 0.9em;
+ color: #34495e;
+}
+
+.status {
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 0.8em;
+ font-weight: 600;
+}
+
+.status.purchased {
+ background: #d4edda;
+ color: #155724;
+}
+
+.status.pending {
+ background: #fff3cd;
+ color: #856404;
+}
+
+.status.mixed {
+ background: #f8d7da;
+ color: #721c24;
+}
+
+/* ๋น๊ต ์ด๋ ฅ */
+.comparison-history {
+ background: white;
+ border-radius: 12px;
+ padding: 25px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+}
+
+.comparison-history h3 {
+ margin: 0 0 20px 0;
+ color: #2c3e50;
+ font-size: 1.3em;
+}
+
+.history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.history-item {
+ padding: 15px;
+ border-radius: 8px;
+ border: 2px solid;
+ transition: all 0.2s ease;
+}
+
+.history-item.applied {
+ border-color: #27ae60;
+ background: #e8f5e8;
+}
+
+.history-item.pending {
+ border-color: #f39c12;
+ background: #fff3cd;
+}
+
+.history-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.comparison-date {
+ font-weight: 600;
+ color: #2c3e50;
+ font-size: 0.9em;
+}
+
+.status.applied {
+ background: #d4edda;
+ color: #155724;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 0.8em;
+ font-weight: 600;
+}
+
+.status.pending {
+ background: #fff3cd;
+ color: #856404;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 0.8em;
+ font-weight: 600;
+}
+
+.history-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 10px;
+}
+
+.history-summary span {
+ font-size: 0.85em;
+ color: #7f8c8d;
+}
+
+.btn-apply-small {
+ padding: 6px 12px;
+ background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.8em;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ align-self: flex-start;
+}
+
+.btn-apply-small:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3);
+}
+
+.no-history {
+ text-align: center;
+ color: #95a5a6;
+ font-style: italic;
+ padding: 40px 20px;
+}
+
+/* ๋ฐ์ํ ๋์์ธ */
+@media (max-width: 1200px) {
+ .revision-content {
+ grid-template-columns: 1fr;
+ }
+
+ .control-grid {
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ }
+}
+
+@media (max-width: 768px) {
+ .enhanced-revision-page {
+ padding: 15px;
+ }
+
+ .page-header {
+ padding: 15px;
+ }
+
+ .page-header h1 {
+ font-size: 1.8em;
+ }
+
+ .revision-controls,
+ .comparison-result,
+ .pipe-length-summary,
+ .comparison-history {
+ padding: 20px;
+ }
+
+ .control-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .summary-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .line-info {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 5px;
+ }
+
+ .line-stats {
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .material-item {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+}
+
+/* ์นดํ
๊ณ ๋ฆฌ๋ณ ์์ฌ ๊ด๋ฆฌ ์น์
*/
+.category-materials-section {
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ padding: 24px;
+ margin-bottom: 24px;
+ border: 1px solid #e2e8f0;
+}
+
+.category-materials-section h3 {
+ margin: 0 0 20px 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.category-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 16px;
+}
+
+.category-card {
+ background: #f8fafc;
+ border: 2px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 20px;
+ transition: all 0.3s ease;
+ position: relative;
+}
+
+.category-card:hover {
+ border-color: #6366f1;
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
+ transform: translateY(-2px);
+}
+
+.category-card.has-revisions {
+ background: linear-gradient(135deg, #fef3c7 0%, #fbbf24 100%);
+ border-color: #f59e0b;
+}
+
+.category-card.has-revisions:hover {
+ border-color: #d97706;
+ box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25);
+}
+
+.category-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.category-icon {
+ font-size: 24px;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.category-info h4 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.category-desc {
+ font-size: 14px;
+ color: #64748b;
+}
+
+.category-stats {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.category-stats .stat-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 8px 12px;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ min-width: 60px;
+}
+
+.category-stats .stat-item.revision {
+ background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
+ color: white;
+}
+
+.category-stats .stat-item.inventory {
+ background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
+ color: white;
+}
+
+.category-stats .stat-label {
+ font-size: 12px;
+ font-weight: 500;
+ margin-bottom: 2px;
+}
+
+.category-stats .stat-value {
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.empty-category {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: #9ca3af;
+ font-size: 14px;
+ font-style: italic;
+}
+
+/* ์นดํ
๊ณ ๋ฆฌ ์นด๋ ๋ฐ์ํ */
+@media (max-width: 768px) {
+ .category-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .category-stats {
+ justify-content: center;
+ }
+}
diff --git a/frontend/src/pages/EnhancedRevisionPage.jsx b/frontend/src/pages/EnhancedRevisionPage.jsx
new file mode 100644
index 0000000..f9f3764
--- /dev/null
+++ b/frontend/src/pages/EnhancedRevisionPage.jsx
@@ -0,0 +1,683 @@
+import React, { useState, useEffect } from 'react';
+import { api } from '../api';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../components/common';
+import FittingRevisionPage from './revision/FittingRevisionPage';
+import FlangeRevisionPage from './revision/FlangeRevisionPage';
+import SpecialRevisionPage from './revision/SpecialRevisionPage';
+import SupportRevisionPage from './revision/SupportRevisionPage';
+import UnclassifiedRevisionPage from './revision/UnclassifiedRevisionPage';
+import ValveRevisionPage from './revision/ValveRevisionPage';
+import GasketRevisionPage from './revision/GasketRevisionPage';
+import BoltRevisionPage from './revision/BoltRevisionPage';
+import PipeCuttingPlanPage from './revision/PipeCuttingPlanPage';
+import './EnhancedRevisionPage.css';
+
+const EnhancedRevisionPage = ({ onNavigate, user }) => {
+ const [jobs, setJobs] = useState([]);
+ const [selectedJob, setSelectedJob] = useState('');
+ const [files, setFiles] = useState([]);
+ const [currentFile, setCurrentFile] = useState('');
+ const [previousFile, setPreviousFile] = useState('');
+ const [comparisonResult, setComparisonResult] = useState(null);
+ const [comparisonHistory, setComparisonHistory] = useState([]);
+ const [pipeLengthSummary, setPipeLengthSummary] = useState(null);
+
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [showApplyDialog, setShowApplyDialog] = useState(false);
+ const [selectedComparison, setSelectedComparison] = useState(null);
+
+ // ์นดํ
๊ณ ๋ฆฌ๋ณ ํ์ด์ง ๋ผ์ฐํ
+ const [selectedCategory, setSelectedCategory] = useState('');
+ const [categoryMaterials, setCategoryMaterials] = useState({});
+
+ // ์์
๋ชฉ๋ก ์กฐํ
+ useEffect(() => {
+ fetchJobs();
+ }, []);
+
+ // ์ ํ๋ ์์
์ ํ์ผ ๋ชฉ๋ก ์กฐํ
+ useEffect(() => {
+ if (selectedJob) {
+ fetchJobFiles();
+ fetchComparisonHistory();
+ }
+ }, [selectedJob]);
+
+ // ํ์ฌ ํ์ผ์ PIPE ๊ธธ์ด ์์ฝ ๋ฐ ์นดํ
๊ณ ๋ฆฌ๋ณ ์์ฌ ์กฐํ
+ useEffect(() => {
+ if (currentFile) {
+ fetchPipeLengthSummary();
+ fetchCategoryMaterials();
+ }
+ }, [currentFile]);
+
+ const fetchJobs = async () => {
+ try {
+ const response = await api.get('/dashboard/projects');
+ setJobs(response.data.projects || []);
+ } catch (err) {
+ setError('์์
๋ชฉ๋ก ์กฐํ ์คํจ: ' + err.message);
+ }
+ };
+
+ const fetchJobFiles = async () => {
+ try {
+ const response = await api.get(`/files/by-job/${selectedJob}`);
+ setFiles(response.data || []);
+ } catch (err) {
+ setError('ํ์ผ ๋ชฉ๋ก ์กฐํ ์คํจ: ' + err.message);
+ }
+ };
+
+ const fetchComparisonHistory = async () => {
+ try {
+ const response = await api.get(`/enhanced-revision/comparison-history/${selectedJob}`);
+ setComparisonHistory(response.data.data || []);
+ } catch (err) {
+ console.error('๋น๊ต ์ด๋ ฅ ์กฐํ ์คํจ:', err);
+ }
+ };
+
+ const fetchPipeLengthSummary = async () => {
+ try {
+ const response = await api.get(`/enhanced-revision/pipe-length-summary/${currentFile}`);
+ setPipeLengthSummary(response.data.data);
+ } catch (err) {
+ console.error('PIPE ๊ธธ์ด ์์ฝ ์กฐํ ์คํจ:', err);
+ }
+ };
+
+ const fetchCategoryMaterials = async () => {
+ if (!currentFile) return;
+
+ try {
+ const categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL', 'UNCLASSIFIED'];
+ const materialStats = {};
+
+ for (const category of categories) {
+ try {
+ const response = await api.get(`/revision-material/category/${currentFile}/${category}`);
+ materialStats[category] = {
+ count: response.data.data?.materials?.length || 0,
+ processing_info: response.data.data?.processing_info || {}
+ };
+ } catch (err) {
+ console.error(`Failed to fetch ${category} materials:`, err);
+ materialStats[category] = { count: 0, processing_info: {} };
+ }
+ }
+
+ setCategoryMaterials(materialStats);
+ } catch (err) {
+ console.error('์นดํ
๊ณ ๋ฆฌ๋ณ ์์ฌ ์กฐํ ์คํจ:', err);
+ }
+ };
+
+ const handleCompareRevisions = async () => {
+ if (!selectedJob || !currentFile) {
+ setError('์์
๊ณผ ํ์ฌ ํ์ผ์ ์ ํํด์ฃผ์ธ์.');
+ return;
+ }
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const params = {
+ job_no: selectedJob,
+ current_file_id: parseInt(currentFile),
+ save_comparison: true
+ };
+
+ if (previousFile) {
+ params.previous_file_id = parseInt(previousFile);
+ }
+
+ const response = await api.post('/enhanced-revision/compare-revisions', null, { params });
+ setComparisonResult(response.data.data);
+
+ // ๋น๊ต ์ด๋ ฅ ์๋ก๊ณ ์นจ
+ fetchComparisonHistory();
+
+ } catch (err) {
+ setError('๋ฆฌ๋น์ ๋น๊ต ์คํจ: ' + err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleApplyChanges = async (comparisonId) => {
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await api.post(`/enhanced-revision/apply-revision-changes/${comparisonId}`);
+
+ if (response.data.success) {
+ alert('๋ฆฌ๋น์ ๋ณ๊ฒฝ์ฌํญ์ด ์ฑ๊ณต์ ์ผ๋ก ์ ์ฉ๋์์ต๋๋ค.');
+ fetchComparisonHistory();
+ setComparisonResult(null);
+ }
+ } catch (err) {
+ setError('๋ณ๊ฒฝ์ฌํญ ์ ์ฉ ์คํจ: ' + err.message);
+ } finally {
+ setLoading(false);
+ setShowApplyDialog(false);
+ }
+ };
+
+ const handleRecalculatePipeLengths = async () => {
+ if (!currentFile) return;
+
+ setLoading(true);
+ try {
+ const response = await api.post(`/enhanced-revision/recalculate-pipe-lengths/${currentFile}`);
+
+ if (response.data.success) {
+ alert(`PIPE ์์ฌ ${response.data.data.updated_count}๊ฐ์ ๊ธธ์ด๋ฅผ ์ฌ๊ณ์ฐํ์ต๋๋ค.`);
+ fetchPipeLengthSummary();
+ }
+ } catch (err) {
+ setError('PIPE ๊ธธ์ด ์ฌ๊ณ์ฐ ์คํจ: ' + err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const renderComparisonSummary = (summary) => {
+ if (!summary) return null;
+
+ return (
+
+
๐ ๋น๊ต ์์ฝ
+
+
+
๐ ๊ตฌ๋งค ์๋ฃ ์์ฌ
+
+ ์ ์ง: {summary.purchased_maintained}
+ ์ถ๊ฐ๊ตฌ๋งค: {summary.purchased_increased}
+ ์์ฌ์ฌ๊ณ : {summary.purchased_decreased}
+
+
+
+
๐ ๊ตฌ๋งค ๋ฏธ์๋ฃ ์์ฌ
+
+ ์ ์ง: {summary.unpurchased_maintained}
+ ์๋์ฆ๊ฐ: {summary.unpurchased_increased}
+ ์๋๊ฐ์: {summary.unpurchased_decreased}
+
+
+
+
๐ ๋ณ๊ฒฝ์ฌํญ
+
+ ์ ๊ท: {summary.new_materials}
+ ์ญ์ : {summary.deleted_materials}
+
+
+
+
+ );
+ };
+
+ const renderChangeDetails = (changes) => {
+ if (!changes) return null;
+
+ return (
+
+
๐ ์์ธ ๋ณ๊ฒฝ์ฌํญ
+
+ {/* ๊ตฌ๋งค ์๋ฃ ์์ฌ ๋ณ๊ฒฝ์ฌํญ */}
+ {changes.purchased_materials && (
+
+
๐ ๊ตฌ๋งค ์๋ฃ ์์ฌ
+
+ {changes.purchased_materials.additional_purchase_needed?.length > 0 && (
+
+
๐ ์ถ๊ฐ ๊ตฌ๋งค ํ์
+
+ {changes.purchased_materials.additional_purchase_needed.map((item, idx) => (
+
+ {item.material.original_description}
+
+ {item.previous_quantity} โ {item.current_quantity}
+ (+{item.additional_needed})
+
+
+ ))}
+
+
+ )}
+
+ {changes.purchased_materials.excess_inventory?.length > 0 && (
+
+
๐ ์์ฌ ์ฌ๊ณ
+
+ {changes.purchased_materials.excess_inventory.map((item, idx) => (
+
+ {item.material.original_description}
+
+ {item.previous_quantity} โ {item.current_quantity}
+ (-{item.excess_quantity})
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* ๊ตฌ๋งค ๋ฏธ์๋ฃ ์์ฌ ๋ณ๊ฒฝ์ฌํญ */}
+ {changes.unpurchased_materials && (
+
+
๐ ๊ตฌ๋งค ๋ฏธ์๋ฃ ์์ฌ
+
+ {changes.unpurchased_materials.quantity_updated?.length > 0 && (
+
+
๐ ์๋ ๋ณ๊ฒฝ
+
+ {changes.unpurchased_materials.quantity_updated.map((item, idx) => (
+
+ {item.material.original_description}
+
+ {item.previous_quantity} โ {item.current_quantity}
+
+
+ ))}
+
+
+ )}
+
+ {changes.unpurchased_materials.quantity_reduced?.length > 0 && (
+
+
๐ ์๋ ๊ฐ์
+
+ {changes.unpurchased_materials.quantity_reduced.map((item, idx) => (
+
+ {item.material.original_description}
+
+ {item.previous_quantity} โ {item.current_quantity}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* ์ ๊ท/์ญ์ ์์ฌ */}
+ {(changes.new_materials?.length > 0 || changes.deleted_materials?.length > 0) && (
+
+
๐ ์ ๊ท/์ญ์ ์์ฌ
+
+ {changes.new_materials?.length > 0 && (
+
+
โ
์ ๊ท ์์ฌ
+
+ {changes.new_materials.map((item, idx) => (
+
+ {item.material.original_description}
+ ์๋: {item.material.quantity}
+
+ ))}
+
+
+ )}
+
+ {changes.deleted_materials?.length > 0 && (
+
+
โ ์ญ์ ๋ ์์ฌ
+
+ {changes.deleted_materials.map((item, idx) => (
+
+ {item.material.original_description}
+ {item.reason}
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+ };
+
+ const renderPipeLengthSummary = () => {
+ if (!pipeLengthSummary) return null;
+
+ return (
+
+
+
๐ง PIPE ์์ฌ ๊ธธ์ด ์์ฝ
+
+
+
+
+ ์ด ๋ผ์ธ: {pipeLengthSummary.total_lines}๊ฐ
+ ์ด ๊ธธ์ด: {pipeLengthSummary.total_length?.toFixed(2)}m
+
+
+
+ {pipeLengthSummary.pipe_lines?.map((line, idx) => (
+
+
+
+ {line.drawing_name} - {line.line_no}
+
+
+ {line.material_grade} {line.schedule} {line.nominal_size}
+
+
+
+ ๊ธธ์ด: {line.total_length?.toFixed(2)}m
+ ๊ตฌ๊ฐ: {line.segment_count}๊ฐ
+
+ {line.purchase_status === 'purchased' ? '๊ตฌ๋งค์๋ฃ' :
+ line.purchase_status === 'pending' ? '๊ตฌ๋งค๋๊ธฐ' : 'ํผ์ฌ'}
+
+
+
+ ))}
+
+
+ );
+ };
+
+ // ์นดํ
๊ณ ๋ฆฌ๋ณ ํ์ด์ง ๋ ๋๋ง
+ if (selectedCategory && currentFile && previousFile) {
+ const categoryProps = {
+ jobNo: selectedJob,
+ fileId: parseInt(currentFile),
+ previousFileId: parseInt(previousFile),
+ onNavigate: (page) => {
+ if (page === 'enhanced-revision') {
+ setSelectedCategory('');
+ } else {
+ onNavigate(page);
+ }
+ },
+ user
+ };
+
+ switch (selectedCategory) {
+ case 'FITTING':
+ return
;
+ case 'FLANGE':
+ return
;
+ case 'SPECIAL':
+ return
;
+ case 'SUPPORT':
+ return
;
+ case 'UNCLASSIFIED':
+ return
;
+ case 'VALVE':
+ return
;
+ case 'GASKET':
+ return
;
+ case 'BOLT':
+ return
;
+ case 'PIPE':
+ return
;
+ default:
+ setSelectedCategory('');
+ break;
+ }
+ }
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
+ ๐ ๊ฐํ๋ ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+
+ ๊ตฌ๋งค ์ํ๋ฅผ ๊ณ ๋ คํ ์ค๋งํธ ๋ฆฌ๋น์ ๋น๊ต
+
+
+
+
+
+ {error &&
setError('')} />}
+
+ {/* ๋ฉ์ธ ์ฝํ
์ธ */}
+
+
+
+
๐ ๋น๊ต ์ค์
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ๋น๊ต ๊ฒฐ๊ณผ */}
+ {comparisonResult && (
+
+
+
๐ ๋น๊ต ๊ฒฐ๊ณผ
+ {comparisonResult.comparison_id && (
+
+ )}
+
+
+ {renderComparisonSummary(comparisonResult.summary)}
+ {renderChangeDetails(comparisonResult.changes)}
+
+ )}
+
+ {/* PIPE ๊ธธ์ด ์์ฝ */}
+ {renderPipeLengthSummary()}
+
+ {/* ์นดํ
๊ณ ๋ฆฌ๋ณ ์์ฌ ๊ด๋ฆฌ */}
+ {currentFile && previousFile && Object.keys(categoryMaterials).length > 0 && (
+
+
๐ ์นดํ
๊ณ ๋ฆฌ๋ณ ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ {[
+ { key: 'PIPE', name: 'PIPE', icon: '๐ง', description: 'Cutting Plan ๊ด๋ฆฌ' },
+ { key: 'FITTING', name: 'FITTING', icon: '๐ง', description: 'ํผํ
์์ฌ' },
+ { key: 'FLANGE', name: 'FLANGE', icon: '๐ฉ', description: 'ํ๋์ง ์์ฌ' },
+ { key: 'VALVE', name: 'VALVE', icon: '๐ฐ', description: '๋ฐธ๋ธ ์์ฌ' },
+ { key: 'GASKET', name: 'GASKET', icon: 'โญ', description: '๊ฐ์ค์ผ ์์ฌ' },
+ { key: 'BOLT', name: 'BOLT', icon: '๐ฉ', description: '๋ณผํธ ์์ฌ' },
+ { key: 'SUPPORT', name: 'SUPPORT', icon: '๐๏ธ', description: '์ง์ง๋ ์์ฌ' },
+ { key: 'SPECIAL', name: 'SPECIAL', icon: 'โญ', description: 'ํน์ ์์ฌ' },
+ { key: 'UNCLASSIFIED', name: 'UNCLASSIFIED', icon: 'โ', description: '๋ฏธ๋ถ๋ฅ ์์ฌ' }
+ ].map(category => {
+ const stats = categoryMaterials[category.key] || { count: 0, processing_info: {} };
+ const hasRevisionMaterials = stats.processing_info?.by_status?.REVISION_MATERIAL > 0;
+
+ return (
+
stats.count > 0 && setSelectedCategory(category.key)}
+ style={{ cursor: stats.count > 0 ? 'pointer' : 'not-allowed' }}
+ >
+
+
{category.icon}
+
+
{category.name}
+ {category.description}
+
+
+
+
+ ์ ์ฒด
+ {stats.count}
+
+ {hasRevisionMaterials && (
+
+ ๋ฆฌ๋น์
+ {stats.processing_info.by_status.REVISION_MATERIAL}
+
+ )}
+ {stats.processing_info?.by_status?.INVENTORY_MATERIAL > 0 && (
+
+ ์ฌ๊ณ
+ {stats.processing_info.by_status.INVENTORY_MATERIAL}
+
+ )}
+
+ {stats.count === 0 && (
+
์๋ฃ ์์
+ )}
+
+ );
+ })}
+
+
+ )}
+
+
+
+ {/* ๋น๊ต ์ด๋ ฅ */}
+
+
๐ ๋น๊ต ์ด๋ ฅ
+ {comparisonHistory.length > 0 ? (
+
+ {comparisonHistory.map(comp => (
+
+
+
+ {new Date(comp.comparison_date).toLocaleString()}
+
+
+ {comp.is_applied ? '์ ์ฉ์๋ฃ' : '๋๊ธฐ์ค'}
+
+
+
+ {comp.summary_stats && (
+ <>
+ ๊ตฌ๋งค์๋ฃ ๋ณ๊ฒฝ: {comp.summary_stats.purchased_increased + comp.summary_stats.purchased_decreased}
+ ๊ตฌ๋งค๋ฏธ์๋ฃ ๋ณ๊ฒฝ: {comp.summary_stats.unpurchased_increased + comp.summary_stats.unpurchased_decreased}
+ ์ ๊ท/์ญ์ : {comp.summary_stats.new_materials + comp.summary_stats.deleted_materials}
+ >
+ )}
+
+ {!comp.is_applied && (
+
+ )}
+
+ ))}
+
+ ) : (
+
๋น๊ต ์ด๋ ฅ์ด ์์ต๋๋ค.
+ )}
+
+
+
+
+ {/* ์ ์ฉ ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */}
+ handleApplyChanges(selectedComparison)}
+ onCancel={() => {
+ setShowApplyDialog(false);
+ setSelectedComparison(null);
+ }}
+ confirmText="์ ์ฉ"
+ cancelText="์ทจ์"
+ />
+
+ );
+};
+
+export default EnhancedRevisionPage;
diff --git a/frontend/src/pages/PipeIssueManagementPage.css b/frontend/src/pages/PipeIssueManagementPage.css
new file mode 100644
index 0000000..3921188
--- /dev/null
+++ b/frontend/src/pages/PipeIssueManagementPage.css
@@ -0,0 +1,593 @@
+/* PIPE ์ด์ ๊ด๋ฆฌ ํ์ด์ง ์คํ์ผ */
+
+.pipe-issue-management-page {
+ padding: 20px;
+ max-width: 1400px;
+ margin: 0 auto;
+ background-color: #f8f9fa;
+ min-height: 100vh;
+}
+
+/* ํ์ด์ง ํค๋ */
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 30px;
+ padding: 20px;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.header-content h1 {
+ margin: 0;
+ color: #2c3e50;
+ font-size: 1.8rem;
+}
+
+.header-content p {
+ margin: 5px 0 0 0;
+ color: #6c757d;
+ font-size: 0.9rem;
+}
+
+.header-actions .btn {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 500;
+ text-decoration: none;
+ display: inline-block;
+}
+
+.btn-secondary {
+ background-color: #6c757d;
+ color: white;
+}
+
+.btn-secondary:hover {
+ background-color: #5a6268;
+}
+
+/* ์ค๋
์ท ์ ๋ณด ์น์
*/
+.snapshot-info-section {
+ margin-bottom: 30px;
+}
+
+.section-header {
+ margin-bottom: 15px;
+}
+
+.section-header h2 {
+ margin: 0;
+ color: #2c3e50;
+ font-size: 1.4rem;
+}
+
+.no-snapshot-warning {
+ display: flex;
+ align-items: center;
+ padding: 30px;
+ background: #fff3cd;
+ border: 1px solid #ffeaa7;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.warning-icon {
+ font-size: 3rem;
+ margin-right: 20px;
+}
+
+.warning-content h3 {
+ margin: 0 0 10px 0;
+ color: #856404;
+}
+
+.warning-content p {
+ margin: 0 0 15px 0;
+ color: #856404;
+}
+
+.snapshot-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.snapshot-details h3 {
+ margin: 0 0 10px 0;
+ color: #2c3e50;
+}
+
+.snapshot-stats {
+ display: flex;
+ gap: 20px;
+}
+
+.stat-item {
+ color: #6c757d;
+ font-size: 0.9rem;
+}
+
+/* ์ด์ ํต๊ณ ์น์
*/
+.issue-stats-section {
+ margin-bottom: 30px;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 20px;
+}
+
+.stat-card {
+ padding: 20px;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ text-align: center;
+}
+
+.stat-card h3 {
+ margin: 0 0 10px 0;
+ color: #2c3e50;
+ font-size: 1.1rem;
+}
+
+.stat-number {
+ font-size: 2.5rem;
+ font-weight: bold;
+ color: #3498db;
+ margin-bottom: 10px;
+}
+
+.stat-breakdown {
+ display: flex;
+ justify-content: center;
+ gap: 15px;
+ flex-wrap: wrap;
+}
+
+.stat-breakdown .stat-item {
+ font-size: 0.8rem;
+ padding: 2px 8px;
+ border-radius: 12px;
+ color: white;
+}
+
+.stat-breakdown .stat-item.open {
+ background-color: #dc3545;
+}
+
+.stat-breakdown .stat-item.progress {
+ background-color: #ffc107;
+ color: #212529;
+}
+
+.stat-breakdown .stat-item.resolved {
+ background-color: #28a745;
+}
+
+.stat-breakdown .stat-item.critical {
+ background-color: #dc3545;
+}
+
+.stat-breakdown .stat-item.high {
+ background-color: #fd7e14;
+}
+
+/* ํํฐ ๋ฐ ์ก์
์น์
*/
+.filter-actions-section {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 30px;
+ padding: 20px;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.filters {
+ display: flex;
+ gap: 15px;
+}
+
+.filter-select {
+ padding: 8px 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ background: white;
+ min-width: 150px;
+}
+
+.actions {
+ display: flex;
+ gap: 10px;
+}
+
+.btn-primary {
+ background-color: #007bff;
+ color: white;
+ padding: 10px 20px;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 500;
+}
+
+.btn-primary:hover {
+ background-color: #0056b3;
+}
+
+.btn-info {
+ background-color: #17a2b8;
+ color: white;
+ padding: 10px 20px;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 500;
+}
+
+.btn-info:hover {
+ background-color: #138496;
+}
+
+/* ์ด์ ๋ชฉ๋ก ์น์
*/
+.drawing-issues-section,
+.segment-issues-section {
+ margin-bottom: 30px;
+}
+
+.no-issues {
+ text-align: center;
+ padding: 40px;
+ background: white;
+ border-radius: 8px;
+ color: #6c757d;
+}
+
+.issues-list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+/* ์ด์ ์นด๋ */
+.issue-card {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ padding: 20px;
+ border-left: 4px solid #ddd;
+}
+
+.issue-card.drawing-issue {
+ border-left-color: #007bff;
+}
+
+.issue-card.segment-issue {
+ border-left-color: #28a745;
+}
+
+.issue-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.issue-title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.area-badge,
+.issue-type-badge {
+ background-color: #e9ecef;
+ color: #495057;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.drawing-name,
+.segment-id {
+ font-weight: 600;
+ color: #2c3e50;
+}
+
+.issue-badges {
+ display: flex;
+ gap: 8px;
+}
+
+.severity-badge,
+.status-badge {
+ color: white;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+.issue-content {
+ margin-bottom: 15px;
+}
+
+.issue-content p {
+ margin: 0;
+ color: #495057;
+ line-height: 1.5;
+}
+
+.issue-changes {
+ margin-top: 10px;
+ display: flex;
+ gap: 15px;
+ flex-wrap: wrap;
+}
+
+.change-item {
+ background-color: #f8f9fa;
+ color: #495057;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ border: 1px solid #dee2e6;
+}
+
+.issue-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-top: 15px;
+ border-top: 1px solid #dee2e6;
+}
+
+.issue-meta {
+ display: flex;
+ gap: 15px;
+ color: #6c757d;
+ font-size: 0.8rem;
+}
+
+.issue-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.btn-sm {
+ padding: 6px 12px;
+ font-size: 0.8rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: 500;
+}
+
+.btn-warning {
+ background-color: #ffc107;
+ color: #212529;
+}
+
+.btn-warning:hover {
+ background-color: #e0a800;
+}
+
+.btn-success {
+ background-color: #28a745;
+ color: white;
+}
+
+.btn-success:hover {
+ background-color: #218838;
+}
+
+/* ๋ชจ๋ฌ ์คํ์ผ */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0,0,0,0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal-content {
+ background: white;
+ border-radius: 8px;
+ padding: 0;
+ max-width: 600px;
+ width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+.modal-content.large {
+ max-width: 800px;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px;
+ border-bottom: 1px solid #dee2e6;
+}
+
+.modal-header h3 {
+ margin: 0;
+ color: #2c3e50;
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ cursor: pointer;
+ color: #6c757d;
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal-close:hover {
+ color: #495057;
+}
+
+.modal-content form {
+ padding: 20px;
+}
+
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 15px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 5px;
+ font-weight: 500;
+ color: #495057;
+}
+
+.form-group input,
+.form-group select,
+.form-group textarea {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 0.9rem;
+ box-sizing: border-box;
+}
+
+.form-group textarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding: 20px;
+ border-top: 1px solid #dee2e6;
+}
+
+/* ๋ฆฌํฌํธ ์คํ์ผ */
+.report-content {
+ padding: 20px;
+}
+
+.report-summary {
+ margin-bottom: 30px;
+ padding: 20px;
+ background-color: #f8f9fa;
+ border-radius: 6px;
+}
+
+.report-summary h4 {
+ margin: 0 0 10px 0;
+ color: #2c3e50;
+}
+
+.report-section {
+ margin-bottom: 25px;
+}
+
+.report-section h4 {
+ margin: 0 0 15px 0;
+ color: #2c3e50;
+ font-size: 1.1rem;
+}
+
+.report-stats p {
+ margin: 0 0 10px 0;
+ font-weight: 500;
+}
+
+.report-stats .stats-breakdown {
+ display: flex;
+ gap: 15px;
+ flex-wrap: wrap;
+}
+
+.report-stats .stat-item {
+ background-color: #e9ecef;
+ color: #495057;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+}
+
+/* ๋ฐ์ํ ๋์์ธ */
+@media (max-width: 768px) {
+ .pipe-issue-management-page {
+ padding: 10px;
+ }
+
+ .page-header {
+ flex-direction: column;
+ gap: 15px;
+ text-align: center;
+ }
+
+ .filter-actions-section {
+ flex-direction: column;
+ gap: 20px;
+ }
+
+ .filters {
+ flex-wrap: wrap;
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .issue-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .issue-footer {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .modal-content {
+ width: 95%;
+ margin: 10px;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/src/pages/PipeIssueManagementPage.jsx b/frontend/src/pages/PipeIssueManagementPage.jsx
new file mode 100644
index 0000000..0174114
--- /dev/null
+++ b/frontend/src/pages/PipeIssueManagementPage.jsx
@@ -0,0 +1,781 @@
+/**
+ * PIPE ๋จ๊ด ์ด์ ๊ด๋ฆฌ ํ์ด์ง
+ *
+ * ์ค๋
์ท ๊ธฐ๋ฐ ๋๋ฉด๋ณ/๋จ๊ด๋ณ ์ด์ ๊ด๋ฆฌ UI
+ */
+
+import React, { useState, useEffect } from 'react';
+import { usePipeIssue } from '../hooks/usePipeIssue';
+import { usePipeRevision } from '../hooks/usePipeRevision';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../components/common';
+import './PipeIssueManagementPage.css';
+
+const PipeIssueManagementPage = ({ onNavigate, jobNo, fileId }) => {
+ // ํ
์ฌ์ฉ
+ const {
+ loading,
+ error,
+ snapshots,
+ currentSnapshot,
+ drawingIssues,
+ segmentIssues,
+ selectedArea,
+ selectedDrawing,
+ statusFilter,
+ setSelectedArea,
+ setSelectedDrawing,
+ setStatusFilter,
+ fetchSnapshots,
+ createDrawingIssue,
+ createSegmentIssue,
+ updateDrawingIssueStatus,
+ updateSegmentIssueStatus,
+ generateIssueReport,
+ setCurrentSnapshot,
+ stats,
+ canManageIssues,
+ clearError
+ } = usePipeIssue(jobNo);
+
+ const {
+ getSnapshotSegments,
+ getAvailableAreas,
+ getAvailableDrawings
+ } = usePipeRevision(jobNo, fileId);
+
+ // ๋ก์ปฌ ์ํ
+ const [showCreateDrawingIssue, setShowCreateDrawingIssue] = useState(false);
+ const [showCreateSegmentIssue, setShowCreateSegmentIssue] = useState(false);
+ const [selectedSegment, setSelectedSegment] = useState(null);
+ const [segments, setSegments] = useState([]);
+ const [availableAreas, setAvailableAreas] = useState([]);
+ const [availableDrawings, setAvailableDrawings] = useState([]);
+ const [issueReport, setIssueReport] = useState(null);
+
+ // ๋๋ฉด ์ด์ ์์ฑ ํผ ์ํ
+ const [drawingIssueForm, setDrawingIssueForm] = useState({
+ area: '',
+ drawing_name: '',
+ issue_description: '',
+ severity: 'medium'
+ });
+
+ // ๋จ๊ด ์ด์ ์์ฑ ํผ ์ํ
+ const [segmentIssueForm, setSegmentIssueForm] = useState({
+ segment_id: '',
+ issue_description: '',
+ issue_type: 'other',
+ length_change: '',
+ new_length: '',
+ material_change: '',
+ severity: 'medium'
+ });
+
+ // ์ด๊ธฐ ๋ฐ์ดํฐ ๋ก๋
+ useEffect(() => {
+ if (jobNo) {
+ fetchSnapshots();
+ }
+ }, [jobNo, fetchSnapshots]);
+
+ // ์ค๋
์ท ๋ณ๊ฒฝ ์ ๊ด๋ จ ๋ฐ์ดํฐ ๋ก๋
+ useEffect(() => {
+ if (currentSnapshot?.snapshot_id) {
+ loadSnapshotData();
+ }
+ }, [currentSnapshot]);
+
+ // ์ค๋
์ท ๋ฐ์ดํฐ ๋ก๋
+ const loadSnapshotData = async () => {
+ if (!currentSnapshot?.snapshot_id) return;
+
+ try {
+ // ๊ตฌ์ญ ๋ชฉ๋ก ๋ก๋
+ const areasData = await getAvailableAreas(currentSnapshot.snapshot_id);
+ setAvailableAreas(areasData?.areas || []);
+
+ // ๋๋ฉด ๋ชฉ๋ก ๋ก๋
+ const drawingsData = await getAvailableDrawings(currentSnapshot.snapshot_id);
+ setAvailableDrawings(drawingsData?.drawings || []);
+
+ // ๋จ๊ด ๋ชฉ๋ก ๋ก๋
+ const segmentsData = await getSnapshotSegments(currentSnapshot.snapshot_id);
+ setSegments(segmentsData?.segments || []);
+ } catch (error) {
+ console.error('์ค๋
์ท ๋ฐ์ดํฐ ๋ก๋ ์คํจ:', error);
+ }
+ };
+
+ // ๊ตฌ์ญ ๋ณ๊ฒฝ ์ ๋๋ฉด ๋ชฉ๋ก ์
๋ฐ์ดํธ
+ useEffect(() => {
+ if (currentSnapshot?.snapshot_id && selectedArea) {
+ const loadDrawings = async () => {
+ const drawingsData = await getAvailableDrawings(currentSnapshot.snapshot_id, selectedArea);
+ setAvailableDrawings(drawingsData?.drawings || []);
+ };
+ loadDrawings();
+ }
+ }, [selectedArea, currentSnapshot, getAvailableDrawings]);
+
+ // ๋๋ฉด ์ด์ ์์ฑ ํธ๋ค๋ฌ
+ const handleCreateDrawingIssue = async (e) => {
+ e.preventDefault();
+
+ if (!drawingIssueForm.area || !drawingIssueForm.drawing_name || !drawingIssueForm.issue_description) {
+ alert('๋ชจ๋ ํ์ ํ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.');
+ return;
+ }
+
+ const result = await createDrawingIssue(drawingIssueForm);
+ if (result) {
+ alert('โ
๋๋ฉด ์ด์๊ฐ ์์ฑ๋์์ต๋๋ค.');
+ setShowCreateDrawingIssue(false);
+ setDrawingIssueForm({
+ area: '',
+ drawing_name: '',
+ issue_description: '',
+ severity: 'medium'
+ });
+ }
+ };
+
+ // ๋จ๊ด ์ด์ ์์ฑ ํธ๋ค๋ฌ
+ const handleCreateSegmentIssue = async (e) => {
+ e.preventDefault();
+
+ if (!segmentIssueForm.segment_id || !segmentIssueForm.issue_description) {
+ alert('๋ชจ๋ ํ์ ํ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.');
+ return;
+ }
+
+ const formData = {
+ ...segmentIssueForm,
+ length_change: segmentIssueForm.length_change ? parseFloat(segmentIssueForm.length_change) : null,
+ new_length: segmentIssueForm.new_length ? parseFloat(segmentIssueForm.new_length) : null
+ };
+
+ const result = await createSegmentIssue(formData);
+ if (result) {
+ alert('โ
๋จ๊ด ์ด์๊ฐ ์์ฑ๋์์ต๋๋ค.');
+ setShowCreateSegmentIssue(false);
+ setSegmentIssueForm({
+ segment_id: '',
+ issue_description: '',
+ issue_type: 'other',
+ length_change: '',
+ new_length: '',
+ material_change: '',
+ severity: 'medium'
+ });
+ }
+ };
+
+ // ์ด์ ์ํ ์
๋ฐ์ดํธ ํธ๋ค๋ฌ
+ const handleUpdateIssueStatus = async (issueId, issueType, newStatus) => {
+ const statusData = {
+ status: newStatus,
+ resolved_by: newStatus === 'resolved' ? 'user' : null
+ };
+
+ let result;
+ if (issueType === 'drawing') {
+ result = await updateDrawingIssueStatus(issueId, statusData);
+ } else {
+ result = await updateSegmentIssueStatus(issueId, statusData);
+ }
+
+ if (result) {
+ alert(`โ
์ด์ ์ํ๊ฐ '${newStatus}'๋ก ๋ณ๊ฒฝ๋์์ต๋๋ค.`);
+ }
+ };
+
+ // ์ด์ ๋ฆฌํฌํธ ์์ฑ ํธ๋ค๋ฌ
+ const handleGenerateReport = async () => {
+ const report = await generateIssueReport();
+ if (report) {
+ setIssueReport(report);
+ alert('โ
์ด์ ๋ฆฌํฌํธ๊ฐ ์์ฑ๋์์ต๋๋ค.');
+ }
+ };
+
+ // ์ฌ๊ฐ๋ ๋ฐฐ์ง ์์
+ const getSeverityColor = (severity) => {
+ switch (severity) {
+ case 'critical': return '#dc3545';
+ case 'high': return '#fd7e14';
+ case 'medium': return '#ffc107';
+ case 'low': return '#28a745';
+ default: return '#6c757d';
+ }
+ };
+
+ // ์ํ ๋ฐฐ์ง ์์
+ const getStatusColor = (status) => {
+ switch (status) {
+ case 'open': return '#dc3545';
+ case 'in_progress': return '#ffc107';
+ case 'resolved': return '#28a745';
+ default: return '#6c757d';
+ }
+ };
+
+ if (loading) return
;
+
+ return (
+
+
+
+
๐ ๏ธ PIPE ๋จ๊ด ์ด์ ๊ด๋ฆฌ
+
ํ์ ๋ Cutting Plan ๊ธฐ์ค ์ด์ ๊ด๋ฆฌ (๋ฆฌ๋น์ ๋ณดํธ)
+
+
+
+
+
+
+ {error &&
}
+
+ {/* ์ค๋
์ท ์ ๋ณด */}
+
+
+
๐ธ ์ค๋
์ท ์ ๋ณด
+
+
+ {!canManageIssues ? (
+
+
โ ๏ธ
+
+
ํ์ ๋ Cutting Plan์ด ์์ต๋๋ค
+
์ด์ ๊ด๋ฆฌ๋ฅผ ์ํด์๋ ๋จผ์ Cutting Plan์ ํ์ ํด์ผ ํฉ๋๋ค.
+
+
+
+ ) : (
+
+
+
{currentSnapshot.snapshot_name}
+
+
+ ๐ ์ด ๋จ๊ด: {currentSnapshot.total_segments}๊ฐ
+
+
+ ๐ ์ด ๋๋ฉด: {currentSnapshot.total_drawings}๊ฐ
+
+
+ ๐ ํ์ ์ผ: {new Date(currentSnapshot.locked_at).toLocaleDateString()}
+
+
+
+
+
+
+
+ )}
+
+
+ {canManageIssues && (
+ <>
+ {/* ์ด์ ํต๊ณ */}
+
+
+
๐ ์ด์ ํํฉ
+
+
+
+
๋๋ฉด ์ด์
+
{stats.drawing.total}
+
+ ๋ฏธํด๊ฒฐ: {stats.drawing.open}
+ ์งํ์ค: {stats.drawing.in_progress}
+ ์๋ฃ: {stats.drawing.resolved}
+
+
+
+
๋จ๊ด ์ด์
+
{stats.segment.total}
+
+ ๋ฏธํด๊ฒฐ: {stats.segment.open}
+ ์งํ์ค: {stats.segment.in_progress}
+ ์๋ฃ: {stats.segment.resolved}
+
+
+
+
์ ์ฒด ์ด์
+
{stats.total}
+
+ ๊ธด๊ธ: {stats.drawing.critical + stats.segment.critical}
+ ๋์: {stats.drawing.high + stats.segment.high}
+
+
+
+
+
+ {/* ํํฐ ๋ฐ ์ก์
*/}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ๋๋ฉด ์ด์ ๋ชฉ๋ก */}
+
+
+
๐ ๋๋ฉด ์ด์ ๋ชฉ๋ก
+
+
+ {drawingIssues.length === 0 ? (
+
+
๋ฑ๋ก๋ ๋๋ฉด ์ด์๊ฐ ์์ต๋๋ค.
+
+ ) : (
+
+ {drawingIssues.map(issue => (
+
+
+
+ {issue.area}
+ {issue.drawing_name}
+
+
+
+ {issue.severity}
+
+
+ {issue.status}
+
+
+
+
+
{issue.issue_description}
+
+
+
+ ๋ณด๊ณ ์: {issue.reported_by}
+ ๋ฑ๋ก์ผ: {new Date(issue.reported_at).toLocaleDateString()}
+
+
+ {issue.status !== 'resolved' && (
+ <>
+
+
+ >
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ {/* ๋จ๊ด ์ด์ ๋ชฉ๋ก */}
+
+
+
๐ง ๋จ๊ด ์ด์ ๋ชฉ๋ก
+
+
+ {segmentIssues.length === 0 ? (
+
+
๋ฑ๋ก๋ ๋จ๊ด ์ด์๊ฐ ์์ต๋๋ค.
+
+ ) : (
+
+ {segmentIssues.map(issue => (
+
+
+
+ ๋จ๊ด #{issue.segment_id}
+ {issue.issue_type && (
+ {issue.issue_type}
+ )}
+
+
+
+ {issue.severity}
+
+
+ {issue.status}
+
+
+
+
+
{issue.issue_description}
+ {(issue.length_change || issue.new_length || issue.material_change) && (
+
+ {issue.length_change && (
+
+ ๊ธธ์ด ๋ณ๊ฒฝ: {issue.length_change > 0 ? '+' : ''}{issue.length_change}mm
+
+ )}
+ {issue.new_length && (
+
+ ์ต์ข
๊ธธ์ด: {issue.new_length}mm
+
+ )}
+ {issue.material_change && (
+
+ ์ฌ์ง ๋ณ๊ฒฝ: {issue.material_change}
+
+ )}
+
+ )}
+
+
+
+ ๋ณด๊ณ ์: {issue.reported_by}
+ ๋ฑ๋ก์ผ: {new Date(issue.reported_at).toLocaleDateString()}
+
+
+ {issue.status !== 'resolved' && (
+ <>
+
+
+ >
+ )}
+
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+ {/* ๋๋ฉด ์ด์ ์์ฑ ๋ชจ๋ฌ */}
+ {showCreateDrawingIssue && (
+
+
+
+
๐ ๋๋ฉด ์ด์ ๋ฑ๋ก
+
+
+
+
+
+ )}
+
+ {/* ๋จ๊ด ์ด์ ์์ฑ ๋ชจ๋ฌ */}
+ {showCreateSegmentIssue && (
+
+
+
+
๐ง ๋จ๊ด ์ด์ ๋ฑ๋ก
+
+
+
+
+
+ )}
+
+ {/* ์ด์ ๋ฆฌํฌํธ ๋ชจ๋ฌ */}
+ {issueReport && (
+
+
+
+
๐ ์ด์ ๋ฆฌํฌํธ
+
+
+
+
+
์ ์ฒด ์์ฝ
+
์ด ์ด์: {issueReport.total_issues}๊ฐ
+
์์ฑ์ผ: {new Date(issueReport.report_generated_at).toLocaleString()}
+
+
+
+
๋๋ฉด ์ด์ ํต๊ณ
+
+
์ด ๊ฐ์: {issueReport.drawing_issues.total}
+
+ {Object.entries(issueReport.drawing_issues.by_status).map(([status, count]) => (
+
+ {status}: {count}๊ฐ
+
+ ))}
+
+
+
+
+
+
๋จ๊ด ์ด์ ํต๊ณ
+
+
์ด ๊ฐ์: {issueReport.segment_issues.total}
+
+ {Object.entries(issueReport.segment_issues.by_status).map(([status, count]) => (
+
+ {status}: {count}๊ฐ
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default PipeIssueManagementPage;
diff --git a/frontend/src/pages/revision/BoltRevisionPage.jsx b/frontend/src/pages/revision/BoltRevisionPage.jsx
new file mode 100644
index 0000000..6371d2b
--- /dev/null
+++ b/frontend/src/pages/revision/BoltRevisionPage.jsx
@@ -0,0 +1,463 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionComparison } from '../../hooks/useRevisionComparison';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import './CategoryRevisionPage.css';
+
+const BoltRevisionPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [actionType, setActionType] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('description');
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [boltTypeFilter, setBoltTypeFilter] = useState('all');
+ const [threadTypeFilter, setThreadTypeFilter] = useState('all');
+ const [lengthFilter, setLengthFilter] = useState('all');
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'BOLT');
+
+ // ๋ฆฌ๋น์ ๋น๊ต ํ
+ const {
+ comparisonResult,
+ loading: comparisonLoading,
+ error: comparisonError,
+ performComparison,
+ getFilteredComparison
+ } = useRevisionComparison(fileId, previousFileId);
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ํํฐ๋ง ๋ฐ ์ ๋ ฌ๋ ์์ฌ ๋ชฉ๋ก
+ const filteredAndSortedMaterials = useMemo(() => {
+ if (!materials) return [];
+
+ let filtered = materials.filter(material => {
+ const matchesSearch = !searchTerm ||
+ material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.bolt_type?.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesStatus = statusFilter === 'all' ||
+ material.processing_info?.display_status === statusFilter;
+
+ const matchesBoltType = boltTypeFilter === 'all' ||
+ material.bolt_type === boltTypeFilter;
+
+ const matchesThreadType = threadTypeFilter === 'all' ||
+ material.thread_type === threadTypeFilter;
+
+ const matchesLength = lengthFilter === 'all' ||
+ material.bolt_length === lengthFilter;
+
+ return matchesSearch && matchesStatus && matchesBoltType && matchesThreadType && matchesLength;
+ });
+
+ // ์ ๋ ฌ
+ filtered.sort((a, b) => {
+ let aValue = a[sortBy] || '';
+ let bValue = b[sortBy] || '';
+
+ if (typeof aValue === 'string') {
+ aValue = aValue.toLowerCase();
+ bValue = bValue.toLowerCase();
+ }
+
+ if (sortOrder === 'asc') {
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
+ } else {
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
+ }
+ });
+
+ return filtered;
+ }, [materials, searchTerm, statusFilter, boltTypeFilter, threadTypeFilter, lengthFilter, sortBy, sortOrder]);
+
+ // ๊ณ ์ ๊ฐ๋ค ์ถ์ถ (ํํฐ ์ต์
์ฉ)
+ const uniqueValues = useMemo(() => {
+ if (!materials) return { boltTypes: [], threadTypes: [], lengths: [] };
+
+ const boltTypes = [...new Set(materials.map(m => m.bolt_type).filter(Boolean))];
+ const threadTypes = [...new Set(materials.map(m => m.thread_type).filter(Boolean))];
+ const lengths = [...new Set(materials.map(m => m.bolt_length).filter(Boolean))];
+
+ return { boltTypes, threadTypes, lengths };
+ }, [materials]);
+
+ // ์ด๊ธฐ ๋น๊ต ์ํ
+ useEffect(() => {
+ if (fileId && previousFileId && !comparisonResult) {
+ performComparison('BOLT');
+ }
+ }, [fileId, previousFileId, comparisonResult, performComparison]);
+
+ // ์์ฌ ์ ํ ์ฒ๋ฆฌ
+ const handleMaterialSelect = (materialId, isSelected) => {
+ const newSelected = new Set(selectedMaterials);
+ if (isSelected) {
+ newSelected.add(materialId);
+ } else {
+ newSelected.delete(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ handleMaterialSelection(materialId, isSelected);
+ };
+
+ // ์ ์ฒด ์ ํ/ํด์
+ const handleSelectAll = (isSelected) => {
+ if (isSelected) {
+ const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
+ setSelectedMaterials(allIds);
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
+ } else {
+ setSelectedMaterials(new Set());
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
+ }
+ };
+
+ // ์ก์
์คํ
+ const executeAction = async (action) => {
+ setActionType(action);
+ setShowConfirmDialog(true);
+ };
+
+ const confirmAction = async () => {
+ try {
+ await handleBulkAction(actionType, Array.from(selectedMaterials));
+ setSelectedMaterials(new Set());
+ setShowConfirmDialog(false);
+ await refreshMaterials();
+ } catch (error) {
+ console.error('Action failed:', error);
+ }
+ };
+
+ // ์ํ๋ณ ์์ ํด๋์ค
+ const getStatusClass = (status) => {
+ switch (status) {
+ case 'REVISION_MATERIAL': return 'status-revision';
+ case 'INVENTORY_MATERIAL': return 'status-inventory';
+ case 'DELETED_MATERIAL': return 'status-deleted';
+ case 'NEW_MATERIAL': return 'status-new';
+ default: return 'status-normal';
+ }
+ };
+
+ // ์๋ ํ์ (์ ์๋ก ๋ณํ)
+ const formatQuantity = (quantity) => {
+ if (quantity === null || quantity === undefined) return '-';
+ return Math.round(parseFloat(quantity) || 0).toString();
+ };
+
+ // BOLT ์ค๋ช
์์ฑ (๋ณผํธ ํ์
๊ณผ ๊ท๊ฒฉ ํฌํจ)
+ const generateBoltDescription = (material) => {
+ const parts = [];
+
+ if (material.bolt_type) parts.push(material.bolt_type);
+ if (material.thread_size) parts.push(material.thread_size);
+ if (material.bolt_length) parts.push(`L${material.bolt_length}mm`);
+ if (material.thread_type) parts.push(material.thread_type);
+ if (material.material_grade) parts.push(material.material_grade);
+
+ const baseDesc = material.description || material.item_name || 'BOLT';
+
+ return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
+ };
+
+ // ๋ณผํธ ์ธํธ ์ ๋ณด ํ์
+ const formatBoltSet = (material) => {
+ const parts = [];
+ if (material.bolt_count) parts.push(`๋ณผํธ ${material.bolt_count}๊ฐ`);
+ if (material.nut_count) parts.push(`๋ํธ ${material.nut_count}๊ฐ`);
+ if (material.washer_count) parts.push(`์์
${material.washer_count}๊ฐ`);
+
+ return parts.length > 0 ? parts.join(' + ') : '-';
+ };
+
+ if (materialsLoading || comparisonLoading || statusLoading) {
+ return
;
+ }
+
+ const error = materialsError || comparisonError || statusError;
+ if (error) {
+ return
window.location.reload()} />;
+ }
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
๐ฉ BOLT ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ ๋ณผํธ ํ์
๊ณผ ๋์ฌ ๊ท๊ฒฉ์ ๊ณ ๋ คํ BOLT ์์ฌ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+
+
+
+
+
+
+
+
+ {/* ์ปจํธ๋กค ์น์
*/}
+
+
+
๐ ์์ฌ ํํฉ ๋ฐ ํํฐ
+ {processingInfo && (
+
+ ์ ์ฒด: {processingInfo.total_materials}๊ฐ |
+ ๋ฆฌ๋น์ : {processingInfo.by_status.REVISION_MATERIAL || 0}๊ฐ |
+ ์ฌ๊ณ : {processingInfo.by_status.INVENTORY_MATERIAL || 0}๊ฐ |
+ ์ญ์ : {processingInfo.by_status.DELETED_MATERIAL || 0}๊ฐ
+
+ )}
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ์ ํ๋ ์์ฌ ์ก์
*/}
+ {selectedMaterials.size > 0 && (
+
+
+ {selectedMaterials.size}๊ฐ ์ ํ๋จ
+
+
+
+
+
+
+
+ )}
+
+
+ {/* ์์ฌ ํ
์ด๋ธ */}
+
+
+
+ 0}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ />
+
+
์ํ
+
์์ฌ๋ช
+
๋ณผํธ ํ์
+
๋์ฌ ํฌ๊ธฐ
+
๊ธธ์ด
+
์ธํธ ๊ตฌ์ฑ
+
์๋
+
๋จ์
+
์ก์
+
+
+
+ {filteredAndSortedMaterials.map((material) => (
+
+
+ handleMaterialSelect(material.id, e.target.checked)}
+ />
+
+
+
+ {material.processing_info?.display_status || 'NORMAL'}
+
+
+
+
+
{generateBoltDescription(material)}
+ {material.processing_info?.notes && (
+
{material.processing_info.notes}
+ )}
+
+
+
{material.bolt_type || '-'}
+
{material.thread_size || '-'}
+
{material.bolt_length ? `${material.bolt_length}mm` : '-'}
+
{formatBoltSet(material)}
+
+
+ {formatQuantity(material.quantity)}
+
+ {material.processing_info?.quantity_change && (
+
+ ({material.processing_info.quantity_change > 0 ? '+' : ''}
+ {formatQuantity(material.processing_info.quantity_change)})
+
+ )}
+
+
{material.unit || 'SET'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {filteredAndSortedMaterials.length === 0 && (
+
+
์กฐ๊ฑด์ ๋ง๋ BOLT ์์ฌ๊ฐ ์์ต๋๋ค.
+
+ )}
+
+
+ {/* ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */}
+ {showConfirmDialog && (
+
setShowConfirmDialog(false)}
+ />
+ )}
+
+ );
+};
+
+export default BoltRevisionPage;
diff --git a/frontend/src/pages/revision/CategoryRevisionPage.css b/frontend/src/pages/revision/CategoryRevisionPage.css
new file mode 100644
index 0000000..fa29450
--- /dev/null
+++ b/frontend/src/pages/revision/CategoryRevisionPage.css
@@ -0,0 +1,537 @@
+/* ์นดํ
๊ณ ๋ฆฌ๋ณ ๋ฆฌ๋น์ ํ์ด์ง ๊ณตํต ์คํ์ผ */
+
+.category-revision-page {
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
+ min-height: 100vh;
+ padding: 20px;
+}
+
+/* ํค๋ ์คํ์ผ - ๊ธฐ์กด materials-page์ ํต์ผ */
+.materials-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px 24px;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ margin-bottom: 24px;
+ border: 1px solid #e2e8f0;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.back-button {
+ padding: 8px 16px;
+ background: #f1f5f9;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ color: #475569;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.back-button:hover {
+ background: #e2e8f0;
+ border-color: #94a3b8;
+ color: #334155;
+}
+
+.header-center h1 {
+ margin: 0 0 4px 0;
+ font-size: 24px;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.header-subtitle {
+ color: #64748b;
+ font-size: 14px;
+ font-weight: 400;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+/* ์ปจํธ๋กค ์น์
*/
+.control-section {
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ margin-bottom: 24px;
+ border: 1px solid #e2e8f0;
+ overflow: hidden;
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 24px;
+ background: #f8fafc;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.section-header h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.processing-summary {
+ font-size: 14px;
+ color: #64748b;
+ font-weight: 500;
+}
+
+.control-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 20px;
+ padding: 20px 24px;
+}
+
+.control-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.control-group label {
+ font-size: 14px;
+ font-weight: 600;
+ color: #374151;
+}
+
+.control-group input,
+.control-group select {
+ padding: 10px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ font-size: 14px;
+ background: white;
+ transition: all 0.2s ease;
+}
+
+.control-group input:focus,
+.control-group select:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+/* ์ ํ๋ ์์ฌ ์ก์
*/
+.selected-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 24px;
+ background: #f0f9ff;
+ border-top: 1px solid #e2e8f0;
+}
+
+.selected-count {
+ font-size: 14px;
+ font-weight: 600;
+ color: #0369a1;
+}
+
+.action-buttons {
+ display: flex;
+ gap: 8px;
+}
+
+.btn-action {
+ padding: 8px 16px;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.btn-purchase {
+ background: #10b981;
+ color: white;
+}
+
+.btn-purchase:hover {
+ background: #059669;
+}
+
+.btn-inventory {
+ background: #f59e0b;
+ color: white;
+}
+
+.btn-inventory:hover {
+ background: #d97706;
+}
+
+.btn-delete {
+ background: #ef4444;
+ color: white;
+}
+
+.btn-delete:hover {
+ background: #dc2626;
+}
+
+/* ์์ฌ ํ
์ด๋ธ */
+.materials-table-container {
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ border: 1px solid #e2e8f0;
+ overflow: hidden;
+}
+
+.table-header {
+ display: grid;
+ grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 1fr 100px;
+ gap: 12px;
+ padding: 16px;
+ background: #f8fafc;
+ border-bottom: 1px solid #e2e8f0;
+ font-weight: 600;
+ font-size: 14px;
+ color: #374151;
+}
+
+.header-cell {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.checkbox-cell {
+ justify-content: center;
+}
+
+.table-body {
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+.table-row {
+ display: grid;
+ grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 1fr 100px;
+ gap: 12px;
+ padding: 16px;
+ border-bottom: 1px solid #f1f5f9;
+ font-size: 14px;
+ color: #374151;
+ transition: all 0.2s ease;
+ align-items: center;
+}
+
+.table-row:hover {
+ background: #f8fafc;
+}
+
+.table-row:last-child {
+ border-bottom: none;
+}
+
+.table-cell {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ min-height: 40px;
+}
+
+.table-cell.checkbox-cell {
+ justify-content: center;
+}
+
+.table-cell.quantity-cell {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+}
+
+/* ์ํ๋ณ ์คํ์ผ */
+.table-row.status-revision {
+ background: #fef3c7;
+ border-left: 4px solid #f59e0b;
+}
+
+.table-row.status-inventory {
+ background: #dbeafe;
+ border-left: 4px solid #3b82f6;
+}
+
+.table-row.status-deleted {
+ background: #fee2e2;
+ border-left: 4px solid #ef4444;
+ opacity: 0.7;
+}
+
+.table-row.status-new {
+ background: #dcfce7;
+ border-left: 4px solid #22c55e;
+}
+
+.table-row.status-normal {
+ background: white;
+}
+
+/* ์ํ ๋ฐฐ์ง */
+.status-badge {
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 500;
+ text-align: center;
+ min-width: 80px;
+}
+
+.status-badge.status-revision {
+ background: #fbbf24;
+ color: #92400e;
+}
+
+.status-badge.status-inventory {
+ background: #60a5fa;
+ color: #1e40af;
+}
+
+.status-badge.status-deleted {
+ background: #f87171;
+ color: #991b1b;
+}
+
+.status-badge.status-new {
+ background: #4ade80;
+ color: #166534;
+}
+
+.status-badge.status-normal {
+ background: #e5e7eb;
+ color: #374151;
+}
+
+/* ์์ฌ ์ ๋ณด */
+.material-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.material-name {
+ font-weight: 500;
+ color: #1f2937;
+}
+
+.material-notes {
+ font-size: 12px;
+ color: #6b7280;
+ font-style: italic;
+}
+
+/* ์๋ ํ์ */
+.quantity-value {
+ font-weight: 600;
+ color: #1f2937;
+}
+
+.quantity-change {
+ font-size: 12px;
+ color: #6b7280;
+}
+
+/* ์ก์
๋ฒํผ */
+.action-buttons-small {
+ display: flex;
+ gap: 4px;
+}
+
+.btn-small {
+ padding: 4px 8px;
+ border: 1px solid #d1d5db;
+ border-radius: 4px;
+ background: white;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.btn-small:hover {
+ background: #f9fafb;
+ border-color: #9ca3af;
+}
+
+.btn-view {
+ color: #3b82f6;
+}
+
+.btn-edit {
+ color: #f59e0b;
+}
+
+/* ๋น ์ํ */
+.empty-state {
+ padding: 60px 20px;
+ text-align: center;
+ color: #6b7280;
+}
+
+.empty-state p {
+ margin: 0;
+ font-size: 16px;
+}
+
+/* ๋ฐ์ํ ๋์์ธ */
+@media (max-width: 1400px) {
+ .table-header,
+ .table-row {
+ grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 100px;
+ }
+
+ .table-header .header-cell:nth-child(7),
+ .table-row .table-cell:nth-child(7) {
+ display: none;
+ }
+}
+
+@media (max-width: 1200px) {
+ .control-grid {
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ }
+
+ .table-header,
+ .table-row {
+ grid-template-columns: 50px 100px 2fr 80px 80px 100px;
+ }
+
+ .table-header .header-cell:nth-child(4),
+ .table-row .table-cell:nth-child(4) {
+ display: none;
+ }
+}
+
+@media (max-width: 768px) {
+ .category-revision-page {
+ padding: 16px;
+ }
+
+ .materials-header {
+ flex-direction: column;
+ gap: 16px;
+ align-items: stretch;
+ }
+
+ .header-left {
+ flex-direction: column;
+ gap: 12px;
+ align-items: stretch;
+ }
+
+ .control-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .selected-actions {
+ flex-direction: column;
+ gap: 12px;
+ align-items: stretch;
+ }
+
+ .action-buttons {
+ justify-content: stretch;
+ }
+
+ .btn-action {
+ flex: 1;
+ }
+
+ .table-header,
+ .table-row {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+
+ .table-header {
+ display: none;
+ }
+
+ .table-row {
+ flex-direction: column;
+ align-items: stretch;
+ padding: 16px;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ }
+
+ .table-cell {
+ justify-content: space-between;
+ padding: 4px 0;
+ border-bottom: 1px solid #f3f4f6;
+ }
+
+ .table-cell:last-child {
+ border-bottom: none;
+ }
+
+ .table-cell::before {
+ content: attr(data-label);
+ font-weight: 600;
+ color: #6b7280;
+ min-width: 80px;
+ }
+
+ .checkbox-cell::before {
+ content: "์ ํ";
+ }
+}
+
+/* ์ ๋๋ฉ์ด์
*/
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.table-row {
+ animation: fadeIn 0.3s ease-out;
+}
+
+.control-section,
+.materials-table-container {
+ animation: fadeIn 0.4s ease-out;
+}
+
+/* ์คํฌ๋กค๋ฐ ์คํ์ผ */
+.table-body::-webkit-scrollbar {
+ width: 8px;
+}
+
+.table-body::-webkit-scrollbar-track {
+ background: #f1f5f9;
+}
+
+.table-body::-webkit-scrollbar-thumb {
+ background: #cbd5e1;
+ border-radius: 4px;
+}
+
+.table-body::-webkit-scrollbar-thumb:hover {
+ background: #94a3b8;
+}
diff --git a/frontend/src/pages/revision/FittingRevisionPage.jsx b/frontend/src/pages/revision/FittingRevisionPage.jsx
new file mode 100644
index 0000000..1397e29
--- /dev/null
+++ b/frontend/src/pages/revision/FittingRevisionPage.jsx
@@ -0,0 +1,141 @@
+import React, { useState, useEffect } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { LoadingSpinner, ErrorMessage } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import { FittingMaterialsView } from '../../components/bom';
+import './CategoryRevisionPage.css';
+
+const FittingRevisionPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [userRequirements, setUserRequirements] = useState({});
+ const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'FITTING');
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ์์ฌ ์
๋ฐ์ดํธ ํจ์
+ const updateMaterial = (materialId, updates) => {
+ // ๋ฆฌ๋น์ ํ์ด์ง์์๋ ์์ฌ ์
๋ฐ์ดํธ ๋ก์ง์ ๋ณ๋๋ก ์ฒ๋ฆฌ
+ console.log('Material update in revision page:', materialId, updates);
+ };
+
+ if (materialsLoading || statusLoading) {
+ return ;
+ }
+
+ const error = materialsError || statusError;
+ if (error) {
+ return window.location.reload()} />;
+ }
+
+ // FITTING ์นดํ
๊ณ ๋ฆฌ๋ง ํํฐ๋ง
+ const fittingMaterials = materials.filter(material =>
+ material.classified_category === 'FITTING' ||
+ material.category === 'FITTING'
+ );
+
+ // ๊ธฐ์กด BOM ๊ด๋ฆฌ ํ์ด์ง์ ๋์ผํ props ๊ตฌ์ฑ
+ const commonProps = {
+ materials: fittingMaterials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ onPurchasedMaterialsUpdate: (materialIds) => {
+ setPurchasedMaterials(prev => {
+ const newSet = new Set(prev);
+ materialIds.forEach(id => newSet.add(id));
+ return newSet;
+ });
+ },
+ updateMaterial,
+ fileId,
+ jobNo,
+ user,
+ onNavigate
+ };
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
๐ง FITTING ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ ๊ตฌ๋งค ์ํ๋ฅผ ๊ณ ๋ คํ FITTING ์์ฌ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+
+
+
+
+
+
+
+
+ {/* ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์ํ ์์ฝ */}
+ {processingInfo && (
+
+
๐ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ํํฉ
+
+
+ ์ ์ฒด ์์ฌ
+ {processingInfo.total_materials || 0}๊ฐ
+
+
+ ๋ฆฌ๋น์ ์์ฌ
+ {processingInfo.by_status?.REVISION_MATERIAL || 0}๊ฐ
+
+
+ ์ฌ๊ณ ์์ฌ
+ {processingInfo.by_status?.INVENTORY_MATERIAL || 0}๊ฐ
+
+
+ ์ญ์ ์์ฌ
+ {processingInfo.by_status?.DELETED_MATERIAL || 0}๊ฐ
+
+
+
+ )}
+
+ {/* ๊ธฐ์กด FITTING ์์ฌ ๋ทฐ ์ปดํฌ๋ํธ ์ฌ์ฉ */}
+
+
+ );
+};
+
+export default FittingRevisionPage;
diff --git a/frontend/src/pages/revision/FlangeRevisionPage.jsx b/frontend/src/pages/revision/FlangeRevisionPage.jsx
new file mode 100644
index 0000000..60f7cf6
--- /dev/null
+++ b/frontend/src/pages/revision/FlangeRevisionPage.jsx
@@ -0,0 +1,141 @@
+import React, { useState, useEffect } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { LoadingSpinner, ErrorMessage } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import { FlangeMaterialsView } from '../../components/bom';
+import './CategoryRevisionPage.css';
+
+const FlangeRevisionPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [userRequirements, setUserRequirements] = useState({});
+ const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'FLANGE');
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ์์ฌ ์
๋ฐ์ดํธ ํจ์
+ const updateMaterial = (materialId, updates) => {
+ // ๋ฆฌ๋น์ ํ์ด์ง์์๋ ์์ฌ ์
๋ฐ์ดํธ ๋ก์ง์ ๋ณ๋๋ก ์ฒ๋ฆฌ
+ console.log('Material update in revision page:', materialId, updates);
+ };
+
+ if (materialsLoading || statusLoading) {
+ return ;
+ }
+
+ const error = materialsError || statusError;
+ if (error) {
+ return window.location.reload()} />;
+ }
+
+ // FLANGE ์นดํ
๊ณ ๋ฆฌ๋ง ํํฐ๋ง
+ const flangeMaterials = materials.filter(material =>
+ material.classified_category === 'FLANGE' ||
+ material.category === 'FLANGE'
+ );
+
+ // ๊ธฐ์กด BOM ๊ด๋ฆฌ ํ์ด์ง์ ๋์ผํ props ๊ตฌ์ฑ
+ const commonProps = {
+ materials: flangeMaterials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ onPurchasedMaterialsUpdate: (materialIds) => {
+ setPurchasedMaterials(prev => {
+ const newSet = new Set(prev);
+ materialIds.forEach(id => newSet.add(id));
+ return newSet;
+ });
+ },
+ updateMaterial,
+ fileId,
+ jobNo,
+ user,
+ onNavigate
+ };
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
๐ฉ FLANGE ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ ๊ตฌ๋งค ์ํ๋ฅผ ๊ณ ๋ คํ FLANGE ์์ฌ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+
+
+
+
+
+
+
+
+ {/* ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์ํ ์์ฝ */}
+ {processingInfo && (
+
+
๐ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ํํฉ
+
+
+ ์ ์ฒด ์์ฌ
+ {processingInfo.total_materials || 0}๊ฐ
+
+
+ ๋ฆฌ๋น์ ์์ฌ
+ {processingInfo.by_status?.REVISION_MATERIAL || 0}๊ฐ
+
+
+ ์ฌ๊ณ ์์ฌ
+ {processingInfo.by_status?.INVENTORY_MATERIAL || 0}๊ฐ
+
+
+ ์ญ์ ์์ฌ
+ {processingInfo.by_status?.DELETED_MATERIAL || 0}๊ฐ
+
+
+
+ )}
+
+ {/* ๊ธฐ์กด FLANGE ์์ฌ ๋ทฐ ์ปดํฌ๋ํธ ์ฌ์ฉ */}
+
+
+ );
+};
+
+export default FlangeRevisionPage;
\ No newline at end of file
diff --git a/frontend/src/pages/revision/GasketRevisionPage.jsx b/frontend/src/pages/revision/GasketRevisionPage.jsx
new file mode 100644
index 0000000..d763579
--- /dev/null
+++ b/frontend/src/pages/revision/GasketRevisionPage.jsx
@@ -0,0 +1,459 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionComparison } from '../../hooks/useRevisionComparison';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import './CategoryRevisionPage.css';
+
+const GasketRevisionPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [actionType, setActionType] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('description');
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [gasketTypeFilter, setGasketTypeFilter] = useState('all');
+ const [materialTypeFilter, setMaterialTypeFilter] = useState('all');
+ const [pressureRatingFilter, setPressureRatingFilter] = useState('all');
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'GASKET');
+
+ // ๋ฆฌ๋น์ ๋น๊ต ํ
+ const {
+ comparisonResult,
+ loading: comparisonLoading,
+ error: comparisonError,
+ performComparison,
+ getFilteredComparison
+ } = useRevisionComparison(fileId, previousFileId);
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ํํฐ๋ง ๋ฐ ์ ๋ ฌ๋ ์์ฌ ๋ชฉ๋ก
+ const filteredAndSortedMaterials = useMemo(() => {
+ if (!materials) return [];
+
+ let filtered = materials.filter(material => {
+ const matchesSearch = !searchTerm ||
+ material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.gasket_type?.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesStatus = statusFilter === 'all' ||
+ material.processing_info?.display_status === statusFilter;
+
+ const matchesGasketType = gasketTypeFilter === 'all' ||
+ material.gasket_type === gasketTypeFilter;
+
+ const matchesMaterialType = materialTypeFilter === 'all' ||
+ material.material_type === materialTypeFilter;
+
+ const matchesPressureRating = pressureRatingFilter === 'all' ||
+ material.pressure_rating === pressureRatingFilter;
+
+ return matchesSearch && matchesStatus && matchesGasketType && matchesMaterialType && matchesPressureRating;
+ });
+
+ // ์ ๋ ฌ
+ filtered.sort((a, b) => {
+ let aValue = a[sortBy] || '';
+ let bValue = b[sortBy] || '';
+
+ if (typeof aValue === 'string') {
+ aValue = aValue.toLowerCase();
+ bValue = bValue.toLowerCase();
+ }
+
+ if (sortOrder === 'asc') {
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
+ } else {
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
+ }
+ });
+
+ return filtered;
+ }, [materials, searchTerm, statusFilter, gasketTypeFilter, materialTypeFilter, pressureRatingFilter, sortBy, sortOrder]);
+
+ // ๊ณ ์ ๊ฐ๋ค ์ถ์ถ (ํํฐ ์ต์
์ฉ)
+ const uniqueValues = useMemo(() => {
+ if (!materials) return { gasketTypes: [], materialTypes: [], pressureRatings: [] };
+
+ const gasketTypes = [...new Set(materials.map(m => m.gasket_type).filter(Boolean))];
+ const materialTypes = [...new Set(materials.map(m => m.material_type).filter(Boolean))];
+ const pressureRatings = [...new Set(materials.map(m => m.pressure_rating).filter(Boolean))];
+
+ return { gasketTypes, materialTypes, pressureRatings };
+ }, [materials]);
+
+ // ์ด๊ธฐ ๋น๊ต ์ํ
+ useEffect(() => {
+ if (fileId && previousFileId && !comparisonResult) {
+ performComparison('GASKET');
+ }
+ }, [fileId, previousFileId, comparisonResult, performComparison]);
+
+ // ์์ฌ ์ ํ ์ฒ๋ฆฌ
+ const handleMaterialSelect = (materialId, isSelected) => {
+ const newSelected = new Set(selectedMaterials);
+ if (isSelected) {
+ newSelected.add(materialId);
+ } else {
+ newSelected.delete(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ handleMaterialSelection(materialId, isSelected);
+ };
+
+ // ์ ์ฒด ์ ํ/ํด์
+ const handleSelectAll = (isSelected) => {
+ if (isSelected) {
+ const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
+ setSelectedMaterials(allIds);
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
+ } else {
+ setSelectedMaterials(new Set());
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
+ }
+ };
+
+ // ์ก์
์คํ
+ const executeAction = async (action) => {
+ setActionType(action);
+ setShowConfirmDialog(true);
+ };
+
+ const confirmAction = async () => {
+ try {
+ await handleBulkAction(actionType, Array.from(selectedMaterials));
+ setSelectedMaterials(new Set());
+ setShowConfirmDialog(false);
+ await refreshMaterials();
+ } catch (error) {
+ console.error('Action failed:', error);
+ }
+ };
+
+ // ์ํ๋ณ ์์ ํด๋์ค
+ const getStatusClass = (status) => {
+ switch (status) {
+ case 'REVISION_MATERIAL': return 'status-revision';
+ case 'INVENTORY_MATERIAL': return 'status-inventory';
+ case 'DELETED_MATERIAL': return 'status-deleted';
+ case 'NEW_MATERIAL': return 'status-new';
+ default: return 'status-normal';
+ }
+ };
+
+ // ์๋ ํ์ (์ ์๋ก ๋ณํ)
+ const formatQuantity = (quantity) => {
+ if (quantity === null || quantity === undefined) return '-';
+ return Math.round(parseFloat(quantity) || 0).toString();
+ };
+
+ // GASKET ์ค๋ช
์์ฑ (๊ฐ์ค์ผ ํ์
๊ณผ ์ฌ์ง ํฌํจ)
+ const generateGasketDescription = (material) => {
+ const parts = [];
+
+ if (material.gasket_type) parts.push(material.gasket_type);
+ if (material.nominal_size) parts.push(material.nominal_size);
+ if (material.material_type) parts.push(material.material_type);
+ if (material.pressure_rating) parts.push(`${material.pressure_rating}#`);
+
+ const baseDesc = material.description || material.item_name || 'GASKET';
+
+ return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
+ };
+
+ // ๊ฐ์ค์ผ ๋๊ป ํ์
+ const formatThickness = (material) => {
+ if (material.thickness) return `${material.thickness}mm`;
+ if (material.gasket_thickness) return `${material.gasket_thickness}mm`;
+ return '-';
+ };
+
+ if (materialsLoading || comparisonLoading || statusLoading) {
+ return ;
+ }
+
+ const error = materialsError || comparisonError || statusError;
+ if (error) {
+ return window.location.reload()} />;
+ }
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
โญ GASKET ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ ๊ฐ์ค์ผ ํ์
๊ณผ ์ฌ์ง์ ๊ณ ๋ คํ GASKET ์์ฌ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+
+
+
+
+
+
+
+
+ {/* ์ปจํธ๋กค ์น์
*/}
+
+
+
๐ ์์ฌ ํํฉ ๋ฐ ํํฐ
+ {processingInfo && (
+
+ ์ ์ฒด: {processingInfo.total_materials}๊ฐ |
+ ๋ฆฌ๋น์ : {processingInfo.by_status.REVISION_MATERIAL || 0}๊ฐ |
+ ์ฌ๊ณ : {processingInfo.by_status.INVENTORY_MATERIAL || 0}๊ฐ |
+ ์ญ์ : {processingInfo.by_status.DELETED_MATERIAL || 0}๊ฐ
+
+ )}
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ์ ํ๋ ์์ฌ ์ก์
*/}
+ {selectedMaterials.size > 0 && (
+
+
+ {selectedMaterials.size}๊ฐ ์ ํ๋จ
+
+
+
+
+
+
+
+ )}
+
+
+ {/* ์์ฌ ํ
์ด๋ธ */}
+
+
+
+ 0}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ />
+
+
์ํ
+
์์ฌ๋ช
+
๊ฐ์ค์ผ ํ์
+
ํฌ๊ธฐ
+
์ฌ์ง
+
๋๊ป
+
์๋ ฅ๋ฑ๊ธ
+
์๋
+
์ก์
+
+
+
+ {filteredAndSortedMaterials.map((material) => (
+
+
+ handleMaterialSelect(material.id, e.target.checked)}
+ />
+
+
+
+ {material.processing_info?.display_status || 'NORMAL'}
+
+
+
+
+
{generateGasketDescription(material)}
+ {material.processing_info?.notes && (
+
{material.processing_info.notes}
+ )}
+
+
+
{material.gasket_type || '-'}
+
{material.nominal_size || '-'}
+
{material.material_type || '-'}
+
{formatThickness(material)}
+
{material.pressure_rating ? `${material.pressure_rating}#` : '-'}
+
+
+ {formatQuantity(material.quantity)}
+
+ {material.processing_info?.quantity_change && (
+
+ ({material.processing_info.quantity_change > 0 ? '+' : ''}
+ {formatQuantity(material.processing_info.quantity_change)})
+
+ )}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {filteredAndSortedMaterials.length === 0 && (
+
+
์กฐ๊ฑด์ ๋ง๋ GASKET ์์ฌ๊ฐ ์์ต๋๋ค.
+
+ )}
+
+
+ {/* ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */}
+ {showConfirmDialog && (
+
setShowConfirmDialog(false)}
+ />
+ )}
+
+ );
+};
+
+export default GasketRevisionPage;
diff --git a/frontend/src/pages/revision/PipeCuttingPlanPage.css b/frontend/src/pages/revision/PipeCuttingPlanPage.css
new file mode 100644
index 0000000..f25d107
--- /dev/null
+++ b/frontend/src/pages/revision/PipeCuttingPlanPage.css
@@ -0,0 +1,666 @@
+/* PIPE Cutting Plan ํ์ด์ง ์ ์ฉ ์คํ์ผ */
+
+/* PIPE ๋ฆฌ๋น์ ์ํ ํ์ */
+.revision-status-section {
+ margin: 20px 0;
+ padding: 0 20px;
+}
+
+.revision-alert {
+ display: flex;
+ align-items: flex-start;
+ gap: 15px;
+ padding: 20px;
+ border-radius: 12px;
+ border-left: 5px solid;
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+}
+
+.revision-alert.pre-cutting {
+ border-left-color: #3b82f6;
+ background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
+}
+
+.revision-alert.post-cutting {
+ border-left-color: #f59e0b;
+ background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
+}
+
+.alert-icon {
+ font-size: 24px;
+ margin-top: 2px;
+}
+
+.alert-content h4 {
+ margin: 0 0 8px 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: #1f2937;
+}
+
+.alert-content p {
+ margin: 0 0 12px 0;
+ color: #4b5563;
+ line-height: 1.5;
+}
+
+.revision-summary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+ margin-top: 12px;
+}
+
+.revision-summary span {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 12px;
+ background: rgba(255, 255, 255, 0.8);
+ border-radius: 20px;
+ font-size: 13px;
+ font-weight: 500;
+ color: #374151;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+/* Cutting Plan ๊ด๋ฆฌ ์น์
*/
+.cutting-plan-management-section {
+ margin: 30px 0;
+ padding: 25px;
+ background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
+ border-radius: 16px;
+ border: 1px solid #e2e8f0;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+}
+
+.cutting-plan-management-section .section-header h3 {
+ margin: 0 0 20px 0;
+ font-size: 20px;
+ font-weight: 600;
+ color: #1f2937;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.cutting-plan-actions {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 15px;
+ margin-bottom: 20px;
+}
+
+.cutting-plan-actions button {
+ padding: 12px 20px;
+ border: none;
+ border-radius: 10px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 48px;
+}
+
+.btn-export-temp {
+ background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
+ color: white;
+ box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
+}
+
+.btn-export-temp:hover:not(:disabled) {
+ background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
+}
+
+.btn-finalize-cutting-plan {
+ background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
+ color: white;
+ box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3);
+}
+
+.btn-finalize-cutting-plan:hover:not(:disabled) {
+ background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 8px rgba(220, 38, 38, 0.4);
+}
+
+.btn-export-finalized {
+ background: linear-gradient(135deg, #059669 0%, #047857 100%);
+ color: white;
+ box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);
+}
+
+.btn-export-finalized:hover:not(:disabled) {
+ background: linear-gradient(135deg, #047857 0%, #065f46 100%);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 8px rgba(5, 150, 105, 0.4);
+}
+
+.btn-issue-management {
+ background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
+ color: white;
+ box-shadow: 0 2px 4px rgba(124, 58, 237, 0.3);
+}
+
+.btn-issue-management:hover:not(:disabled) {
+ background: linear-gradient(135deg, #6d28d9 0%, #5b21b6 100%);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 8px rgba(124, 58, 237, 0.4);
+}
+
+.cutting-plan-actions button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none !important;
+ box-shadow: none !important;
+}
+
+.action-descriptions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 15px;
+ background: rgba(249, 250, 251, 0.8);
+ border-radius: 8px;
+ border: 1px solid #e5e7eb;
+}
+
+.action-desc {
+ font-size: 13px;
+ color: #4b5563;
+ line-height: 1.4;
+}
+
+.action-desc strong {
+ color: #1f2937;
+ font-weight: 600;
+}
+
+/* ๋ฐ์ํ */
+@media (max-width: 768px) {
+ .cutting-plan-actions {
+ grid-template-columns: 1fr;
+ }
+
+ .cutting-plan-actions button {
+ font-size: 13px;
+ padding: 10px 16px;
+ }
+}
+
+.pipe-cutting-plan-page {
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
+ min-height: 100vh;
+ padding: 20px;
+}
+
+/* ๋ฆฌ๋น์ ๊ฒฝ๊ณ ์น์
*/
+.revision-warning {
+ background: linear-gradient(135deg, #fef3c7 0%, #fbbf24 100%);
+ border: 2px solid #f59e0b;
+ border-radius: 12px;
+ padding: 20px;
+ margin-bottom: 24px;
+ box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25);
+}
+
+.warning-content h3 {
+ margin: 0 0 12px 0;
+ color: #92400e;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.warning-content p {
+ margin: 0 0 16px 0;
+ color: #92400e;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.highlight {
+ background: rgba(239, 68, 68, 0.2);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-weight: 600;
+ color: #dc2626;
+}
+
+.btn-force-upload {
+ background: #dc2626;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ padding: 10px 20px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.btn-force-upload:hover {
+ background: #b91c1c;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
+}
+
+/* ๋ถ๋ฅ ์น์
*/
+.classification-section {
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ margin-bottom: 24px;
+ border: 1px solid #e2e8f0;
+ overflow: hidden;
+}
+
+.classification-controls {
+ display: grid;
+ grid-template-columns: 200px 1fr 250px;
+ gap: 20px;
+ padding: 20px 24px;
+ align-items: end;
+}
+
+.control-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.control-group label {
+ font-size: 14px;
+ font-weight: 600;
+ color: #374151;
+}
+
+.control-group select,
+.control-group input {
+ padding: 10px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ font-size: 14px;
+ background: white;
+ transition: all 0.2s ease;
+}
+
+.control-group select:focus,
+.control-group input:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+.control-group select:disabled,
+.control-group input:disabled {
+ background: #f9fafb;
+ color: #9ca3af;
+ cursor: not-allowed;
+}
+
+.btn-start-cutting-plan {
+ padding: 12px 20px;
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ height: fit-content;
+}
+
+.btn-start-cutting-plan:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
+}
+
+.btn-start-cutting-plan:disabled {
+ background: #9ca3af;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+/* ์์ฌ ํํฉ ์์ฝ */
+.materials-summary {
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ padding: 20px 24px;
+ margin-bottom: 24px;
+ border: 1px solid #e2e8f0;
+}
+
+.summary-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 20px;
+}
+
+.stat-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 16px;
+ background: #f8fafc;
+ border-radius: 8px;
+ border: 1px solid #f1f5f9;
+}
+
+.stat-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: #64748b;
+ margin-bottom: 4px;
+}
+
+.stat-value {
+ font-size: 24px;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+/* Cutting Plan ์ฝํ
์ธ */
+.cutting-plan-content {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+/* ๊ตฌ์ญ ์น์
*/
+.area-section {
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ border: 1px solid #e2e8f0;
+ overflow: hidden;
+}
+
+.area-section.unassigned {
+ border-color: #fbbf24;
+ background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
+}
+
+.area-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 24px;
+ background: #f8fafc;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.area-section.unassigned .area-header {
+ background: #fbbf24;
+ color: white;
+}
+
+.area-header h4 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.area-section.unassigned .area-header h4 {
+ color: white;
+}
+
+.area-count {
+ font-size: 14px;
+ color: #64748b;
+ font-weight: 500;
+}
+
+.area-section.unassigned .area-count {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+/* PIPE ํ
์ด๋ธ */
+.pipe-table {
+ width: 100%;
+}
+
+.pipe-table .table-header {
+ display: grid;
+ grid-template-columns: 100px 150px 120px 200px 100px 120px 100px;
+ gap: 12px;
+ padding: 16px 24px;
+ background: #f8fafc;
+ border-bottom: 1px solid #e2e8f0;
+ font-weight: 600;
+ font-size: 14px;
+ color: #374151;
+}
+
+.area-section.unassigned .pipe-table .table-header {
+ background: rgba(251, 191, 36, 0.1);
+}
+
+.pipe-table .table-body {
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.pipe-table .table-row {
+ display: grid;
+ grid-template-columns: 100px 150px 120px 200px 100px 120px 100px;
+ gap: 12px;
+ padding: 16px 24px;
+ border-bottom: 1px solid #f1f5f9;
+ font-size: 14px;
+ color: #374151;
+ transition: all 0.2s ease;
+ align-items: center;
+}
+
+.pipe-table .table-row:hover {
+ background: #f8fafc;
+}
+
+.pipe-table .table-row:last-child {
+ border-bottom: none;
+}
+
+.pipe-table .header-cell,
+.pipe-table .table-cell {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ min-height: 40px;
+}
+
+.pipe-table .table-cell select {
+ width: 100%;
+ padding: 6px 8px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 13px;
+ background: white;
+}
+
+.pipe-table .table-cell select:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
+}
+
+/* ์ก์
๋ฒํผ */
+.action-buttons-small {
+ display: flex;
+ gap: 4px;
+}
+
+.btn-small {
+ padding: 4px 8px;
+ border: 1px solid #d1d5db;
+ border-radius: 4px;
+ background: white;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ min-width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.btn-small:hover {
+ background: #f9fafb;
+ border-color: #9ca3af;
+}
+
+.btn-small.btn-edit {
+ color: #f59e0b;
+}
+
+.btn-small.btn-delete {
+ color: #ef4444;
+}
+
+.btn-small.btn-delete:hover {
+ background: #fef2f2;
+ border-color: #fca5a5;
+}
+
+/* ๋น ์ํ */
+.empty-state {
+ padding: 60px 20px;
+ text-align: center;
+ color: #6b7280;
+ background: white;
+ border-radius: 12px;
+ border: 1px solid #e2e8f0;
+}
+
+.empty-state p {
+ margin: 0;
+ font-size: 16px;
+}
+
+/* ๋ฐ์ํ ๋์์ธ */
+@media (max-width: 1400px) {
+ .pipe-table .table-header,
+ .pipe-table .table-row {
+ grid-template-columns: 80px 130px 100px 180px 80px 100px 80px;
+ }
+}
+
+@media (max-width: 1200px) {
+ .classification-controls {
+ grid-template-columns: 1fr;
+ gap: 16px;
+ }
+
+ .pipe-table .table-header,
+ .pipe-table .table-row {
+ grid-template-columns: 80px 120px 160px 80px 100px 80px;
+ }
+
+ .pipe-table .table-header .header-cell:nth-child(3),
+ .pipe-table .table-row .table-cell:nth-child(3) {
+ display: none;
+ }
+}
+
+@media (max-width: 768px) {
+ .pipe-cutting-plan-page {
+ padding: 16px;
+ }
+
+ .classification-controls {
+ padding: 16px;
+ }
+
+ .summary-stats {
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 12px;
+ }
+
+ .pipe-table .table-header {
+ display: none;
+ }
+
+ .pipe-table .table-row {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ flex-direction: column;
+ align-items: stretch;
+ padding: 16px;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ }
+
+ .pipe-table .table-cell {
+ justify-content: space-between;
+ padding: 4px 0;
+ border-bottom: 1px solid #f3f4f6;
+ }
+
+ .pipe-table .table-cell:last-child {
+ border-bottom: none;
+ justify-content: center;
+ }
+
+ .pipe-table .table-cell::before {
+ content: attr(data-label);
+ font-weight: 600;
+ color: #6b7280;
+ min-width: 80px;
+ }
+
+ .area-header {
+ flex-direction: column;
+ gap: 8px;
+ align-items: stretch;
+ text-align: center;
+ }
+}
+
+/* ์ ๋๋ฉ์ด์
*/
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.area-section {
+ animation: slideIn 0.3s ease-out;
+}
+
+.pipe-table .table-row {
+ animation: slideIn 0.2s ease-out;
+}
+
+/* ์คํฌ๋กค๋ฐ ์คํ์ผ */
+.pipe-table .table-body::-webkit-scrollbar {
+ width: 8px;
+}
+
+.pipe-table .table-body::-webkit-scrollbar-track {
+ background: #f1f5f9;
+}
+
+.pipe-table .table-body::-webkit-scrollbar-thumb {
+ background: #cbd5e1;
+ border-radius: 4px;
+}
+
+.pipe-table .table-body::-webkit-scrollbar-thumb:hover {
+ background: #94a3b8;
+}
diff --git a/frontend/src/pages/revision/PipeCuttingPlanPage.jsx b/frontend/src/pages/revision/PipeCuttingPlanPage.jsx
new file mode 100644
index 0000000..d09d292
--- /dev/null
+++ b/frontend/src/pages/revision/PipeCuttingPlanPage.jsx
@@ -0,0 +1,681 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionComparison } from '../../hooks/useRevisionComparison';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { usePipeRevision } from '../../hooks/usePipeRevision';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import './CategoryRevisionPage.css';
+import './PipeCuttingPlanPage.css';
+
+const PipeCuttingPlanPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [actionType, setActionType] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [selectedArea, setSelectedArea] = useState('');
+ const [searchDrawing, setSearchDrawing] = useState('');
+ const [cuttingPlanStarted, setCuttingPlanStarted] = useState(false);
+ const [areaAssignments, setAreaAssignments] = useState({});
+ const [endPreparations, setEndPreparations] = useState({});
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'PIPE');
+
+ // ๋ฆฌ๋น์ ๋น๊ต ํ
+ const {
+ comparisonResult,
+ loading: comparisonLoading,
+ error: comparisonError,
+ performComparison,
+ getFilteredComparison
+ } = useRevisionComparison(fileId, previousFileId);
+
+ // PIPE ์ ์ฉ ๋ฆฌ๋น์ ํ
+ const {
+ revisionStatus: pipeRevisionStatus,
+ comparisonResult: pipeComparisonResult,
+ loading: pipeRevisionLoading,
+ error: pipeRevisionError,
+ checkRevisionStatus,
+ handlePreCuttingPlanRevision,
+ handlePostCuttingPlanRevision,
+ processRevisionAutomatically,
+ finalizeCuttingPlan,
+ getSnapshotStatus,
+ exportFinalizedExcel,
+ checkFinalizationStatus,
+ isPreCuttingPlan,
+ isPostCuttingPlan,
+ requiresAction
+ } = usePipeRevision(jobNo, fileId);
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ๊ตฌ์ญ ์ต์
+ const areaOptions = ['#01', '#02', '#03', '#04', '#05', '#06', '#07', '#08', '#09', '#10'];
+
+ // ์ปดํฌ๋ํธ ๋ง์ดํธ ์ ๋ฐ์ดํฐ ๋ก๋ ๋ฐ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+ useEffect(() => {
+ refreshMaterials();
+
+ // PIPE ๋ฆฌ๋น์ ์๋ ์ฒ๋ฆฌ
+ if (jobNo && fileId && requiresAction) {
+ handlePipeRevisionAutomatically();
+ }
+ }, [refreshMaterials, jobNo, fileId, requiresAction]);
+
+ // PIPE ๋ฆฌ๋น์ ์๋ ์ฒ๋ฆฌ ํจ์
+ const handlePipeRevisionAutomatically = async () => {
+ try {
+ const result = await processRevisionAutomatically();
+
+ if (result.success) {
+ if (result.type === 'pre_cutting_plan') {
+ // Cutting Plan ์์ฑ ์ ๋ฆฌ๋น์ - ๊ธฐ์กด ๋ฐ์ดํฐ ์ญ์ ๋จ
+ alert(`${result.message}\n์๋ก์ด Cutting Plan์ ์์ฑํด์ฃผ์ธ์.`);
+ setCuttingPlanStarted(false);
+ setAreaAssignments({});
+ } else if (result.type === 'post_cutting_plan') {
+ // Cutting Plan ์์ฑ ํ ๋ฆฌ๋น์ - ๋น๊ต ๊ฒฐ๊ณผ ํ์
+ alert(`${result.message}\n๋ณ๊ฒฝ์ฌํญ์ ๊ฒํ ํด์ฃผ์ธ์.`);
+ setCuttingPlanStarted(true);
+ }
+ } else {
+ console.error('PIPE ๋ฆฌ๋น์ ์๋ ์ฒ๋ฆฌ ์คํจ:', result.message);
+ }
+ } catch (error) {
+ console.error('PIPE ๋ฆฌ๋น์ ์๋ ์ฒ๋ฆฌ ์ค ์ค๋ฅ:', error);
+ }
+ };
+
+ // ๋๋จ ์ฒ๋ฆฌ ์ต์
+ const endPrepOptions = [
+ { value: 'plain', label: '๋ฌด๊ฐ์ ' },
+ { value: 'single_bevel', label: 'ํ๊ฐ์ ' },
+ { value: 'double_bevel', label: '์๊ฐ์ ' }
+ ];
+
+ // ํํฐ๋ง๋ ์์ฌ ๋ชฉ๋ก (๋๋ฉด ๊ฒ์ ์ ์ฉ)
+ const filteredMaterials = useMemo(() => {
+ if (!materials) return [];
+
+ return materials.filter(material => {
+ const matchesDrawing = !searchDrawing ||
+ material.drawing_name?.toLowerCase().includes(searchDrawing.toLowerCase());
+
+ return matchesDrawing;
+ });
+ }, [materials, searchDrawing]);
+
+ // ๊ตฌ์ญ๋ณ๋ก ๊ทธ๋ฃนํ๋ ์์ฌ
+ const groupedMaterials = useMemo(() => {
+ const grouped = {
+ assigned: {},
+ unassigned: []
+ };
+
+ filteredMaterials.forEach(material => {
+ const assignedArea = areaAssignments[material.id];
+ if (assignedArea) {
+ if (!grouped.assigned[assignedArea]) {
+ grouped.assigned[assignedArea] = [];
+ }
+ grouped.assigned[assignedArea].push(material);
+ } else {
+ grouped.unassigned.push(material);
+ }
+ });
+
+ return grouped;
+ }, [filteredMaterials, areaAssignments]);
+
+ // ์ด๊ธฐ ๋น๊ต ์ํ
+ useEffect(() => {
+ if (fileId && previousFileId && !comparisonResult) {
+ performComparison('PIPE');
+ }
+ }, [fileId, previousFileId, comparisonResult, performComparison]);
+
+ // Cutting Plan ์์
+ const handleStartCuttingPlan = () => {
+ if (!selectedArea) {
+ alert('๊ตฌ์ญ์ ์ ํํด์ฃผ์ธ์.');
+ return;
+ }
+
+ // ์ ํ๋ ๊ตฌ์ญ๊ณผ ๊ฒ์๋ ๋๋ฉด์ ๋ง๋ ์์ฌ๋ค์ ์๋ ํ ๋น
+ const newAssignments = { ...areaAssignments };
+ filteredMaterials.forEach(material => {
+ if (!newAssignments[material.id]) {
+ newAssignments[material.id] = selectedArea;
+ }
+ });
+
+ setAreaAssignments(newAssignments);
+ setCuttingPlanStarted(true);
+ };
+
+ // ๊ตฌ์ญ ํ ๋น ๋ณ๊ฒฝ
+ const handleAreaAssignment = (materialId, area) => {
+ setAreaAssignments(prev => ({
+ ...prev,
+ [materialId]: area
+ }));
+ };
+
+ // ๋๋จ ์ฒ๋ฆฌ ๋ณ๊ฒฝ
+ const handleEndPrepChange = (materialId, endPrep) => {
+ setEndPreparations(prev => ({
+ ...prev,
+ [materialId]: endPrep
+ }));
+ };
+
+ // ์์ฌ ์ญ์
+ const handleRemoveMaterial = (materialId) => {
+ setSelectedMaterials(prev => {
+ const newSet = new Set(prev);
+ newSet.add(materialId);
+ return newSet;
+ });
+ setActionType('delete_pipe_segment');
+ setShowConfirmDialog(true);
+ };
+
+ // ์ก์
์คํ
+ const confirmAction = async () => {
+ try {
+ if (actionType === 'delete_pipe_segment') {
+ // PIPE ์ธ๊ทธ๋จผํธ ์ญ์ ๋ก์ง
+ console.log('Deleting pipe segments:', Array.from(selectedMaterials));
+ } else if (actionType === 'force_revision_upload') {
+ // ๊ฐ์ ๋ฆฌ๋น์ ์
๋ก๋ ๋ก์ง
+ uploadNewRevision();
+ }
+
+ setSelectedMaterials(new Set());
+ setShowConfirmDialog(false);
+ await refreshMaterials();
+ } catch (error) {
+ console.error('Action failed:', error);
+ }
+ };
+
+ // ํ์ดํ ์ ๋ณด ํฌ๋งทํ
+ const formatPipeInfo = (material) => {
+ const parts = [];
+ if (material.material_grade) parts.push(material.material_grade);
+ if (material.schedule) parts.push(material.schedule);
+ if (material.nominal_size) parts.push(material.nominal_size);
+
+ return parts.join(' ') || '-';
+ };
+
+ // ๊ธธ์ด ํฌ๋งทํ
+ const formatLength = (length) => {
+ if (!length) return '-';
+ return `${parseFloat(length).toFixed(1)}mm`;
+ };
+
+ // ์์ Excel ๋ด๋ณด๋ด๊ธฐ (ํ์ฌ ์์
์ค์ธ ๋ฐ์ดํฐ)
+ const handleExportTempExcel = async () => {
+ try {
+ alert('์์ Excel ๋ด๋ณด๋ด๊ธฐ ๊ธฐ๋ฅ์ ๊ตฌํ ์์ ์
๋๋ค.\nํ์ฌ ์์
์ค์ธ ๋ฐ์ดํฐ๋ฅผ ๊ธฐ์ค์ผ๋ก ์์ฑ๋ฉ๋๋ค.');
+ } catch (error) {
+ console.error('์์ Excel ๋ด๋ณด๋ด๊ธฐ ์คํจ:', error);
+ alert('Excel ๋ด๋ณด๋ด๊ธฐ์ ์คํจํ์ต๋๋ค.');
+ }
+ };
+
+ // Cutting Plan ํ์
+ const handleFinalizeCuttingPlan = async () => {
+ try {
+ const confirmed = window.confirm(
+ 'โ ๏ธ Cutting Plan์ ํ์ ํ์๊ฒ ์ต๋๊น?\n\n' +
+ 'ํ์ ํ์๋:\n' +
+ 'โข ๋ฐ์ดํฐ๊ฐ ๊ณ ์ ๋์ด ๋ฆฌ๋น์ ์ํฅ์ ๋ฐ์ง ์์ต๋๋ค\n' +
+ 'โข ์ด์ ๊ด๋ฆฌ๋ฅผ ์์ํ ์ ์์ต๋๋ค\n' +
+ 'โข Excel ๋ด๋ณด๋ด๊ธฐ๊ฐ ๊ณ ์ ๋ ๋ฐ์ดํฐ๋ก ์ ๊ณต๋ฉ๋๋ค'
+ );
+
+ if (!confirmed) return;
+
+ const result = await finalizeCuttingPlan();
+ if (result && result.success) {
+ alert(`โ
${result.message}\n\n์ค๋
์ท ID: ${result.snapshot_id}\n์ด ๋จ๊ด: ${result.total_segments}๊ฐ`);
+ // ํ์ด์ง ์๋ก๊ณ ์นจ ๋๋ ์ํ ์
๋ฐ์ดํธ
+ window.location.reload();
+ } else {
+ alert(`โ Cutting Plan ํ์ ์คํจ\n${result?.message || '์ ์ ์๋ ์ค๋ฅ'}`);
+ }
+ } catch (error) {
+ console.error('Cutting Plan ํ์ ์คํจ:', error);
+ alert('Cutting Plan ํ์ ์ ์คํจํ์ต๋๋ค.');
+ }
+ };
+
+ // ํ์ ๋ Excel ๋ด๋ณด๋ด๊ธฐ (๊ณ ์ ๋ ๋ฐ์ดํฐ)
+ const handleExportFinalizedExcel = async () => {
+ try {
+ const result = await exportFinalizedExcel();
+ if (result && result.success) {
+ alert('โ
ํ์ ๋ Excel ํ์ผ์ด ๋ค์ด๋ก๋๋์์ต๋๋ค.\n์ด ํ์ผ์ ๋ฆฌ๋น์ ๊ณผ ๋ฌด๊ดํ๊ฒ ๊ณ ์ ๋ ๋ฐ์ดํฐ์
๋๋ค.');
+ } else {
+ alert(`โ Excel ๋ด๋ณด๋ด๊ธฐ ์คํจ\n${result?.message || '์ ์ ์๋ ์ค๋ฅ'}`);
+ }
+ } catch (error) {
+ console.error('ํ์ ๋ Excel ๋ด๋ณด๋ด๊ธฐ ์คํจ:', error);
+ alert('Excel ๋ด๋ณด๋ด๊ธฐ์ ์คํจํ์ต๋๋ค.');
+ }
+ };
+
+ // ์ด์ ๊ด๋ฆฌ ํ์ด์ง๋ก ์ด๋
+ const handleGoToIssueManagement = async () => {
+ try {
+ const snapshotStatus = await getSnapshotStatus();
+ if (snapshotStatus && snapshotStatus.has_snapshot && snapshotStatus.is_locked) {
+ // ์ด์ ๊ด๋ฆฌ ํ์ด์ง๋ก ์ด๋
+ onNavigate('pipe-issue-management');
+ } else {
+ alert('โ ์ด์ ๊ด๋ฆฌ๋ฅผ ์์ํ๋ ค๋ฉด ๋จผ์ Cutting Plan์ ํ์ ํด์ฃผ์ธ์.');
+ }
+ } catch (error) {
+ console.error('์ด์ ๊ด๋ฆฌ ํ์ด์ง ์ด๋ ์คํจ:', error);
+ alert('์ด์ ๊ด๋ฆฌ ํ์ด์ง ์ ๊ทผ์ ์คํจํ์ต๋๋ค.');
+ }
+ };
+
+ if (materialsLoading || comparisonLoading || statusLoading || pipeRevisionLoading) {
+ return ;
+ }
+
+ const error = materialsError || comparisonError || statusError || pipeRevisionError;
+ if (error) {
+ return window.location.reload()} />;
+ }
+
+ return (
+
+ {/* PIPE ๋ฆฌ๋น์ ์ํ ํ์ */}
+ {pipeRevisionStatus && requiresAction && (
+
+
+
+ {isPreCuttingPlan ? '๐' : 'โ ๏ธ'}
+
+
+
+ {isPreCuttingPlan ? 'Cutting Plan ์์ฑ ์ ๋ฆฌ๋น์ ' : 'Cutting Plan ์์ฑ ํ ๋ฆฌ๋น์ '}
+
+
{pipeRevisionStatus.message}
+ {isPostCuttingPlan && pipeComparisonResult && (
+
+ ๋ณ๊ฒฝ๋ ๋๋ฉด: {pipeComparisonResult.summary?.changed_drawings_count || 0}๊ฐ
+ ์ถ๊ฐ๋ ๋จ๊ด: {pipeComparisonResult.summary?.added_segments || 0}๊ฐ
+ ์ญ์ ๋ ๋จ๊ด: {pipeComparisonResult.summary?.removed_segments || 0}๊ฐ
+ ์์ ๋ ๋จ๊ด: {pipeComparisonResult.summary?.modified_segments || 0}๊ฐ
+
+ )}
+
+
+
+ )}
+
+ {/* ํค๋ */}
+
+
+
+
+
๐ง PIPE Cutting Plan ๊ด๋ฆฌ
+
+ ๋๋ฉด-๋ผ์ธ๋ฒํธ-๊ธธ์ด ๊ธฐ๋ฐ ํ์ดํ ์ ๋จ ๊ณํ ๊ด๋ฆฌ
+
+
+
+
+
+
+
+
+ {/* ๋ฆฌ๋น์ ๊ฒฝ๊ณ (Cutting Plan ์์ ์ ) */}
+ {!cuttingPlanStarted && (
+
+
+
โ ๏ธ PIPE ๋ฆฌ๋น์ ์ฒ๋ฆฌ ์๋ด
+
+ Cutting Plan ์์ฑ ์ ์ ๋ฆฌ๋น์ ์ด ๋ฐ์ํ๋ฉด
+ ๊ธฐ์กด ๋จ๊ด์ ๋ณด๊ฐ ์ ๋ถ ์ญ์ ๋๊ณ
+ ์ BOM ํ์ผ ์
๋ก๋๊ฐ ํ์ํฉ๋๋ค.
+
+ {revisionStatus?.has_revision && (
+
+ )}
+
+
+ )}
+
+ {/* ๋ถ๋ฅ ์น์
*/}
+
+
+
๐ ๊ตฌ์ญ ๋ฐ ๋๋ฉด ๋ถ๋ฅ
+
+
+
+
+
+
+
+
+
+
+ setSearchDrawing(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+ {/* ์์ฌ ํํฉ */}
+
+
+
+ ์ ์ฒด ๋จ๊ด
+ {filteredMaterials.length}
+
+
+ ํ ๋น๋ ๋จ๊ด
+ {Object.keys(areaAssignments).length}
+
+
+ ๋ฏธํ ๋น ๋จ๊ด
+ {groupedMaterials.unassigned.length}
+
+
+
+
+ {/* ๊ตฌ์ญ๋ณ ์์ฌ ํ
์ด๋ธ */}
+
+ {/* ํ ๋น๋ ๊ตฌ์ญ๋ค */}
+ {Object.keys(groupedMaterials.assigned).sort().map(area => (
+
+
+
๐ ๊ตฌ์ญ {area}
+ {groupedMaterials.assigned[area].length}๊ฐ ๋จ๊ด
+
+
+
+
+
๊ตฌ์ญ
+
๋๋ฉด
+
๋ผ์ธ๋ฒํธ
+
ํ์ดํ์ ๋ณด(์ฌ์ง)
+
๊ธธ์ด
+
๋๋จ์ ๋ณด
+
์ก์
+
+
+
+ {groupedMaterials.assigned[area].map(material => (
+
+
+
+
+
{material.drawing_name || '-'}
+
{material.line_no || '-'}
+
{formatPipeInfo(material)}
+
{formatLength(material.length || material.total_length)}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ ))}
+
+ {/* ๋ฏธํ ๋น ๋จ๊ด๋ค */}
+ {groupedMaterials.unassigned.length > 0 && (
+
+
+
โ ๋ฏธํ ๋น ๋จ๊ด
+ {groupedMaterials.unassigned.length}๊ฐ ๋จ๊ด
+
+
+
+
+
๊ตฌ์ญ
+
๋๋ฉด
+
๋ผ์ธ๋ฒํธ
+
ํ์ดํ์ ๋ณด(์ฌ์ง)
+
๊ธธ์ด
+
๋๋จ์ ๋ณด
+
์ก์
+
+
+
+ {groupedMaterials.unassigned.map(material => (
+
+
+
+
+
{material.drawing_name || '-'}
+
{material.line_no || '-'}
+
{formatPipeInfo(material)}
+
{formatLength(material.length || material.total_length)}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* ๋น ์ํ */}
+ {filteredMaterials.length === 0 && (
+
+
์กฐ๊ฑด์ ๋ง๋ PIPE ์์ฌ๊ฐ ์์ต๋๋ค.
+
+ )}
+
+
+ {/* Cutting Plan ๊ด๋ฆฌ ์ก์
*/}
+
+
+
๐ง Cutting Plan ๊ด๋ฆฌ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ๐ ์์ Excel: ํ์ฌ ์์
์ค์ธ ๋ฐ์ดํฐ (๋ฆฌ๋น์ ์ ๋ณ๊ฒฝ๋จ)
+
+
+ ๐ ํ์ : ๋ฐ์ดํฐ ๊ณ ์ ๋ฐ ์ด์ ๊ด๋ฆฌ ์์ (๋ฆฌ๋น์ ๋ณดํธ)
+
+
+ ๐ ํ์ ๋ Excel: ๊ณ ์ ๋ ๋ฐ์ดํฐ (๋ฆฌ๋น์ ๊ณผ ๋ฌด๊ด)
+
+
+
+
+ {/* ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */}
+ {showConfirmDialog && (
+
setShowConfirmDialog(false)}
+ />
+ )}
+
+ );
+};
+
+export default PipeCuttingPlanPage;
diff --git a/frontend/src/pages/revision/SpecialRevisionPage.jsx b/frontend/src/pages/revision/SpecialRevisionPage.jsx
new file mode 100644
index 0000000..d3bee4d
--- /dev/null
+++ b/frontend/src/pages/revision/SpecialRevisionPage.jsx
@@ -0,0 +1,460 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionComparison } from '../../hooks/useRevisionComparison';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import './CategoryRevisionPage.css';
+
+const SpecialRevisionPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [actionType, setActionType] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('description');
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [subcategoryFilter, setSubcategoryFilter] = useState('all');
+ const [priorityFilter, setPriorityFilter] = useState('all');
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'SPECIAL');
+
+ // ๋ฆฌ๋น์ ๋น๊ต ํ
+ const {
+ comparisonResult,
+ loading: comparisonLoading,
+ error: comparisonError,
+ performComparison,
+ getFilteredComparison
+ } = useRevisionComparison(fileId, previousFileId);
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ํํฐ๋ง ๋ฐ ์ ๋ ฌ๋ ์์ฌ ๋ชฉ๋ก
+ const filteredAndSortedMaterials = useMemo(() => {
+ if (!materials) return [];
+
+ let filtered = materials.filter(material => {
+ const matchesSearch = !searchTerm ||
+ material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.brand?.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesStatus = statusFilter === 'all' ||
+ material.processing_info?.display_status === statusFilter;
+
+ const matchesSubcategory = subcategoryFilter === 'all' ||
+ material.subcategory === subcategoryFilter;
+
+ const matchesPriority = priorityFilter === 'all' ||
+ material.processing_info?.priority === priorityFilter;
+
+ return matchesSearch && matchesStatus && matchesSubcategory && matchesPriority;
+ });
+
+ // ์ ๋ ฌ
+ filtered.sort((a, b) => {
+ let aValue = a[sortBy] || '';
+ let bValue = b[sortBy] || '';
+
+ if (typeof aValue === 'string') {
+ aValue = aValue.toLowerCase();
+ bValue = bValue.toLowerCase();
+ }
+
+ if (sortOrder === 'asc') {
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
+ } else {
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
+ }
+ });
+
+ return filtered;
+ }, [materials, searchTerm, statusFilter, subcategoryFilter, priorityFilter, sortBy, sortOrder]);
+
+ // ๊ณ ์ ๊ฐ๋ค ์ถ์ถ (ํํฐ ์ต์
์ฉ)
+ const uniqueValues = useMemo(() => {
+ if (!materials) return { subcategories: [], priorities: [] };
+
+ const subcategories = [...new Set(materials.map(m => m.subcategory).filter(Boolean))];
+ const priorities = [...new Set(materials.map(m => m.processing_info?.priority).filter(Boolean))];
+
+ return { subcategories, priorities };
+ }, [materials]);
+
+ // ์ด๊ธฐ ๋น๊ต ์ํ
+ useEffect(() => {
+ if (fileId && previousFileId && !comparisonResult) {
+ performComparison('SPECIAL');
+ }
+ }, [fileId, previousFileId, comparisonResult, performComparison]);
+
+ // ์์ฌ ์ ํ ์ฒ๋ฆฌ
+ const handleMaterialSelect = (materialId, isSelected) => {
+ const newSelected = new Set(selectedMaterials);
+ if (isSelected) {
+ newSelected.add(materialId);
+ } else {
+ newSelected.delete(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ handleMaterialSelection(materialId, isSelected);
+ };
+
+ // ์ ์ฒด ์ ํ/ํด์
+ const handleSelectAll = (isSelected) => {
+ if (isSelected) {
+ const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
+ setSelectedMaterials(allIds);
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
+ } else {
+ setSelectedMaterials(new Set());
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
+ }
+ };
+
+ // ์ก์
์คํ
+ const executeAction = async (action) => {
+ setActionType(action);
+ setShowConfirmDialog(true);
+ };
+
+ const confirmAction = async () => {
+ try {
+ await handleBulkAction(actionType, Array.from(selectedMaterials));
+ setSelectedMaterials(new Set());
+ setShowConfirmDialog(false);
+ await refreshMaterials();
+ } catch (error) {
+ console.error('Action failed:', error);
+ }
+ };
+
+ // ์ํ๋ณ ์์ ํด๋์ค
+ const getStatusClass = (status) => {
+ switch (status) {
+ case 'REVISION_MATERIAL': return 'status-revision';
+ case 'INVENTORY_MATERIAL': return 'status-inventory';
+ case 'DELETED_MATERIAL': return 'status-deleted';
+ case 'NEW_MATERIAL': return 'status-new';
+ default: return 'status-normal';
+ }
+ };
+
+ // ์ฐ์ ์์๋ณ ์์ ํด๋์ค
+ const getPriorityClass = (priority) => {
+ switch (priority) {
+ case 'high': return 'priority-high';
+ case 'medium': return 'priority-medium';
+ case 'low': return 'priority-low';
+ default: return 'priority-normal';
+ }
+ };
+
+ // ์๋ ํ์ (์ ์๋ก ๋ณํ)
+ const formatQuantity = (quantity) => {
+ if (quantity === null || quantity === undefined) return '-';
+ return Math.round(parseFloat(quantity) || 0).toString();
+ };
+
+ // SPECIAL ์์ฌ ์ค๋ช
์์ฑ (๋ธ๋๋, ๋ชจ๋ธ ํฌํจ)
+ const generateSpecialDescription = (material) => {
+ const parts = [];
+
+ if (material.brand) parts.push(`[${material.brand}]`);
+ if (material.description || material.item_name) {
+ parts.push(material.description || material.item_name);
+ }
+ if (material.model_number) parts.push(`(${material.model_number})`);
+
+ return parts.length > 0 ? parts.join(' ') : 'SPECIAL ์์ฌ';
+ };
+
+ if (materialsLoading || comparisonLoading || statusLoading) {
+ return ;
+ }
+
+ const error = materialsError || comparisonError || statusError;
+ if (error) {
+ return window.location.reload()} />;
+ }
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
โญ SPECIAL ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ ํน์ ์์ฌ ๋ฐ ๋ธ๋๋๋ณ SPECIAL ์์ฌ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+
+
+
+
+
+
+
+
+ {/* ์ปจํธ๋กค ์น์
*/}
+
+
+
๐ ์์ฌ ํํฉ ๋ฐ ํํฐ
+ {processingInfo && (
+
+ ์ ์ฒด: {processingInfo.total_materials}๊ฐ |
+ ๋ฆฌ๋น์ : {processingInfo.by_status.REVISION_MATERIAL || 0}๊ฐ |
+ ์ฌ๊ณ : {processingInfo.by_status.INVENTORY_MATERIAL || 0}๊ฐ |
+ ์ญ์ : {processingInfo.by_status.DELETED_MATERIAL || 0}๊ฐ |
+ ๋์ ์ฐ์ ์์: {processingInfo.by_priority?.high || 0}๊ฐ
+
+ )}
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ์ ํ๋ ์์ฌ ์ก์
*/}
+ {selectedMaterials.size > 0 && (
+
+
+ {selectedMaterials.size}๊ฐ ์ ํ๋จ
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* ์์ฌ ํ
์ด๋ธ */}
+
+
+
+ 0}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ />
+
+
์ํ
+
์ฐ์ ์์
+
์์ฌ๋ช
+
๋ธ๋๋
+
๋๋ฉด๋ช
+
์๋
+
๋จ์
+
์ก์
+
+
+
+ {filteredAndSortedMaterials.map((material) => (
+
+
+ handleMaterialSelect(material.id, e.target.checked)}
+ />
+
+
+
+ {material.processing_info?.display_status || 'NORMAL'}
+
+
+
+
+ {material.processing_info?.priority === 'high' ? '๐ด' :
+ material.processing_info?.priority === 'medium' ? '๐ก' :
+ material.processing_info?.priority === 'low' ? '๐ข' : 'โช'}
+
+
+
+
+
{generateSpecialDescription(material)}
+ {material.processing_info?.notes && (
+
{material.processing_info.notes}
+ )}
+ {material.subcategory && (
+
๐ {material.subcategory}
+ )}
+
+
+
{material.brand || '-'}
+
{material.drawing_name || '-'}
+
+
+ {formatQuantity(material.quantity)}
+
+ {material.processing_info?.quantity_change && (
+
+ ({material.processing_info.quantity_change > 0 ? '+' : ''}
+ {formatQuantity(material.processing_info.quantity_change)})
+
+ )}
+
+
{material.unit || 'EA'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {filteredAndSortedMaterials.length === 0 && (
+
+
์กฐ๊ฑด์ ๋ง๋ SPECIAL ์์ฌ๊ฐ ์์ต๋๋ค.
+
+ )}
+
+
+ {/* ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */}
+ {showConfirmDialog && (
+
setShowConfirmDialog(false)}
+ />
+ )}
+
+ );
+};
+
+export default SpecialRevisionPage;
diff --git a/frontend/src/pages/revision/SupportRevisionPage.jsx b/frontend/src/pages/revision/SupportRevisionPage.jsx
new file mode 100644
index 0000000..98cac4b
--- /dev/null
+++ b/frontend/src/pages/revision/SupportRevisionPage.jsx
@@ -0,0 +1,450 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionComparison } from '../../hooks/useRevisionComparison';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import './CategoryRevisionPage.css';
+
+const SupportRevisionPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [actionType, setActionType] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('description');
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [supportTypeFilter, setSupportTypeFilter] = useState('all');
+ const [loadRatingFilter, setLoadRatingFilter] = useState('all');
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'SUPPORT');
+
+ // ๋ฆฌ๋น์ ๋น๊ต ํ
+ const {
+ comparisonResult,
+ loading: comparisonLoading,
+ error: comparisonError,
+ performComparison,
+ getFilteredComparison
+ } = useRevisionComparison(fileId, previousFileId);
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ํํฐ๋ง ๋ฐ ์ ๋ ฌ๋ ์์ฌ ๋ชฉ๋ก
+ const filteredAndSortedMaterials = useMemo(() => {
+ if (!materials) return [];
+
+ let filtered = materials.filter(material => {
+ const matchesSearch = !searchTerm ||
+ material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.support_type?.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesStatus = statusFilter === 'all' ||
+ material.processing_info?.display_status === statusFilter;
+
+ const matchesSupportType = supportTypeFilter === 'all' ||
+ material.support_type === supportTypeFilter;
+
+ const matchesLoadRating = loadRatingFilter === 'all' ||
+ material.load_rating === loadRatingFilter;
+
+ return matchesSearch && matchesStatus && matchesSupportType && matchesLoadRating;
+ });
+
+ // ์ ๋ ฌ
+ filtered.sort((a, b) => {
+ let aValue = a[sortBy] || '';
+ let bValue = b[sortBy] || '';
+
+ if (typeof aValue === 'string') {
+ aValue = aValue.toLowerCase();
+ bValue = bValue.toLowerCase();
+ }
+
+ if (sortOrder === 'asc') {
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
+ } else {
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
+ }
+ });
+
+ return filtered;
+ }, [materials, searchTerm, statusFilter, supportTypeFilter, loadRatingFilter, sortBy, sortOrder]);
+
+ // ๊ณ ์ ๊ฐ๋ค ์ถ์ถ (ํํฐ ์ต์
์ฉ)
+ const uniqueValues = useMemo(() => {
+ if (!materials) return { supportTypes: [], loadRatings: [] };
+
+ const supportTypes = [...new Set(materials.map(m => m.support_type).filter(Boolean))];
+ const loadRatings = [...new Set(materials.map(m => m.load_rating).filter(Boolean))];
+
+ return { supportTypes, loadRatings };
+ }, [materials]);
+
+ // ์ด๊ธฐ ๋น๊ต ์ํ
+ useEffect(() => {
+ if (fileId && previousFileId && !comparisonResult) {
+ performComparison('SUPPORT');
+ }
+ }, [fileId, previousFileId, comparisonResult, performComparison]);
+
+ // ์์ฌ ์ ํ ์ฒ๋ฆฌ
+ const handleMaterialSelect = (materialId, isSelected) => {
+ const newSelected = new Set(selectedMaterials);
+ if (isSelected) {
+ newSelected.add(materialId);
+ } else {
+ newSelected.delete(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ handleMaterialSelection(materialId, isSelected);
+ };
+
+ // ์ ์ฒด ์ ํ/ํด์
+ const handleSelectAll = (isSelected) => {
+ if (isSelected) {
+ const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
+ setSelectedMaterials(allIds);
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
+ } else {
+ setSelectedMaterials(new Set());
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
+ }
+ };
+
+ // ์ก์
์คํ
+ const executeAction = async (action) => {
+ setActionType(action);
+ setShowConfirmDialog(true);
+ };
+
+ const confirmAction = async () => {
+ try {
+ await handleBulkAction(actionType, Array.from(selectedMaterials));
+ setSelectedMaterials(new Set());
+ setShowConfirmDialog(false);
+ await refreshMaterials();
+ } catch (error) {
+ console.error('Action failed:', error);
+ }
+ };
+
+ // ์ํ๋ณ ์์ ํด๋์ค
+ const getStatusClass = (status) => {
+ switch (status) {
+ case 'REVISION_MATERIAL': return 'status-revision';
+ case 'INVENTORY_MATERIAL': return 'status-inventory';
+ case 'DELETED_MATERIAL': return 'status-deleted';
+ case 'NEW_MATERIAL': return 'status-new';
+ default: return 'status-normal';
+ }
+ };
+
+ // ์๋ ํ์ (์ ์๋ก ๋ณํ)
+ const formatQuantity = (quantity) => {
+ if (quantity === null || quantity === undefined) return '-';
+ return Math.round(parseFloat(quantity) || 0).toString();
+ };
+
+ // SUPPORT ์์ฌ ์ค๋ช
์์ฑ (์ง์ง๋ ํ์
๊ณผ ํ์ค ์ ๋ณด ํฌํจ)
+ const generateSupportDescription = (material) => {
+ const parts = [];
+
+ if (material.support_type) parts.push(material.support_type);
+ if (material.pipe_size) parts.push(`${material.pipe_size}"`);
+ if (material.load_rating) parts.push(`${material.load_rating} ๋ฑ๊ธ`);
+ if (material.material_grade) parts.push(material.material_grade);
+
+ const baseDesc = material.description || material.item_name || 'SUPPORT';
+
+ return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
+ };
+
+ // ์น์ ์ ๋ณด ํ์
+ const formatDimensions = (material) => {
+ const dims = [];
+ if (material.length_mm) dims.push(`L${material.length_mm}`);
+ if (material.width_mm) dims.push(`W${material.width_mm}`);
+ if (material.height_mm) dims.push(`H${material.height_mm}`);
+
+ return dims.length > 0 ? dims.join('ร') : '-';
+ };
+
+ if (materialsLoading || comparisonLoading || statusLoading) {
+ return ;
+ }
+
+ const error = materialsError || comparisonError || statusError;
+ if (error) {
+ return window.location.reload()} />;
+ }
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
๐๏ธ SUPPORT ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ ์ง์ง๋ ํ์
๊ณผ ํ์ค๋ฑ๊ธ์ ๊ณ ๋ คํ SUPPORT ์์ฌ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+
+
+
+
+
+
+
+
+ {/* ์ปจํธ๋กค ์น์
*/}
+
+
+
๐ ์์ฌ ํํฉ ๋ฐ ํํฐ
+ {processingInfo && (
+
+ ์ ์ฒด: {processingInfo.total_materials}๊ฐ |
+ ๋ฆฌ๋น์ : {processingInfo.by_status.REVISION_MATERIAL || 0}๊ฐ |
+ ์ฌ๊ณ : {processingInfo.by_status.INVENTORY_MATERIAL || 0}๊ฐ |
+ ์ญ์ : {processingInfo.by_status.DELETED_MATERIAL || 0}๊ฐ
+
+ )}
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ์ ํ๋ ์์ฌ ์ก์
*/}
+ {selectedMaterials.size > 0 && (
+
+
+ {selectedMaterials.size}๊ฐ ์ ํ๋จ
+
+
+
+
+
+
+
+ )}
+
+
+ {/* ์์ฌ ํ
์ด๋ธ */}
+
+
+
+ 0}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ />
+
+
์ํ
+
์์ฌ๋ช
+
์ง์ง๋ ํ์
+
ํ์ดํ ํฌ๊ธฐ
+
ํ์ค๋ฑ๊ธ
+
์น์
+
์๋
+
๋จ์
+
์ก์
+
+
+
+ {filteredAndSortedMaterials.map((material) => (
+
+
+ handleMaterialSelect(material.id, e.target.checked)}
+ />
+
+
+
+ {material.processing_info?.display_status || 'NORMAL'}
+
+
+
+
+
{generateSupportDescription(material)}
+ {material.processing_info?.notes && (
+
{material.processing_info.notes}
+ )}
+ {material.load_capacity && (
+
๐ช ํ์ค์ฉ๋: {material.load_capacity}
+ )}
+
+
+
{material.support_type || '-'}
+
{material.pipe_size ? `${material.pipe_size}"` : '-'}
+
{material.load_rating || '-'}
+
{formatDimensions(material)}
+
+
+ {formatQuantity(material.quantity)}
+
+ {material.processing_info?.quantity_change && (
+
+ ({material.processing_info.quantity_change > 0 ? '+' : ''}
+ {formatQuantity(material.processing_info.quantity_change)})
+
+ )}
+
+
{material.unit || 'EA'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {filteredAndSortedMaterials.length === 0 && (
+
+
์กฐ๊ฑด์ ๋ง๋ SUPPORT ์์ฌ๊ฐ ์์ต๋๋ค.
+
+ )}
+
+
+ {/* ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */}
+ {showConfirmDialog && (
+
setShowConfirmDialog(false)}
+ />
+ )}
+
+ );
+};
+
+export default SupportRevisionPage;
diff --git a/frontend/src/pages/revision/UnclassifiedRevisionPage.jsx b/frontend/src/pages/revision/UnclassifiedRevisionPage.jsx
new file mode 100644
index 0000000..7e7611a
--- /dev/null
+++ b/frontend/src/pages/revision/UnclassifiedRevisionPage.jsx
@@ -0,0 +1,483 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionComparison } from '../../hooks/useRevisionComparison';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import './CategoryRevisionPage.css';
+
+const UnclassifiedRevisionPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [actionType, setActionType] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('description');
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [classificationFilter, setClassificationFilter] = useState('all');
+ const [showClassificationTools, setShowClassificationTools] = useState(false);
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'UNCLASSIFIED');
+
+ // ๋ฆฌ๋น์ ๋น๊ต ํ
+ const {
+ comparisonResult,
+ loading: comparisonLoading,
+ error: comparisonError,
+ performComparison,
+ getFilteredComparison
+ } = useRevisionComparison(fileId, previousFileId);
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ํํฐ๋ง ๋ฐ ์ ๋ ฌ๋ ์์ฌ ๋ชฉ๋ก
+ const filteredAndSortedMaterials = useMemo(() => {
+ if (!materials) return [];
+
+ let filtered = materials.filter(material => {
+ const matchesSearch = !searchTerm ||
+ material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesStatus = statusFilter === 'all' ||
+ material.processing_info?.display_status === statusFilter;
+
+ const matchesClassification = classificationFilter === 'all' ||
+ (classificationFilter === 'needs_classification' && material.classification_confidence < 0.5) ||
+ (classificationFilter === 'low_confidence' && material.classification_confidence >= 0.5 && material.classification_confidence < 0.8) ||
+ (classificationFilter === 'high_confidence' && material.classification_confidence >= 0.8);
+
+ return matchesSearch && matchesStatus && matchesClassification;
+ });
+
+ // ์ ๋ ฌ
+ filtered.sort((a, b) => {
+ let aValue = a[sortBy] || '';
+ let bValue = b[sortBy] || '';
+
+ if (typeof aValue === 'string') {
+ aValue = aValue.toLowerCase();
+ bValue = bValue.toLowerCase();
+ }
+
+ if (sortOrder === 'asc') {
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
+ } else {
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
+ }
+ });
+
+ return filtered;
+ }, [materials, searchTerm, statusFilter, classificationFilter, sortBy, sortOrder]);
+
+ // ๋ถ๋ฅ ์ ๋ขฐ๋๋ณ ํต๊ณ
+ const classificationStats = useMemo(() => {
+ if (!materials) return { needsClassification: 0, lowConfidence: 0, highConfidence: 0 };
+
+ return materials.reduce((stats, material) => {
+ const confidence = material.classification_confidence || 0;
+ if (confidence < 0.5) stats.needsClassification++;
+ else if (confidence < 0.8) stats.lowConfidence++;
+ else stats.highConfidence++;
+ return stats;
+ }, { needsClassification: 0, lowConfidence: 0, highConfidence: 0 });
+ }, [materials]);
+
+ // ์ด๊ธฐ ๋น๊ต ์ํ
+ useEffect(() => {
+ if (fileId && previousFileId && !comparisonResult) {
+ performComparison('UNCLASSIFIED');
+ }
+ }, [fileId, previousFileId, comparisonResult, performComparison]);
+
+ // ์์ฌ ์ ํ ์ฒ๋ฆฌ
+ const handleMaterialSelect = (materialId, isSelected) => {
+ const newSelected = new Set(selectedMaterials);
+ if (isSelected) {
+ newSelected.add(materialId);
+ } else {
+ newSelected.delete(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ handleMaterialSelection(materialId, isSelected);
+ };
+
+ // ์ ์ฒด ์ ํ/ํด์
+ const handleSelectAll = (isSelected) => {
+ if (isSelected) {
+ const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
+ setSelectedMaterials(allIds);
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
+ } else {
+ setSelectedMaterials(new Set());
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
+ }
+ };
+
+ // ์ก์
์คํ
+ const executeAction = async (action) => {
+ setActionType(action);
+ setShowConfirmDialog(true);
+ };
+
+ const confirmAction = async () => {
+ try {
+ await handleBulkAction(actionType, Array.from(selectedMaterials));
+ setSelectedMaterials(new Set());
+ setShowConfirmDialog(false);
+ await refreshMaterials();
+ } catch (error) {
+ console.error('Action failed:', error);
+ }
+ };
+
+ // ์ํ๋ณ ์์ ํด๋์ค
+ const getStatusClass = (status) => {
+ switch (status) {
+ case 'REVISION_MATERIAL': return 'status-revision';
+ case 'INVENTORY_MATERIAL': return 'status-inventory';
+ case 'DELETED_MATERIAL': return 'status-deleted';
+ case 'NEW_MATERIAL': return 'status-new';
+ default: return 'status-normal';
+ }
+ };
+
+ // ๋ถ๋ฅ ์ ๋ขฐ๋๋ณ ์์ ํด๋์ค
+ const getConfidenceClass = (confidence) => {
+ if (confidence < 0.5) return 'confidence-low';
+ if (confidence < 0.8) return 'confidence-medium';
+ return 'confidence-high';
+ };
+
+ // ์๋ ํ์ (์ ์๋ก ๋ณํ)
+ const formatQuantity = (quantity) => {
+ if (quantity === null || quantity === undefined) return '-';
+ return Math.round(parseFloat(quantity) || 0).toString();
+ };
+
+ // ๋ถ๋ฅ ์ ๋ขฐ๋ ํ์
+ const formatConfidence = (confidence) => {
+ if (confidence === null || confidence === undefined) return '0%';
+ return `${Math.round(confidence * 100)}%`;
+ };
+
+ // ๋ถ๋ฅ ์ ์ ์นดํ
๊ณ ๋ฆฌ ํ์
+ const getSuggestedCategory = (material) => {
+ // ๊ฐ๋จํ ํค์๋ ๊ธฐ๋ฐ ๋ถ๋ฅ ์ ์
+ const desc = (material.description || material.item_name || '').toLowerCase();
+
+ if (desc.includes('pipe') || desc.includes('ํ์ดํ')) return 'PIPE';
+ if (desc.includes('flange') || desc.includes('ํ๋์ง')) return 'FLANGE';
+ if (desc.includes('fitting') || desc.includes('ํผํ
')) return 'FITTING';
+ if (desc.includes('support') || desc.includes('์ง์ง๋')) return 'SUPPORT';
+ if (desc.includes('valve') || desc.includes('๋ฐธ๋ธ')) return 'SPECIAL';
+
+ return '์๋ ๋ถ๋ฅ ํ์';
+ };
+
+ if (materialsLoading || comparisonLoading || statusLoading) {
+ return ;
+ }
+
+ const error = materialsError || comparisonError || statusError;
+ if (error) {
+ return window.location.reload()} />;
+ }
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
โ UNCLASSIFIED ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ ๋ฏธ๋ถ๋ฅ ์์ฌ์ ๋ฆฌ๋น์ ์ฒ๋ฆฌ ๋ฐ ๋ถ๋ฅ ์์
+
+
+
+
+
+
+
+
+ {/* ๋ถ๋ฅ ํต๊ณ ์นด๋ */}
+
+
+
๐ ๋ถ๋ฅ ํํฉ
+
+
+
+
+
{classificationStats.needsClassification}
+
๋ถ๋ฅ ํ์
+
+
+
{classificationStats.lowConfidence}
+
๋ฎ์ ์ ๋ขฐ๋
+
+
+
{classificationStats.highConfidence}
+
๋์ ์ ๋ขฐ๋
+
+
+
+
+ {/* ์ปจํธ๋กค ์น์
*/}
+
+
+
๐ ์์ฌ ํํฉ ๋ฐ ํํฐ
+ {processingInfo && (
+
+ ์ ์ฒด: {processingInfo.total_materials}๊ฐ |
+ ๋ฆฌ๋น์ : {processingInfo.by_status.REVISION_MATERIAL || 0}๊ฐ |
+ ์ฌ๊ณ : {processingInfo.by_status.INVENTORY_MATERIAL || 0}๊ฐ |
+ ์ญ์ : {processingInfo.by_status.DELETED_MATERIAL || 0}๊ฐ
+
+ )}
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ์ ํ๋ ์์ฌ ์ก์
*/}
+ {selectedMaterials.size > 0 && (
+
+
+ {selectedMaterials.size}๊ฐ ์ ํ๋จ
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* ์์ฌ ํ
์ด๋ธ */}
+
+
+
+ 0}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ />
+
+
์ํ
+
์์ฌ๋ช
+
๋ถ๋ฅ ์ ๋ขฐ๋
+
์ ์ ์นดํ
๊ณ ๋ฆฌ
+
๋๋ฉด๋ช
+
์๋
+
๋จ์
+
์ก์
+
+
+
+ {filteredAndSortedMaterials.map((material) => (
+
+
+ handleMaterialSelect(material.id, e.target.checked)}
+ />
+
+
+
+ {material.processing_info?.display_status || 'NORMAL'}
+
+
+
+
+
{material.description || material.item_name || '์์ฌ๋ช
์์'}
+ {material.processing_info?.notes && (
+
{material.processing_info.notes}
+ )}
+
+
+
+
+ {formatConfidence(material.classification_confidence)}
+
+
+
+
+ {getSuggestedCategory(material)}
+
+
+
{material.drawing_name || '-'}
+
+
+ {formatQuantity(material.quantity)}
+
+ {material.processing_info?.quantity_change && (
+
+ ({material.processing_info.quantity_change > 0 ? '+' : ''}
+ {formatQuantity(material.processing_info.quantity_change)})
+
+ )}
+
+
{material.unit || 'EA'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {filteredAndSortedMaterials.length === 0 && (
+
+
์กฐ๊ฑด์ ๋ง๋ UNCLASSIFIED ์์ฌ๊ฐ ์์ต๋๋ค.
+ {materials && materials.length === 0 && (
+
๐ ๋ชจ๋ ์์ฌ๊ฐ ๋ถ๋ฅ๋์์ต๋๋ค!
+ )}
+
+ )}
+
+
+ {/* ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */}
+ {showConfirmDialog && (
+
setShowConfirmDialog(false)}
+ />
+ )}
+
+ );
+};
+
+export default UnclassifiedRevisionPage;
diff --git a/frontend/src/pages/revision/ValveRevisionPage.jsx b/frontend/src/pages/revision/ValveRevisionPage.jsx
new file mode 100644
index 0000000..6e7b819
--- /dev/null
+++ b/frontend/src/pages/revision/ValveRevisionPage.jsx
@@ -0,0 +1,453 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { useRevisionLogic } from '../../hooks/useRevisionLogic';
+import { useRevisionComparison } from '../../hooks/useRevisionComparison';
+import { useRevisionStatus } from '../../hooks/useRevisionStatus';
+import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
+import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
+import './CategoryRevisionPage.css';
+
+const ValveRevisionPage = ({
+ jobNo,
+ fileId,
+ previousFileId,
+ onNavigate,
+ user
+}) => {
+ const [selectedMaterials, setSelectedMaterials] = useState(new Set());
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [actionType, setActionType] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('description');
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [valveTypeFilter, setValveTypeFilter] = useState('all');
+ const [pressureRatingFilter, setPressureRatingFilter] = useState('all');
+ const [connectionFilter, setConnectionFilter] = useState('all');
+
+ // ๋ฆฌ๋น์ ๋ก์ง ํ
+ const {
+ materials,
+ loading: materialsLoading,
+ error: materialsError,
+ processingInfo,
+ handleMaterialSelection,
+ handleBulkAction,
+ refreshMaterials
+ } = useRevisionLogic(fileId, 'VALVE');
+
+ // ๋ฆฌ๋น์ ๋น๊ต ํ
+ const {
+ comparisonResult,
+ loading: comparisonLoading,
+ error: comparisonError,
+ performComparison,
+ getFilteredComparison
+ } = useRevisionComparison(fileId, previousFileId);
+
+ // ๋ฆฌ๋น์ ์ํ ํ
+ const {
+ revisionStatus,
+ loading: statusLoading,
+ error: statusError,
+ uploadNewRevision,
+ navigateToRevision
+ } = useRevisionStatus(jobNo, fileId);
+
+ // ํํฐ๋ง ๋ฐ ์ ๋ ฌ๋ ์์ฌ ๋ชฉ๋ก
+ const filteredAndSortedMaterials = useMemo(() => {
+ if (!materials) return [];
+
+ let filtered = materials.filter(material => {
+ const matchesSearch = !searchTerm ||
+ material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ material.valve_type?.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesStatus = statusFilter === 'all' ||
+ material.processing_info?.display_status === statusFilter;
+
+ const matchesValveType = valveTypeFilter === 'all' ||
+ material.valve_type === valveTypeFilter;
+
+ const matchesPressureRating = pressureRatingFilter === 'all' ||
+ material.pressure_rating === pressureRatingFilter;
+
+ const matchesConnection = connectionFilter === 'all' ||
+ material.connection_method === connectionFilter;
+
+ return matchesSearch && matchesStatus && matchesValveType && matchesPressureRating && matchesConnection;
+ });
+
+ // ์ ๋ ฌ
+ filtered.sort((a, b) => {
+ let aValue = a[sortBy] || '';
+ let bValue = b[sortBy] || '';
+
+ if (typeof aValue === 'string') {
+ aValue = aValue.toLowerCase();
+ bValue = bValue.toLowerCase();
+ }
+
+ if (sortOrder === 'asc') {
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
+ } else {
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
+ }
+ });
+
+ return filtered;
+ }, [materials, searchTerm, statusFilter, valveTypeFilter, pressureRatingFilter, connectionFilter, sortBy, sortOrder]);
+
+ // ๊ณ ์ ๊ฐ๋ค ์ถ์ถ (ํํฐ ์ต์
์ฉ)
+ const uniqueValues = useMemo(() => {
+ if (!materials) return { valveTypes: [], pressureRatings: [], connections: [] };
+
+ const valveTypes = [...new Set(materials.map(m => m.valve_type).filter(Boolean))];
+ const pressureRatings = [...new Set(materials.map(m => m.pressure_rating).filter(Boolean))];
+ const connections = [...new Set(materials.map(m => m.connection_method).filter(Boolean))];
+
+ return { valveTypes, pressureRatings, connections };
+ }, [materials]);
+
+ // ์ด๊ธฐ ๋น๊ต ์ํ
+ useEffect(() => {
+ if (fileId && previousFileId && !comparisonResult) {
+ performComparison('VALVE');
+ }
+ }, [fileId, previousFileId, comparisonResult, performComparison]);
+
+ // ์์ฌ ์ ํ ์ฒ๋ฆฌ
+ const handleMaterialSelect = (materialId, isSelected) => {
+ const newSelected = new Set(selectedMaterials);
+ if (isSelected) {
+ newSelected.add(materialId);
+ } else {
+ newSelected.delete(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ handleMaterialSelection(materialId, isSelected);
+ };
+
+ // ์ ์ฒด ์ ํ/ํด์
+ const handleSelectAll = (isSelected) => {
+ if (isSelected) {
+ const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
+ setSelectedMaterials(allIds);
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
+ } else {
+ setSelectedMaterials(new Set());
+ filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
+ }
+ };
+
+ // ์ก์
์คํ
+ const executeAction = async (action) => {
+ setActionType(action);
+ setShowConfirmDialog(true);
+ };
+
+ const confirmAction = async () => {
+ try {
+ await handleBulkAction(actionType, Array.from(selectedMaterials));
+ setSelectedMaterials(new Set());
+ setShowConfirmDialog(false);
+ await refreshMaterials();
+ } catch (error) {
+ console.error('Action failed:', error);
+ }
+ };
+
+ // ์ํ๋ณ ์์ ํด๋์ค
+ const getStatusClass = (status) => {
+ switch (status) {
+ case 'REVISION_MATERIAL': return 'status-revision';
+ case 'INVENTORY_MATERIAL': return 'status-inventory';
+ case 'DELETED_MATERIAL': return 'status-deleted';
+ case 'NEW_MATERIAL': return 'status-new';
+ default: return 'status-normal';
+ }
+ };
+
+ // ์๋ ํ์ (์ ์๋ก ๋ณํ)
+ const formatQuantity = (quantity) => {
+ if (quantity === null || quantity === undefined) return '-';
+ return Math.round(parseFloat(quantity) || 0).toString();
+ };
+
+ // VALVE ์ค๋ช
์์ฑ (๋ฐธ๋ธ ํ์
๊ณผ ์ฐ๊ฒฐ ๋ฐฉ์ ํฌํจ)
+ const generateValveDescription = (material) => {
+ const parts = [];
+
+ if (material.valve_type) parts.push(material.valve_type);
+ if (material.nominal_size) parts.push(material.nominal_size);
+ if (material.pressure_rating) parts.push(`${material.pressure_rating}#`);
+ if (material.connection_method) parts.push(material.connection_method);
+ if (material.material_grade) parts.push(material.material_grade);
+
+ const baseDesc = material.description || material.item_name || 'VALVE';
+
+ return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
+ };
+
+ if (materialsLoading || comparisonLoading || statusLoading) {
+ return ;
+ }
+
+ const error = materialsError || comparisonError || statusError;
+ if (error) {
+ return window.location.reload()} />;
+ }
+
+ return (
+
+ {/* ํค๋ */}
+
+
+
+
+
๐ฐ VALVE ๋ฆฌ๋น์ ๊ด๋ฆฌ
+
+ ๋ฐธ๋ธ ํ์
๊ณผ ์ฐ๊ฒฐ ๋ฐฉ์์ ๊ณ ๋ คํ VALVE ์์ฌ ๋ฆฌ๋น์ ์ฒ๋ฆฌ
+
+
+
+
+
+
+
+
+ {/* ์ปจํธ๋กค ์น์
*/}
+
+
+
๐ ์์ฌ ํํฉ ๋ฐ ํํฐ
+ {processingInfo && (
+
+ ์ ์ฒด: {processingInfo.total_materials}๊ฐ |
+ ๋ฆฌ๋น์ : {processingInfo.by_status.REVISION_MATERIAL || 0}๊ฐ |
+ ์ฌ๊ณ : {processingInfo.by_status.INVENTORY_MATERIAL || 0}๊ฐ |
+ ์ญ์ : {processingInfo.by_status.DELETED_MATERIAL || 0}๊ฐ
+
+ )}
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ์ ํ๋ ์์ฌ ์ก์
*/}
+ {selectedMaterials.size > 0 && (
+
+
+ {selectedMaterials.size}๊ฐ ์ ํ๋จ
+
+
+
+
+
+
+
+ )}
+
+
+ {/* ์์ฌ ํ
์ด๋ธ */}
+
+
+
+ 0}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ />
+
+
์ํ
+
์์ฌ๋ช
+
๋ฐธ๋ธ ํ์
+
ํฌ๊ธฐ
+
์๋ ฅ๋ฑ๊ธ
+
์ฐ๊ฒฐ๋ฐฉ์
+
์๋
+
๋จ์
+
์ก์
+
+
+
+ {filteredAndSortedMaterials.map((material) => (
+
+
+ handleMaterialSelect(material.id, e.target.checked)}
+ />
+
+
+
+ {material.processing_info?.display_status || 'NORMAL'}
+
+
+
+
+
{generateValveDescription(material)}
+ {material.processing_info?.notes && (
+
{material.processing_info.notes}
+ )}
+
+
+
{material.valve_type || '-'}
+
{material.nominal_size || '-'}
+
{material.pressure_rating ? `${material.pressure_rating}#` : '-'}
+
{material.connection_method || '-'}
+
+
+ {formatQuantity(material.quantity)}
+
+ {material.processing_info?.quantity_change && (
+
+ ({material.processing_info.quantity_change > 0 ? '+' : ''}
+ {formatQuantity(material.processing_info.quantity_change)})
+
+ )}
+
+
{material.unit || 'EA'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {filteredAndSortedMaterials.length === 0 && (
+
+
์กฐ๊ฑด์ ๋ง๋ VALVE ์์ฌ๊ฐ ์์ต๋๋ค.
+
+ )}
+
+
+ {/* ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */}
+ {showConfirmDialog && (
+
setShowConfirmDialog(false)}
+ />
+ )}
+
+ );
+};
+
+export default ValveRevisionPage;
diff --git a/frontend/src/utils/pipeUtils.js b/frontend/src/utils/pipeUtils.js
new file mode 100644
index 0000000..780b038
--- /dev/null
+++ b/frontend/src/utils/pipeUtils.js
@@ -0,0 +1,592 @@
+/**
+ * PIPE ์์คํ
๊ณตํต ์ ํธ๋ฆฌํฐ
+ *
+ * ๋ชจ๋ PIPE ๊ด๋ จ ์ปดํฌ๋ํธ์์ ๊ณตํต์ผ๋ก ์ฌ์ฉ๋๋ ํจ์๋ค์ ๋ชจ์๋์ ์ ํธ๋ฆฌํฐ ๋ชจ๋
+ */
+
+// ========== PIPE ์์ ์ ์ ==========
+
+export const PIPE_CONSTANTS = {
+ // ๊ธธ์ด ๊ด๋ จ
+ STANDARD_PIPE_LENGTH_MM: 6000, // ํ์ค ํ์ดํ ๊ธธ์ด (6M)
+ CUTTING_LOSS_PER_CUT_MM: 2, // ์ ๋จ๋น ์์ค (2mm)
+
+ // ๋ถ๋ฅ ๊ด๋ จ
+ PIPE_CATEGORY: "PIPE",
+
+ // ๋๋จ ์ฒ๋ฆฌ ํ์
+ END_PREPARATION_TYPES: {
+ "๋ฌด๊ฐ์ ": "PLAIN",
+ "ํ๊ฐ์ ": "SINGLE_BEVEL",
+ "์๊ฐ์ ": "DOUBLE_BEVEL"
+ },
+
+ // ์ํ ๊ด๋ จ
+ REVISION_TYPES: {
+ NO_REVISION: "no_revision",
+ PRE_CUTTING_PLAN: "pre_cutting_plan",
+ POST_CUTTING_PLAN: "post_cutting_plan"
+ },
+
+ CHANGE_TYPES: {
+ ADDED: "added",
+ REMOVED: "removed",
+ MODIFIED: "modified",
+ UNCHANGED: "unchanged"
+ },
+
+ // UI ๊ด๋ จ
+ STATUS_COLORS: {
+ success: '#28a745',
+ warning: '#ffc107',
+ danger: '#dc3545',
+ info: '#17a2b8',
+ primary: '#007bff'
+ }
+};
+
+// ========== ๊ณ์ฐ ์ ํธ๋ฆฌํฐ ==========
+
+export class PipeCalculator {
+ /**
+ * PIPE ๊ตฌ๋งค ์๋ ๊ณ์ฐ
+ * @param {Array} materials - PIPE ์์ฌ ๋ฆฌ์คํธ
+ * @returns {Object} ๊ณ์ฐ ๊ฒฐ๊ณผ
+ */
+ static calculatePipePurchaseQuantity(materials) {
+ let totalBomLength = 0;
+ let cuttingCount = 0;
+ const pipeDetails = [];
+
+ materials.forEach(material => {
+ const lengthMm = parseFloat(material.length || material.length_mm || 0);
+ const quantity = parseInt(material.quantity || 1);
+
+ if (lengthMm > 0) {
+ const totalLength = lengthMm * quantity;
+ totalBomLength += totalLength;
+ cuttingCount += quantity;
+
+ pipeDetails.push({
+ description: material.description || '',
+ originalDescription: material.original_description || '',
+ drawingName: material.drawing_name || '',
+ lineNo: material.line_no || '',
+ lengthMm: lengthMm,
+ quantity: quantity,
+ totalLength: totalLength
+ });
+ }
+ });
+
+ // ์ ๋จ ์์ค ๊ณ์ฐ
+ const cuttingLoss = cuttingCount * PIPE_CONSTANTS.CUTTING_LOSS_PER_CUT_MM;
+
+ // ์ด ํ์ ๊ธธ์ด
+ const requiredLength = totalBomLength + cuttingLoss;
+
+ // 6M ๋จ์๋ก ์ฌ๋ฆผ ๊ณ์ฐ
+ const pipesNeeded = requiredLength > 0 ? Math.ceil(requiredLength / PIPE_CONSTANTS.STANDARD_PIPE_LENGTH_MM) : 0;
+ const totalPurchaseLength = pipesNeeded * PIPE_CONSTANTS.STANDARD_PIPE_LENGTH_MM;
+ const wasteLength = pipesNeeded > 0 ? totalPurchaseLength - requiredLength : 0;
+
+ return {
+ bomQuantity: totalBomLength,
+ cuttingCount: cuttingCount,
+ cuttingLoss: cuttingLoss,
+ requiredLength: requiredLength,
+ pipesCount: pipesNeeded,
+ calculatedQty: totalPurchaseLength,
+ wasteLength: wasteLength,
+ utilizationRate: totalPurchaseLength > 0 ? (requiredLength / totalPurchaseLength * 100) : 0,
+ unit: 'mm',
+ pipeDetails: pipeDetails,
+ summary: {
+ totalMaterials: materials.length,
+ totalDrawings: new Set(materials.map(m => m.drawing_name).filter(Boolean)).size,
+ averageLength: materials.length > 0 ? totalBomLength / materials.length : 0
+ }
+ };
+ }
+
+ /**
+ * ๊ธธ์ด ๋ณํ๋ ๊ณ์ฐ
+ * @param {number} oldLength - ์ด์ ๊ธธ์ด
+ * @param {number} newLength - ์๋ก์ด ๊ธธ์ด
+ * @returns {Object} ๋ณํ๋ ์ ๋ณด
+ */
+ static calculateLengthDifference(oldLength, newLength) {
+ const difference = newLength - oldLength;
+ const percentage = oldLength > 0 ? (difference / oldLength * 100) : 0;
+
+ return {
+ oldLength: oldLength,
+ newLength: newLength,
+ difference: difference,
+ percentage: percentage,
+ changeType: difference > 0 ? 'increased' : difference < 0 ? 'decreased' : 'unchanged'
+ };
+ }
+}
+
+// ========== ๋น๊ต ์ ํธ๋ฆฌํฐ ==========
+
+export class PipeComparator {
+ /**
+ * ๋จ๊ด ๋ฐ์ดํฐ ๋น๊ต
+ * @param {Array} oldSegments - ์ด์ ๋จ๊ด ๋ฐ์ดํฐ
+ * @param {Array} newSegments - ์๋ก์ด ๋จ๊ด ๋ฐ์ดํฐ
+ * @returns {Object} ๋น๊ต ๊ฒฐ๊ณผ
+ */
+ static comparePipeSegments(oldSegments, newSegments) {
+ // ํค ์์ฑ ํจ์
+ const createSegmentKey = (segment) => {
+ return [
+ segment.drawing_name || '',
+ segment.material_grade || '',
+ segment.length || 0,
+ segment.end_preparation || '๋ฌด๊ฐ์ '
+ ].join('|');
+ };
+
+ // ๊ธฐ์กด ๋ฐ์ดํฐ๋ฅผ ํค๋ก ๋งคํ
+ const oldMap = {};
+ oldSegments.forEach(segment => {
+ const key = createSegmentKey(segment);
+ if (!oldMap[key]) oldMap[key] = [];
+ oldMap[key].push(segment);
+ });
+
+ // ์๋ก์ด ๋ฐ์ดํฐ๋ฅผ ํค๋ก ๋งคํ
+ const newMap = {};
+ newSegments.forEach(segment => {
+ const key = createSegmentKey(segment);
+ if (!newMap[key]) newMap[key] = [];
+ newMap[key].push(segment);
+ });
+
+ // ๋น๊ต ๊ฒฐ๊ณผ ์์ฑ
+ const changes = {
+ added: [],
+ removed: [],
+ modified: [],
+ unchanged: []
+ };
+
+ const allKeys = new Set([...Object.keys(oldMap), ...Object.keys(newMap)]);
+
+ allKeys.forEach(key => {
+ const oldCount = oldMap[key] ? oldMap[key].length : 0;
+ const newCount = newMap[key] ? newMap[key].length : 0;
+
+ if (oldCount === 0) {
+ // ์๋ก ์ถ๊ฐ๋ ํญ๋ชฉ
+ newMap[key].forEach(segment => {
+ changes.added.push({
+ ...segment,
+ changeType: 'added',
+ quantityChange: newCount
+ });
+ });
+ } else if (newCount === 0) {
+ // ์ญ์ ๋ ํญ๋ชฉ
+ oldMap[key].forEach(segment => {
+ changes.removed.push({
+ ...segment,
+ changeType: 'removed',
+ quantityChange: -oldCount
+ });
+ });
+ } else if (oldCount !== newCount) {
+ // ์๋์ด ๋ณ๊ฒฝ๋ ํญ๋ชฉ
+ const baseSegment = newMap[key][0] || oldMap[key][0];
+ changes.modified.push({
+ ...baseSegment,
+ changeType: 'modified',
+ oldQuantity: oldCount,
+ newQuantity: newCount,
+ quantityChange: newCount - oldCount
+ });
+ } else {
+ // ๋ณ๊ฒฝ๋์ง ์์ ํญ๋ชฉ
+ const baseSegment = newMap[key][0];
+ changes.unchanged.push({
+ ...baseSegment,
+ changeType: 'unchanged',
+ quantity: oldCount
+ });
+ }
+ });
+
+ // ํต๊ณ ์์ฑ
+ const stats = {
+ totalOld: oldSegments.length,
+ totalNew: newSegments.length,
+ addedCount: changes.added.length,
+ removedCount: changes.removed.length,
+ modifiedCount: changes.modified.length,
+ unchangedCount: changes.unchanged.length,
+ changedDrawings: new Set([
+ ...changes.added,
+ ...changes.removed,
+ ...changes.modified
+ ].map(item => item.drawing_name).filter(Boolean)).size
+ };
+
+ return {
+ changes: changes,
+ statistics: stats,
+ hasChanges: stats.addedCount + stats.removedCount + stats.modifiedCount > 0
+ };
+ }
+}
+
+// ========== ๊ฒ์ฆ ์ ํธ๋ฆฌํฐ ==========
+
+export class PipeValidator {
+ /**
+ * PIPE ๋ฐ์ดํฐ ์ ํจ์ฑ ๊ฒ์ฆ
+ * @param {Object} pipeData - ๊ฒ์ฆํ PIPE ๋ฐ์ดํฐ
+ * @returns {Object} ๊ฒ์ฆ ๊ฒฐ๊ณผ
+ */
+ static validatePipeData(pipeData) {
+ const errors = [];
+ const warnings = [];
+
+ // ํ์ ํ๋ ๊ฒ์ฆ
+ const requiredFields = ['drawing_name', 'material_grade', 'length'];
+ requiredFields.forEach(field => {
+ if (!pipeData[field]) {
+ errors.push(`ํ์ ํ๋ ๋๋ฝ: ${field}`);
+ }
+ });
+
+ // ๊ธธ์ด ๊ฒ์ฆ
+ const length = parseFloat(pipeData.length || 0);
+ if (length <= 0) {
+ errors.push("๊ธธ์ด๋ 0๋ณด๋ค ์ปค์ผ ํฉ๋๋ค");
+ } else if (length > 20000) { // 20m ์ด๊ณผ์ ๊ฒฝ๊ณ
+ warnings.push(`๊ธธ์ด๊ฐ ๋น์ ์์ ์ผ๋ก ํฝ๋๋ค: ${length}mm`);
+ }
+
+ // ์๋ ๊ฒ์ฆ
+ const quantity = parseInt(pipeData.quantity || 1);
+ if (quantity <= 0) {
+ errors.push("์๋์ 0๋ณด๋ค ์ปค์ผ ํฉ๋๋ค");
+ }
+
+ // ๋๋ฉด๋ช
๊ฒ์ฆ
+ const drawingName = pipeData.drawing_name || '';
+ if (drawingName === 'UNKNOWN') {
+ warnings.push("๋๋ฉด๋ช
์ด ์ง์ ๋์ง ์์์ต๋๋ค");
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors: errors,
+ warnings: warnings,
+ errorCount: errors.length,
+ warningCount: warnings.length
+ };
+ }
+
+ /**
+ * Cutting Plan ๋ฐ์ดํฐ ์ ์ฒด ๊ฒ์ฆ
+ * @param {Array} cuttingPlan - Cutting Plan ๋ฐ์ดํฐ ๋ฆฌ์คํธ
+ * @returns {Object} ๊ฒ์ฆ ๊ฒฐ๊ณผ
+ */
+ static validateCuttingPlanData(cuttingPlan) {
+ const totalErrors = [];
+ const totalWarnings = [];
+ let validItems = 0;
+
+ cuttingPlan.forEach((item, index) => {
+ const validation = PipeValidator.validatePipeData(item);
+
+ if (validation.isValid) {
+ validItems++;
+ } else {
+ validation.errors.forEach(error => {
+ totalErrors.push(`ํญ๋ชฉ ${index + 1}: ${error}`);
+ });
+ }
+
+ validation.warnings.forEach(warning => {
+ totalWarnings.push(`ํญ๋ชฉ ${index + 1}: ${warning}`);
+ });
+ });
+
+ return {
+ isValid: totalErrors.length === 0,
+ totalItems: cuttingPlan.length,
+ validItems: validItems,
+ invalidItems: cuttingPlan.length - validItems,
+ errors: totalErrors,
+ warnings: totalWarnings,
+ validationRate: cuttingPlan.length > 0 ? (validItems / cuttingPlan.length * 100) : 0
+ };
+ }
+}
+
+// ========== ํฌ๋งทํ
์ ํธ๋ฆฌํฐ ==========
+
+export class PipeFormatter {
+ /**
+ * ๊ธธ์ด ํฌ๋งทํ
+ * @param {number} lengthMm - ๊ธธ์ด (mm)
+ * @param {string} unit - ํ์ ๋จ์
+ * @returns {string} ํฌ๋งท๋ ๊ธธ์ด ๋ฌธ์์ด
+ */
+ static formatLength(lengthMm, unit = 'mm') {
+ if (unit === 'm') {
+ return `${(lengthMm / 1000).toFixed(3)}m`;
+ } else if (unit === 'mm') {
+ return `${Math.round(lengthMm)}mm`;
+ } else {
+ return `${lengthMm}`;
+ }
+ }
+
+ /**
+ * PIPE ์ค๋ช
ํฌ๋งทํ
+ * @param {Object} pipeData - PIPE ๋ฐ์ดํฐ
+ * @returns {string} ํฌ๋งท๋ ์ค๋ช
+ */
+ static formatPipeDescription(pipeData) {
+ const parts = [];
+
+ if (pipeData.material_grade) {
+ parts.push(pipeData.material_grade);
+ }
+
+ if (pipeData.main_nom) {
+ parts.push(pipeData.main_nom);
+ }
+
+ if (pipeData.schedule) {
+ parts.push(pipeData.schedule);
+ }
+
+ if (pipeData.length) {
+ parts.push(PipeFormatter.formatLength(pipeData.length));
+ }
+
+ return parts.length > 0 ? parts.join(' ') : 'PIPE';
+ }
+
+ /**
+ * ๋ณ๊ฒฝ์ฌํญ ์์ฝ ํฌ๋งทํ
+ * @param {Object} changes - ๋ณ๊ฒฝ์ฌํญ ๋์
๋๋ฆฌ
+ * @returns {string} ํฌ๋งท๋ ์์ฝ ๋ฌธ์์ด
+ */
+ static formatChangeSummary(changes) {
+ const summaryParts = [];
+
+ if (changes.added && changes.added.length > 0) {
+ summaryParts.push(`์ถ๊ฐ ${changes.added.length}๊ฐ`);
+ }
+
+ if (changes.removed && changes.removed.length > 0) {
+ summaryParts.push(`์ญ์ ${changes.removed.length}๊ฐ`);
+ }
+
+ if (changes.modified && changes.modified.length > 0) {
+ summaryParts.push(`์์ ${changes.modified.length}๊ฐ`);
+ }
+
+ if (summaryParts.length === 0) {
+ return "๋ณ๊ฒฝ์ฌํญ ์์";
+ }
+
+ return summaryParts.join(', ');
+ }
+
+ /**
+ * ์ซ์๋ฅผ ์ฒ ๋จ์ ์ฝค๋ง๋ก ํฌ๋งทํ
+ * @param {number} number - ํฌ๋งทํ ์ซ์
+ * @returns {string} ํฌ๋งท๋ ์ซ์ ๋ฌธ์์ด
+ */
+ static formatNumber(number) {
+ return new Intl.NumberFormat('ko-KR').format(number);
+ }
+
+ /**
+ * ๋ฐฑ๋ถ์จ ํฌ๋งทํ
+ * @param {number} value - ๋ฐฑ๋ถ์จ ๊ฐ
+ * @param {number} decimals - ์์์ ์๋ฆฌ์
+ * @returns {string} ํฌ๋งท๋ ๋ฐฑ๋ถ์จ ๋ฌธ์์ด
+ */
+ static formatPercentage(value, decimals = 1) {
+ return `${value.toFixed(decimals)}%`;
+ }
+}
+
+// ========== UI ์ ํธ๋ฆฌํฐ ==========
+
+export class PipeUIUtils {
+ /**
+ * ๋ณ๊ฒฝ ํ์
์ ๋ฐ๋ฅธ ์์ ๋ฐํ
+ * @param {string} changeType - ๋ณ๊ฒฝ ํ์
+ * @returns {string} CSS ์์ ์ฝ๋
+ */
+ static getChangeTypeColor(changeType) {
+ const colors = {
+ added: PIPE_CONSTANTS.STATUS_COLORS.success,
+ removed: PIPE_CONSTANTS.STATUS_COLORS.danger,
+ modified: PIPE_CONSTANTS.STATUS_COLORS.warning,
+ unchanged: PIPE_CONSTANTS.STATUS_COLORS.info
+ };
+
+ return colors[changeType] || PIPE_CONSTANTS.STATUS_COLORS.primary;
+ }
+
+ /**
+ * ๋ณ๊ฒฝ ํ์
์ ๋ฐ๋ฅธ ์์ด์ฝ ๋ฐํ
+ * @param {string} changeType - ๋ณ๊ฒฝ ํ์
+ * @returns {string} ์์ด์ฝ ํด๋์ค๋ช
+ */
+ static getChangeTypeIcon(changeType) {
+ const icons = {
+ added: 'โ',
+ removed: 'โ',
+ modified: '๐',
+ unchanged: 'โ
'
+ };
+
+ return icons[changeType] || 'โ';
+ }
+
+ /**
+ * ์ํ์ ๋ฐ๋ฅธ ๋ฐฐ์ง ์คํ์ผ ์์ฑ
+ * @param {string} status - ์ํ
+ * @returns {Object} ์คํ์ผ ๊ฐ์ฒด
+ */
+ static createStatusBadge(status) {
+ const styles = {
+ success: {
+ backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.success,
+ color: 'white'
+ },
+ warning: {
+ backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.warning,
+ color: 'black'
+ },
+ danger: {
+ backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.danger,
+ color: 'white'
+ },
+ info: {
+ backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.info,
+ color: 'white'
+ }
+ };
+
+ return {
+ padding: '4px 8px',
+ borderRadius: '4px',
+ fontSize: '12px',
+ fontWeight: 'bold',
+ display: 'inline-block',
+ ...styles[status]
+ };
+ }
+}
+
+// ========== ๋ฐ์ดํฐ ๋ณํ ์ ํธ๋ฆฌํฐ ==========
+
+export class PipeDataTransformer {
+ /**
+ * API ์๋ต ๋ฐ์ดํฐ๋ฅผ UI์ฉ ๋ฐ์ดํฐ๋ก ๋ณํ
+ * @param {Object} apiData - API ์๋ต ๋ฐ์ดํฐ
+ * @returns {Object} ๋ณํ๋ ๋ฐ์ดํฐ
+ */
+ static transformApiDataForUI(apiData) {
+ if (!apiData) return null;
+
+ return {
+ ...apiData,
+ formattedLength: PipeFormatter.formatLength(apiData.length || 0),
+ formattedDescription: PipeFormatter.formatPipeDescription(apiData),
+ changeTypeColor: PipeUIUtils.getChangeTypeColor(apiData.change_type),
+ changeTypeIcon: PipeUIUtils.getChangeTypeIcon(apiData.change_type)
+ };
+ }
+
+ /**
+ * UI ๋ฐ์ดํฐ๋ฅผ API ์ ์ก์ฉ ๋ฐ์ดํฐ๋ก ๋ณํ
+ * @param {Object} uiData - UI ๋ฐ์ดํฐ
+ * @returns {Object} ๋ณํ๋ ๋ฐ์ดํฐ
+ */
+ static transformUIDataForAPI(uiData) {
+ const apiData = { ...uiData };
+
+ // UI ์ ์ฉ ํ๋ ์ ๊ฑฐ
+ delete apiData.formattedLength;
+ delete apiData.formattedDescription;
+ delete apiData.changeTypeColor;
+ delete apiData.changeTypeIcon;
+
+ // ์ซ์ ํ์
๋ณํ
+ if (apiData.length) {
+ apiData.length = parseFloat(apiData.length);
+ }
+ if (apiData.quantity) {
+ apiData.quantity = parseInt(apiData.quantity);
+ }
+
+ return apiData;
+ }
+}
+
+// ========== ๋ก๊น
์ ํธ๋ฆฌํฐ ==========
+
+export class PipeLogger {
+ /**
+ * PIPE ์์
๋ก๊น
+ * @param {string} operation - ์์
์ ํ
+ * @param {string} jobNo - ์์
๋ฒํธ
+ * @param {Object} details - ์์ธ ์ ๋ณด
+ */
+ static logPipeOperation(operation, jobNo, details = {}) {
+ const message = `๐ง PIPE ${operation} | Job: ${jobNo}`;
+
+ if (Object.keys(details).length > 0) {
+ const detailParts = Object.entries(details).map(([key, value]) => `${key}: ${value}`);
+ console.log(`${message} | ${detailParts.join(', ')}`);
+ } else {
+ console.log(message);
+ }
+ }
+
+ /**
+ * PIPE ์ค๋ฅ ๋ก๊น
+ * @param {string} operation - ์์
์ ํ
+ * @param {string} jobNo - ์์
๋ฒํธ
+ * @param {Error} error - ์ค๋ฅ ๊ฐ์ฒด
+ * @param {Object} context - ์ปจํ
์คํธ ์ ๋ณด
+ */
+ static logPipeError(operation, jobNo, error, context = {}) {
+ const message = `โ PIPE ${operation} ์คํจ | Job: ${jobNo} | Error: ${error.message}`;
+
+ if (Object.keys(context).length > 0) {
+ const contextParts = Object.entries(context).map(([key, value]) => `${key}: ${value}`);
+ console.error(`${message} | Context: ${contextParts.join(', ')}`);
+ } else {
+ console.error(message);
+ }
+
+ console.error(error);
+ }
+}
+
+// ๊ธฐ๋ณธ export
+export default {
+ PIPE_CONSTANTS,
+ PipeCalculator,
+ PipeComparator,
+ PipeValidator,
+ PipeFormatter,
+ PipeUIUtils,
+ PipeDataTransformer,
+ PipeLogger
+};