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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { SearchableSelectionModal } from './SearchableSelectionModal';
|
||||
export type {
|
||||
SearchableSelectionModalProps,
|
||||
SingleSelectProps,
|
||||
MultipleSelectProps,
|
||||
} from './types';
|
||||
84
src/components/organisms/SearchableSelectionModal/types.ts
Normal file
84
src/components/organisms/SearchableSelectionModal/types.ts
Normal 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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user