summaryrefslogtreecommitdiff
path: root/lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx')
-rw-r--r--lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx541
1 files changed, 541 insertions, 0 deletions
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<Item[]>([])
+ const [selectedItems, setSelectedItems] = React.useState<Set<number>>(new Set())
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+
+ // Section 2: 벤더 선택
+ const [vendorSearch, setVendorSearch] = React.useState("")
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [selectedVendors, setSelectedVendors] = React.useState<Set<number>>(new Set())
+ const [isLoadingVendors, setIsLoadingVendors] = React.useState(false)
+
+ // Section 3: 벤더별 담당자 선택
+ const [vendorContacts, setVendorContacts] = React.useState<Record<number, Contact[]>>({})
+ const [selectedContacts, setSelectedContacts] = React.useState<Record<number, Set<number>>>({})
+ const [expandedVendors, setExpandedVendors] = React.useState<Set<number>>(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<number, Contact[]> = {}
+ 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<number, number[]> = {}
+ 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 (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ {trigger || (
+ <Button variant="outline" size="sm">
+ 담당자-아이템 매핑 추가
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-6xl h-[90vh] flex flex-col overflow-hidden">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>아이템별 협력업체별 담당자 설정</DialogTitle>
+ <DialogDescription>
+ 아이템, 협력업체, 담당자를 선택하여 매핑을 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid grid-cols-3 gap-4 flex-1 min-h-0 overflow-hidden">
+ {/* Section 1: 아이템 선택 */}
+ <div className="flex flex-col border rounded-lg p-3 min-h-0">
+ <div className="space-y-2 flex-shrink-0">
+ <Label className="text-sm font-semibold">
+ 1. 아이템 선택 ({selectedItems.size}개 선택)
+ </Label>
+
+ {/* 아이템 타입 필터 */}
+ <RadioGroup value={selectedItemType} onValueChange={(value) => handleItemTypeChange(value as 'ship' | 'top' | 'hull')}>
+ <div className="flex gap-3 text-sm">
+ <div className="flex items-center space-x-1">
+ <RadioGroupItem value="ship" id="type-ship" />
+ <Label htmlFor="type-ship" className="cursor-pointer font-normal">조선</Label>
+ </div>
+ <div className="flex items-center space-x-1">
+ <RadioGroupItem value="top" id="type-top" />
+ <Label htmlFor="type-top" className="cursor-pointer font-normal">해양 TOP</Label>
+ </div>
+ <div className="flex items-center space-x-1">
+ <RadioGroupItem value="hull" id="type-hull" />
+ <Label htmlFor="type-hull" className="cursor-pointer font-normal">해양 HULL</Label>
+ </div>
+ </div>
+ </RadioGroup>
+
+ {/* 검색 */}
+ <div className="relative">
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템코드, 이름, 공종, 선종 검색..."
+ className="pl-8 h-9 text-sm"
+ value={itemSearch}
+ onChange={(e) => setItemSearch(e.target.value)}
+ />
+ </div>
+ </div>
+
+ {/* 아이템 리스트 */}
+ <ScrollArea className="flex-1 min-h-0 mt-2 -mx-3 px-3">
+ {isLoadingItems ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin" />
+ </div>
+ ) : (
+ <div className="space-y-1">
+ {items.length > 0 && items.map(item => (
+ <div
+ key={item.id}
+ className="flex items-start space-x-2 p-2 rounded hover:bg-accent cursor-pointer text-sm"
+ onClick={() => handleItemSelect(item.id)}
+ >
+ {selectedItems.has(item.id) ? (
+ <CheckSquare className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
+ ) : (
+ <Square className="h-4 w-4 flex-shrink-0 mt-0.5" />
+ )}
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <div className="font-medium break-words">{item.itemCode}</div>
+ <div className="text-xs text-muted-foreground break-words">{item.itemList}</div>
+ <div className="text-xs text-muted-foreground break-words">
+ {item.workType}
+ {item.shipTypes && ` • ${item.shipTypes}`}
+ {item.subItemList && ` • ${item.subItemList}`}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+
+ {/* Section 2: 벤더 선택 */}
+ <div className="flex flex-col border rounded-lg p-3 min-h-0">
+ <div className="space-y-2 flex-shrink-0">
+ <Label className="text-sm font-semibold">
+ 2. 협력업체 선택 ({selectedVendors.size}개 선택)
+ </Label>
+
+ {/* 검색 */}
+ <div className="relative">
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="벤더코드, 벤더명, 이메일 검색..."
+ className="pl-8 h-9 text-sm"
+ value={vendorSearch}
+ onChange={(e) => setVendorSearch(e.target.value)}
+ />
+ </div>
+ </div>
+
+ {/* 벤더 리스트 */}
+ <ScrollArea className="flex-1 min-h-0 mt-2 -mx-3 px-3">
+ {isLoadingVendors ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin" />
+ </div>
+ ) : (
+ <div className="space-y-1">
+ {vendors.map(vendor => (
+ <div
+ key={vendor.id}
+ className="flex items-start space-x-2 p-2 rounded hover:bg-accent cursor-pointer text-sm"
+ onClick={() => handleVendorSelect(vendor.id)}
+ >
+ {selectedVendors.has(vendor.id) ? (
+ <CheckSquare className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
+ ) : (
+ <Square className="h-4 w-4 flex-shrink-0 mt-0.5" />
+ )}
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <div className="font-medium break-words">{vendor.vendorName}</div>
+ {vendor.vendorCode && (
+ <div className="text-xs text-muted-foreground break-words">{vendor.vendorCode}</div>
+ )}
+ <div className="text-xs text-muted-foreground break-words">
+ {vendor.techVendorType} • {vendor.status}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+
+ {/* Section 3: 벤더별 담당자 선택 */}
+ <div className="flex flex-col border rounded-lg p-3 min-h-0">
+ <Label className="text-sm font-semibold flex-shrink-0">
+ 3. 담당자 선택 ({getTotalSelectedContacts()}명 선택)
+ </Label>
+
+ <ScrollArea className="flex-1 min-h-0 mt-2 -mx-3 px-3">
+ {isLoadingContacts ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin" />
+ </div>
+ ) : selectedVendors.size === 0 ? (
+ <div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
+ 협력업체를 먼저 선택해주세요
+ </div>
+ ) : (
+ <div className="space-y-1">
+ {Array.from(selectedVendors).map(vendorId => {
+ const contacts = vendorContacts[vendorId] || []
+ const vendor = vendors.find(v => v.id === vendorId)
+ const isExpanded = expandedVendors.has(vendorId)
+
+ return (
+ <div key={vendorId} className="border rounded">
+ {/* 벤더 헤더 */}
+ <div
+ className="flex items-center space-x-2 p-2 cursor-pointer hover:bg-accent"
+ onClick={() => toggleVendorExpand(vendorId)}
+ >
+ {isExpanded ? (
+ <ChevronDown className="h-4 w-4 flex-shrink-0" />
+ ) : (
+ <ChevronRight className="h-4 w-4 flex-shrink-0" />
+ )}
+ <div className="flex-1 min-w-0">
+ <div className="text-sm font-medium truncate">{vendor?.vendorName}</div>
+ <div className="text-xs text-muted-foreground">
+ 담당자 {contacts.length}명 • {selectedContacts[vendorId]?.size || 0}명 선택
+ </div>
+ </div>
+ </div>
+
+ {/* 담당자 리스트 */}
+ {isExpanded && (
+ <div className="border-t bg-muted/30">
+ {contacts.length === 0 ? (
+ <div className="p-2 text-xs text-muted-foreground text-center">
+ 등록된 담당자가 없습니다
+ </div>
+ ) : (
+ contacts.map(contact => (
+ <div
+ key={contact.id}
+ className="flex items-start space-x-2 p-2 hover:bg-accent cursor-pointer text-sm"
+ onClick={() => handleContactSelect(vendorId, contact.id)}
+ >
+ {selectedContacts[vendorId]?.has(contact.id) ? (
+ <CheckSquare className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
+ ) : (
+ <Square className="h-4 w-4 flex-shrink-0 mt-0.5" />
+ )}
+ <div className="flex-1 min-w-0">
+ <div className="font-medium truncate">
+ {contact.contactName}
+ {contact.isPrimary && (
+ <span className="ml-1 text-xs text-primary">(주담당)</span>
+ )}
+ </div>
+ <div className="text-xs text-muted-foreground truncate">
+ {contact.contactPosition}
+ </div>
+ <div className="text-xs text-muted-foreground truncate">
+ {contact.contactEmail}
+ </div>
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ )}
+ </div>
+ )
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+ </div>
+
+ <DialogFooter className="flex-shrink-0 mt-4">
+ <Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={!canSubmit || isSubmitting}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 추가 중...
+ </>
+ ) : (
+ "매핑 추가"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+