feat(WEB): 절곡품 선생산→재고적재 Phase 3 - 수주 절곡 재고 현황 표시

- orders/actions: checkBendingStock() 서버 액션 추가
- orders/index: BendingStockItem 타입 및 함수 export
- 수주 상세페이지: 절곡품 재고 현황 카드 (충족/부족 뱃지, 테이블)
  - 수주확정 이후 상태에서 자동 로드
This commit is contained in:
2026-02-21 16:26:14 +09:00
parent f5fbe1efc8
commit b5f5ce591f
3 changed files with 239 additions and 7 deletions

View File

@@ -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<OrderDocumentType>("contract");
// 절곡 재고 현황
const [bendingStock, setBendingStock] = useState<BendingStockItem[]>([]);
const [bendingStockLoading, setBendingStockLoading] = useState(false);
// 제품-부품 트리 확장 상태 (key: "floor-symbol")
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(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() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg"></CardTitle>
{order.products && order.products.length > 0 && (
{((order.nodes && order.nodes.length > 0) || (order.products && order.products.length > 0)) && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
@@ -573,7 +604,88 @@ export default function OrderDetailPage() {
</div>
</CardHeader>
<CardContent>
{order.products && order.products.length > 0 ? (
{/* 노드 기반 렌더링 (개소별 items가 정확히 분배된 경우) */}
{order.nodes && order.nodes.length > 0 && order.nodes.some(n => n.items && n.items.length > 0) ? (
<div className="space-y-3">
{order.nodes.map((node, nodeIndex) => {
const productKey = `node-${node.id}`;
const isExpanded = expandedProducts.has(productKey);
const product = order.products?.[nodeIndex];
const nodeWidth = (node.options?.width as number) || product?.openWidth;
const nodeHeight = (node.options?.height as number) || product?.openHeight;
const productName = product?.productName || node.name || `개소 ${nodeIndex + 1}`;
const nodeItems = node.items || [];
return (
<div
key={node.id}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* 제품 헤더 (클릭하면 확장/축소) */}
<button
type="button"
onClick={() => toggleProduct(productKey)}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<Package className="h-5 w-5 text-blue-600" />
<div>
<span className="font-medium">{productName}</span>
{nodeWidth && nodeHeight && (
<span className="ml-2 text-sm text-gray-500">
({nodeWidth} × {nodeHeight})
</span>
)}
</div>
</div>
<span className="text-sm text-gray-500">
{nodeItems.length}
</span>
</button>
{/* 부품 목록 (확장 시 표시) */}
{isExpanded && (
<div className="border-t">
{nodeItems.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodeItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-4 text-center text-gray-400 text-sm">
</div>
)}
</div>
)}
</div>
);
})}
</div>
) : order.products && order.products.length > 0 ? (
<div className="space-y-3">
{order.products.map((product, productIndex) => {
const productKey = `${product.floor || ""}-${product.code || ""}`;
@@ -653,8 +765,10 @@ export default function OrderDetailPage() {
</CardContent>
</Card>
{/* 기타부품 (아코디언) */}
{/* 기타부품 (아코디언) - 노드 기반이 아닌 경우만 표시 */}
{(() => {
// 노드 기반 렌더링인 경우 기타부품 불필요
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() {
</div>
</CardContent>
</Card>
{/* 절곡 재고 현황 */}
{bendingStock.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5" />
{bendingStock.some(s => s.status === 'insufficient') && (
<BadgeSm className="bg-red-100 text-red-700 border-red-200"></BadgeSm>
)}
{bendingStock.every(s => s.status === 'sufficient') && (
<BadgeSm className="bg-green-100 text-green-700 border-green-200"></BadgeSm>
)}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bendingStock.map((item) => (
<TableRow key={item.itemId}>
<TableCell className="font-mono text-sm">{item.itemCode}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-center">{formatQuantity(item.neededQty, item.unit)}</TableCell>
<TableCell className="text-center">{formatQuantity(item.availableQty, item.unit)}</TableCell>
<TableCell className={`text-center ${item.shortfallQty > 0 ? 'text-red-600 font-semibold' : ''}`}>
{item.shortfallQty > 0 ? formatQuantity(item.shortfallQty, item.unit) : '-'}
</TableCell>
<TableCell className="text-center">
{item.status === 'sufficient' ? (
<BadgeSm className="bg-green-100 text-green-700 border-green-200"></BadgeSm>
) : (
<BadgeSm className="bg-red-100 text-red-700 border-red-200"></BadgeSm>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{bendingStockLoading && (
<Card>
<CardContent className="py-4 text-center text-muted-foreground text-sm">
...
</CardContent>
</Card>
)}
</div>
);
}, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems]);
}, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems, bendingStock, bendingStockLoading]);
// 견적 수정 핸들러
const handleEditQuote = () => {

View File

@@ -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<ApiBendingStockItem[]>({
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로 조회)
* 견적 상세페이지에서 수주등록 버튼 클릭 시 사용

View File

@@ -15,8 +15,10 @@ export {
createProductionOrder,
revertProductionOrder,
revertOrderConfirmation,
checkBendingStock,
getQuoteByIdForSelect,
type Order,
type BendingStockItem,
type OrderItem as OrderItemApi,
type OrderFormData as OrderApiFormData,
type OrderItemFormData,