From 473cfa005262ce5a2841d1c50dc41564b05dc535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Feb 2026 20:22:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EC=88=98=EC=A3=BC=EA=B4=80=EB=A6=AC]?= =?UTF-8?q?=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=EB=B3=84=20=EA=B7=B8=EB=A3=B9=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderNode 인터페이스 + ApiOrderNode 타입 정의 (actions.ts) - transformNodeApiToFrontend 변환 함수 (재귀 children/items 포함) - Order 타입에 nodes 필드, ApiOrder에 root_nodes 필드 추가 - OrderSalesDetailView: 노드 존재 시 개소별 카드 UI, 없으면 레거시 플랫 테이블 - OrderNodeCard: 접기/펼치기, 노드 상태 뱃지, 자재 테이블, 재귀 하위 노드 지원 - index.ts에 OrderNode export 추가 --- .../orders/OrderSalesDetailView.tsx | 285 ++++++++++++++---- src/components/orders/actions.ts | 58 ++++ src/components/orders/index.ts | 1 + 3 files changed, 279 insertions(+), 65 deletions(-) diff --git a/src/components/orders/OrderSalesDetailView.tsx b/src/components/orders/OrderSalesDetailView.tsx index d48f6304..4803d8e9 100644 --- a/src/components/orders/OrderSalesDetailView.tsx +++ b/src/components/orders/OrderSalesDetailView.tsx @@ -30,6 +30,9 @@ import { FileCheck, ClipboardList, CheckCircle2, + ChevronDown, + ChevronRight, + MapPin, } from "lucide-react"; import { toast } from "sonner"; import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate"; @@ -58,6 +61,7 @@ import { getOrderById, updateOrderStatus, type Order, + type OrderNode, type OrderStatus, } from "@/components/orders"; import { ServerErrorPage } from "@/components/common/ServerErrorPage"; @@ -96,6 +100,119 @@ function InfoItem({ label, value }: { label: string; value: string }) { ); } +// 노드 상태 뱃지 +const NODE_STATUS_CONFIG: Record = { + PENDING: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" }, + CONFIRMED: { label: "확정", className: "bg-blue-100 text-blue-700 border-blue-200" }, + IN_PRODUCTION: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" }, + PRODUCED: { label: "생산완료", className: "bg-blue-600 text-white border-blue-600" }, + SHIPPED: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" }, + COMPLETED: { label: "완료", className: "bg-gray-500 text-white border-gray-500" }, + CANCELLED: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" }, +}; + +// 노드별 카드 컴포넌트 (재귀 지원) +function OrderNodeCard({ node, depth = 0 }: { node: OrderNode; depth?: number }) { + const [isOpen, setIsOpen] = useState(true); + const statusConfig = NODE_STATUS_CONFIG[node.statusCode] || NODE_STATUS_CONFIG.PENDING; + const options = node.options || {}; + + return ( +
0 ? 'ml-6' : ''}`}> + {/* 노드 헤더 */} + + + {/* 노드 내용 (접기/펼치기) */} + {isOpen && ( +
+ {/* 해당 노드의 자재 테이블 */} + {node.items.length > 0 && ( +
+ + + + 순번 + 품목코드 + 품명 + 규격 + 수량 + 단위 + 단가 + 금액 + + + + {node.items.map((item, index) => ( + + {index + 1} + + + {item.itemCode} + + + {item.itemName} + {item.spec} + {item.quantity} + {item.unit} + + {formatAmount(item.unitPrice)}원 + + + {formatAmount(item.amount ?? 0)}원 + + + ))} + +
+
+ )} + + {/* 하위 노드 재귀 */} + {node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + interface OrderSalesDetailViewProps { orderId: string; } @@ -375,75 +492,113 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) { )} {/* 제품 내역 */} - - - 제품 내역 - - -
- - - - 순번 - 품목코드 - 품명 - - 부호 - 규격 - 수량 - 단위 - 단가 - 금액 - - - - {(order.items || []).map((item, index) => ( - - {index + 1} - - - {item.itemCode} - - - {item.itemName} - {item.type || "-"} - {item.symbol || "-"} - {item.spec} - {item.quantity} - {item.unit} - - {formatAmount(item.unitPrice)}원 - - - {formatAmount(item.amount ?? 0)}원 - - - ))} - -
-
+ {order.nodes && order.nodes.length > 0 ? ( + /* 노드별 그룹 표시 */ + + + + + 개소별 내역 ({order.nodes.length}개소) + + + + {order.nodes.map((node) => ( + + ))} - {/* 합계 */} -
-
- 소계: - - {formatAmount(order.subtotal ?? 0)}원 - + {/* 합계 */} +
+
+ 소계: + + {formatAmount(order.subtotal ?? 0)}원 + +
+
+ 할인율: + {order.discountRate}% +
+
+ 총금액: + + {formatAmount(order.totalAmount ?? 0)}원 + +
-
- 할인율: - {order.discountRate}% + + + ) : ( + /* 레거시 플랫 테이블 (노드 없는 기존 수주) */ + + + 제품 내역 + + +
+ + + + 순번 + 품목코드 + 품명 + + 부호 + 규격 + 수량 + 단위 + 단가 + 금액 + + + + {(order.items || []).map((item, index) => ( + + {index + 1} + + + {item.itemCode} + + + {item.itemName} + {item.type || "-"} + {item.symbol || "-"} + {item.spec} + {item.quantity} + {item.unit} + + {formatAmount(item.unitPrice)}원 + + + {formatAmount(item.amount ?? 0)}원 + + + ))} + +
-
- 총금액: - - {formatAmount(order.totalAmount ?? 0)}원 - + + {/* 합계 */} +
+
+ 소계: + + {formatAmount(order.subtotal ?? 0)}원 + +
+
+ 할인율: + {order.discountRate}% +
+
+ 총금액: + + {formatAmount(order.totalAmount ?? 0)}원 + +
-
-
-
+ + + )}
); }, [order, openDocumentModal]); diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index 890ae5f7..c5c16352 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -45,12 +45,14 @@ interface ApiOrder { updated_at: string; client?: ApiClient | null; items?: ApiOrderItem[]; + root_nodes?: ApiOrderNode[]; quote?: ApiQuote | null; } interface ApiOrderItem { id: number; order_id: number; + order_node_id: number | null; item_id: number | null; item_code: string | null; item_name: string; @@ -67,6 +69,24 @@ interface ApiOrderItem { sort_order: number; } +interface ApiOrderNode { + id: number; + order_id: number; + parent_id: number | null; + node_type: string; + code: string; + name: string; + status_code: string; + quantity: number; + unit_price: number; + total_price: number; + options: Record | null; + depth: number; + sort_order: number; + children?: ApiOrderNode[]; + items?: ApiOrderItem[]; +} + interface ApiClient { id: number; name: string; @@ -248,6 +268,7 @@ export interface Order { remarks?: string; note?: string; items?: OrderItem[]; + nodes?: OrderNode[]; // 목록 페이지용 추가 필드 productName?: string; // 제품명 (첫 번째 품목명) receiverAddress?: string; // 수신주소 @@ -278,6 +299,23 @@ export interface Order { }>; } +export interface OrderNode { + id: number; + parentId: number | null; + nodeType: string; + code: string; + name: string; + statusCode: string; + quantity: number; + unitPrice: number; + totalPrice: number; + options: Record | null; + depth: number; + sortOrder: number; + children: OrderNode[]; + items: OrderItem[]; +} + export interface OrderItem { id: string; itemId?: number; @@ -502,6 +540,7 @@ function transformApiToFrontend(apiData: ApiOrder): Order { remarks: apiData.remarks ?? undefined, note: apiData.note ?? undefined, items: apiData.items?.map(transformItemApiToFrontend) || [], + nodes: apiData.root_nodes?.map(transformNodeApiToFrontend) || [], // 목록 페이지용 추가 필드 productName: apiData.items?.[0]?.item_name ?? undefined, receiverAddress: apiData.options?.shipping_address ?? undefined, @@ -534,6 +573,25 @@ function transformApiToFrontend(apiData: ApiOrder): Order { }; } +function transformNodeApiToFrontend(apiNode: ApiOrderNode): OrderNode { + return { + id: apiNode.id, + parentId: apiNode.parent_id, + nodeType: apiNode.node_type, + code: apiNode.code, + name: apiNode.name, + statusCode: apiNode.status_code, + quantity: apiNode.quantity, + unitPrice: apiNode.unit_price, + totalPrice: apiNode.total_price, + options: apiNode.options, + depth: apiNode.depth, + sortOrder: apiNode.sort_order, + children: (apiNode.children || []).map(transformNodeApiToFrontend), + items: (apiNode.items || []).map(transformItemApiToFrontend), + }; +} + function transformItemApiToFrontend(apiItem: ApiOrderItem): OrderItem { return { id: String(apiItem.id), diff --git a/src/components/orders/index.ts b/src/components/orders/index.ts index fa65a67a..da0f964e 100644 --- a/src/components/orders/index.ts +++ b/src/components/orders/index.ts @@ -21,6 +21,7 @@ export { type OrderFormData as OrderApiFormData, type OrderItemFormData, type OrderStats, + type OrderNode, type OrderStatus, type QuotationForSelect, type QuotationItem,