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:
@@ -1,26 +1,15 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수주 선택 모달
|
||||
* 수주 선택 모달 (다중선택)
|
||||
*
|
||||
* 기획서 기반 신규 생성:
|
||||
* - 검색 입력
|
||||
* SearchableSelectionModal 공통 컴포넌트 기반
|
||||
* - 체크박스 테이블 (수주번호, 현장명, 납품일, 개소)
|
||||
* - 취소/선택 버튼
|
||||
* - 전체선택/개별선택
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -30,6 +19,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
import { getOrderSelectList } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { OrderSelectItem } from './types';
|
||||
@@ -48,173 +38,73 @@ export function OrderSelectModal({
|
||||
onSelect,
|
||||
excludeIds = [],
|
||||
}: OrderSelectModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [items, setItems] = useState<OrderSelectItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 데이터 로드
|
||||
const loadItems = useCallback(async (q?: string) => {
|
||||
setIsLoading(true);
|
||||
const handleFetchData = useCallback(async (query: string) => {
|
||||
try {
|
||||
const result = await getOrderSelectList({ q: q || undefined });
|
||||
const result = await getOrderSelectList({ q: query || undefined });
|
||||
if (result.success) {
|
||||
// 이미 선택된 항목 제외
|
||||
const filtered = result.data.filter((item) => !excludeIds.includes(item.id));
|
||||
setItems(filtered);
|
||||
} else {
|
||||
toast.error(result.error || '수주 목록을 불러오는데 실패했습니다.');
|
||||
return result.data.filter((item) => !excludeIds.includes(item.id));
|
||||
}
|
||||
toast.error(result.error || '수주 목록을 불러오는데 실패했습니다.');
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[OrderSelectModal] loadItems error:', error);
|
||||
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
return [];
|
||||
}
|
||||
}, [excludeIds]);
|
||||
|
||||
// 모달 열릴 때 데이터 로드 & 상태 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchTerm('');
|
||||
setSelectedIds(new Set());
|
||||
loadItems();
|
||||
}
|
||||
}, [open, loadItems]);
|
||||
|
||||
// 검색
|
||||
const handleSearch = useCallback(() => {
|
||||
loadItems(searchTerm);
|
||||
}, [searchTerm, loadItems]);
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}, [handleSearch]);
|
||||
|
||||
// 체크박스 토글
|
||||
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) => item.id));
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
// 선택 확인
|
||||
const handleConfirm = useCallback(() => {
|
||||
const selectedItems = items.filter((item) => selectedIds.has(item.id));
|
||||
onSelect(selectedItems);
|
||||
onOpenChange(false);
|
||||
}, [items, selectedIds, onSelect, onOpenChange]);
|
||||
|
||||
const isAllSelected = items.length > 0 && selectedIds.size === items.length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<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={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="수주번호, 현장명 검색..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="max-h-[400px] overflow-y-auto border rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleToggleAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>수주번호</TableHead>
|
||||
<TableHead>현장명</TableHead>
|
||||
<TableHead className="text-center">납품일</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggle(item.id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(item.id)}
|
||||
onCheckedChange={() => handleToggle(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryDate}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
{searchTerm ? '검색 결과가 없습니다.' : '수주 데이터가 없습니다.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<SearchableSelectionModal<OrderSelectItem>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="수주 선택"
|
||||
searchPlaceholder="수주번호, 현장명 검색..."
|
||||
fetchData={handleFetchData}
|
||||
keyExtractor={(item) => item.id}
|
||||
searchMode="enter"
|
||||
loadOnOpen
|
||||
dialogClassName="sm:max-w-2xl"
|
||||
listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md"
|
||||
mode="multiple"
|
||||
onSelect={onSelect}
|
||||
confirmLabel="선택"
|
||||
allowSelectAll
|
||||
listWrapper={(children, selectState) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
{selectState && (
|
||||
<Checkbox
|
||||
checked={selectState.isAllSelected}
|
||||
onCheckedChange={selectState.onToggleAll}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
선택 ({selectedIds.size}건)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableHead>
|
||||
<TableHead>수주번호</TableHead>
|
||||
<TableHead>현장명</TableHead>
|
||||
<TableHead className="text-center">납품일</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{children}
|
||||
{/* 빈 상태는 공통 컴포넌트에서 처리 */}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
renderItem={(item, isSelected) => (
|
||||
<TableRow className="cursor-pointer hover:bg-muted/50">
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} />
|
||||
</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryDate}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user