diff options
Diffstat (limited to 'lib/contact-possible-items')
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"
|
