From b5f5ce591fe180416544df3b0fdbdb603e928b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 16:26:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=ED=92=88=20?= =?UTF-8?q?=EC=84=A0=EC=83=9D=EC=82=B0=E2=86=92=EC=9E=AC=EA=B3=A0=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20Phase=203=20-=20=EC=88=98=EC=A3=BC=20=EC=A0=88?= =?UTF-8?q?=EA=B3=A1=20=EC=9E=AC=EA=B3=A0=20=ED=98=84=ED=99=A9=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orders/actions: checkBendingStock() 서버 액션 추가 - orders/index: BendingStockItem 타입 및 함수 export - 수주 상세페이지: 절곡품 재고 현황 카드 (충족/부족 뱃지, 테이블) - 수주확정 이후 상태에서 자동 로드 --- .../order-management-sales/[id]/page.tsx | 186 +++++++++++++++++- src/components/orders/actions.ts | 58 ++++++ src/components/orders/index.ts | 2 + 3 files changed, 239 insertions(+), 7 deletions(-) diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx index a897ca5d..1b183728 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx @@ -70,8 +70,10 @@ import { revertOrderConfirmation, deleteOrder, createProductionOrder, + checkBendingStock, type Order, type OrderStatus, + type BendingStockItem, } from "@/components/orders"; import { sendSalesOrderNotification } from "@/lib/actions/fcm"; import { OrderSalesDetailEdit } from "@/components/orders/OrderSalesDetailEdit"; @@ -173,6 +175,10 @@ export default function OrderDetailPage() { const [documentModalOpen, setDocumentModalOpen] = useState(false); const [documentType, setDocumentType] = useState("contract"); + // 절곡 재고 현황 + const [bendingStock, setBendingStock] = useState([]); + const [bendingStockLoading, setBendingStockLoading] = useState(false); + // 제품-부품 트리 확장 상태 (key: "floor-symbol") const [expandedProducts, setExpandedProducts] = useState>(new Set()); @@ -199,6 +205,28 @@ export default function OrderDetailPage() { loadOrder(); }, [orderId]); + // 절곡 재고 현황 로드 (수주확정 이후 상태에서만) + useEffect(() => { + if (!order) return; + const confirmableStatuses: OrderStatus[] = ["order_confirmed", "production_ordered", "in_production"]; + if (!confirmableStatuses.includes(order.status)) return; + + async function loadBendingStock() { + setBendingStockLoading(true); + try { + const result = await checkBendingStock(orderId); + if (result.success && result.data) { + setBendingStock(result.data); + } + } catch { + // 조용히 실패 (필수 기능 아님) + } finally { + setBendingStockLoading(false); + } + } + loadBendingStock(); + }, [order?.status, orderId]); + const handleBack = () => { router.push("/sales/order-management-sales"); }; @@ -449,10 +477,13 @@ export default function OrderDetailPage() { // 모든 제품 확장 const expandAllProducts = () => { - if (order?.products) { - const allKeys = order.products.map((p) => `${p.floor || ""}-${p.code || ""}`); - setExpandedProducts(new Set(allKeys)); + const keys: string[] = []; + if (order?.nodes && order.nodes.length > 0 && order.nodes.some(n => n.items && n.items.length > 0)) { + order.nodes.forEach((n) => keys.push(`node-${n.id}`)); + } else if (order?.products) { + order.products.forEach((p) => keys.push(`${p.floor || ""}-${p.code || ""}`)); } + setExpandedProducts(new Set(keys)); }; // 모든 제품 축소 @@ -549,7 +580,7 @@ export default function OrderDetailPage() {
제품내용 - {order.products && order.products.length > 0 && ( + {((order.nodes && order.nodes.length > 0) || (order.products && order.products.length > 0)) && (
+ + {/* 부품 목록 (확장 시 표시) */} + {isExpanded && ( +
+ {nodeItems.length > 0 ? ( + + + + 순번 + 품목명 + 규격 + 수량 + 단위 + + + + {nodeItems.map((item, index) => ( + + {index + 1} + {item.itemName} + {item.spec || "-"} + {formatQuantity(item.quantity, item.unit)} + {item.unit || "-"} + + ))} + +
+ ) : ( +
+ 연결된 부품이 없습니다 +
+ )} +
+ )} +
+ ); + })} +
+ ) : order.products && order.products.length > 0 ? (
{order.products.map((product, productIndex) => { const productKey = `${product.floor || ""}-${product.code || ""}`; @@ -653,8 +765,10 @@ export default function OrderDetailPage() { - {/* 기타부품 (아코디언) */} + {/* 기타부품 (아코디언) - 노드 기반이 아닌 경우만 표시 */} {(() => { + // 노드 기반 렌더링인 경우 기타부품 불필요 + if (order.nodes && order.nodes.length > 0 && order.nodes.some(n => n.items && n.items.length > 0)) return null; const unmatchedItems = getUnmatchedItems(); if (unmatchedItems.length === 0) return null; return ( @@ -737,9 +851,67 @@ export default function OrderDetailPage() {
+ {/* 절곡 재고 현황 */} + {bendingStock.length > 0 && ( + + + + + 절곡품 재고 현황 + {bendingStock.some(s => s.status === 'insufficient') && ( + 부족 + )} + {bendingStock.every(s => s.status === 'sufficient') && ( + 충족 + )} + + + + + + + 품목코드 + 품목명 + 필요수량 + 가용재고 + 부족수량 + 상태 + + + + {bendingStock.map((item) => ( + + {item.itemCode} + {item.itemName} + {formatQuantity(item.neededQty, item.unit)} + {formatQuantity(item.availableQty, item.unit)} + 0 ? 'text-red-600 font-semibold' : ''}`}> + {item.shortfallQty > 0 ? formatQuantity(item.shortfallQty, item.unit) : '-'} + + + {item.status === 'sufficient' ? ( + 충족 + ) : ( + 부족 + )} + + + ))} + +
+
+
+ )} + {bendingStockLoading && ( + + + 절곡품 재고 확인 중... + + + )} ); - }, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems]); + }, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems, bendingStock, bendingStockLoading]); // 견적 수정 핸들러 const handleEditQuote = () => { diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index 6becba82..0d168503 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -1144,6 +1144,64 @@ export async function revertOrderConfirmation(orderId: string): Promise<{ }; } +/** + * 절곡 BOM 품목 재고 현황 조회 + */ +export interface BendingStockItem { + itemId: number; + itemCode: string; + itemName: string; + unit: string; + neededQty: number; + stockQty: number; + reservedQty: number; + availableQty: number; + shortfallQty: number; + status: 'sufficient' | 'insufficient'; +} + +export async function checkBendingStock(orderId: string): Promise<{ + success: boolean; + data?: BendingStockItem[]; + error?: string; + __authError?: boolean; +}> { + interface ApiBendingStockItem { + item_id: number; + item_code: string; + item_name: string; + unit: string; + needed_qty: number; + stock_qty: number; + reserved_qty: number; + available_qty: number; + shortfall_qty: number; + status: string; + } + + const result = await executeServerAction({ + url: buildApiUrl(`/api/v1/orders/${orderId}/bending-stock`), + errorMessage: '절곡 재고 현황 조회에 실패했습니다.', + }); + if (result.__authError) return { success: false, __authError: true }; + if (!result.success || !result.data) return { success: false, error: result.error }; + + const items: BendingStockItem[] = result.data.map((item) => ({ + itemId: item.item_id, + itemCode: item.item_code, + itemName: item.item_name, + unit: item.unit, + neededQty: item.needed_qty, + stockQty: item.stock_qty, + reservedQty: item.reserved_qty, + availableQty: item.available_qty, + shortfallQty: item.shortfall_qty, + status: item.status as 'sufficient' | 'insufficient', + })); + + return { success: true, data: items }; +} + /** * 수주 변환용 단일 견적 조회 (ID로 조회) * 견적 상세페이지에서 수주등록 버튼 클릭 시 사용 diff --git a/src/components/orders/index.ts b/src/components/orders/index.ts index da0f964e..389551ec 100644 --- a/src/components/orders/index.ts +++ b/src/components/orders/index.ts @@ -15,8 +15,10 @@ export { createProductionOrder, revertProductionOrder, revertOrderConfirmation, + checkBendingStock, getQuoteByIdForSelect, type Order, + type BendingStockItem, type OrderItem as OrderItemApi, type OrderFormData as OrderApiFormData, type OrderItemFormData,