diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-08 14:19:37 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-08 14:19:37 +0900 |
| commit | 2ac7deb8494cf4123f0cff3321860585a44f157c (patch) | |
| tree | 789b6980c8f863a0f675fad38c4a17d91ba28bf3 /lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx | |
| parent | 71c0ba1f01b98770ec2c60cdb935ffb36c1830a9 (diff) | |
| parent | e37cce51ccfa3dcb91904b2492df3a29970fadf7 (diff) | |
Merge remote-tracking branch 'origin/sec-patch' into table-v2
Diffstat (limited to 'lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx')
| -rw-r--r-- | lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx | 406 |
1 files changed, 406 insertions, 0 deletions
diff --git a/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx b/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx new file mode 100644 index 00000000..bd53b3cc --- /dev/null +++ b/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx @@ -0,0 +1,406 @@ +"use client"; + +import * as React from "react"; +import { Search, X } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + getItemsForVendorMapping, + getConnectableVendorsForItem, + connectItemWithVendors, +} from "../service"; + +type ItemType = "SHIP" | "TOP" | "HULL"; + +interface ItemData { + id: number; + itemCode: string | null; + itemList: string | null; + workType: string | null; + shipTypes?: string | null; + subItemList?: string | null; + itemType: ItemType; + createdAt: Date; + updatedAt: Date; +} + +interface VendorData { + id: number; + vendorName: string; + email: string | null; + techVendorType: string; + status: string; +} + +interface ConnectItemVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConnected?: () => void; +} + +export function ConnectItemVendorDialog({ + open, + onOpenChange, + onConnected, +}: ConnectItemVendorDialogProps) { + const [items, setItems] = React.useState<ItemData[]>([]); + const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]); + const [itemSearch, setItemSearch] = React.useState(""); + const [selectedItem, setSelectedItem] = React.useState<ItemData | null>(null); + + const [vendors, setVendors] = React.useState<VendorData[]>([]); + const [filteredVendors, setFilteredVendors] = React.useState<VendorData[]>([]); + const [vendorSearch, setVendorSearch] = React.useState(""); + const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([]); + + const [isLoadingItems, setIsLoadingItems] = React.useState(false); + const [isLoadingVendors, setIsLoadingVendors] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 다이얼로그가 열릴 때 전체 아이템 목록 로드 + React.useEffect(() => { + if (open) { + loadItems(); + } + }, [open]); + + // 아이템 검색 필터링 + React.useEffect(() => { + if (!itemSearch) { + setFilteredItems(items); + return; + } + + const lowered = itemSearch.toLowerCase(); + const filtered = items.filter((item) => + [item.itemCode, item.itemList, item.workType, item.shipTypes, item.subItemList] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(lowered)) + ); + setFilteredItems(filtered); + }, [items, itemSearch]); + + // 벤더 검색 필터링 + React.useEffect(() => { + if (!vendorSearch) { + setFilteredVendors(vendors); + return; + } + + const lowered = vendorSearch.toLowerCase(); + const filtered = vendors.filter((vendor) => + [vendor.vendorName, vendor.email, vendor.techVendorType, vendor.status] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(lowered)) + ); + setFilteredVendors(filtered); + }, [vendors, vendorSearch]); + + // 특정 아이템 선택 시 연결 가능한 벤더 목록 로드 + React.useEffect(() => { + if (!selectedItem) { + setVendors([]); + setFilteredVendors([]); + setSelectedVendorIds([]); + return; + } + loadVendors(selectedItem); + }, [selectedItem]); + + const loadItems = async () => { + setIsLoadingItems(true); + try { + const result = await getItemsForVendorMapping(); + if (result.error) { + throw new Error(result.error); + } + const validItems = (result.data as ItemData[]).filter((item) => item.itemCode != null); + setItems(validItems); + } catch (error) { + console.error("Failed to load items for mapping:", error); + toast.error("아이템 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoadingItems(false); + } + }; + + const loadVendors = async (item: ItemData) => { + setIsLoadingVendors(true); + try { + const result = await getConnectableVendorsForItem(item.id, item.itemType); + if (result.error) { + throw new Error(result.error); + } + setVendors(result.data as VendorData[]); + } catch (error) { + console.error("Failed to load vendors for item:", error); + toast.error("연결 가능한 벤더 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoadingVendors(false); + } + }; + + const handleItemSelect = (item: ItemData) => { + if (!item.itemCode) return; + setSelectedItem(item); + }; + + const handleVendorToggle = (vendorId: number) => { + setSelectedVendorIds((prev) => + prev.includes(vendorId) + ? prev.filter((id) => id !== vendorId) + : [...prev, vendorId] + ); + }; + + const handleSubmit = async () => { + if (!selectedItem || selectedVendorIds.length === 0) return; + + setIsSubmitting(true); + try { + const result = await connectItemWithVendors({ + itemId: selectedItem.id, + itemType: selectedItem.itemType, + vendorIds: selectedVendorIds, + }); + + if (!result.success) { + throw new Error(result.error || "연결에 실패했습니다."); + } + + const successCount = result.successCount || 0; + const skippedCount = result.skipped?.length || 0; + + toast.success( + `${successCount}개 벤더와 연결되었습니다${ + skippedCount > 0 ? ` (${skippedCount}개 중복 제외)` : "" + }` + ); + + onConnected?.(); + handleClose(); + } catch (error) { + console.error("Failed to connect item with vendors:", error); + toast.error(error instanceof Error ? error.message : "연결 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + setTimeout(() => { + setItemSearch(""); + setVendorSearch(""); + setSelectedItem(null); + setSelectedVendorIds([]); + setItems([]); + setFilteredItems([]); + setVendors([]); + setFilteredVendors([]); + }, 200); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>아이템 기준 벤더 연결</DialogTitle> + <DialogDescription> + 연결할 아이템을 먼저 선택한 후, 해당 아이템과 연결할 벤더를 선택하세요. + </DialogDescription> + </DialogHeader> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0"> + {/* 아이템 선택 영역 */} + <div className="flex flex-col space-y-3"> + <div className="space-y-2"> + <Label htmlFor="item-search">아이템 검색</Label> + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> + <Input + id="item-search" + placeholder="아이템코드, 아이템리스트, 공종, 선종 검색..." + value={itemSearch} + onChange={(e) => setItemSearch(e.target.value)} + className="pl-10" + /> + </div> + </div> + + {selectedItem && ( + <div className="space-y-2"> + <Label>선택된 아이템</Label> + <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50"> + <Badge variant="default" className="text-xs"> + {[selectedItem.itemType, selectedItem.itemCode, selectedItem.shipTypes] + .filter(Boolean) + .join("-")} + <X + className="ml-1 h-3 w-3 cursor-pointer" + onClick={(e) => { + e.stopPropagation(); + setSelectedItem(null); + }} + /> + </Badge> + </div> + </div> + )} + + <div className="flex-1 min-h-0 overflow-hidden"> + <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2 h-full"> + {isLoadingItems ? ( + <div className="text-center py-4">아이템 로딩 중...</div> + ) : filteredItems.length === 0 ? ( + <div className="text-center py-4 text-muted-foreground"> + 아이템이 없습니다. + </div> + ) : ( + <div className="space-y-2"> + {filteredItems.map((item) => { + if (!item.itemCode) return null; + const isSelected = selectedItem?.id === item.id && selectedItem.itemType === item.itemType; + const itemKey = `${item.itemType}-${item.id}-${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ""}`; + return ( + <div + key={`item-${itemKey}`} + className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${ + isSelected + ? "bg-primary/10 border-primary hover:bg-primary/20" + : "hover:bg-gray-50" + }`} + onClick={() => handleItemSelect(item)} + > + <div className="font-medium"> + {[`[${item.itemType}]`, item.itemCode, item.shipTypes] + .filter(Boolean) + .join(" ")} + </div> + <div className="text-sm text-muted-foreground"> + {item.itemList || "-"} + </div> + <div className="flex flex-wrap gap-2 mt-1 text-xs"> + <span>공종: {item.workType || "-"}</span> + {item.shipTypes && <span>선종: {item.shipTypes}</span>} + {item.subItemList && <span>서브아이템: {item.subItemList}</span>} + </div> + </div> + ); + })} + </div> + )} + </div> + </div> + </div> + + {/* 벤더 선택 영역 */} + <div className="flex flex-col space-y-3"> + <div className="space-y-2"> + <Label htmlFor="vendor-search">벤더 검색</Label> + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> + <Input + id="vendor-search" + placeholder="벤더명, 이메일, 벤더타입, 상태로 검색..." + value={vendorSearch} + onChange={(e) => setVendorSearch(e.target.value)} + className="pl-10" + disabled={!selectedItem} + /> + </div> + </div> + + {selectedVendorIds.length > 0 && ( + <div className="space-y-2"> + <Label>선택된 벤더 ({selectedVendorIds.length}개)</Label> + <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto"> + {vendors + .filter((vendor) => selectedVendorIds.includes(vendor.id)) + .map((vendor) => ( + <Badge key={`selected-vendor-${vendor.id}`} variant="default" className="text-xs"> + {vendor.vendorName} + <X + className="ml-1 h-3 w-3 cursor-pointer" + onClick={(e) => { + e.stopPropagation(); + handleVendorToggle(vendor.id); + }} + /> + </Badge> + ))} + </div> + </div> + )} + + <div className="flex-1 min-h-0 overflow-hidden"> + <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2 h-full"> + {!selectedItem ? ( + <div className="text-center py-4 text-muted-foreground"> + 아이템을 먼저 선택해주세요. + </div> + ) : isLoadingVendors ? ( + <div className="text-center py-4">벤더 로딩 중...</div> + ) : filteredVendors.length === 0 ? ( + <div className="text-center py-4 text-muted-foreground"> + 연결 가능한 벤더가 없습니다. + </div> + ) : ( + <div className="space-y-2"> + {filteredVendors.map((vendor) => { + const isSelected = selectedVendorIds.includes(vendor.id); + return ( + <div + key={`vendor-${vendor.id}`} + className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${ + isSelected + ? "bg-primary/10 border-primary hover:bg-primary/20" + : "hover:bg-gray-50" + }`} + onClick={() => handleVendorToggle(vendor.id)} + > + <div className="font-medium">{vendor.vendorName}</div> + <div className="text-sm text-muted-foreground"> + {vendor.email || "-"} + </div> + <div className="flex flex-wrap gap-2 mt-1 text-xs"> + <span>타입: {vendor.techVendorType || "-"}</span> + <span>상태: {vendor.status || "-"}</span> + </div> + </div> + ); + })} + </div> + )} + </div> + </div> + </div> + </div> + + <div className="flex justify-end gap-2 pt-4 border-t"> + <Button variant="outline" onClick={handleClose}> + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={!selectedItem || selectedVendorIds.length === 0 || isSubmitting} + > + {isSubmitting ? "연결 중..." : `연결 (${selectedVendorIds.length})`} + </Button> + </div> + </DialogContent> + </Dialog> + ); +} + |
