feat: [자재투입] 재고 없는 자재에 재고 검색 기능 추가
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<StockSearchResult[]>([]);
|
||||
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({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 재고 검색 패널 */}
|
||||
{!group.lots.some(l => l.stockLotId !== null) && searchOpenGroup !== group.groupKey && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchOpenGroup(group.groupKey);
|
||||
setSearchQuery(group.materialCode || group.materialName);
|
||||
setSearchResults([]);
|
||||
handleStockSearch(group.materialCode || group.materialName);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-xs text-red-600 bg-red-50 border-t flex items-center gap-1.5 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
매칭 가능한 입고 재고 없음 — 클릭하여 재고 검색
|
||||
</button>
|
||||
)}
|
||||
{searchOpenGroup === group.groupKey && (
|
||||
<div className="px-4 py-3 bg-blue-50 border-t space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-3.5 w-3.5 text-blue-500 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleStockSearch(searchQuery)}>
|
||||
{isSearching ? <Loader2 className="h-3 w-3 animate-spin" /> : '검색'}
|
||||
</Button>
|
||||
<button onClick={() => setSearchOpenGroup(null)} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{searchResults.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] text-blue-600 font-medium">{searchResults.length}건 검색됨:</p>
|
||||
{searchResults.map((s) => (
|
||||
<div key={s.itemId} className={cn(
|
||||
"text-[11px] px-2 py-1.5 rounded border",
|
||||
s.availableQty > 0 ? "bg-white border-emerald-200" : "bg-gray-50 border-gray-200"
|
||||
)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">{s.itemCode}</span>
|
||||
<span className={s.availableQty > 0 ? "text-emerald-600 font-semibold" : "text-gray-400"}>
|
||||
가용 {formatNumber(s.availableQty)} EA
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-500">{s.itemName}</div>
|
||||
{s.lots.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{s.lots.slice(0, 3).map((l, li) => (
|
||||
<div key={li} className="text-[10px] text-gray-400 ml-2">
|
||||
LOT {l.lotNo} | {formatNumber(l.availableQty)} EA | FIFO #{l.fifoOrder}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isSearching ? (
|
||||
<p className="text-[11px] text-gray-400 flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" />검색 중...</p>
|
||||
) : searchQuery && (
|
||||
<p className="text-[11px] text-red-500">검색 결과 없음 — 해당 품목의 입고 처리가 필요합니다</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user