refactor(WEB): SearchableSelectionModal 공통화 및 actions lookup 통합

- SearchableSelectionModal<T> 제네릭 컴포넌트 추출 (organisms)
- 검색 모달 5개 리팩토링: SupplierSearch, QuotationSelect, SalesOrderSelect, OrderSelect, ItemSearch
- shared-lookups API 유틸 추가 (거래처/품목/수주 등 공통 조회)
- create-crud-service 확장 (lookup, search 메서드)
- actions.ts 20+개 파일 lookup 패턴 통일
- 공통 페이지 패턴 가이드 문서 추가
- CLAUDE.md Common Component Usage Rules 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-10 16:01:23 +09:00
parent 0643d56194
commit 437d5f6834
42 changed files with 1683 additions and 1144 deletions

View File

@@ -1,28 +1,18 @@
/**
* 품목 검색 모달
*
* - 품목 코드/이름으로 검색
* - 품목 목록에서 선택
* - API 연동
* SearchableSelectionModal 공통 컴포넌트 기반
*/
"use client";
'use client';
import { useState, useEffect, useMemo, useCallback } from "react";
import { Search, X, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { fetchItems } from "@/lib/api/items";
import type { ItemMaster, ItemType } from "@/types/item";
import { useCallback } from 'react';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
import { fetchItems } from '@/lib/api/items';
import type { ItemMaster, ItemType } from '@/types/item';
// =============================================================================
// Props
// Props (기존과 동일 — 사용처 변경 없음)
// =============================================================================
interface ItemSearchModalProps {
@@ -34,6 +24,12 @@ interface ItemSearchModalProps {
itemType?: string;
}
// 검색어 유효성: 영문, 한글, 숫자 1자 이상
const isValidSearchQuery = (query: string) => {
if (!query || !query.trim()) return false;
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
};
// =============================================================================
// 컴포넌트
// =============================================================================
@@ -45,168 +41,73 @@ export function ItemSearchModal({
tabLabel,
itemType,
}: ItemSearchModalProps) {
const [searchQuery, setSearchQuery] = useState("");
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);
}
const handleFetchData = useCallback(async (query: string) => {
const data = await fetchItems({
search: query || undefined,
itemType: itemType as ItemType | undefined,
per_page: 50,
});
return data;
}, [itemType]);
// 검색어 유효성 검사: 영문, 한글, 숫자 1자 이상
const isValidSearchQuery = useCallback((query: string) => {
if (!query || !query.trim()) return false;
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
}, []);
// 모달 열릴 때 초기화 (자동 로드 안함)
useEffect(() => {
if (open) {
setItems([]);
setError(null);
}
}, [open]);
// 검색어 변경 시 디바운스 검색 (유효한 검색어만)
useEffect(() => {
if (!open) return;
// 검색어가 유효하지 않으면 결과 초기화
if (!isValidSearchQuery(searchQuery)) {
setItems([]);
return;
}
const timer = setTimeout(() => {
loadItems(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, open, loadItems, isValidSearchQuery]);
// 검색 결과 그대로 사용 (서버에서 이미 필터링됨)
const filteredItems = items;
const handleSelect = (item: ItemMaster) => {
const handleSelect = useCallback((item: ItemMaster) => {
onSelectItem({
code: item.itemCode,
name: item.itemName,
specification: item.specification || undefined,
});
onOpenChange(false);
setSearchQuery("");
};
const handleClose = () => {
onOpenChange(false);
setSearchQuery("");
};
}, [onSelectItem]);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{tabLabel && <span className="text-sm font-normal text-gray-500 ml-2">({tabLabel})</span>}
</DialogTitle>
</DialogHeader>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="품목코드 또는 품목명 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-10"
/>
{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>
{/* 품목 목록 */}
<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>
<SearchableSelectionModal<ItemMaster>
open={open}
onOpenChange={onOpenChange}
title={
<>
{tabLabel && <span className="text-sm font-normal text-gray-500 ml-2">({tabLabel})</span>}
</>
}
searchPlaceholder="품목코드 또는 품목명 검색..."
fetchData={handleFetchData}
keyExtractor={(item) => item.id?.toString() ?? item.itemCode}
validateSearch={isValidSearchQuery}
invalidSearchMessage="영문, 한글 또는 숫자 1자 이상 입력하세요"
emptyQueryMessage="품목코드 또는 품목명을 입력하세요"
loadingMessage="품목 검색 중..."
dialogClassName="sm:max-w-[500px]"
infoText={(items, isLoading) =>
!isLoading ? (
<span className="text-xs text-gray-400 text-right block">
{items.length}
</span>
) : null
}
mode="single"
onSelect={handleSelect}
renderItem={(item) => (
<div className="p-3 hover:bg-blue-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{item.itemCode}</span>
<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>
)}
</div>
) : error ? (
<div className="p-4 text-center text-red-500 text-sm">
{error}
</div>
) : filteredItems.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
{!searchQuery
? "품목코드 또는 품목명을 입력하세요"
: !isValidSearchQuery(searchQuery)
? "영문, 한글 또는 숫자 1자 이상 입력하세요"
: "검색 결과가 없습니다"}
</div>
) : (
<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">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{item.itemCode}</span>
<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>
)}
</div>
{item.unit && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{item.unit}
</span>
)}
</div>
{item.specification && (
<p className="text-xs text-gray-400 mt-1">{item.specification}</p>
)}
</div>
))}
</div>
)}
</div>
{/* 품목 개수 표시 */}
{!isLoading && !error && (
<div className="text-xs text-gray-400 text-right">
{filteredItems.length}
{item.unit && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{item.unit}
</span>
)}
</div>
)}
</DialogContent>
</Dialog>
{item.specification && (
<p className="text-xs text-gray-400 mt-1">{item.specification}</p>
)}
</div>
)}
/>
);
}
}