From bbf276882ec813beee465cec785fd2d31ff15a54 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 20 Oct 2025 07:43:56 +0000 Subject: (최겸) 기술영업 아이템별 담당자 매핑 dialog 추가개발 건 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/add-contact-item-mapping-dialog.tsx | 541 +++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx (limited to 'lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx') diff --git a/lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx b/lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx new file mode 100644 index 00000000..e1f76bba --- /dev/null +++ b/lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx @@ -0,0 +1,541 @@ +"use client" + +import * as React from "react" +import { Search, Loader2, CheckSquare, Square, ChevronDown, ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { + searchShipbuildingItems, + searchOffshoreTopItems, + searchOffshoreHullItems, + searchTechVendors, + getTechVendorsContactsForMapping, + addContactItemMapping, +} from "../service-add-mapping" + +interface AddContactItemMappingDialogProps { + trigger?: React.ReactNode +} + +interface Item { + id: number + itemCode: string + itemList: string + workType: string | null + shipTypes?: string | null + subItemList?: string | null +} + +interface Vendor { + id: number + vendorCode: string | null + vendorName: string + email: string | null + techVendorType: string + status: string +} + +interface Contact { + id: number + contactName: string + contactPosition: string | null + contactTitle: string | null + contactEmail: string + contactPhone: string | null + isPrimary: boolean +} + +export function AddContactItemMappingDialog({ trigger }: AddContactItemMappingDialogProps) { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + const [selectedItemType, setSelectedItemType] = React.useState<'ship' | 'top' | 'hull'>('ship') + const [itemSearch, setItemSearch] = React.useState("") + const [items, setItems] = React.useState([]) + const [selectedItems, setSelectedItems] = React.useState>(new Set()) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + + // Section 2: 벤더 선택 + const [vendorSearch, setVendorSearch] = React.useState("") + const [vendors, setVendors] = React.useState([]) + const [selectedVendors, setSelectedVendors] = React.useState>(new Set()) + const [isLoadingVendors, setIsLoadingVendors] = React.useState(false) + + // Section 3: 벤더별 담당자 선택 + const [vendorContacts, setVendorContacts] = React.useState>({}) + const [selectedContacts, setSelectedContacts] = React.useState>>({}) + const [expandedVendors, setExpandedVendors] = React.useState>(new Set()) + const [isLoadingContacts, setIsLoadingContacts] = React.useState(false) + + // 아이템 타입 변경 시 아이템 로드 + React.useEffect(() => { + loadItems() + }, [selectedItemType, itemSearch]) + + // 벤더 검색 디바운싱 + React.useEffect(() => { + const timer = setTimeout(() => { + loadVendors() + }, 300) + return () => clearTimeout(timer) + }, [vendorSearch]) + + // 선택된 벤더 변경 시 담당자 로드 + React.useEffect(() => { + if (selectedVendors.size > 0) { + loadContacts() + } + }, [selectedVendors]) + + const loadItems = async () => { + setIsLoadingItems(true) + try { + let result + if (selectedItemType === 'ship') { + result = await searchShipbuildingItems(itemSearch) + } else if (selectedItemType === 'top') { + result = await searchOffshoreTopItems(itemSearch) + } else { + result = await searchOffshoreHullItems(itemSearch) + } + + setItems(result.data || []) + } catch (err) { + toast.error("아이템 로드 실패") + } finally { + setIsLoadingItems(false) + } + } + + const loadVendors = async () => { + setIsLoadingVendors(true) + try { + const result = await searchTechVendors(vendorSearch) + setVendors(result.data || []) + } catch (err) { + toast.error("벤더 로드 실패") + } finally { + setIsLoadingVendors(false) + } + } + + const loadContacts = async () => { + setIsLoadingContacts(true) + try { + const vendorIds = Array.from(selectedVendors) + const result = await getTechVendorsContactsForMapping(vendorIds) + + if (result.data) { + const contacts: Record = {} + Object.entries(result.data).forEach(([vendorIdStr, vendorData]) => { + const vendorId = parseInt(vendorIdStr) + contacts[vendorId] = vendorData.contacts + }) + setVendorContacts(contacts) + } + } catch (err) { + toast.error("담당자 로드 실패") + } finally { + setIsLoadingContacts(false) + } + } + const handleItemTypeChange = (type: 'ship' | 'top' | 'hull') => { + setSelectedItemType(type) + setSelectedItems(new Set()) // 타입 변경 시 선택 초기화 + setItemSearch("") // 검색어도 초기화 + } + + const handleItemSelect = (itemId: number) => { + setSelectedItems(prev => { + const newSet = new Set(prev) + if (newSet.has(itemId)) { + newSet.delete(itemId) + } else { + newSet.add(itemId) + } + return newSet + }) + } + + const handleVendorSelect = (vendorId: number) => { + setSelectedVendors(prev => { + const newSet = new Set(prev) + if (newSet.has(vendorId)) { + newSet.delete(vendorId) + // 벤더 선택 해제 시 해당 벤더의 담당자 선택도 초기화 + setSelectedContacts(prev => { + const newContacts = { ...prev } + delete newContacts[vendorId] + return newContacts + }) + } else { + newSet.add(vendorId) + } + return newSet + }) + } + + const handleContactSelect = (vendorId: number, contactId: number) => { + setSelectedContacts(prev => { + const vendorContacts = prev[vendorId] || new Set() + const newSet = new Set(vendorContacts) + if (newSet.has(contactId)) { + newSet.delete(contactId) + } else { + newSet.add(contactId) + } + return { ...prev, [vendorId]: newSet } + }) + } + + const toggleVendorExpand = (vendorId: number) => { + setExpandedVendors(prev => { + const newSet = new Set(prev) + if (newSet.has(vendorId)) { + newSet.delete(vendorId) + } else { + newSet.add(vendorId) + } + return newSet + }) + } + const handleSubmit = async () => { + // 검증 + if (selectedItems.size === 0) { + toast.error("최소 1개 이상의 아이템을 선택해주세요.") + return + } + + if (selectedVendors.size === 0) { + toast.error("최소 1개 이상의 벤더를 선택해주세요.") + return + } + + const hasSelectedContacts = Object.values(selectedContacts).some(contacts => contacts.size > 0) + if (!hasSelectedContacts) { + toast.error("최소 1개 이상의 담당자를 선택해주세요.") + return + } + + setIsSubmitting(true) + try { + // 선택된 아이템 변환 + const itemType = selectedItemType === 'ship' ? 'SHIP' : selectedItemType === 'top' ? 'TOP' : 'HULL' + const itemsToAdd: { id: number; type: 'SHIP' | 'TOP' | 'HULL' }[] = [] + selectedItems.forEach(id => itemsToAdd.push({ id, type: itemType })) + + + // 벤더별 담당자 ID를 객체로 변환 + const contactsByVendor: Record = {} + Object.entries(selectedContacts).forEach(([vendorIdStr, contacts]) => { + contactsByVendor[parseInt(vendorIdStr)] = Array.from(contacts) + }) + + const result = await addContactItemMapping({ + items: itemsToAdd, + vendors: Array.from(selectedVendors), + contactsByVendor, + }) + + if (result.error) { + toast.error(result.error) + } else { + toast.success("담당자-아이템 매핑이 추가되었습니다.") + setOpen(false) + // 초기화 + resetForm() + } + } catch (err) { + toast.error("매핑 추가 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + const resetForm = () => { + setSelectedItemType('ship') + setItemSearch("") + setSelectedItems(new Set()) + setVendorSearch("") + setSelectedVendors(new Set()) + setSelectedContacts({}) + setExpandedVendors(new Set()) + } + + const getTotalSelectedContacts = () => { + return Object.values(selectedContacts).reduce((sum, contacts) => sum + contacts.size, 0) + } + + // 매핑 추가 버튼 활성화 조건 + const canSubmit = selectedItems.size > 0 && selectedVendors.size > 0 && getTotalSelectedContacts() > 0 + + return ( + + + {trigger || ( + + )} + + + + 아이템별 협력업체별 담당자 설정 + + 아이템, 협력업체, 담당자를 선택하여 매핑을 추가합니다. + + + +
+ {/* Section 1: 아이템 선택 */} +
+
+ + + {/* 아이템 타입 필터 */} + handleItemTypeChange(value as 'ship' | 'top' | 'hull')}> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* 검색 */} +
+ + setItemSearch(e.target.value)} + /> +
+
+ + {/* 아이템 리스트 */} + + {isLoadingItems ? ( +
+ +
+ ) : ( +
+ {items.length > 0 && items.map(item => ( +
handleItemSelect(item.id)} + > + {selectedItems.has(item.id) ? ( + + ) : ( + + )} +
+
{item.itemCode}
+
{item.itemList}
+
+ {item.workType} + {item.shipTypes && ` • ${item.shipTypes}`} + {item.subItemList && ` • ${item.subItemList}`} +
+
+
+ ))} +
+ )} +
+
+ + {/* Section 2: 벤더 선택 */} +
+
+ + + {/* 검색 */} +
+ + setVendorSearch(e.target.value)} + /> +
+
+ + {/* 벤더 리스트 */} + + {isLoadingVendors ? ( +
+ +
+ ) : ( +
+ {vendors.map(vendor => ( +
handleVendorSelect(vendor.id)} + > + {selectedVendors.has(vendor.id) ? ( + + ) : ( + + )} +
+
{vendor.vendorName}
+ {vendor.vendorCode && ( +
{vendor.vendorCode}
+ )} +
+ {vendor.techVendorType} • {vendor.status} +
+
+
+ ))} +
+ )} +
+
+ + {/* Section 3: 벤더별 담당자 선택 */} +
+ + + + {isLoadingContacts ? ( +
+ +
+ ) : selectedVendors.size === 0 ? ( +
+ 협력업체를 먼저 선택해주세요 +
+ ) : ( +
+ {Array.from(selectedVendors).map(vendorId => { + const contacts = vendorContacts[vendorId] || [] + const vendor = vendors.find(v => v.id === vendorId) + const isExpanded = expandedVendors.has(vendorId) + + return ( +
+ {/* 벤더 헤더 */} +
toggleVendorExpand(vendorId)} + > + {isExpanded ? ( + + ) : ( + + )} +
+
{vendor?.vendorName}
+
+ 담당자 {contacts.length}명 • {selectedContacts[vendorId]?.size || 0}명 선택 +
+
+
+ + {/* 담당자 리스트 */} + {isExpanded && ( +
+ {contacts.length === 0 ? ( +
+ 등록된 담당자가 없습니다 +
+ ) : ( + contacts.map(contact => ( +
handleContactSelect(vendorId, contact.id)} + > + {selectedContacts[vendorId]?.has(contact.id) ? ( + + ) : ( + + )} +
+
+ {contact.contactName} + {contact.isPrimary && ( + (주담당) + )} +
+
+ {contact.contactPosition} +
+
+ {contact.contactEmail} +
+
+
+ )) + )} +
+ )} +
+ ) + })} +
+ )} +
+
+
+ + + + + +
+
+ ) +} + -- cgit v1.2.3