2026-01-12 15:26:17 +09:00
|
|
|
/**
|
|
|
|
|
* 품목 검색 모달
|
|
|
|
|
*
|
2026-01-27 14:28:17 +09:00
|
|
|
* - 품목 코드/이름으로 검색
|
2026-01-12 15:26:17 +09:00
|
|
|
* - 품목 목록에서 선택
|
2026-01-27 14:28:17 +09:00
|
|
|
* - API 연동
|
2026-01-12 15:26:17 +09:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
"use client";
|
|
|
|
|
|
2026-01-27 14:28:17 +09:00
|
|
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
|
|
|
import { Search, X, Loader2 } from "lucide-react";
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "../ui/dialog";
|
|
|
|
|
import { Input } from "../ui/input";
|
2026-01-27 14:28:17 +09:00
|
|
|
import { fetchItems } from "@/lib/api/items";
|
|
|
|
|
import type { ItemMaster, ItemType } from "@/types/item";
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Props
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
interface ItemSearchModalProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
2026-01-27 14:28:17 +09:00
|
|
|
onSelectItem: (item: { code: string; name: string; specification?: string }) => void;
|
2026-01-12 15:26:17 +09:00
|
|
|
tabLabel?: string;
|
2026-01-27 14:28:17 +09:00
|
|
|
/** 품목 유형 필터 (예: 'RM', 'SF', 'FG') */
|
|
|
|
|
itemType?: string;
|
2026-01-12 15:26:17 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// 컴포넌트
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
export function ItemSearchModal({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
onSelectItem,
|
|
|
|
|
tabLabel,
|
2026-01-27 14:28:17 +09:00
|
|
|
itemType,
|
2026-01-12 15:26:17 +09:00
|
|
|
}: ItemSearchModalProps) {
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
2026-01-27 14:28:17 +09:00
|
|
|
const [items, setItems] = useState<ItemMaster[]>([]);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// 품목 목록 조회
|
|
|
|
|
const loadItems = useCallback(async (search?: string) => {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const data = await fetchItems({
|
|
|
|
|
search: search || undefined,
|
|
|
|
|
itemType: itemType as ItemType | undefined,
|
|
|
|
|
per_page: 50,
|
|
|
|
|
});
|
|
|
|
|
setItems(data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[ItemSearchModal] 품목 조회 오류:", err);
|
|
|
|
|
setError("품목 목록을 불러오는데 실패했습니다.");
|
|
|
|
|
setItems([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [itemType]);
|
|
|
|
|
|
2026-02-07 03:27:23 +09:00
|
|
|
// 검색어 유효성 검사: 영문, 한글, 숫자 1자 이상
|
2026-01-27 14:28:17 +09:00
|
|
|
const isValidSearchQuery = useCallback((query: string) => {
|
2026-02-07 03:27:23 +09:00
|
|
|
if (!query || !query.trim()) return false;
|
|
|
|
|
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
|
2026-01-27 14:28:17 +09:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 모달 열릴 때 초기화 (자동 로드 안함)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) {
|
|
|
|
|
setItems([]);
|
|
|
|
|
setError(null);
|
|
|
|
|
}
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
// 검색어 변경 시 디바운스 검색 (유효한 검색어만)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
|
|
|
|
// 검색어가 유효하지 않으면 결과 초기화
|
|
|
|
|
if (!isValidSearchQuery(searchQuery)) {
|
|
|
|
|
setItems([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
loadItems(searchQuery);
|
|
|
|
|
}, 300);
|
2026-01-12 15:26:17 +09:00
|
|
|
|
2026-01-27 14:28:17 +09:00
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}, [searchQuery, open, loadItems, isValidSearchQuery]);
|
|
|
|
|
|
|
|
|
|
// 검색 결과 그대로 사용 (서버에서 이미 필터링됨)
|
|
|
|
|
const filteredItems = items;
|
|
|
|
|
|
|
|
|
|
const handleSelect = (item: ItemMaster) => {
|
|
|
|
|
onSelectItem({
|
|
|
|
|
code: item.itemCode,
|
|
|
|
|
name: item.itemName,
|
|
|
|
|
specification: item.specification || undefined,
|
|
|
|
|
});
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
setSearchQuery("");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleClose = () => {
|
2026-01-12 15:26:17 +09:00
|
|
|
onOpenChange(false);
|
|
|
|
|
setSearchQuery("");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-27 14:28:17 +09:00
|
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
|
|
|
<DialogContent className="sm:max-w-[500px]">
|
2026-01-12 15:26:17 +09:00
|
|
|
<DialogHeader>
|
2026-01-27 14:28:17 +09:00
|
|
|
<DialogTitle>
|
|
|
|
|
품목 검색
|
|
|
|
|
{tabLabel && <span className="text-sm font-normal text-gray-500 ml-2">({tabLabel})</span>}
|
|
|
|
|
</DialogTitle>
|
2026-01-12 15:26:17 +09:00
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
{/* 검색 입력 */}
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
|
|
|
<Input
|
2026-01-27 14:28:17 +09:00
|
|
|
placeholder="품목코드 또는 품목명 검색..."
|
2026-01-12 15:26:17 +09:00
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
2026-01-27 14:28:17 +09:00
|
|
|
className="pl-10 pr-10"
|
2026-01-12 15:26:17 +09:00
|
|
|
/>
|
|
|
|
|
{searchQuery && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSearchQuery("")}
|
|
|
|
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 품목 목록 */}
|
2026-01-27 14:28:17 +09:00
|
|
|
<div className="max-h-[400px] overflow-y-auto border rounded-lg">
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="flex items-center justify-center p-8 text-gray-500">
|
|
|
|
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
|
|
|
|
<span>품목 검색 중...</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : error ? (
|
|
|
|
|
<div className="p-4 text-center text-red-500 text-sm">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
) : filteredItems.length === 0 ? (
|
2026-01-12 15:26:17 +09:00
|
|
|
<div className="p-4 text-center text-gray-500 text-sm">
|
2026-01-27 14:28:17 +09:00
|
|
|
{!searchQuery
|
|
|
|
|
? "품목코드 또는 품목명을 입력하세요"
|
|
|
|
|
: !isValidSearchQuery(searchQuery)
|
2026-02-07 03:27:23 +09:00
|
|
|
? "영문, 한글 또는 숫자 1자 이상 입력하세요"
|
2026-01-27 14:28:17 +09:00
|
|
|
: "검색 결과가 없습니다"}
|
2026-01-12 15:26:17 +09:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-01-27 14:28:17 +09:00
|
|
|
<div className="divide-y">
|
|
|
|
|
{filteredItems.map((item, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={item.id ?? `${item.itemCode}-${index}`}
|
|
|
|
|
onClick={() => handleSelect(item)}
|
|
|
|
|
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-02-05 21:58:06 +09:00
|
|
|
<div className="flex items-center gap-2">
|
2026-01-27 14:28:17 +09:00
|
|
|
<span className="font-semibold text-gray-900">{item.itemCode}</span>
|
2026-02-05 21:58:06 +09:00
|
|
|
<span className="text-sm text-gray-600">{item.itemName}</span>
|
|
|
|
|
{item.hasInspectionTemplate && (
|
|
|
|
|
<span className="text-xs text-white bg-green-500 px-1.5 py-0.5 rounded">
|
|
|
|
|
수입검사
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-01-27 14:28:17 +09:00
|
|
|
</div>
|
|
|
|
|
{item.unit && (
|
|
|
|
|
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
|
|
|
|
{item.unit}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-01-12 15:26:17 +09:00
|
|
|
</div>
|
2026-01-27 14:28:17 +09:00
|
|
|
{item.specification && (
|
|
|
|
|
<p className="text-xs text-gray-400 mt-1">{item.specification}</p>
|
|
|
|
|
)}
|
2026-01-12 15:26:17 +09:00
|
|
|
</div>
|
2026-01-27 14:28:17 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
2026-01-12 15:26:17 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-27 14:28:17 +09:00
|
|
|
|
|
|
|
|
{/* 품목 개수 표시 */}
|
|
|
|
|
{!isLoading && !error && (
|
|
|
|
|
<div className="text-xs text-gray-400 text-right">
|
|
|
|
|
총 {filteredItems.length}개 품목
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-12 15:26:17 +09:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|