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

@@ -0,0 +1,252 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { Search, X, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useSearchableData } from './useSearchableData';
import type { SearchableSelectionModalProps } from './types';
export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps<T>) {
const {
open,
onOpenChange,
title,
searchPlaceholder = '검색...',
fetchData,
keyExtractor,
renderItem,
searchMode = 'debounce',
debounceDelay = 300,
validateSearch,
invalidSearchMessage,
loadOnOpen = false,
emptyQueryMessage = '검색어를 입력하세요',
noResultMessage = '검색 결과가 없습니다.',
loadingMessage = '검색 중...',
dialogClassName,
listContainerClassName = 'max-h-[400px] overflow-y-auto border rounded-lg',
listWrapper,
infoText,
mode,
} = props;
const {
searchQuery,
setSearchQuery,
items,
isLoading,
error,
triggerSearch,
handleSearchKeyDown,
} = useSearchableData<T>({
open,
fetchData,
searchMode,
debounceDelay,
validateSearch,
loadOnOpen,
});
// 다중선택 상태
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 모달 열릴 때 선택 초기화
useEffect(() => {
if (open) {
setSelectedIds(new Set());
}
}, [open]);
// 단일선택 핸들러
const handleSingleSelect = useCallback((item: T) => {
if (mode === 'single') {
(props as { onSelect: (item: T) => void }).onSelect(item);
onOpenChange(false);
}
}, [mode, props, onOpenChange]);
// 다중선택 토글
const handleToggle = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// 전체선택 토글
const handleToggleAll = useCallback(() => {
setSelectedIds((prev) => {
if (prev.size === items.length) {
return new Set();
}
return new Set(items.map((item) => keyExtractor(item)));
});
}, [items, keyExtractor]);
// 다중선택 확인
const handleConfirm = useCallback(() => {
if (mode === 'multiple') {
const selectedItems = items.filter((item) => selectedIds.has(keyExtractor(item)));
(props as { onSelect: (items: T[]) => void }).onSelect(selectedItems);
onOpenChange(false);
}
}, [mode, items, selectedIds, keyExtractor, props, onOpenChange]);
// 클릭 핸들러: 모드에 따라 분기
const handleItemClick = useCallback((item: T) => {
if (mode === 'single') {
handleSingleSelect(item);
} else {
handleToggle(keyExtractor(item));
}
}, [mode, handleSingleSelect, handleToggle, keyExtractor]);
const isAllSelected = items.length > 0 && selectedIds.size === items.length;
const isSelected = (item: T) => selectedIds.has(keyExtractor(item));
// 빈 상태 메시지 결정
const getEmptyMessage = () => {
if (error) return null; // error는 별도 표시
if (!searchQuery && !loadOnOpen) return emptyQueryMessage;
if (searchQuery && validateSearch && !validateSearch(searchQuery)) {
return invalidSearchMessage || emptyQueryMessage;
}
return noResultMessage;
};
// 리스트 콘텐츠 렌더링
const renderListContent = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
<span>{loadingMessage}</span>
</div>
);
}
if (error) {
return (
<div className="p-4 text-center text-red-500 text-sm">
{error}
</div>
);
}
if (items.length === 0) {
return (
<div className="p-4 text-center text-muted-foreground text-sm">
{getEmptyMessage()}
</div>
);
}
const itemElements = items.map((item) => (
<div key={keyExtractor(item)} onClick={() => handleItemClick(item)} className="cursor-pointer">
{renderItem(item, isSelected(item))}
</div>
));
if (listWrapper) {
const selectState = mode === 'multiple'
? { isAllSelected, onToggleAll: handleToggleAll }
: undefined;
return listWrapper(<>{itemElements}</>, selectState);
}
return <div className="divide-y">{itemElements}</div>;
};
const multiProps = mode === 'multiple' ? props as Extract<typeof props, { mode: 'multiple' }> : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={dialogClassName}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{/* 검색 입력 */}
{searchMode === 'enter' ? (
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder}
className="pl-9"
/>
</div>
<Button variant="outline" onClick={triggerSearch}>
</Button>
</div>
) : (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
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-muted-foreground hover:text-foreground" />
</button>
)}
</div>
)}
{/* 정보 텍스트 */}
{infoText && (
<div className="text-sm text-muted-foreground">
{infoText(items, isLoading)}
</div>
)}
{/* 다중선택 헤더 (전체선택 등) */}
{mode === 'multiple' && multiProps?.renderHeader && (
multiProps.renderHeader({ isAllSelected, onToggleAll: handleToggleAll })
)}
{/* 리스트 */}
<div className={listContainerClassName}>
{renderListContent()}
</div>
{/* 다중선택 푸터 */}
{mode === 'multiple' && (
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm} disabled={selectedIds.size === 0}>
{multiProps?.confirmLabel || '선택'} ({selectedIds.size})
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,6 @@
export { SearchableSelectionModal } from './SearchableSelectionModal';
export type {
SearchableSelectionModalProps,
SingleSelectProps,
MultipleSelectProps,
} from './types';

View File

@@ -0,0 +1,84 @@
import { ReactNode } from 'react';
// =============================================================================
// 공통 Props
// =============================================================================
interface BaseProps<T> {
/** 모달 열림 상태 */
open: boolean;
/** 모달 열림/닫힘 제어 */
onOpenChange: (open: boolean) => void;
/** 모달 제목 */
title: ReactNode;
/** 검색 placeholder */
searchPlaceholder?: string;
/** 데이터 조회 함수 (검색어 → 결과 배열) */
fetchData: (query: string) => Promise<T[]>;
/** 고유 키 추출 */
keyExtractor: (item: T) => string;
/** 아이템 렌더링 */
renderItem: (item: T, isSelected: boolean) => ReactNode;
// 검색 설정
/** 검색 모드: debounce(자동) vs enter(수동) */
searchMode?: 'debounce' | 'enter';
/** 디바운스 딜레이 (ms) - searchMode='debounce'일 때 */
debounceDelay?: number;
/** 검색어 유효성 검사 (false면 검색 안 함) */
validateSearch?: (query: string) => boolean;
/** 유효하지 않은 검색어일 때 메시지 */
invalidSearchMessage?: string;
/** 모달 열릴 때 자동 로드 여부 */
loadOnOpen?: boolean;
/** 검색어 없을 때 안내 메시지 */
emptyQueryMessage?: string;
/** 검색 결과 없을 때 메시지 */
noResultMessage?: string;
/** 로딩 메시지 */
loadingMessage?: string;
// 레이아웃
/** Dialog 최대 너비 클래스 */
dialogClassName?: string;
/** 리스트 컨테이너 클래스 */
listContainerClassName?: string;
/** 리스트 래퍼 (Table 헤더 등 커스텀 구조) */
listWrapper?: (children: ReactNode, selectState?: {
isAllSelected: boolean;
onToggleAll: () => void;
}) => ReactNode;
/** 푸터 상단 정보 영역 (예: "총 X건") */
infoText?: (items: T[], isLoading: boolean) => ReactNode;
}
// =============================================================================
// 단일 선택
// =============================================================================
export interface SingleSelectProps<T> extends BaseProps<T> {
mode: 'single';
onSelect: (item: T) => void;
}
// =============================================================================
// 다중 선택
// =============================================================================
export interface MultipleSelectProps<T> extends BaseProps<T> {
mode: 'multiple';
onSelect: (items: T[]) => void;
/** 확인 버튼 라벨 (기본: "선택") */
confirmLabel?: string;
/** 전체선택 허용 */
allowSelectAll?: boolean;
/** 헤더 영역 (전체선택 체크박스 등) */
renderHeader?: (params: {
isAllSelected: boolean;
onToggleAll: () => void;
}) => ReactNode;
}
export type SearchableSelectionModalProps<T> =
| SingleSelectProps<T>
| MultipleSelectProps<T>;

View File

@@ -0,0 +1,119 @@
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseSearchableDataOptions<T> {
open: boolean;
fetchData: (query: string) => Promise<T[]>;
searchMode: 'debounce' | 'enter';
debounceDelay: number;
validateSearch?: (query: string) => boolean;
loadOnOpen: boolean;
}
interface UseSearchableDataReturn<T> {
searchQuery: string;
setSearchQuery: (query: string) => void;
items: T[];
isLoading: boolean;
error: string | null;
triggerSearch: () => void;
handleSearchKeyDown: (e: React.KeyboardEvent) => void;
}
export function useSearchableData<T>({
open,
fetchData,
searchMode,
debounceDelay,
validateSearch,
loadOnOpen,
}: UseSearchableDataOptions<T>): UseSearchableDataReturn<T> {
const [searchQuery, setSearchQuery] = useState('');
const [items, setItems] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const prevOpenRef = useRef(false);
// 실제 API 호출
const doFetch = useCallback(async (query: string) => {
setIsLoading(true);
setError(null);
try {
const data = await fetchData(query);
setItems(data);
} catch (err) {
console.error('[useSearchableData] fetch error:', err);
setError('데이터를 불러오는데 실패했습니다.');
setItems([]);
} finally {
setIsLoading(false);
}
}, [fetchData]);
// 모달 열릴 때 초기화 + loadOnOpen
useEffect(() => {
if (open && !prevOpenRef.current) {
// 방금 열림
setSearchQuery('');
setError(null);
if (loadOnOpen) {
doFetch('');
} else {
setItems([]);
}
}
if (!open && prevOpenRef.current) {
// 방금 닫힘
setItems([]);
setSearchQuery('');
setError(null);
}
prevOpenRef.current = open;
}, [open, loadOnOpen, doFetch]);
// 디바운스 모드: 검색어 변경 시 자동 검색
useEffect(() => {
if (!open || searchMode !== 'debounce') return;
// 검색어가 비어있고 loadOnOpen이면 이미 로드됨 → 스킵
if (!searchQuery && loadOnOpen) return;
// 검색어가 비어있고 loadOnOpen이 아니면 → 결과 초기화
if (!searchQuery && !loadOnOpen) {
setItems([]);
return;
}
// 유효성 검사
if (validateSearch && !validateSearch(searchQuery)) {
setItems([]);
return;
}
const timer = setTimeout(() => {
doFetch(searchQuery);
}, debounceDelay);
return () => clearTimeout(timer);
}, [searchQuery, open, searchMode, debounceDelay, validateSearch, doFetch, loadOnOpen]);
// 수동 검색 트리거 (enter 모드)
const triggerSearch = useCallback(() => {
doFetch(searchQuery);
}, [searchQuery, doFetch]);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
triggerSearch();
}
}, [triggerSearch]);
return {
searchQuery,
setSearchQuery,
items,
isLoading,
error,
triggerSearch,
handleSearchKeyDown,
};
}

View File

@@ -8,3 +8,5 @@ export { MobileCard, ListMobileCard, InfoField } from "./MobileCard";
export type { MobileCardProps, InfoFieldProps } from "./MobileCard";
export { EmptyState } from "./EmptyState";
export { ScreenVersionHistory } from "./ScreenVersionHistory";
export { SearchableSelectionModal } from "./SearchableSelectionModal";
export type { SearchableSelectionModalProps, SingleSelectProps, MultipleSelectProps } from "./SearchableSelectionModal";