feat: [자재투입] 재고 없는 자재에 재고 검색 기능 추가

This commit is contained in:
김보곤
2026-03-22 10:02:54 +09:00
parent 695b4c305e
commit 948dc1e1ab
2 changed files with 134 additions and 2 deletions

View File

@@ -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>
);
})}

View File

@@ -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,
})),
})),
};
}