summaryrefslogtreecommitdiff
path: root/lib/tech-vendors
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tech-vendors')
-rw-r--r--lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx406
-rw-r--r--lib/tech-vendors/possible-items/possible-items-table.tsx18
-rw-r--r--lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx17
-rw-r--r--lib/tech-vendors/service.ts261
4 files changed, 695 insertions, 7 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>
+ );
+}
+
diff --git a/lib/tech-vendors/possible-items/possible-items-table.tsx b/lib/tech-vendors/possible-items/possible-items-table.tsx
index 100ef04a..226cddf7 100644
--- a/lib/tech-vendors/possible-items/possible-items-table.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-table.tsx
@@ -37,7 +37,8 @@ import { getTechVendorPossibleItems } from "../../tech-vendor-possible-items/ser
import { deleteTechVendorPossibleItem, getTechVendorDetailById } from "../service"
import type { TechVendorPossibleItem } from "../validations"
import { PossibleItemsTableToolbarActions } from "./possible-items-toolbar-actions"
-import { AddItemDialog } from "./add-item-dialog" // 주석처리
+import { AddItemDialog } from "./add-item-dialog"
+import { ConnectItemVendorDialog } from "./connect-item-vendor-dialog"
interface TechVendorPossibleItemsTableProps {
promises: Promise<
@@ -55,7 +56,8 @@ export function TechVendorPossibleItemsTable({
// Suspense로 받아온 데이터
const [{ data, pageCount }] = React.use(promises)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorPossibleItem> | null>(null)
- const [showAddDialog, setShowAddDialog] = React.useState(false) // 주석처리
+ const [showAddDialog, setShowAddDialog] = React.useState(false)
+ const [showConnectDialog, setShowConnectDialog] = React.useState(false)
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
const [isDeleting, setIsDeleting] = React.useState(false)
@@ -189,7 +191,8 @@ export function TechVendorPossibleItemsTable({
<PossibleItemsTableToolbarActions
table={table}
vendorId={vendorId}
- onAdd={() => setShowAddDialog(true)} // 주석처리
+ onAdd={() => setShowAddDialog(true)}
+ onConnect={() => setShowConnectDialog(true)}
onRefresh={() => {
// 페이지 새로고침을 위한 콜백
window.location.reload()
@@ -199,13 +202,20 @@ export function TechVendorPossibleItemsTable({
</DataTableAdvancedToolbar>
</DataTable>
- {/* Add Item Dialog */}
+ {/* Add Item Dialog (벤더 기준) */}
<AddItemDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
vendorId={vendorId}
/>
+ {/* Item -> Vendor Connect Dialog (아이템 기준) */}
+ <ConnectItemVendorDialog
+ open={showConnectDialog}
+ onOpenChange={setShowConnectDialog}
+ onConnected={() => window.location.reload()}
+ />
+
{/* Vendor Items Dialog */}
<Dialog open={showItemsDialog} onOpenChange={setShowItemsDialog}>
<DialogContent className="max-w-2xl max-h-[80vh]">
diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
index 49a673ff..428f4ce5 100644
--- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
@@ -39,14 +39,16 @@ import {
interface PossibleItemsTableToolbarActionsProps {
table: Table<TechVendorPossibleItem>
vendorId: number
- onAdd: () => void // 주석처리
+ onAdd: () => void
+ onConnect: () => void
onRefresh?: () => void // 데이터 새로고침 콜백
}
export function PossibleItemsTableToolbarActions({
table,
vendorId,
- onAdd, // 주석처리
+ onAdd,
+ onConnect,
onRefresh,
}: PossibleItemsTableToolbarActionsProps) {
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
@@ -362,7 +364,16 @@ export function PossibleItemsTableToolbarActions({
onClick={onAdd}
>
<Plus className="mr-2 h-4 w-4" />
- 아이템 연결
+ 기존 아이템 연결
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onConnect}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 아이템-벤더 연결
</Button>
{selectedRows.length > 0 && (
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts
index 940e59ce..5290b6a0 100644
--- a/lib/tech-vendors/service.ts
+++ b/lib/tech-vendors/service.ts
@@ -703,6 +703,267 @@ export interface ItemDropdownOption {
subItemList: string | null;
}
+export interface ItemForVendorMapping {
+ id: number;
+ itemCode: string | null;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+ itemType: "SHIP" | "TOP" | "HULL";
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface VendorForItemMapping {
+ id: number;
+ vendorName: string;
+ email: string | null;
+ techVendorType: string;
+ status: string;
+}
+
+const itemTypeToVendorType: Record<"SHIP" | "TOP" | "HULL", string> = {
+ SHIP: "조선",
+ TOP: "해양TOP",
+ HULL: "해양HULL",
+};
+
+function parseVendorTypes(value: string | string[] | null) {
+ if (!value) return [] as string[];
+ if (Array.isArray(value)) {
+ return value
+ .map((type) => type.trim())
+ .filter((type) => type.length > 0);
+ }
+ return value
+ .split(",")
+ .map((type) => type.trim())
+ .filter((type) => type.length > 0);
+}
+
+/**
+ * 아이템 기준으로 벤더 매핑 시 사용할 전체 아이템 목록 조회
+ * 벤더에 관계없이 전 타입을 모두 가져온다.
+ */
+export async function getItemsForVendorMapping() {
+ return unstable_cache(
+ async () => {
+ try {
+ const items: ItemForVendorMapping[] = [];
+
+ const shipbuildingItems = await db
+ .select({
+ id: itemShipbuilding.id,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ itemCode: itemShipbuilding.itemCode,
+ itemList: itemShipbuilding.itemList,
+ workType: itemShipbuilding.workType,
+ shipTypes: itemShipbuilding.shipTypes,
+ })
+ .from(itemShipbuilding)
+ .orderBy(asc(itemShipbuilding.itemCode));
+
+ items.push(
+ ...shipbuildingItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "SHIP" as const,
+ }))
+ );
+
+ const offshoreTopItems = await db
+ .select({
+ id: itemOffshoreTop.id,
+ createdAt: itemOffshoreTop.createdAt,
+ updatedAt: itemOffshoreTop.updatedAt,
+ itemCode: itemOffshoreTop.itemCode,
+ itemList: itemOffshoreTop.itemList,
+ workType: itemOffshoreTop.workType,
+ subItemList: itemOffshoreTop.subItemList,
+ })
+ .from(itemOffshoreTop)
+ .orderBy(asc(itemOffshoreTop.itemCode));
+
+ items.push(
+ ...offshoreTopItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "TOP" as const,
+ }))
+ );
+
+ const offshoreHullItems = await db
+ .select({
+ id: itemOffshoreHull.id,
+ createdAt: itemOffshoreHull.createdAt,
+ updatedAt: itemOffshoreHull.updatedAt,
+ itemCode: itemOffshoreHull.itemCode,
+ itemList: itemOffshoreHull.itemList,
+ workType: itemOffshoreHull.workType,
+ subItemList: itemOffshoreHull.subItemList,
+ })
+ .from(itemOffshoreHull)
+ .orderBy(asc(itemOffshoreHull.itemCode));
+
+ items.push(
+ ...offshoreHullItems
+ .filter((item) => item.itemCode != null)
+ .map((item) => ({
+ ...item,
+ itemType: "HULL" as const,
+ }))
+ );
+
+ return { data: items, error: null };
+ } catch (err) {
+ console.error("Failed to fetch items for vendor mapping:", err);
+ return {
+ data: [],
+ error: "아이템 목록을 불러오는데 실패했습니다.",
+ };
+ }
+ },
+ ["items-for-vendor-mapping"],
+ {
+ revalidate: 3600,
+ tags: ["items"],
+ }
+ )();
+}
+
+/**
+ * 특정 아이템에 연결 가능한 벤더 목록을 조회
+ * - 이미 연결된 벤더는 제외
+ * - 아이템 타입과 벤더 타입(조선/해양TOP/해양HULL) 매칭
+ */
+export async function getConnectableVendorsForItem(
+ itemId: number,
+ itemType: "SHIP" | "TOP" | "HULL"
+) {
+ unstable_noStore();
+
+ try {
+ // 1) 이미 연결된 벤더 ID 조회
+ const existingVendors = await db
+ .select({ vendorId: techVendorPossibleItems.vendorId })
+ .from(techVendorPossibleItems)
+ .where(
+ itemType === "SHIP"
+ ? eq(techVendorPossibleItems.shipbuildingItemId, itemId)
+ : itemType === "TOP"
+ ? eq(techVendorPossibleItems.offshoreTopItemId, itemId)
+ : eq(techVendorPossibleItems.offshoreHullItemId, itemId)
+ );
+
+ const existingVendorIds = existingVendors.map((row) => row.vendorId);
+
+ // 2) 모든 벤더 조회 후 타입 매칭 + 중복 제외
+ const vendorRows = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ email: techVendors.email,
+ techVendorType: techVendors.techVendorType,
+ status: techVendors.status,
+ })
+ .from(techVendors);
+
+ const targetType = itemTypeToVendorType[itemType];
+
+ const availableVendors: VendorForItemMapping[] = vendorRows
+ .map((vendor) => ({
+ ...vendor,
+ vendorTypes: parseVendorTypes(vendor.techVendorType),
+ }))
+ .filter(
+ (vendor) =>
+ vendor.vendorTypes.includes(targetType) &&
+ !existingVendorIds.includes(vendor.id)
+ )
+ .map(({ vendorTypes, ...rest }) => rest);
+
+ return { data: availableVendors, error: null };
+ } catch (err) {
+ console.error("Failed to fetch connectable vendors:", err);
+ return { data: [], error: "연결 가능한 벤더 조회에 실패했습니다." };
+ }
+}
+
+/**
+ * 선택한 아이템을 여러 벤더와 연결
+ * - 중복 연결은 건너뜀
+ */
+export async function connectItemWithVendors(input: {
+ itemId: number;
+ itemType: "SHIP" | "TOP" | "HULL";
+ vendorIds: number[];
+}) {
+ unstable_noStore();
+
+ if (!input.vendorIds || input.vendorIds.length === 0) {
+ return { success: false, error: "연결할 벤더를 선택해주세요." };
+ }
+
+ try {
+ let successCount = 0;
+ const skipped: number[] = [];
+
+ await db.transaction(async (tx) => {
+ for (const vendorId of input.vendorIds) {
+ const whereConditions = [eq(techVendorPossibleItems.vendorId, vendorId)];
+
+ if (input.itemType === "SHIP") {
+ whereConditions.push(eq(techVendorPossibleItems.shipbuildingItemId, input.itemId));
+ } else if (input.itemType === "TOP") {
+ whereConditions.push(eq(techVendorPossibleItems.offshoreTopItemId, input.itemId));
+ } else {
+ whereConditions.push(eq(techVendorPossibleItems.offshoreHullItemId, input.itemId));
+ }
+
+ const existing = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(...whereConditions),
+ });
+
+ if (existing) {
+ skipped.push(vendorId);
+ continue;
+ }
+
+ const insertData: {
+ vendorId: number;
+ shipbuildingItemId?: number;
+ offshoreTopItemId?: number;
+ offshoreHullItemId?: number;
+ } = { vendorId };
+
+ if (input.itemType === "SHIP") {
+ insertData.shipbuildingItemId = input.itemId;
+ } else if (input.itemType === "TOP") {
+ insertData.offshoreTopItemId = input.itemId;
+ } else {
+ insertData.offshoreHullItemId = input.itemId;
+ }
+
+ await tx.insert(techVendorPossibleItems).values(insertData);
+ successCount += 1;
+ }
+ });
+
+ input.vendorIds.forEach((vendorId) => {
+ revalidateTag(`tech-vendor-possible-items-${vendorId}`);
+ });
+
+ return { success: true, successCount, skipped };
+ } catch (err) {
+ console.error("Failed to connect item with vendors:", err);
+ return { success: false, error: getErrorMessage(err) };
+ }
+}
+
/**
* Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
* 아이템 코드, 이름, 설명만 간소화해서 반환