summaryrefslogtreecommitdiff
path: root/lib/contact-possible-items
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-20 07:43:56 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-20 07:43:56 +0000
commitbbf276882ec813beee465cec785fd2d31ff15a54 (patch)
tree5a7fe2671714c77dced08b079093613fb936bc50 /lib/contact-possible-items
parent45c5925185100da0da319e322b0696711cfbf14c (diff)
(최겸) 기술영업 아이템별 담당자 매핑 dialog 추가개발 건
Diffstat (limited to 'lib/contact-possible-items')
-rw-r--r--lib/contact-possible-items/service-add-mapping.ts312
-rw-r--r--lib/contact-possible-items/table/add-contact-item-mapping-dialog.tsx541
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx2
3 files changed, 855 insertions, 0 deletions
diff --git a/lib/contact-possible-items/service-add-mapping.ts b/lib/contact-possible-items/service-add-mapping.ts
new file mode 100644
index 00000000..39215549
--- /dev/null
+++ b/lib/contact-possible-items/service-add-mapping.ts
@@ -0,0 +1,312 @@
+"use server"
+
+import { revalidatePath } from 'next/cache'
+import { eq, and, or, ilike, inArray } from 'drizzle-orm'
+import db from '@/db/db'
+import { techSalesContactPossibleItems } from "@/db/schema/techSales"
+import { techVendors, techVendorContacts, techVendorPossibleItems } from "@/db/schema/techVendors"
+import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"
+
+/**
+ * 조선 아이템 검색
+ */
+export async function searchShipbuildingItems(search: string = "") {
+ try {
+ const where = search
+ ? or(
+ ilike(itemShipbuilding.itemCode, `%${search}%`),
+ ilike(itemShipbuilding.itemList, `%${search}%`),
+ ilike(itemShipbuilding.workType, `%${search}%`),
+ ilike(itemShipbuilding.shipTypes, `%${search}%`)
+ )
+ : undefined
+
+ const items = await db
+ .select({
+ id: itemShipbuilding.id,
+ itemCode: itemShipbuilding.itemCode,
+ itemList: itemShipbuilding.itemList,
+ workType: itemShipbuilding.workType,
+ shipTypes: itemShipbuilding.shipTypes,
+ })
+ .from(itemShipbuilding)
+ .where(where)
+ .limit(100)
+
+ return { data: items, error: null }
+ } catch (err) {
+ console.error("조선 아이템 검색 오류:", err)
+ return { data: [], error: "조선 아이템 검색에 실패했습니다." }
+ }
+}
+
+/**
+ * 해양 TOP 아이템 검색
+ */
+export async function searchOffshoreTopItems(search: string = "") {
+ try {
+ const where = search
+ ? or(
+ ilike(itemOffshoreTop.itemCode, `%${search}%`),
+ ilike(itemOffshoreTop.itemList, `%${search}%`),
+ ilike(itemOffshoreTop.workType, `%${search}%`)
+ )
+ : undefined
+
+ const items = await db
+ .select({
+ id: itemOffshoreTop.id,
+ itemCode: itemOffshoreTop.itemCode,
+ itemList: itemOffshoreTop.itemList,
+ workType: itemOffshoreTop.workType,
+ subItemList: itemOffshoreTop.subItemList,
+ })
+ .from(itemOffshoreTop)
+ .where(where)
+ .limit(100)
+
+ return { data: items, error: null }
+ } catch (err) {
+ console.error("해양 TOP 아이템 검색 오류:", err)
+ return { data: [], error: "해양 TOP 아이템 검색에 실패했습니다." }
+ }
+}
+
+/**
+ * 해양 HULL 아이템 검색
+ */
+export async function searchOffshoreHullItems(search: string = "") {
+ try {
+ const where = search
+ ? or(
+ ilike(itemOffshoreHull.itemCode, `%${search}%`),
+ ilike(itemOffshoreHull.itemList, `%${search}%`),
+ ilike(itemOffshoreHull.workType, `%${search}%`)
+ )
+ : undefined
+
+ const items = await db
+ .select({
+ id: itemOffshoreHull.id,
+ itemCode: itemOffshoreHull.itemCode,
+ itemList: itemOffshoreHull.itemList,
+ workType: itemOffshoreHull.workType,
+ subItemList: itemOffshoreHull.subItemList,
+ })
+ .from(itemOffshoreHull)
+ .where(where)
+ .limit(100)
+
+ return { data: items, error: null }
+ } catch (err) {
+ console.error("해양 HULL 아이템 검색 오류:", err)
+ return { data: [], error: "해양 HULL 아이템 검색에 실패했습니다." }
+ }
+}
+
+/**
+ * 기술영업 벤더 검색
+ */
+export async function searchTechVendors(search: string = "") {
+ try {
+ const where = search
+ ? or(
+ ilike(techVendors.vendorCode, `%${search}%`),
+ ilike(techVendors.vendorName, `%${search}%`),
+ ilike(techVendors.email, `%${search}%`)
+ )
+ : undefined
+
+ const vendors = await db
+ .select({
+ id: techVendors.id,
+ vendorCode: techVendors.vendorCode,
+ vendorName: techVendors.vendorName,
+ email: techVendors.email,
+ techVendorType: techVendors.techVendorType,
+ status: techVendors.status,
+ })
+ .from(techVendors)
+ .where(where)
+ .limit(100)
+
+ return { data: vendors, error: null }
+ } catch (err) {
+ console.error("벤더 검색 오류:", err)
+ return { data: [], error: "벤더 검색에 실패했습니다." }
+ }
+}
+
+/**
+ * 여러 벤더의 담당자 조회
+ */
+export async function getTechVendorsContactsForMapping(vendorIds: number[]) {
+ try {
+ if (vendorIds.length === 0) {
+ return { data: {}, error: null }
+ }
+
+ const contactsWithVendor = await db
+ .select({
+ contactId: techVendorContacts.id,
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactTitle: techVendorContacts.contactTitle,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ isPrimary: techVendorContacts.isPrimary,
+ vendorId: techVendorContacts.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode
+ })
+ .from(techVendorContacts)
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(inArray(techVendorContacts.vendorId, vendorIds))
+
+ // 벤더별로 그룹화
+ const contactsByVendor = contactsWithVendor.reduce((acc, row) => {
+ const vendorId = row.vendorId
+ if (!acc[vendorId]) {
+ acc[vendorId] = {
+ vendor: {
+ id: vendorId,
+ vendorName: row.vendorName || '',
+ vendorCode: row.vendorCode || ''
+ },
+ contacts: []
+ }
+ }
+ acc[vendorId].contacts.push({
+ id: row.contactId,
+ contactName: row.contactName,
+ contactPosition: row.contactPosition,
+ contactTitle: row.contactTitle,
+ contactEmail: row.contactEmail,
+ contactPhone: row.contactPhone,
+ isPrimary: row.isPrimary
+ })
+ return acc
+ }, {} as Record<number, {
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ }
+ contacts: Array<{
+ id: number
+ contactName: string
+ contactPosition: string | null
+ contactTitle: string | null
+ contactEmail: string
+ contactPhone: string | null
+ isPrimary: boolean
+ }>
+ }>)
+
+ return { data: contactsByVendor, error: null }
+ } catch (err) {
+ console.error("벤더 담당자 조회 오류:", err)
+ return { data: {}, error: "벤더 담당자 조회에 실패했습니다." }
+ }
+}
+
+/**
+ * 담당자-아이템 매핑 추가
+ * 선택된 아이템과 벤더를 기준으로 possible-items 테이블에 정보가 있으면 해당 id 사용,
+ * 없으면 possible-items 테이블에 insert 후, insert한 id를 가지고 담당자와 연결
+ */
+interface AddContactItemMappingInput {
+ items: {
+ id: number
+ type: 'SHIP' | 'TOP' | 'HULL'
+ }[]
+ vendors: number[]
+ contactsByVendor: Record<number, number[]> // vendorId -> contactIds[]
+}
+
+export async function addContactItemMapping(input: AddContactItemMappingInput) {
+ try {
+ return await db.transaction(async (tx) => {
+ const insertedMappings: Array<typeof techSalesContactPossibleItems.$inferSelect> = []
+
+ // 각 아이템 x 벤더 조합에 대해 처리
+ for (const item of input.items) {
+ for (const vendorId of input.vendors) {
+ const contactIds = input.contactsByVendor[vendorId] || []
+ if (contactIds.length === 0) continue
+
+ // techVendorPossibleItems에서 기존 항목 찾기
+ let vendorPossibleItemId: number | null = null
+
+ const whereCondition = and(
+ eq(techVendorPossibleItems.vendorId, vendorId),
+ item.type === 'SHIP'
+ ? eq(techVendorPossibleItems.shipbuildingItemId, item.id)
+ : item.type === 'TOP'
+ ? eq(techVendorPossibleItems.offshoreTopItemId, item.id)
+ : eq(techVendorPossibleItems.offshoreHullItemId, item.id)
+ )
+
+ const existingPossibleItem = await tx
+ .select({ id: techVendorPossibleItems.id })
+ .from(techVendorPossibleItems)
+ .where(whereCondition)
+ .limit(1)
+
+ if (existingPossibleItem.length > 0) {
+ vendorPossibleItemId = existingPossibleItem[0].id
+ } else {
+ // 없으면 새로 생성
+ const newPossibleItem = await tx
+ .insert(techVendorPossibleItems)
+ .values({
+ vendorId,
+ shipbuildingItemId: item.type === 'SHIP' ? item.id : null,
+ offshoreTopItemId: item.type === 'TOP' ? item.id : null,
+ offshoreHullItemId: item.type === 'HULL' ? item.id : null,
+ })
+ .returning({ id: techVendorPossibleItems.id })
+
+ vendorPossibleItemId = newPossibleItem[0].id
+ }
+
+ // 각 담당자에 대해 techSalesContactPossibleItems에 추가
+ for (const contactId of contactIds) {
+ // 중복 체크
+ const existing = await tx
+ .select({ id: techSalesContactPossibleItems.id })
+ .from(techSalesContactPossibleItems)
+ .where(
+ and(
+ eq(techSalesContactPossibleItems.contactId, contactId),
+ eq(techSalesContactPossibleItems.vendorPossibleItemId, vendorPossibleItemId)
+ )
+ )
+ .limit(1)
+
+ if (existing.length === 0) {
+ const inserted = await tx
+ .insert(techSalesContactPossibleItems)
+ .values({
+ contactId,
+ vendorPossibleItemId,
+ })
+ .returning()
+
+ if (inserted.length > 0) {
+ insertedMappings.push(inserted[0])
+ }
+ }
+ }
+ }
+ }
+
+ revalidatePath("/evcp/contact-possible-items")
+ return { data: insertedMappings, error: null }
+ })
+ } catch (err) {
+ console.error("담당자-아이템 매핑 추가 오류:", err)
+ return { data: null, error: "담당자-아이템 매핑 추가에 실패했습니다." }
+ }
+}
+
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>
+ )
+}
+
diff --git a/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx b/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx
index 4125399b..fd7c254b 100644
--- a/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx
+++ b/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx
@@ -7,6 +7,7 @@ import * as ExcelJS from 'exceljs'
import { Button } from "@/components/ui/button"
import { ContactPossibleItemDetail } from "../service"
+import { AddContactItemMappingDialog } from "./add-contact-item-mapping-dialog"
interface ContactPossibleItemsTableToolbarActionsProps {
table: Table<ContactPossibleItemDetail>
@@ -43,6 +44,7 @@ export function ContactPossibleItemsTableToolbarActions({
return (
<div className="flex items-center gap-2">
+ <AddContactItemMappingDialog />
<Button
variant="outline"
size="sm"