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,24 +1,15 @@
/**
* 발주처(매입 거래처) 검색 모달
*
* - 거래처명으로 검색
* SearchableSelectionModal 공통 컴포넌트 기반
* - 매입 가능 거래처만 표시 (client_type: PURCHASE, BOTH)
* - ItemSearchModal과 동일한 Dialog + 클라이언트 프록시 패턴
* - 최소 입력 조건: 한글 완성형 1자 또는 영문 2자 이상
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Search, X, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useCallback } from 'react';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
// =============================================================================
// 타입
@@ -40,7 +31,7 @@ interface SupplierSearchModalProps {
}
// =============================================================================
// API 응답 변환
// API
// =============================================================================
interface ApiClientResponse {
@@ -63,20 +54,13 @@ function transformClientFromApi(apiClient: ApiClientResponse): SupplierItem {
};
}
/**
* 매입 가능 거래처 조회 (클라이언트 프록시 경유)
* client_type: PURCHASE 또는 BOTH
*/
async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
const params = new URLSearchParams();
if (search) params.set('q', search);
params.set('size', '50');
// 매입 가능 거래처만 (PURCHASE, BOTH)
params.set('client_type', 'PURCHASE,BOTH');
const url = `/api/proxy/clients?${params.toString()}`;
const response = await fetch(url, {
const response = await fetch(`/api/proxy/clients?${params.toString()}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -87,7 +71,6 @@ async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
}
const result = await response.json();
let rawItems: ApiClientResponse[] = [];
if (result.success && result.data) {
@@ -107,9 +90,6 @@ async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
// 유효성 검사
// =============================================================================
/**
* 검색어 유효성: 한글 완성형 1자 이상 또는 영문 2자 이상
*/
function isValidSearchQuery(query: string): boolean {
if (!query || !query.trim()) return false;
const trimmed = query.trim();
@@ -130,139 +110,51 @@ export function SupplierSearchModal({
onOpenChange,
onSelectSupplier,
}: SupplierSearchModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [suppliers, setSuppliers] = useState<SupplierItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 거래처 목록 조회
const loadSuppliers = useCallback(async (search?: string) => {
setIsLoading(true);
setError(null);
try {
const data = await fetchPurchaseClients(search);
setSuppliers(data);
} catch (err) {
console.error('[SupplierSearchModal] 거래처 조회 오류:', err);
setError('거래처 목록을 불러오는데 실패했습니다.');
setSuppliers([]);
} finally {
setIsLoading(false);
}
const handleFetchData = useCallback(async (query: string) => {
return fetchPurchaseClients(query || undefined);
}, []);
// 모달 열릴 때 초기화
useEffect(() => {
if (open) {
setSuppliers([]);
setError(null);
}
}, [open]);
// 검색어 변경 시 디바운스 검색
useEffect(() => {
if (!open) return;
if (!isValidSearchQuery(searchQuery)) {
setSuppliers([]);
return;
}
const timer = setTimeout(() => {
loadSuppliers(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, open, loadSuppliers]);
const handleSelect = (supplier: SupplierItem) => {
const handleSelect = useCallback((supplier: SupplierItem) => {
onSelectSupplier({ name: supplier.name, code: supplier.clientCode });
onOpenChange(false);
setSearchQuery('');
};
const handleClose = () => {
onOpenChange(false);
setSearchQuery('');
};
}, [onSelectSupplier]);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </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>
</div>
) : error ? (
<div className="p-4 text-center text-red-500 text-sm">
{error}
</div>
) : suppliers.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
{!searchQuery
? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요'
: !isValidSearchQuery(searchQuery)
? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요'
: '검색 결과가 없습니다'}
</div>
) : (
<div className="divide-y">
{suppliers.map((supplier, index) => (
<div
key={`${supplier.id}-${index}`}
onClick={() => handleSelect(supplier)}
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{supplier.name}</span>
{supplier.clientCode && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{supplier.clientCode}
</span>
)}
</div>
{supplier.contactPerson && (
<p className="text-xs text-gray-400 mt-1">: {supplier.contactPerson}</p>
)}
</div>
))}
</div>
)}
</div>
{/* 거래처 개수 표시 */}
{!isLoading && !error && (
<div className="text-xs text-gray-400 text-right">
{suppliers.length}
<SearchableSelectionModal<SupplierItem>
open={open}
onOpenChange={onOpenChange}
title="발주처 검색"
searchPlaceholder="거래처명 검색..."
fetchData={handleFetchData}
keyExtractor={(s) => `${s.id}`}
validateSearch={isValidSearchQuery}
invalidSearchMessage="한글 1자(완성형) 또는 영문 2자 이상 입력하세요"
emptyQueryMessage="한글 1자(완성형) 또는 영문 2자 이상 입력하세요"
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={(supplier) => (
<div className="p-3 hover:bg-blue-50 transition-colors">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{supplier.name}</span>
{supplier.clientCode && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{supplier.clientCode}
</span>
)}
</div>
)}
</DialogContent>
</Dialog>
{supplier.contactPerson && (
<p className="text-xs text-gray-400 mt-1">: {supplier.contactPerson}</p>
)}
</div>
)}
/>
);
}
}

View File

@@ -18,6 +18,7 @@ const USE_MOCK_DATA = false;
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction } from '@/lib/api/execute-server-action';
@@ -360,13 +361,7 @@ interface ReceivingApiData {
has_inspection_template?: boolean;
}
interface ReceivingApiPaginatedResponse {
data: ReceivingApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type ReceivingApiPaginatedResponse = PaginatedApiResponse<ReceivingApiData>;
interface ReceivingApiStatsResponse {
receiving_pending_count: number;

View File

@@ -12,6 +12,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type {
StockItem,
StockDetail,
@@ -84,13 +85,7 @@ interface StockLotApiData {
updated_at?: string;
}
interface ItemApiPaginatedResponse {
data: ItemApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type ItemApiPaginatedResponse = PaginatedApiResponse<ItemApiData>;
interface StockApiStatsResponse {
total_items: number;