summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/items-tech/table/add-items-dialog.tsx65
-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
-rw-r--r--lib/techsales-rfq/service.ts169
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx42
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx47
-rw-r--r--lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx348
9 files changed, 1358 insertions, 15 deletions
diff --git a/lib/items-tech/table/add-items-dialog.tsx b/lib/items-tech/table/add-items-dialog.tsx
index 01a072da..a4c644b6 100644
--- a/lib/items-tech/table/add-items-dialog.tsx
+++ b/lib/items-tech/table/add-items-dialog.tsx
@@ -34,7 +34,7 @@ import {
} from "@/components/ui/select"
import { toast } from "sonner"
-import { createShipbuildingItem, createOffshoreTopItem, createOffshoreHullItem } from "../service"
+import { createShipbuildingItem, createOffshoreTopItem, createOffshoreHullItem, getShipTypes } from "../service"
import { ItemType } from "./delete-items-dialog"
// 조선 공종 유형 정의
@@ -88,6 +88,8 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
const router = useRouter()
const [open, setOpen] = React.useState(false)
const [isAddPending, startAddTransition] = React.useTransition()
+ const [shipTypeOptions, setShipTypeOptions] = React.useState<string[]>([])
+ const [isShipTypeLoading, setIsShipTypeLoading] = React.useState(false)
// 기본값 설정
const getDefaultValues = () => {
@@ -97,7 +99,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
}
if (itemType === 'shipbuilding') {
- defaults.shipTypes = "OPTION"
+ defaults.shipTypes = ""
} else {
defaults.itemList = ""
defaults.subItemList = ""
@@ -124,6 +126,42 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
defaultValues: getDefaultValues(),
})
+ // shipTypes 목록 로드 (조선 아이템 생성 시)
+ React.useEffect(() => {
+ if (itemType !== 'shipbuilding' || !open) return
+
+ let isMounted = true
+ const loadShipTypes = async () => {
+ try {
+ setIsShipTypeLoading(true)
+ const { data, error } = await getShipTypes()
+ if (!isMounted) return
+ if (error) {
+ toast.error("선종 목록을 불러오지 못했습니다")
+ return
+ }
+ const options = (data || []).filter((v): v is string => Boolean(v))
+ setShipTypeOptions(options)
+ // 기본값 자동 설정
+ if (options.length > 0 && !form.getValues("shipTypes")) {
+ form.setValue("shipTypes", options[0])
+ }
+ } catch (err) {
+ console.error("shipTypes load error:", err)
+ if (isMounted) {
+ toast.error("선종 목록 로드 중 오류가 발생했습니다")
+ }
+ } finally {
+ if (isMounted) setIsShipTypeLoading(false)
+ }
+ }
+
+ loadShipTypes()
+ return () => {
+ isMounted = false
+ }
+ }, [itemType, open, form])
+
const onSubmit = async (data: ItemFormValues) => {
startAddTransition(async () => {
try {
@@ -276,7 +314,28 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) {
<FormItem>
<FormLabel>선종 <span style={{ color: 'red' }}>*</span></FormLabel>
<FormControl>
- <Input placeholder="선종을 입력하세요" {...field} />
+ <Select
+ onValueChange={field.onChange}
+ value={field.value ?? ""}
+ disabled={isShipTypeLoading || shipTypeOptions.length === 0}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={isShipTypeLoading ? "불러오는 중..." : "선종을 선택하세요"} />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.length === 0 ? (
+ <div className="px-3 py-2 text-sm text-muted-foreground">
+ {isShipTypeLoading ? "불러오는 중..." : "선종 없음"}
+ </div>
+ ) : (
+ shipTypeOptions.map((type) => (
+ <SelectItem key={type} value={type}>
+ {type}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
</FormControl>
<FormMessage />
</FormItem>
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 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
* 아이템 코드, 이름, 설명만 간소화해서 반환
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index cf4d02e2..13d0bbce 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -3662,7 +3662,8 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number
updatedAt: techSalesVendorQuotationAttachments.updatedAt,
})
.from(techSalesVendorQuotationAttachments)
- .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .where(and(eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.isVendorUpload, true)))
.orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
return { data: attachments };
@@ -3680,6 +3681,172 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number
}
/**
+ * 기술영업 RFQ 기준 벤더 견적서 요약 목록 조회 (eml 첨부 전용)
+ */
+export async function getTechSalesVendorQuotationsForRfq(rfqId: number) {
+ unstable_noStore();
+ try {
+ const quotations = await db
+ .select({
+ id: techSalesVendorQuotations.id,
+ vendorId: techSalesVendorQuotations.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ status: techSalesVendorQuotations.status,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
+ .where(eq(techSalesVendorQuotations.rfqId, rfqId))
+ .orderBy(
+ asc(techVendors.vendorName),
+ asc(techSalesVendorQuotations.id)
+ );
+
+ return { data: quotations, error: null };
+ } catch (error) {
+ console.error("기술영업 RFQ 벤더 견적서 목록 조회 오류:", error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 eml 첨부파일 조회 (isVendorUpload = false)
+ */
+export async function getTechSalesVendorQuotationEmlAttachments(quotationId: number) {
+ unstable_noStore();
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(
+ and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.isVendorUpload, false)
+ )
+ )
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments, error: null };
+ } catch (error) {
+ console.error("기술영업 벤더 견적서 eml 첨부파일 조회 오류:", error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 eml 첨부파일 업로드/삭제 처리
+ * - isVendorUpload = false 로 저장 (메일 등 별도 전달 문서 보관용)
+ */
+export async function processTechSalesVendorQuotationEmlAttachments(params: {
+ quotationId: number;
+ newFiles?: { file: File; description?: string }[];
+ deleteAttachmentIds?: number[];
+ uploadedBy: number;
+ revisionId?: number;
+}) {
+ unstable_noStore();
+ const { quotationId, newFiles = [], deleteAttachmentIds = [], uploadedBy, revisionId } = params;
+
+ try {
+ // 견적서 확인
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ columns: { id: true, rfqId: true, quotationVersion: true },
+ });
+
+ if (!quotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ const targetRevisionId = revisionId ?? quotation.quotationVersion ?? 0;
+
+ await db.transaction(async (tx) => {
+ // 삭제 처리 (벤더 업로드 파일은 삭제하지 않음)
+ if (deleteAttachmentIds.length > 0) {
+ const deletable = await tx.query.techSalesVendorQuotationAttachments.findMany({
+ where: inArray(techSalesVendorQuotationAttachments.id, deleteAttachmentIds),
+ });
+
+ for (const attachment of deletable) {
+ if (attachment.isVendorUpload) {
+ throw new Error("벤더가 업로드한 파일은 여기서 삭제할 수 없습니다.");
+ }
+
+ await tx
+ .delete(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.id, attachment.id));
+
+ try {
+ deleteFile(attachment.filePath);
+ } catch (fileError) {
+ console.warn("eml 첨부파일 삭제 중 파일 시스템 오류:", fileError);
+ }
+ }
+ }
+
+ // 업로드 처리
+ if (newFiles.length > 0) {
+ for (const { file, description } of newFiles) {
+ const saveResult = await saveFile({
+ file,
+ directory: `techsales-quotations/${quotationId}/eml`,
+ userId: uploadedBy.toString(),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId,
+ revisionId: targetRevisionId,
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName || file.name,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ filePath: saveResult.publicPath!,
+ description: description || null,
+ uploadedBy,
+ isVendorUpload: false,
+ });
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag(`quotation-${quotationId}`);
+ revalidateTag("quotation-attachments");
+ revalidateTag("techSalesVendorQuotations");
+ if (quotation.rfqId) {
+ revalidateTag(`techSalesRfq-${quotation.rfqId}`);
+ }
+ revalidateTag("techSalesRfqs");
+
+ const refreshed = await getTechSalesVendorQuotationEmlAttachments(quotationId);
+ return { data: refreshed.data, error: refreshed.error };
+ } catch (error) {
+ console.error("기술영업 벤더 견적서 eml 첨부파일 처리 오류:", error);
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+/**
* 특정 리비전의 견적서 첨부파일 조회
*/
export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index fe9befe5..d3a12385 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -5,7 +5,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table";
import { formatDate } from "@/lib/utils"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users } from "lucide-react";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -79,6 +79,7 @@ interface GetColumnsProps<TData> {
onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
openContactsDialog?: (quotationId: number, vendorName?: string) => void; // 담당자 조회 다이얼로그 열기
+ openEmlAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // eml 첨부파일 sheet 열기
}
export function getRfqDetailColumns({
@@ -86,7 +87,8 @@ export function getRfqDetailColumns({
unreadMessages = {},
onQuotationClick,
openQuotationAttachmentsSheet,
- openContactsDialog
+ openContactsDialog,
+ openEmlAttachmentsSheet
}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
return [
{
@@ -351,6 +353,42 @@ export function getRfqDetailColumns({
size: 80,
},
{
+ id: "emlAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="eml 첨부" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original;
+ const handleClick = () => {
+ if (!openEmlAttachmentsSheet) return;
+ openEmlAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ };
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="eml 첨부파일 관리"
+ title="eml 첨부파일 관리"
+ >
+ <Mail className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "eml 첨부"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
id: "contacts",
header: "담당자",
cell: ({ row }) => {
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index 72f03dc3..aee15594 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -19,6 +19,7 @@ import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
import { DeleteVendorDialog } from "./delete-vendors-dialog"
import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import { TechSalesVendorEmlAttachmentsSheet, type VendorEmlAttachment } from "../tech-sales-vendor-eml-attachments-sheet"
import type { QuotationInfo } from "./rfq-detail-column"
import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
@@ -89,6 +90,12 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+ // eml 첨부파일 sheet 상태 관리
+ const [emlAttachmentsSheetOpen, setEmlAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationForEml, setSelectedQuotationForEml] = useState<QuotationInfo | null>(null)
+ const [emlAttachments, setEmlAttachments] = useState<VendorEmlAttachment[]>([])
+ const [isLoadingEmlAttachments, setIsLoadingEmlAttachments] = useState(false)
+
// 벤더 contact 선택 다이얼로그 상태 관리
const [contactSelectionDialogOpen, setContactSelectionDialogOpen] = useState(false)
@@ -463,6 +470,31 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
}
}, [])
+ // eml 첨부파일 sheet 열기 핸들러
+ const handleOpenEmlAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingEmlAttachments(true)
+ setSelectedQuotationForEml(quotationInfo)
+ setEmlAttachmentsSheetOpen(true)
+
+ const { getTechSalesVendorQuotationEmlAttachments } = await import("@/lib/techsales-rfq/service")
+ const result = await getTechSalesVendorQuotationEmlAttachments(quotationId)
+
+ if (result.error) {
+ toast.error(result.error)
+ setEmlAttachments([])
+ } else {
+ setEmlAttachments(result.data || [])
+ }
+ } catch (error) {
+ console.error("eml 첨부파일 조회 오류:", error)
+ toast.error("eml 첨부파일을 불러오는 중 오류가 발생했습니다.")
+ setEmlAttachments([])
+ } finally {
+ setIsLoadingEmlAttachments(false)
+ }
+ }, [])
+
// 담당자 조회 다이얼로그 열기 함수
const handleOpenContactsDialog = useCallback((quotationId: number, vendorName?: string) => {
setSelectedQuotationForContacts({ id: quotationId, vendorName })
@@ -554,8 +586,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
unreadMessages,
onQuotationClick: handleOpenHistoryDialog,
openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet,
- openContactsDialog: handleOpenContactsDialog
- }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog])
+ openContactsDialog: handleOpenContactsDialog,
+ openEmlAttachmentsSheet: handleOpenEmlAttachmentsSheet
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog, handleOpenEmlAttachmentsSheet])
// 필터 필드 정의 (메모이제이션)
const advancedFilterFields = useMemo(
@@ -928,6 +961,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
isLoading={isLoadingAttachments}
/>
+ {/* eml 첨부파일 Sheet */}
+ <TechSalesVendorEmlAttachmentsSheet
+ open={emlAttachmentsSheetOpen}
+ onOpenChange={setEmlAttachmentsSheetOpen}
+ quotation={selectedQuotationForEml}
+ attachments={emlAttachments}
+ isLoading={isLoadingEmlAttachments}
+ onAttachmentsChange={setEmlAttachments}
+ />
+
{/* 벤더 contact 선택 다이얼로그 */}
<VendorContactSelectionDialog
open={contactSelectionDialogOpen}
diff --git a/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx
new file mode 100644
index 00000000..2b6f6753
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx
@@ -0,0 +1,348 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list"
+import { Badge } from "@/components/ui/badge"
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/components/ui/form"
+import { toast } from "sonner"
+import { Download, Loader, Trash2, X } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { useSession } from "next-auth/react"
+import { useForm } from "react-hook-form"
+import { formatDate } from "@/lib/utils"
+import {
+ getTechSalesVendorQuotationEmlAttachments,
+ processTechSalesVendorQuotationEmlAttachments,
+} from "@/lib/techsales-rfq/service"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+export interface VendorEmlAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ uploadedBy: number | null
+ vendorId: number | null
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface TechSalesVendorEmlAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ quotation: QuotationInfo | null
+ attachments: VendorEmlAttachment[]
+ onAttachmentsChange?: (attachments: VendorEmlAttachment[]) => void
+ isLoading?: boolean
+}
+
+export function TechSalesVendorEmlAttachmentsSheet({
+ quotation,
+ attachments,
+ onAttachmentsChange,
+ isLoading = false,
+ ...props
+}: TechSalesVendorEmlAttachmentsSheetProps) {
+ const session = useSession()
+ const [isPending, setIsPending] = React.useState(false)
+ const [existing, setExisting] = React.useState<VendorEmlAttachment[]>(attachments)
+ const [newUploads, setNewUploads] = React.useState<File[]>([])
+ const [deleteIds, setDeleteIds] = React.useState<number[]>([])
+
+ const form = useForm({
+ defaultValues: {
+ dummy: true,
+ },
+ })
+
+ // sync when parent changes
+ React.useEffect(() => {
+ setExisting(attachments)
+ setNewUploads([])
+ setDeleteIds([])
+ }, [attachments])
+
+ const handleDownloadClick = React.useCallback(async (attachment: VendorEmlAttachment) => {
+ try {
+ const { downloadFile } = await import("@/lib/file-download")
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error("다운로드 오류:", error)
+ toast.error(error)
+ },
+ })
+ } catch (error) {
+ console.error("다운로드 오류:", error)
+ toast.error("파일 다운로드 중 오류가 발생했습니다.")
+ }
+ }, [])
+
+ const handleDropAccepted = React.useCallback((accepted: File[]) => {
+ setNewUploads((prev) => [...prev, ...accepted])
+ }, [])
+
+ const handleDropRejected = React.useCallback(() => {
+ toast.error("파일 크기가 너무 크거나 지원하지 않는 형식입니다.")
+ }, [])
+
+ const handleRemoveExisting = React.useCallback((id: number) => {
+ setDeleteIds((prev) => (prev.includes(id) ? prev : [...prev, id]))
+ setExisting((prev) => prev.filter((att) => att.id !== id))
+ }, [])
+
+ const handleRemoveNewUpload = React.useCallback((index: number) => {
+ setNewUploads((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const handleSubmit = async () => {
+ if (!quotation) {
+ toast.error("견적 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ const userId = Number(session.data?.user.id || 0)
+ if (!userId) {
+ toast.error("로그인 정보를 확인해주세요.")
+ return
+ }
+
+ setIsPending(true)
+ try {
+ const result = await processTechSalesVendorQuotationEmlAttachments({
+ quotationId: quotation.id,
+ newFiles: newUploads.map((file) => ({ file })),
+ deleteAttachmentIds: deleteIds,
+ uploadedBy: userId,
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ const refreshed =
+ result.data ||
+ (await getTechSalesVendorQuotationEmlAttachments(quotation.id)).data ||
+ []
+
+ setExisting(refreshed)
+ setNewUploads([])
+ setDeleteIds([])
+ onAttachmentsChange?.(refreshed)
+ toast.success("Eml 첨부파일이 저장되었습니다.")
+ props.onOpenChange?.(false)
+ } catch (error) {
+ console.error("eml 첨부파일 저장 오류:", error)
+ toast.error("eml 첨부파일 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const totalNewSize = newUploads.reduce((acc, f) => acc + f.size, 0)
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>eml 첨부파일</SheetTitle>
+ <SheetDescription>
+ <div className="space-y-1">
+ {quotation?.vendorName && <div>벤더: {quotation.vendorName}</div>}
+ {quotation?.rfqCode && <div>RFQ: {quotation.rfqCode}</div>}
+ </div>
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={(e) => e.preventDefault()} className="flex flex-1 flex-col gap-6">
+ {/* 기존 첨부 */}
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 기존 첨부파일 ({existing.length}개)
+ </h6>
+ {isLoading ? (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader className="h-4 w-4 animate-spin" />
+ 로딩 중...
+ </div>
+ ) : existing.length === 0 ? (
+ <div className="text-sm text-muted-foreground">첨부파일이 없습니다.</div>
+ ) : (
+ existing.map((att) => (
+ <div
+ key={att.id}
+ className="flex items-start justify-between p-3 border rounded-md gap-3"
+ >
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <p className="text-sm font-medium break-words leading-tight">
+ {att.originalFileName || att.fileName}
+ </p>
+ <Badge variant="outline" className="text-xs shrink-0">
+ rev {att.revisionId}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {prettyBytes(att.fileSize)} • {formatDate(att.createdAt, "KR")}
+ </p>
+ {att.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {att.description}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ type="button"
+ onClick={() => handleDownloadClick(att)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ type="button"
+ onClick={() => handleRemoveExisting(att.id)}
+ title="삭제"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+
+ {/* 새 업로드 */}
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={form.control}
+ name="dummy"
+ render={() => (
+ <FormItem>
+ <FormLabel>새 eml 파일 업로드</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>복수 파일 업로드 가능</FormDescription>
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {newUploads.length > 0 && (
+ <div className="grid gap-3">
+ <div className="flex items-center justify-between">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 새 파일 ({newUploads.length}개)
+ </h6>
+ <span className="text-xs text-muted-foreground">
+ 총 용량 {prettyBytes(totalNewSize)}
+ </span>
+ </div>
+ <FileList>
+ {newUploads.map((file, idx) => (
+ <FileListItem key={`${file.name}-${idx}`}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>{prettyBytes(file.size)}</FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => handleRemoveNewUpload(idx)}>
+ <X />
+ <span className="sr-only">제거</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 닫기
+ </Button>
+ </SheetClose>
+ <Button
+ type="button"
+ onClick={handleSubmit}
+ disabled={isPending || (!newUploads.length && deleteIds.length === 0)}
+ >
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
+