From 948dc1e1ab0c3c8ebbb383ccfa29b73be75793f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sun, 22 Mar 2026 10:02:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EC=9E=90=EC=9E=AC=ED=88=AC=EC=9E=85]?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=A0=20=EC=97=86=EB=8A=94=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=EC=97=90=20=EC=9E=AC=EA=B3=A0=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkerScreen/MaterialInputModal.tsx | 87 ++++++++++++++++++- .../production/WorkerScreen/actions.ts | 49 +++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/components/production/WorkerScreen/MaterialInputModal.tsx b/src/components/production/WorkerScreen/MaterialInputModal.tsx index e53f3d15..6e4e8d01 100644 --- a/src/components/production/WorkerScreen/MaterialInputModal.tsx +++ b/src/components/production/WorkerScreen/MaterialInputModal.tsx @@ -14,7 +14,7 @@ */ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Loader2, Check, Zap, Info } from 'lucide-react'; +import { Loader2, Check, Zap, Info, Search, X } from 'lucide-react'; import { ContentSkeleton } from '@/components/ui/skeleton'; import { Dialog, @@ -34,7 +34,7 @@ import { } from '@/components/ui/table'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; -import { getMaterialsForWorkOrder, registerMaterialInput, getMaterialsForItem, registerMaterialInputForItem, type MaterialForInput, type MaterialForItemInput } from './actions'; +import { getMaterialsForWorkOrder, registerMaterialInput, getMaterialsForItem, registerMaterialInputForItem, searchStockByCode, type MaterialForInput, type MaterialForItemInput, type StockSearchResult } from './actions'; import type { WorkOrder } from '../ProductionDashboard/types'; import type { MaterialInput } from './types'; import { formatNumber } from '@/lib/utils/amount'; @@ -89,6 +89,20 @@ export function MaterialInputModal({ const [isSubmitting, setIsSubmitting] = useState(false); const materialsLoadedRef = useRef(false); + // 재고 검색 상태 + const [searchOpenGroup, setSearchOpenGroup] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + const handleStockSearch = useCallback(async (query: string) => { + if (!query.trim()) { setSearchResults([]); return; } + setIsSearching(true); + const result = await searchStockByCode(query.trim()); + if (result.success) setSearchResults(result.data); + setIsSearching(false); + }, []); + // 목업 자재 데이터 (개발용) const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 5 }, (_, i) => ({ stockLotId: 100 + i, @@ -742,6 +756,75 @@ export function MaterialInputModal({ + + {/* 재고 검색 패널 */} + {!group.lots.some(l => l.stockLotId !== null) && searchOpenGroup !== group.groupKey && ( + + )} + {searchOpenGroup === group.groupKey && ( +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleStockSearch(searchQuery)} + placeholder="품목코드 또는 자재명 검색" + className="flex-1 text-xs px-2 py-1.5 border rounded bg-white" + /> + + +
+ {searchResults.length > 0 ? ( +
+

{searchResults.length}건 검색됨:

+ {searchResults.map((s) => ( +
0 ? "bg-white border-emerald-200" : "bg-gray-50 border-gray-200" + )}> +
+ {s.itemCode} + 0 ? "text-emerald-600 font-semibold" : "text-gray-400"}> + 가용 {formatNumber(s.availableQty)} EA + +
+
{s.itemName}
+ {s.lots.length > 0 && ( +
+ {s.lots.slice(0, 3).map((l, li) => ( +
+ LOT {l.lotNo} | {formatNumber(l.availableQty)} EA | FIFO #{l.fifoOrder} +
+ ))} +
+ )} +
+ ))} +
+ ) : isSearching ? ( +

검색 중...

+ ) : searchQuery && ( +

검색 결과 없음 — 해당 품목의 입고 처리가 필요합니다

+ )} +
+ )} ); })} diff --git a/src/components/production/WorkerScreen/actions.ts b/src/components/production/WorkerScreen/actions.ts index 9024f3f2..aefa0683 100644 --- a/src/components/production/WorkerScreen/actions.ts +++ b/src/components/production/WorkerScreen/actions.ts @@ -873,4 +873,53 @@ export async function getDepartmentUsers(departmentId: number): Promise<{ success: true, data: list.map((u) => ({ id: u.id, name: u.name })), }; +} + +// ===== 자재 투입용 재고 검색 ===== +export interface StockSearchResult { + itemId: number; + itemCode: string; + itemName: string; + stockQty: number; + availableQty: number; + lotCount: number; + lots: Array<{ lotNo: string; availableQty: number; fifoOrder: number }>; +} + +export async function searchStockByCode( + search: string +): Promise<{ success: boolean; data: StockSearchResult[]; error?: string }> { + const result = await executeServerAction<{ + data: Array<{ + id: number; + item_id: number; + item_code: string; + item_name: string; + stock_qty: number; + available_qty: number; + lot_count: number; + lots?: Array<{ lot_no: string; available_qty: number; fifo_order: number }>; + }>; + }>({ + url: buildApiUrl('/api/v1/stocks', { search, per_page: 10, with_lots: 'true' }), + errorMessage: '재고 검색에 실패했습니다.', + }); + if (!result.success || !result.data) return { success: false, data: [], error: result.error }; + const items = Array.isArray(result.data) ? result.data : (result.data.data || []); + return { + success: true, + data: items.map((s) => ({ + itemId: s.item_id, + itemCode: s.item_code, + itemName: s.item_name, + stockQty: s.stock_qty || 0, + availableQty: s.available_qty || 0, + lotCount: s.lot_count || 0, + lots: (s.lots || []).map((l) => ({ + lotNo: l.lot_no, + availableQty: l.available_qty, + fifoOrder: l.fifo_order, + })), + })), + }; } \ No newline at end of file