feat(WEB): 절곡품 선생산→재고적재 Phase 3 - 수주 절곡 재고 현황 표시
- orders/actions: checkBendingStock() 서버 액션 추가 - orders/index: BendingStockItem 타입 및 함수 export - 수주 상세페이지: 절곡품 재고 현황 카드 (충족/부족 뱃지, 테이블) - 수주확정 이후 상태에서 자동 로드
This commit is contained in:
@@ -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 = () => {
|
||||
|
||||
@@ -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로 조회)
|
||||
* 견적 상세페이지에서 수주등록 버튼 클릭 시 사용
|
||||
|
||||
@@ -15,8 +15,10 @@ export {
|
||||
createProductionOrder,
|
||||
revertProductionOrder,
|
||||
revertOrderConfirmation,
|
||||
checkBendingStock,
|
||||
getQuoteByIdForSelect,
|
||||
type Order,
|
||||
type BendingStockItem,
|
||||
type OrderItem as OrderItemApi,
|
||||
type OrderFormData as OrderApiFormData,
|
||||
type OrderItemFormData,
|
||||
|
||||
Reference in New Issue
Block a user