"use client" import * as React from "react" import { toast } from "sonner" import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" import { Input } from "@/components/ui/input" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { CalendarIcon } from "lucide-react" import { format } from "date-fns" import { ko } from "date-fns/locale" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription, } from "@/components/ui/form" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import * as z from "zod" import { EstimateProjectSelector } from "@/components/BidProjectSelector" import { type Project } from "@/lib/rfqs/service" import { createTechSalesRfq } from "@/lib/techsales-rfq/service" import { useSession } from "next-auth/react" import { Separator } from "@/components/ui/separator" import { Badge } from "@/components/ui/badge" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" import { ScrollArea } from "@/components/ui/scroll-area" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" // 실제 데이터 서비스 import import { getWorkTypes, getAllShipbuildingItemsForCache, getShipTypes, type ShipbuildingItem, type WorkType } from "@/lib/items-tech/service" // 유효성 검증 스키마 - 자재코드(item_code) 배열로 변경 const createRfqSchema = z.object({ biddingProjectId: z.number({ required_error: "프로젝트를 선택해주세요.", }), materialCodes: z.array(z.string()).min(1, { message: "적어도 하나의 자재코드를 선택해야 합니다.", }), dueDate: z.date({ required_error: "마감일을 선택해주세요.", }), }) // 폼 데이터 타입 type CreateRfqFormValues = z.infer // 공종 타입 정의 interface WorkTypeOption { code: WorkType name: string description: string } interface CreateRfqDialogProps { onCreated?: () => void; } export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { const { data: session } = useSession() const [isProcessing, setIsProcessing] = React.useState(false) const [isDialogOpen, setIsDialogOpen] = React.useState(false) const [selectedProject, setSelectedProject] = React.useState(null) // 검색 및 필터링 상태 const [itemSearchQuery, setItemSearchQuery] = React.useState("") const [selectedWorkType, setSelectedWorkType] = React.useState(null) const [selectedShipType, setSelectedShipType] = React.useState(null) const [selectedItems, setSelectedItems] = React.useState([]) // 데이터 상태 const [workTypes, setWorkTypes] = React.useState([]) const [allItems, setAllItems] = React.useState([]) const [shipTypes, setShipTypes] = React.useState([]) const [isLoadingItems, setIsLoadingItems] = React.useState(false) const [dataLoadError, setDataLoadError] = React.useState(null) const [retryCount, setRetryCount] = React.useState(0) // 데이터 로딩 함수를 useCallback으로 메모이제이션 const loadData = React.useCallback(async (isRetry = false) => { try { if (!isRetry) { setIsLoadingItems(true) setDataLoadError(null) } console.log(`데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([ getWorkTypes(), getAllShipbuildingItemsForCache(), getShipTypes() ]) console.log("WorkTypes 결과:", workTypesResult) console.log("Items 결과:", itemsResult) console.log("ShipTypes 결과:", shipTypesResult) // WorkTypes 설정 if (Array.isArray(workTypesResult)) { setWorkTypes(workTypesResult) } else { console.error("WorkTypes 데이터 형식 오류:", workTypesResult) throw new Error("공종 데이터를 불러올 수 없습니다.") } // Items 설정 if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) { setAllItems(itemsResult.data) console.log("아이템 설정 완료:", itemsResult.data.length, "개") } else { console.error("아이템 로딩 실패:", itemsResult.error) throw new Error(itemsResult.error || "아이템 데이터를 불러올 수 없습니다.") } // ShipTypes 설정 if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) { setShipTypes(shipTypesResult.data) console.log("선종 설정 완료:", shipTypesResult.data) } else { console.error("선종 로딩 실패:", shipTypesResult.error) throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.") } // 성공 시 재시도 카운터 리셋 setRetryCount(0) setDataLoadError(null) console.log("데이터 로딩 완료") } catch (error) { const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' console.error("데이터 로딩 오류:", errorMessage) setDataLoadError(errorMessage) // 3회까지 자동 재시도 (500ms 간격) if (retryCount < 2) { console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) setTimeout(() => { setRetryCount(prev => prev + 1) loadData(true) }, 500 * (retryCount + 1)) } else { // 재시도 실패 시 사용자에게 알림 toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) } } finally { if (!isRetry) { setIsLoadingItems(false) } } }, [retryCount]) // 다이얼로그가 열릴 때마다 데이터 로딩 React.useEffect(() => { if (isDialogOpen) { // 다이얼로그가 열릴 때마다 데이터 상태 초기화 및 로딩 setDataLoadError(null) setRetryCount(0) // 이미 데이터가 있고 에러가 없다면 로딩하지 않음 (성능 최적화) if (allItems.length > 0 && workTypes.length > 0 && shipTypes.length > 0 && !dataLoadError) { console.log("기존 데이터 사용 (캐시)") return } loadData() } }, [isDialogOpen, loadData, allItems.length, workTypes.length, shipTypes.length, dataLoadError]) // 수동 새로고침 함수 const handleRefreshData = React.useCallback(() => { setDataLoadError(null) setRetryCount(0) loadData() }, [loadData]) // RFQ 생성 폼 const form = useForm({ resolver: zodResolver(createRfqSchema), defaultValues: { biddingProjectId: undefined, materialCodes: [], dueDate: undefined, // 기본값 제거 } }) // 필터링된 아이템 목록 가져오기 const availableItems = React.useMemo(() => { let filtered = [...allItems] // 선종 필터 if (selectedShipType) { filtered = filtered.filter(item => item.shipTypes === selectedShipType) } // 공종 필터 if (selectedWorkType) { filtered = filtered.filter(item => item.workType === selectedWorkType) } // 검색어 필터 if (itemSearchQuery && itemSearchQuery.trim()) { const query = itemSearchQuery.toLowerCase().trim() filtered = filtered.filter(item => item.itemCode.toLowerCase().includes(query) || (item.itemList && item.itemList.toLowerCase().includes(query)) ) } return filtered }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType]) // 사용 가능한 선종 목록 가져오기 const availableShipTypes = React.useMemo(() => { return shipTypes }, [shipTypes]) // 프로젝트 선택 처리 const handleProjectSelect = (project: Project) => { setSelectedProject(project) form.setValue("biddingProjectId", project.id) // 선택 초기화 setSelectedItems([]) setSelectedShipType(null) setSelectedWorkType(null) setItemSearchQuery("") form.setValue("materialCodes", []) } // 아이템 선택/해제 처리 const handleItemToggle = (item: ShipbuildingItem) => { const isSelected = selectedItems.some(selected => selected.id === item.id) if (isSelected) { // 아이템 선택 해제 const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) setSelectedItems(newSelectedItems) form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) } else { // 아이템 선택 추가 const newSelectedItems = [...selectedItems, item] setSelectedItems(newSelectedItems) form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) } } // 아이템 제거 처리 const handleRemoveItem = (itemId: number) => { const newSelectedItems = selectedItems.filter(item => item.id !== itemId) setSelectedItems(newSelectedItems) form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) } // RFQ 생성 함수 const handleCreateRfq = async (data: CreateRfqFormValues) => { try { setIsProcessing(true) // 사용자 인증 확인 if (!session?.user?.id) { throw new Error("로그인이 필요합니다") } // 선택된 아이템들을 아이템명(itemList)으로 그룹핑 const groupedItems = selectedItems.reduce((groups, item) => { const actualItemName = item.itemList // 실제 조선 아이템명 if (!actualItemName) { throw new Error(`아이템 "${item.itemCode}"의 아이템명(itemList)이 없습니다.`) } if (!groups[actualItemName]) { groups[actualItemName] = [] } groups[actualItemName].push(item) return groups }, {} as Record) const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 const joinedItemCodes = itemCodes.join(',') return { actualItemName, items, itemCodes, joinedItemCodes, codeLength: joinedItemCodes.length, isOverLimit: joinedItemCodes.length > 255 } }) // 255자 초과 그룹 확인 const overLimitGroups = rfqGroups.filter(group => group.isOverLimit) if (overLimitGroups.length > 0) { const groupNames = overLimitGroups.map(g => `"${g.actualItemName}" (${g.codeLength}자)`).join(', ') throw new Error(`다음 아이템 그룹의 자재코드가 255자를 초과합니다: ${groupNames}`) } // 각 그룹별로 RFQ 생성 const createPromises = rfqGroups.map(group => createTechSalesRfq({ biddingProjectId: data.biddingProjectId, itemShipbuildingId: group.items[0].id, // 그룹의 첫 번째 아이템의 shipbuilding ID 사용 materialGroupCodes: group.itemCodes, // 해당 그룹의 자재코드들 createdBy: Number(session.user.id), dueDate: data.dueDate, }) ) const results = await Promise.all(createPromises) // 오류 확인 const errors = results.filter(result => result.error) if (errors.length > 0) { throw new Error(errors.map(e => e.error).join(', ')) } // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 const totalRfqs = results.reduce((sum, result) => sum + (result.data?.length || 0), 0) toast.success(`${rfqGroups.length}개 아이템 그룹으로 총 ${totalRfqs}개의 RFQ가 성공적으로 생성되었습니다`) setIsDialogOpen(false) form.reset({ biddingProjectId: undefined, materialCodes: [], dueDate: undefined, // 기본값 제거 }) setSelectedProject(null) setItemSearchQuery("") setSelectedWorkType(null) setSelectedShipType(null) setSelectedItems([]) // 에러 상태 및 재시도 카운터 초기화 setDataLoadError(null) setRetryCount(0) // 생성 후 콜백 실행 if (onCreated) { onCreated() } } catch (error) { console.error("RFQ 생성 오류:", error) toast.error(`RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) } finally { setIsProcessing(false) } } return ( { setIsDialogOpen(open) if (!open) { form.reset({ biddingProjectId: undefined, materialCodes: [], dueDate: undefined, // 기본값 제거 }) setSelectedProject(null) setItemSearchQuery("") setSelectedWorkType(null) setSelectedShipType(null) setSelectedItems([]) // 에러 상태 및 재시도 카운터 초기화 setDataLoadError(null) setRetryCount(0) } }} > RFQ 생성
{/* 프로젝트 선택 */}
( 입찰 프로젝트 )} /> {/* 선종 선택 */}
선종 선택
{/* 데이터 로딩 에러 표시 */} {dataLoadError && (
{dataLoadError}
)} { setSelectedShipType(null) setSelectedItems([]) form.setValue("materialCodes", []) }} > 전체 선종 {availableShipTypes.map(shipType => ( { setSelectedShipType(shipType) setSelectedItems([]) form.setValue("materialCodes", []) }} > {shipType} ))}
{/* 마감일 설정 */} ( 마감일 date < new Date() || date < new Date("1900-01-01") } initialFocus /> )} />
{/* 아이템 선택 영역 */}
조선 아이템 선택 {selectedShipType ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` : "먼저 선종을 선택해주세요"}
{/* 아이템 검색 및 필터 */}
setItemSearchQuery(e.target.value)} className="pl-8 pr-8" disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} /> {itemSearchQuery && ( )}
{/* 공종 필터 */} setSelectedWorkType(null)} > 전체 공종 {workTypes.map(workType => ( setSelectedWorkType(workType.code)} > {workType.name} ))}
{/* 아이템 목록 */}
{dataLoadError ? (

데이터 로딩에 실패했습니다

{dataLoadError}

) : isLoadingItems ? (
아이템을 불러오는 중... {retryCount > 0 && (

재시도 {retryCount}회

)}
) : availableItems.length > 0 ? ( [...availableItems] .sort((a, b) => { // itemList 기준으로 정렬 (없는 경우 itemName 사용, 둘 다 없으면 맨 뒤로) const aName = a.itemList || 'zzz' const bName = b.itemList || 'zzz' return aName.localeCompare(bName, 'ko', { numeric: true }) }) .map((item) => { const isSelected = selectedItems.some(selected => selected.id === item.id) return (
handleItemToggle(item)} >
{isSelected ? ( ) : ( )}
{item.itemList || '아이템명 없음'}
{item.itemCode || '자재그룹코드 없음'}
공종: {item.workType} • 선종: {item.shipTypes}
) }) ) : (
{itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
)}
{/* 선택된 아이템 목록 */} ( 선택된 아이템 ({selectedItems.length}개)
{selectedItems.length > 0 ? (
{selectedItems.map((item) => ( {item.itemList || '아이템명 없음'} ({item.itemCode}) handleRemoveItem(item.id)} /> ))}
) : (
선택된 아이템이 없습니다
)}
)} /> {/* RFQ 그룹핑 미리보기 */}
생성될 RFQ 그룹 미리보기
{(() => { // 아이템명(itemList)으로 그룹핑 const groupedItems = selectedItems.reduce((groups, item) => { const actualItemName = item.itemList // 실제 조선 아이템명 if (!actualItemName) { return groups // itemList가 없는 경우 제외 } if (!groups[actualItemName]) { groups[actualItemName] = [] } groups[actualItemName].push(item) return groups }, {} as Record) const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 const joinedItemCodes = itemCodes.join(',') return { actualItemName, items, itemCodes, joinedItemCodes, codeLength: joinedItemCodes.length, isOverLimit: joinedItemCodes.length > 255 } }) return (
총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑)
RFQ # 아이템명 자재그룹코드 개수 길이 상태 {rfqGroups.map((group, index) => ( #{index + 1}
{group.actualItemName}
{group.itemCodes.length}개 {group.codeLength}/255자 {group.isOverLimit ? ( 초과 ) : ( 정상 )}
))}
) })()}
{/* Footer - Sticky 버튼 영역 */}
) }