diff options
| -rw-r--r-- | lib/items-tech/table/add-items-dialog.tsx | 65 | ||||
| -rw-r--r-- | lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx | 406 | ||||
| -rw-r--r-- | lib/tech-vendors/possible-items/possible-items-table.tsx | 18 | ||||
| -rw-r--r-- | lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx | 17 | ||||
| -rw-r--r-- | lib/tech-vendors/service.ts | 261 | ||||
| -rw-r--r-- | lib/techsales-rfq/service.ts | 169 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx | 42 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx | 47 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx | 348 |
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> + ) +} + |
