summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/table')
-rw-r--r--lib/techsales-rfq/table/create-rfq-hull-dialog.tsx652
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx (renamed from lib/techsales-rfq/table/create-rfq-dialog.tsx)353
-rw-r--r--lib/techsales-rfq/table/create-rfq-top-dialog.tsx594
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx271
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx25
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx1
-rw-r--r--lib/techsales-rfq/table/project-detail-dialog.tsx202
-rw-r--r--lib/techsales-rfq/table/rfq-filter-sheet.tsx6
-rw-r--r--lib/techsales-rfq/table/rfq-items-view-dialog.tsx198
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx269
-rw-r--r--lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx23
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx127
12 files changed, 1923 insertions, 798 deletions
diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
new file mode 100644
index 00000000..4ba98cc7
--- /dev/null
+++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
@@ -0,0 +1,652 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
+import { type Project } from "@/lib/rfqs/service"
+import { createTechSalesHullRfq } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+import { Separator } from "@/components/ui/separator"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+// import {
+// Table,
+// TableBody,
+// TableCell,
+// TableHead,
+// TableHeader,
+// TableRow,
+// } from "@/components/ui/table"
+
+// 공종 타입 import
+import {
+ getOffshoreHullWorkTypes,
+ getAllOffshoreHullItemsForCache,
+ type OffshoreHullWorkType,
+ type OffshoreHullTechItem
+} from "@/lib/items-tech/service"
+
+// 해양 HULL 아이템 타입 정의 (이미 service에서 import하므로 제거)
+
+// 유효성 검증 스키마
+const createHullRfqSchema = z.object({
+ biddingProjectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ itemIds: z.array(z.number()).min(1, {
+ message: "적어도 하나의 아이템을 선택해야 합니다.",
+ }),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+ description: z.string().optional(),
+})
+
+// 폼 데이터 타입
+type CreateHullRfqFormValues = z.infer<typeof createHullRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: OffshoreHullWorkType
+ name: string
+ description: string
+}
+
+interface CreateHullRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
+ const { data: session } = useSession()
+
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ // 검색 및 필터링 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState("")
+ const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreHullWorkType | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<OffshoreHullTechItem[]>([])
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [allItems, setAllItems] = React.useState<OffshoreHullTechItem[]>([])
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+ const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
+ const [retryCount, setRetryCount] = React.useState(0)
+
+ // 데이터 로딩 함수
+ const loadData = React.useCallback(async (isRetry = false) => {
+ try {
+ if (!isRetry) {
+ setIsLoadingItems(true)
+ setDataLoadError(null)
+ }
+
+ console.log(`해양 Hull RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+
+ const [workTypesResult, hullItemsResult] = await Promise.all([
+ getOffshoreHullWorkTypes(),
+ getAllOffshoreHullItemsForCache()
+ ])
+
+ console.log("Hull - WorkTypes 결과:", workTypesResult)
+ console.log("Hull - Items 결과:", hullItemsResult)
+
+ // WorkTypes 설정
+ if (Array.isArray(workTypesResult)) {
+ setWorkTypes(workTypesResult)
+ } else {
+ throw new Error("공종 데이터를 불러올 수 없습니다.")
+ }
+
+ // Hull Items 설정
+ if (hullItemsResult.data && Array.isArray(hullItemsResult.data)) {
+ setAllItems(hullItemsResult.data as OffshoreHullTechItem[])
+ console.log("Hull 아이템 설정 완료:", hullItemsResult.data.length, "개")
+ } else {
+ throw new Error(hullItemsResult.error || "Hull 아이템 데이터를 불러올 수 없습니다.")
+ }
+
+ // 성공 시 재시도 카운터 리셋
+ setRetryCount(0)
+ setDataLoadError(null)
+ console.log("해양 Hull RFQ 데이터 로딩 완료")
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ console.error("해양 Hull RFQ 데이터 로딩 오류:", errorMessage)
+
+ setDataLoadError(errorMessage)
+
+ // 3회까지 자동 재시도 (500ms 간격)
+ if (retryCount < 2) {
+ console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
+ setTimeout(() => {
+ setRetryCount(prev => prev + 1)
+ loadData(true)
+ }, 500 * (retryCount + 1))
+ } else {
+ // 재시도 실패 시 사용자에게 알림
+ toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
+ }
+ } finally {
+ if (!isRetry) {
+ setIsLoadingItems(false)
+ }
+ }
+ }, [retryCount])
+
+ // 다이얼로그가 열릴 때마다 데이터 로딩
+ React.useEffect(() => {
+ if (isDialogOpen) {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }
+ }, [isDialogOpen, loadData])
+
+ // 수동 새로고침 함수
+ const handleRefreshData = React.useCallback(() => {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }, [loadData])
+
+ // RFQ 생성 폼
+ const form = useForm<CreateHullRfqFormValues>({
+ resolver: zodResolver(createHullRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ }
+ })
+
+ // 필터링된 아이템 목록 가져오기
+ const availableItems = React.useMemo(() => {
+ let filtered = [...allItems]
+
+ // 공종 필터
+ if (selectedWorkType) {
+ filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreHullTechItem['workType'])
+ }
+
+ // 검색어 필터
+ if (itemSearchQuery && itemSearchQuery.trim()) {
+ const query = itemSearchQuery.toLowerCase().trim()
+ filtered = filtered.filter(item =>
+ item.itemCode.toLowerCase().includes(query) ||
+ (item.itemList && item.itemList.toLowerCase().includes(query)) ||
+ (item.subItemList && item.subItemList.toLowerCase().includes(query))
+ )
+ }
+
+ return filtered
+ }, [allItems, itemSearchQuery, selectedWorkType])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ setSelectedWorkType(null)
+ setItemSearchQuery("")
+ form.setValue("itemIds", [])
+ }
+
+ // 아이템 선택/해제 처리
+ const handleItemToggle = (item: OffshoreHullTechItem) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ if (isSelected) {
+ const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ } else {
+ const newSelectedItems = [...selectedItems, item]
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ }
+ }
+
+ // RFQ 생성 함수
+ const handleCreateRfq = async (data: CreateHullRfqFormValues) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 해양 Hull RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesHullRfq({
+ biddingProjectId: data.biddingProjectId,
+ itemIds: data.itemIds,
+ dueDate: data.dueDate,
+ description: data.description,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
+ toast.success(`${selectedItems.length}개 아이템으로 해양 Hull RFQ가 성공적으로 생성되었습니다`)
+
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+
+ // 생성 후 콜백 실행
+ if (onCreated) {
+ onCreated()
+ }
+
+ } catch (error) {
+ console.error("해양 Hull RFQ 생성 오류:", error)
+ toast.error(`해양 Hull RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ return (
+ <Dialog
+ open={isDialogOpen}
+ onOpenChange={(open) => {
+ setIsDialogOpen(open)
+ if (!open) {
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+ }
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">해양 Hull RFQ 생성</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent
+ className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
+ style={{ width: '1200px' }}
+ >
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle>해양 Hull RFQ 생성</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6 p-1 overflow-y-auto">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
+ {/* 프로젝트 선택 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="biddingProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰 프로젝트</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="입찰 프로젝트를 선택하세요"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ 설명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ 설명을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* 마감일 설정 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ <div className="space-y-6">
+ {/* 아이템 선택 영역 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>해양 Hull 아이템 선택</FormLabel>
+ <FormDescription>
+ 해양 Hull 아이템을 선택하세요
+ </FormDescription>
+ </div>
+
+ {/* 데이터 로딩 에러 표시 */}
+ {dataLoadError && (
+ <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <X className="h-4 w-4 text-destructive" />
+ <span className="text-sm text-destructive">{dataLoadError}</span>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8 text-xs"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 아이템 검색 및 필터 */}
+ <div className="space-y-2">
+ <div className="flex space-x-2">
+ <div className="relative flex-1">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템 검색..."
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-8 pr-8"
+ disabled={isLoadingItems || dataLoadError !== null}
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuCheckboxItem
+ checked={selectedWorkType === null}
+ onCheckedChange={() => setSelectedWorkType(null)}
+ >
+ 전체 공종
+ </DropdownMenuCheckboxItem>
+ {workTypes.map(workType => (
+ <DropdownMenuCheckboxItem
+ key={workType.code}
+ checked={selectedWorkType === workType.code}
+ onCheckedChange={() => setSelectedWorkType(workType.code)}
+ >
+ {workType.name}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-[300px]">
+ <div className="p-2 space-y-1">
+ {dataLoadError ? (
+ <div className="text-center py-8">
+ <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
+ <div className="flex flex-col items-center gap-3">
+ <X className="h-8 w-8 text-destructive" />
+ <div className="text-center">
+ <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
+ <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : isLoadingItems ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
+ 아이템을 불러오는 중...
+ {retryCount > 0 && (
+ <p className="text-xs mt-1">재시도 {retryCount}회</p>
+ )}
+ </div>
+ ) : availableItems.length > 0 ? (
+ [...availableItems]
+ .sort((a, b) => {
+ const aName = a.itemList || 'zzz'
+ const bName = b.itemList || 'zzz'
+ return aName.localeCompare(bName, 'ko', { numeric: true })
+ })
+ .map((item) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ return (
+ <div
+ key={item.id}
+ className={cn(
+ "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
+ isSelected && "bg-muted"
+ )}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ {isSelected ? (
+ <CheckSquare className="h-4 w-4" />
+ ) : (
+ <Square className="h-4 w-4" />
+ )}
+ <div className="flex-1">
+ {/* Hull 아이템 표시: "item_list / sub_item_list" / item_code / 공종 */}
+ <div className="font-medium">
+ {item.itemList || '아이템명 없음'}
+ {item.subItemList && ` / ${item.subItemList}`}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode || '아이템코드 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ })
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* Footer - Sticky 버튼 영역 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(handleCreateRfq)}
+ disabled={
+ isProcessing ||
+ !selectedProject ||
+ selectedItems.length === 0
+ }
+ >
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 Hull RFQ 생성하기`}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
index 81c85649..8a66f26e 100644
--- a/lib/techsales-rfq/table/create-rfq-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
@@ -32,10 +32,9 @@ import { useForm } from "react-hook-form"
import * as z from "zod"
import { EstimateProjectSelector } from "@/components/BidProjectSelector"
import { type Project } from "@/lib/rfqs/service"
-import { createTechSalesRfq } from "@/lib/techsales-rfq/service"
+import { createTechSalesShipRfq } from "@/lib/techsales-rfq/service"
import { useSession } from "next-auth/react"
import { Separator } from "@/components/ui/separator"
-import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -44,16 +43,8 @@ import {
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-// 실제 데이터 서비스 import
+// 조선 아이템 서비스 import
import {
getWorkTypes,
getAllShipbuildingItemsForCache,
@@ -62,21 +53,23 @@ import {
type WorkType
} from "@/lib/items-tech/service"
-// 유효성 검증 스키마 - 자재코드(item_code) 배열로 변경
-const createRfqSchema = z.object({
+
+// 유효성 검증 스키마
+const createShipRfqSchema = z.object({
biddingProjectId: z.number({
required_error: "프로젝트를 선택해주세요.",
}),
- materialCodes: z.array(z.string()).min(1, {
- message: "적어도 하나의 자재코드를 선택해야 합니다.",
+ itemIds: z.array(z.number()).min(1, {
+ message: "적어도 하나의 아이템을 선택해야 합니다.",
}),
dueDate: z.date({
required_error: "마감일을 선택해주세요.",
}),
+ description: z.string().optional(),
})
// 폼 데이터 타입
-type CreateRfqFormValues = z.infer<typeof createRfqSchema>
+type CreateShipRfqFormValues = z.infer<typeof createShipRfqSchema>
// 공종 타입 정의
interface WorkTypeOption {
@@ -85,11 +78,11 @@ interface WorkTypeOption {
description: string
}
-interface CreateRfqDialogProps {
+interface CreateShipRfqDialogProps {
onCreated?: () => void;
}
-export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
+export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
const { data: session } = useSession()
const [isProcessing, setIsProcessing] = React.useState(false)
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
@@ -109,7 +102,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
const [retryCount, setRetryCount] = React.useState(0)
- // 데이터 로딩 함수를 useCallback으로 메모이제이션
+ // 데이터 로딩 함수
const loadData = React.useCallback(async (isRetry = false) => {
try {
if (!isRetry) {
@@ -117,7 +110,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
setDataLoadError(null)
}
- console.log(`데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+ console.log(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([
getWorkTypes(),
@@ -125,25 +118,23 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
getShipTypes()
])
- console.log("WorkTypes 결과:", workTypesResult)
- console.log("Items 결과:", itemsResult)
- console.log("ShipTypes 결과:", shipTypesResult)
+ console.log("Ship - WorkTypes 결과:", workTypesResult)
+ console.log("Ship - Items 결과:", itemsResult)
+ console.log("Ship - ShipTypes 결과:", shipTypesResult)
// WorkTypes 설정
if (Array.isArray(workTypesResult)) {
setWorkTypes(workTypesResult)
} else {
- console.error("WorkTypes 데이터 형식 오류:", workTypesResult)
throw new Error("공종 데이터를 불러올 수 없습니다.")
}
// Items 설정
if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) {
setAllItems(itemsResult.data)
- console.log("아이템 설정 완료:", itemsResult.data.length, "개")
+ console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개")
} else {
- console.error("아이템 로딩 실패:", itemsResult.error)
- throw new Error(itemsResult.error || "아이템 데이터를 불러올 수 없습니다.")
+ throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.")
}
// ShipTypes 설정
@@ -151,18 +142,17 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
setShipTypes(shipTypesResult.data)
console.log("선종 설정 완료:", shipTypesResult.data)
} else {
- console.error("선종 로딩 실패:", shipTypesResult.error)
throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.")
}
// 성공 시 재시도 카운터 리셋
setRetryCount(0)
setDataLoadError(null)
- console.log("데이터 로딩 완료")
+ console.log("조선 RFQ 데이터 로딩 완료")
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
- console.error("데이터 로딩 오류:", errorMessage)
+ console.error("조선 RFQ 데이터 로딩 오류:", errorMessage)
setDataLoadError(errorMessage)
@@ -187,19 +177,11 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
// 다이얼로그가 열릴 때마다 데이터 로딩
React.useEffect(() => {
if (isDialogOpen) {
- // 다이얼로그가 열릴 때마다 데이터 상태 초기화 및 로딩
setDataLoadError(null)
setRetryCount(0)
-
- // 이미 데이터가 있고 에러가 없다면 로딩하지 않음 (성능 최적화)
- if (allItems.length > 0 && workTypes.length > 0 && shipTypes.length > 0 && !dataLoadError) {
- console.log("기존 데이터 사용 (캐시)")
- return
- }
-
loadData()
}
- }, [isDialogOpen, loadData, allItems.length, workTypes.length, shipTypes.length, dataLoadError])
+ }, [isDialogOpen, loadData])
// 수동 새로고침 함수
const handleRefreshData = React.useCallback(() => {
@@ -209,12 +191,13 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
}, [loadData])
// RFQ 생성 폼
- const form = useForm<CreateRfqFormValues>({
- resolver: zodResolver(createRfqSchema),
+ const form = useForm<CreateShipRfqFormValues>({
+ resolver: zodResolver(createShipRfqSchema),
defaultValues: {
biddingProjectId: undefined,
- materialCodes: [],
- dueDate: undefined, // 기본값 제거
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
}
})
@@ -258,7 +241,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
setSelectedShipType(null)
setSelectedWorkType(null)
setItemSearchQuery("")
- form.setValue("materialCodes", [])
+ form.setValue("itemIds", [])
}
// 아이템 선택/해제 처리
@@ -266,27 +249,18 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
const isSelected = selectedItems.some(selected => selected.id === item.id)
if (isSelected) {
- // 아이템 선택 해제
const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
setSelectedItems(newSelectedItems)
- form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode))
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
} else {
- // 아이템 선택 추가
const newSelectedItems = [...selectedItems, item]
setSelectedItems(newSelectedItems)
- form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode))
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
}
}
- // 아이템 제거 처리
- const handleRemoveItem = (itemId: number) => {
- const newSelectedItems = selectedItems.filter(item => item.id !== itemId)
- setSelectedItems(newSelectedItems)
- form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode))
- }
-
// RFQ 생성 함수
- const handleCreateRfq = async (data: CreateRfqFormValues) => {
+ const handleCreateRfq = async (data: CreateShipRfqFormValues) => {
try {
setIsProcessing(true)
@@ -295,73 +269,34 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
throw new Error("로그인이 필요합니다")
}
- // 선택된 아이템들을 아이템명(itemList)으로 그룹핑
- const groupedItems = selectedItems.reduce((groups, item) => {
- const actualItemName = item.itemList // 실제 조선 아이템명
- if (!actualItemName) {
- throw new Error(`아이템 "${item.itemCode}"의 아이템명(itemList)이 없습니다.`)
- }
- if (!groups[actualItemName]) {
- groups[actualItemName] = []
- }
- groups[actualItemName].push(item)
- return groups
- }, {} as Record<string, typeof selectedItems>)
-
- const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => {
- const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들
- const joinedItemCodes = itemCodes.join(',')
- return {
- actualItemName,
- items,
- itemCodes,
- joinedItemCodes,
- codeLength: joinedItemCodes.length,
- isOverLimit: joinedItemCodes.length > 255
- }
+ // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesShipRfq({
+ biddingProjectId: data.biddingProjectId,
+ itemIds: data.itemIds,
+ dueDate: data.dueDate,
+ description: data.description,
+ createdBy: Number(session.user.id),
})
-
- // 255자 초과 그룹 확인
- const overLimitGroups = rfqGroups.filter(group => group.isOverLimit)
- if (overLimitGroups.length > 0) {
- const groupNames = overLimitGroups.map(g => `"${g.actualItemName}" (${g.codeLength}자)`).join(', ')
- throw new Error(`다음 아이템 그룹의 자재코드가 255자를 초과합니다: ${groupNames}`)
- }
-
- // 각 그룹별로 RFQ 생성
- const createPromises = rfqGroups.map(group =>
- createTechSalesRfq({
- biddingProjectId: data.biddingProjectId,
- itemShipbuildingId: group.items[0].id, // 그룹의 첫 번째 아이템의 shipbuilding ID 사용
- materialGroupCodes: group.itemCodes, // 해당 그룹의 자재코드들
- createdBy: Number(session.user.id),
- dueDate: data.dueDate,
- })
- )
-
- const results = await Promise.all(createPromises)
- // 오류 확인
- const errors = results.filter(result => result.error)
- if (errors.length > 0) {
- throw new Error(errors.map(e => e.error).join(', '))
+ if (result.error) {
+ throw new Error(result.error)
}
// 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
- const totalRfqs = results.reduce((sum, result) => sum + (result.data?.length || 0), 0)
- toast.success(`${rfqGroups.length}개 아이템 그룹으로 총 ${totalRfqs}개의 RFQ가 성공적으로 생성되었습니다`)
+ toast.success(`${selectedItems.length}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`)
+
setIsDialogOpen(false)
form.reset({
biddingProjectId: undefined,
- materialCodes: [],
- dueDate: undefined, // 기본값 제거
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
})
setSelectedProject(null)
setItemSearchQuery("")
setSelectedWorkType(null)
setSelectedShipType(null)
setSelectedItems([])
- // 에러 상태 및 재시도 카운터 초기화
setDataLoadError(null)
setRetryCount(0)
@@ -371,8 +306,8 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
}
} catch (error) {
- console.error("RFQ 생성 오류:", error)
- toast.error(`RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ console.error("조선 RFQ 생성 오류:", error)
+ toast.error(`조선 RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
} finally {
setIsProcessing(false)
}
@@ -386,15 +321,15 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
if (!open) {
form.reset({
biddingProjectId: undefined,
- materialCodes: [],
- dueDate: undefined, // 기본값 제거
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
})
setSelectedProject(null)
setItemSearchQuery("")
setSelectedWorkType(null)
setSelectedShipType(null)
setSelectedItems([])
- // 에러 상태 및 재시도 카운터 초기화
setDataLoadError(null)
setRetryCount(0)
}
@@ -408,7 +343,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
disabled={isProcessing}
>
<Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">RFQ 생성</span>
+ <span className="hidden sm:inline">조선 RFQ 생성</span>
</Button>
</DialogTrigger>
<DialogContent
@@ -416,7 +351,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
style={{ width: '1200px' }}
>
<DialogHeader className="border-b pb-4">
- <DialogTitle>RFQ 생성</DialogTitle>
+ <DialogTitle>조선 RFQ 생성</DialogTitle>
</DialogHeader>
<div className="space-y-6 p-1 overflow-y-auto">
@@ -444,6 +379,26 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
<Separator className="my-4" />
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ 설명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ 설명을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
{/* 선종 선택 */}
<div className="space-y-4">
<div>
@@ -495,7 +450,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
) : selectedShipType ? (
selectedShipType
) : (
- "전체조회: 선종을 선택해야 생성가능합니다."
+ "선종을 선택하세요"
)}
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
@@ -506,7 +461,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
onCheckedChange={() => {
setSelectedShipType(null)
setSelectedItems([])
- form.setValue("materialCodes", [])
+ form.setValue("itemIds", [])
}}
>
전체 선종
@@ -518,7 +473,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
onCheckedChange={() => {
setSelectedShipType(shipType)
setSelectedItems([])
- form.setValue("materialCodes", [])
+ form.setValue("itemIds", [])
}}
>
{shipType}
@@ -581,7 +536,10 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
<div>
<FormLabel>조선 아이템 선택</FormLabel>
<FormDescription>
- {selectedShipType ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` : "먼저 선종을 선택해주세요"}
+ {selectedShipType
+ ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요`
+ : "먼저 선종을 선택해주세요"
+ }
</FormDescription>
</div>
@@ -686,13 +644,13 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
) : availableItems.length > 0 ? (
[...availableItems]
.sort((a, b) => {
- // itemList 기준으로 정렬 (없는 경우 itemName 사용, 둘 다 없으면 맨 뒤로)
const aName = a.itemList || 'zzz'
const bName = b.itemList || 'zzz'
return aName.localeCompare(bName, 'ko', { numeric: true })
})
.map((item) => {
const isSelected = selectedItems.some(selected => selected.id === item.id)
+
return (
<div
key={item.id}
@@ -731,124 +689,6 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
</div>
</ScrollArea>
</div>
-
- {/* 선택된 아이템 목록 */}
- <FormField
- control={form.control}
- name="materialCodes"
- render={() => (
- <FormItem>
- <FormLabel>선택된 아이템 ({selectedItems.length}개)</FormLabel>
- <div className="min-h-[80px] p-3 border rounded-md bg-muted/50">
- {selectedItems.length > 0 ? (
- <div className="flex flex-wrap gap-2">
- {selectedItems.map((item) => (
- <Badge
- key={item.id}
- variant="secondary"
- className="flex items-center gap-1"
- >
- {item.itemList || '아이템명 없음'} ({item.itemCode})
- <X
- className="h-3 w-3 cursor-pointer hover:text-destructive"
- onClick={() => handleRemoveItem(item.id)}
- />
- </Badge>
- ))}
- </div>
- ) : (
- <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
- 선택된 아이템이 없습니다
- </div>
- )}
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ 그룹핑 미리보기 */}
- <div className="space-y-3">
- <FormLabel>생성될 RFQ 그룹 미리보기</FormLabel>
- <div className="border rounded-md bg-background">
- {(() => {
- // 아이템명(itemList)으로 그룹핑
- const groupedItems = selectedItems.reduce((groups, item) => {
- const actualItemName = item.itemList // 실제 조선 아이템명
- if (!actualItemName) {
- return groups // itemList가 없는 경우 제외
- }
- if (!groups[actualItemName]) {
- groups[actualItemName] = []
- }
- groups[actualItemName].push(item)
- return groups
- }, {} as Record<string, typeof selectedItems>)
-
- const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => {
- const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들
- const joinedItemCodes = itemCodes.join(',')
- return {
- actualItemName,
- items,
- itemCodes,
- joinedItemCodes,
- codeLength: joinedItemCodes.length,
- isOverLimit: joinedItemCodes.length > 255
- }
- })
-
- return (
- <div className="space-y-3">
- <div className="text-sm text-muted-foreground p-3 border-b">
- 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑)
- </div>
- <ScrollArea className="h-[200px]">
- <Table>
- <TableHeader className="sticky top-0 bg-background">
- <TableRow>
- <TableHead className="w-[80px]">RFQ #</TableHead>
- <TableHead>아이템명</TableHead>
- <TableHead className="w-[120px]">자재그룹코드 개수</TableHead>
- <TableHead className="w-[100px]">길이</TableHead>
- <TableHead className="w-[80px]">상태</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {rfqGroups.map((group, index) => (
- <TableRow
- key={group.actualItemName}
- className={group.isOverLimit ? "bg-destructive/5" : ""}
- >
- <TableCell className="font-medium">#{index + 1}</TableCell>
- <TableCell>
- <div className="max-w-[200px] truncate" title={group.actualItemName}>
- {group.actualItemName}
- </div>
- </TableCell>
- <TableCell>{group.itemCodes.length}개</TableCell>
- <TableCell>
- <span className={group.isOverLimit ? "text-destructive font-medium" : ""}>
- {group.codeLength}/255자
- </span>
- </TableCell>
- <TableCell>
- {group.isOverLimit ? (
- <Badge variant="destructive" className="text-xs">초과</Badge>
- ) : (
- <Badge variant="secondary" className="text-xs">정상</Badge>
- )}
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </ScrollArea>
- </div>
- )
- })()}
- </div>
- </div>
</div>
</div>
</div>
@@ -873,41 +713,10 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
disabled={
isProcessing ||
!selectedProject ||
- selectedItems.length === 0 ||
- // 255자 초과 그룹이 있는지 확인
- (() => {
- const groupedItems = selectedItems.reduce((groups, item) => {
- const actualItemName = item.itemList // 실제 조선 아이템명
- if (!actualItemName) {
- return groups // itemList가 없는 경우 제외
- }
- if (!groups[actualItemName]) {
- groups[actualItemName] = []
- }
- groups[actualItemName].push(item.itemCode)
- return groups
- }, {} as Record<string, string[]>)
-
- return Object.values(groupedItems).some(itemCodes => itemCodes.join(',').length > 255)
- })()
+ selectedItems.length === 0
}
>
- {isProcessing ? "처리 중..." : (() => {
- const groupedItems = selectedItems.reduce((groups, item) => {
- const actualItemName = item.itemList // 실제 조선 아이템명
- if (!actualItemName) {
- return groups // itemList가 없는 경우 제외
- }
- if (!groups[actualItemName]) {
- groups[actualItemName] = []
- }
- groups[actualItemName].push(item.itemCode)
- return groups
- }, {} as Record<string, string[]>)
-
- const groupCount = Object.keys(groupedItems).length
- return `${groupCount}개 아이템 그룹으로 생성하기`
- })()}
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 조선 RFQ 생성하기`}
</Button>
</div>
</div>
diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
new file mode 100644
index 00000000..70f56ebd
--- /dev/null
+++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
@@ -0,0 +1,594 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
+import { type Project } from "@/lib/rfqs/service"
+import { createTechSalesTopRfq } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+import { Separator } from "@/components/ui/separator"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// 공종 타입 import
+import {
+ getOffshoreTopWorkTypes,
+ getAllOffshoreTopItemsForCache,
+ type OffshoreTopWorkType,
+ type OffshoreTopTechItem
+} from "@/lib/items-tech/service"
+
+// 해양 TOP 아이템 타입 정의 (이미 service에서 import하므로 제거)
+
+// 유효성 검증 스키마
+const createTopRfqSchema = z.object({
+ biddingProjectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ itemIds: z.array(z.number()).min(1, {
+ message: "적어도 하나의 아이템을 선택해야 합니다.",
+ }),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+ description: z.string().optional(),
+})
+
+// 폼 데이터 타입
+type CreateTopRfqFormValues = z.infer<typeof createTopRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: OffshoreTopWorkType
+ name: string
+ description: string
+}
+
+interface CreateTopRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
+ const { data: session } = useSession()
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ // 검색 및 필터링 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState("")
+ const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreTopWorkType | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<OffshoreTopTechItem[]>([])
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [allItems, setAllItems] = React.useState<OffshoreTopTechItem[]>([])
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+ const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
+ const [retryCount, setRetryCount] = React.useState(0)
+
+ // 데이터 로딩 함수
+ const loadData = React.useCallback(async (isRetry = false) => {
+ try {
+ if (!isRetry) {
+ setIsLoadingItems(true)
+ setDataLoadError(null)
+ }
+
+ console.log(`해양 TOP RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+
+ const [workTypesResult, topItemsResult] = await Promise.all([
+ getOffshoreTopWorkTypes(),
+ getAllOffshoreTopItemsForCache()
+ ])
+
+ console.log("TOP - WorkTypes 결과:", workTypesResult)
+ console.log("TOP - Items 결과:", topItemsResult)
+
+ // WorkTypes 설정
+ if (Array.isArray(workTypesResult)) {
+ setWorkTypes(workTypesResult)
+ } else {
+ throw new Error("공종 데이터를 불러올 수 없습니다.")
+ }
+
+ // TOP Items 설정
+ if (topItemsResult.data && Array.isArray(topItemsResult.data)) {
+ setAllItems(topItemsResult.data as OffshoreTopTechItem[])
+ console.log("TOP 아이템 설정 완료:", topItemsResult.data.length, "개")
+ } else {
+ throw new Error("TOP 아이템 데이터를 불러올 수 없습니다.")
+ }
+
+ // 성공 시 재시도 카운터 리셋
+ setRetryCount(0)
+ setDataLoadError(null)
+ console.log("해양 TOP RFQ 데이터 로딩 완료")
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ console.error("해양 TOP RFQ 데이터 로딩 오류:", errorMessage)
+
+ setDataLoadError(errorMessage)
+
+ // 3회까지 자동 재시도 (500ms 간격)
+ if (retryCount < 2) {
+ console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
+ setTimeout(() => {
+ setRetryCount(prev => prev + 1)
+ loadData(true)
+ }, 500 * (retryCount + 1))
+ } else {
+ // 재시도 실패 시 사용자에게 알림
+ toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
+ }
+ } finally {
+ if (!isRetry) {
+ setIsLoadingItems(false)
+ }
+ }
+ }, [retryCount])
+
+ // 다이얼로그가 열릴 때마다 데이터 로딩
+ React.useEffect(() => {
+ if (isDialogOpen) {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }
+ }, [isDialogOpen, loadData])
+
+ // 수동 새로고침 함수
+ const handleRefreshData = React.useCallback(() => {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }, [loadData])
+
+ // RFQ 생성 폼
+ const form = useForm<CreateTopRfqFormValues>({
+ resolver: zodResolver(createTopRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ }
+ })
+
+ // 필터링된 아이템 목록 가져오기
+ const availableItems = React.useMemo(() => {
+ let filtered = [...allItems]
+
+ // 공종 필터
+ if (selectedWorkType) {
+ filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreTopTechItem['workType'])
+ }
+
+ // 검색어 필터
+ if (itemSearchQuery && itemSearchQuery.trim()) {
+ const query = itemSearchQuery.toLowerCase().trim()
+ filtered = filtered.filter(item =>
+ item.itemCode.toLowerCase().includes(query) ||
+ (item.itemList && item.itemList.toLowerCase().includes(query)) ||
+ (item.subItemList && item.subItemList.toLowerCase().includes(query))
+ )
+ }
+
+ return filtered
+ }, [allItems, itemSearchQuery, selectedWorkType])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ setSelectedWorkType(null)
+ setItemSearchQuery("")
+ form.setValue("itemIds", [])
+ }
+
+ // 아이템 선택/해제 처리
+ const handleItemToggle = (item: OffshoreTopTechItem) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ if (isSelected) {
+ const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ } else {
+ const newSelectedItems = [...selectedItems, item]
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ }
+ }
+
+ // RFQ 생성 함수
+ const handleCreateRfq = async (data: CreateTopRfqFormValues) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 해양 TOP RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesTopRfq({
+ biddingProjectId: data.biddingProjectId,
+ itemIds: data.itemIds,
+ dueDate: data.dueDate,
+ description: data.description,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
+ toast.success(`${selectedItems.length}개 아이템으로 해양 TOP RFQ가 성공적으로 생성되었습니다`)
+
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+
+ // 생성 후 콜백 실행
+ if (onCreated) {
+ onCreated()
+ }
+
+ } catch (error) {
+ console.error("해양 TOP RFQ 생성 오류:", error)
+ toast.error(`해양 TOP RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ return (
+ <Dialog
+ open={isDialogOpen}
+ onOpenChange={(open) => {
+ setIsDialogOpen(open)
+ if (!open) {
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+ }
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">해양 TOP RFQ 생성</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent
+ className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
+ style={{ width: '1200px' }}
+ >
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle>해양 TOP RFQ 생성</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6 p-1 overflow-y-auto">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
+ {/* 프로젝트 선택 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="biddingProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰 프로젝트</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="입찰 프로젝트를 선택하세요"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* 마감일 설정 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ <div className="space-y-6">
+ {/* 아이템 선택 영역 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>아이템 선택</FormLabel>
+ <FormDescription>
+ 해양 TOP RFQ를 생성하려면 아이템을 선택하세요
+ </FormDescription>
+ </div>
+
+ {/* 아이템 검색 및 필터 */}
+ <div className="space-y-2">
+ <div className="flex space-x-2">
+ <div className="relative flex-1">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템 검색..."
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-8 pr-8"
+ disabled={isLoadingItems || dataLoadError !== null}
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuCheckboxItem
+ checked={selectedWorkType === null}
+ onCheckedChange={() => setSelectedWorkType(null)}
+ >
+ 전체 공종
+ </DropdownMenuCheckboxItem>
+ {workTypes.map(workType => (
+ <DropdownMenuCheckboxItem
+ key={workType.code}
+ checked={selectedWorkType === workType.code}
+ onCheckedChange={() => setSelectedWorkType(workType.code)}
+ >
+ {workType.name}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-[300px]">
+ <div className="p-2 space-y-1">
+ {dataLoadError ? (
+ <div className="text-center py-8">
+ <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
+ <div className="flex flex-col items-center gap-3">
+ <X className="h-8 w-8 text-destructive" />
+ <div className="text-center">
+ <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
+ <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : isLoadingItems ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
+ 아이템을 불러오는 중...
+ {retryCount > 0 && (
+ <p className="text-xs mt-1">재시도 {retryCount}회</p>
+ )}
+ </div>
+ ) : availableItems.length > 0 ? (
+ [...availableItems]
+ .sort((a, b) => {
+ const aName = a.itemList || 'zzz'
+ const bName = b.itemList || 'zzz'
+ return aName.localeCompare(bName, 'ko', { numeric: true })
+ })
+ .map((item) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ return (
+ <div
+ key={item.id}
+ className={cn(
+ "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
+ isSelected && "bg-muted"
+ )}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ {isSelected ? (
+ <CheckSquare className="h-4 w-4" />
+ ) : (
+ <Square className="h-4 w-4" />
+ )}
+ <div className="flex-1">
+ <div className="font-medium">
+ {item.itemList || '아이템명 없음'}
+ {item.subItemList && ` / ${item.subItemList}`}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode || '아이템코드 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ })
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* Footer - Sticky 버튼 영역 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(handleCreateRfq)}
+ disabled={
+ isProcessing ||
+ !selectedProject ||
+ selectedItems.length === 0
+ }
+ >
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 TOP RFQ 생성하기`}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
index b66f4d77..3574111f 100644
--- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
@@ -6,7 +6,7 @@ import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { toast } from "sonner"
-import { Check, X, Search, Loader2 } from "lucide-react"
+import { Check, X, Search, Loader2, Star } from "lucide-react"
import { useSession } from "next-auth/react"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -15,8 +15,8 @@ import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Badge } from "@/components/ui/badge"
-import { addVendorsToTechSalesRfq } from "@/lib/techsales-rfq/service"
-import { searchVendors } from "@/lib/vendors/service"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service"
// 폼 유효성 검증 스키마 - 간단화
const vendorFormSchema = z.object({
@@ -33,13 +33,15 @@ type TechSalesRfq = {
[key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
}
-// 벤더 검색 결과 타입 (searchVendors 함수 반환 타입과 일치)
+// 벤더 검색 결과 타입 (techVendor 기반)
type VendorSearchResult = {
id: number
vendorName: string
vendorCode: string | null
status: string
country: string | null
+ techVendorType?: string | null
+ matchedItemCount?: number // 후보 벤더 정보
}
interface AddVendorDialogProps {
@@ -61,10 +63,14 @@ export function AddVendorDialog({
const [isSubmitting, setIsSubmitting] = useState(false)
const [searchTerm, setSearchTerm] = useState("")
const [searchResults, setSearchResults] = useState<VendorSearchResult[]>([])
+ const [candidateVendors, setCandidateVendors] = useState<VendorSearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
+ const [isLoadingCandidates, setIsLoadingCandidates] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
+ const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false)
// 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지
const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([])
+ const [activeTab, setActiveTab] = useState("candidates")
const form = useForm<VendorFormValues>({
resolver: zodResolver(vendorFormSchema),
@@ -75,7 +81,32 @@ export function AddVendorDialog({
const selectedVendorIds = form.watch("vendorIds")
- // 검색 함수 (디바운스 적용)
+ // 후보 벤더 로드 함수
+ const loadCandidateVendors = useCallback(async () => {
+ if (!selectedRfq?.id) return
+
+ setIsLoadingCandidates(true)
+ try {
+ const result = await getTechSalesRfqCandidateVendors(selectedRfq.id)
+ if (result.error) {
+ toast.error(result.error)
+ setCandidateVendors([])
+ } else {
+ // 이미 추가된 벤더 제외
+ const filteredCandidates = result.data?.filter(vendor => !existingVendorIds.includes(vendor.id)) || []
+ setCandidateVendors(filteredCandidates)
+ }
+ setHasCandidatesLoaded(true)
+ } catch (error) {
+ console.error("후보 벤더 로드 오류:", error)
+ toast.error("후보 벤더를 불러오는 중 오류가 발생했습니다")
+ setCandidateVendors([])
+ } finally {
+ setIsLoadingCandidates(false)
+ }
+ }, [selectedRfq?.id, existingVendorIds])
+
+ // 벤더 검색 함수 (techVendor 기반)
const searchVendorsDebounced = useCallback(
async (term: string) => {
if (!term.trim()) {
@@ -86,9 +117,15 @@ export function AddVendorDialog({
setIsSearching(true)
try {
- const results = await searchVendors(term, 100)
+ // 선택된 RFQ의 타입을 기반으로 벤더 검색
+ const rfqType = selectedRfq?.rfqCode?.includes("SHIP") ? "SHIP" :
+ selectedRfq?.rfqCode?.includes("TOP") ? "TOP" :
+ selectedRfq?.rfqCode?.includes("HULL") ? "HULL" : undefined;
+
+ const results = await searchTechVendors(term, 100, rfqType)
+
// 이미 추가된 벤더 제외
- const filteredResults = results.filter(vendor => !existingVendorIds.includes(vendor.id))
+ const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id))
setSearchResults(filteredResults)
setHasSearched(true)
} catch (error) {
@@ -111,6 +148,13 @@ export function AddVendorDialog({
return () => clearTimeout(timer)
}, [searchTerm, searchVendorsDebounced])
+ // 다이얼로그 열릴 때 후보 벤더 로드
+ useEffect(() => {
+ if (open && selectedRfq?.id && !hasCandidatesLoaded) {
+ loadCandidateVendors()
+ }
+ }, [open, selectedRfq?.id, hasCandidatesLoaded, loadCandidateVendors])
+
// 벤더 선택/해제 핸들러
const handleVendorToggle = (vendor: VendorSearchResult) => {
const currentIds = form.getValues("vendorIds")
@@ -155,8 +199,8 @@ export function AddVendorDialog({
try {
setIsSubmitting(true)
- // 서비스 함수 호출
- const result = await addVendorsToTechSalesRfq({
+ // 새로운 서비스 함수 호출
+ const result = await addTechVendorsToTechSalesRfq({
rfqId: selectedRfq.id,
vendorIds: values.vendorIds,
createdBy: Number(session.user.id),
@@ -165,15 +209,16 @@ export function AddVendorDialog({
if (result.error) {
toast.error(result.error)
} else {
- const successMessage = `${result.successCount}개의 벤더가 성공적으로 추가되었습니다`
- const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : ""
- toast.success(successMessage + errorMessage)
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`)
onOpenChange(false)
form.reset()
setSearchTerm("")
setSearchResults([])
+ setCandidateVendors([])
setHasSearched(false)
+ setHasCandidatesLoaded(false)
setSelectedVendorData([])
onSuccess?.()
}
@@ -191,14 +236,69 @@ export function AddVendorDialog({
form.reset()
setSearchTerm("")
setSearchResults([])
+ setCandidateVendors([])
setHasSearched(false)
+ setHasCandidatesLoaded(false)
setSelectedVendorData([])
+ setActiveTab("candidates")
}
}, [open, form])
+ // 벤더 목록 렌더링 함수
+ const renderVendorList = (vendors: VendorSearchResult[], showMatchCount = false) => (
+ <ScrollArea className="h-60 border rounded-md">
+ <div className="p-2 space-y-1">
+ {vendors.length > 0 ? (
+ vendors.map((vendor, index) => (
+ <div
+ key={`${vendor.id}-${index}`} // 고유한 키 생성
+ className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${
+ selectedVendorIds.includes(vendor.id) ? "bg-muted" : ""
+ }`}
+ onClick={() => handleVendorToggle(vendor)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ <Check
+ className={`h-4 w-4 ${
+ selectedVendorIds.includes(vendor.id)
+ ? "opacity-100"
+ : "opacity-0"
+ }`}
+ />
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{vendor.vendorName}</span>
+ {showMatchCount && vendor.matchedItemCount && vendor.matchedItemCount > 0 && (
+ <Badge variant="secondary" className="text-xs flex items-center gap-1">
+ <Star className="h-3 w-3" />
+ {vendor.matchedItemCount}개 매칭
+ </Badge>
+ )}
+ {vendor.techVendorType && (
+ <Badge variant="outline" className="text-xs">
+ {vendor.techVendorType}
+ </Badge>
+ )}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {showMatchCount ? "매칭되는 후보 벤더가 없습니다" : "검색 결과가 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ )
+
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col">
+ <DialogContent className="sm:max-w-[800px] max-h-[80vh] flex flex-col">
{/* 헤더 */}
<DialogHeader>
<DialogTitle>벤더 추가</DialogTitle>
@@ -217,73 +317,91 @@ export function AddVendorDialog({
<div className="flex-1 overflow-y-auto">
<Form {...form}>
<form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 벤더 검색 필드 */}
- <div className="space-y-2">
- <label className="text-sm font-medium">벤더 검색</label>
- <div className="relative">
- <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
- <Input
- placeholder="벤더명 또는 벤더코드로 검색..."
- value={searchTerm}
- onChange={(e) => setSearchTerm(e.target.value)}
- className="pl-10"
- />
- {isSearching && (
- <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
- )}
- </div>
- </div>
+ {/* 탭 메뉴 */}
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="candidates">
+ 후보 벤더 ({candidateVendors.length})
+ </TabsTrigger>
+ <TabsTrigger value="search">
+ 벤더 검색
+ </TabsTrigger>
+ </TabsList>
- {/* 검색 결과 */}
- {hasSearched && (
- <div className="space-y-2">
- <div className="text-sm font-medium">
- 검색 결과 ({searchResults.length}개)
- </div>
- <ScrollArea className="h-60 border rounded-md">
- <div className="p-2 space-y-1">
- {searchResults.length > 0 ? (
- searchResults.map((vendor) => (
- <div
- key={vendor.id}
- className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${
- selectedVendorIds.includes(vendor.id) ? "bg-muted" : ""
- }`}
- onClick={() => handleVendorToggle(vendor)}
- >
- <div className="flex items-center space-x-2 flex-1">
- <Check
- className={`h-4 w-4 ${
- selectedVendorIds.includes(vendor.id)
- ? "opacity-100"
- : "opacity-0"
- }`}
- />
- <div className="flex-1">
- <div className="font-medium">{vendor.vendorName}</div>
- <div className="text-sm text-muted-foreground">
- {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
- </div>
- </div>
- </div>
- </div>
- ))
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- 검색 결과가 없습니다
+ {/* 후보 벤더 탭 */}
+ <TabsContent value="candidates" className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="text-sm font-medium">추천 후보 벤더</label>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setHasCandidatesLoaded(false)
+ loadCandidateVendors()
+ }}
+ disabled={isLoadingCandidates}
+ >
+ {isLoadingCandidates ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ "새로고침"
+ )}
+ </Button>
+ </div>
+
+ {isLoadingCandidates ? (
+ <div className="h-60 border rounded-md flex items-center justify-center">
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span>후보 벤더를 불러오는 중...</span>
</div>
+ </div>
+ ) : (
+ renderVendorList(candidateVendors, true)
+ )}
+
+ <div className="text-xs text-muted-foreground bg-blue-50 p-2 rounded">
+ 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다.
+ </div>
+ </div>
+ </TabsContent>
+
+ {/* 벤더 검색 탭 */}
+ <TabsContent value="search" className="space-y-4">
+ {/* 벤더 검색 필드 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">벤더 검색</label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="벤더명 또는 벤더코드로 검색..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+ {isSearching && (
+ <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
- </ScrollArea>
- </div>
- )}
+ </div>
- {/* 검색 안내 메시지 */}
- {!hasSearched && !searchTerm && (
- <div className="text-center py-8 text-muted-foreground border rounded-md">
- 벤더명 또는 벤더코드를 입력하여 검색해주세요
- </div>
- )}
+ {/* 검색 결과 */}
+ {hasSearched ? (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">
+ 검색 결과 ({searchResults.length}개)
+ </div>
+ {renderVendorList(searchResults)}
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground border rounded-md">
+ 벤더명 또는 벤더코드를 입력하여 검색해주세요
+ </div>
+ )}
+ </TabsContent>
+ </Tabs>
{/* 선택된 벤더 목록 - 하단에 항상 표시 */}
<FormField
@@ -324,10 +442,9 @@ export function AddVendorDialog({
{/* 안내 메시지 */}
<div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
- {/* <p>• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.</p> */}
+ <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p>
<p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
<p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
- <p>• 이미 추가된 벤더는 검색 결과에서 체크됩니다.</p>
</div>
</form>
</Form>
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 ba530fe3..f2eda8d9 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -30,8 +30,6 @@ interface TechSalesRfq {
rfqSendDate?: Date | null
dueDate?: Date | null
createdByName?: string | null
- // 필요에 따라 다른 필드들 추가
- [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
}
// 프로퍼티 정의
@@ -100,16 +98,12 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
try {
// 실제 벤더 견적 데이터 다시 로딩
- const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
+ const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service")
- const result = await getTechSalesVendorQuotationsWithJoin({
- rfqId: selectedRfqId,
- page: 1,
- perPage: 1000,
- })
+ const result = await getTechSalesRfqTechVendors(selectedRfqId)
// 데이터 변환
- const transformedData = result.data?.map(item => ({
+ const transformedData = result.data?.map((item: any) => ({
...item,
detailId: item.id,
rfqId: selectedRfqId,
@@ -209,9 +203,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
}
// 서비스 함수 호출
- const { removeVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+ const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
- const result = await removeVendorsFromTechSalesRfq({
+ const result = await removeTechVendorsFromTechSalesRfq({
rfqId: selectedRfqId,
vendorIds: vendorIds
});
@@ -219,9 +213,8 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
if (result.error) {
toast.error(result.error);
} else {
- const successMessage = `${result.successCount}개의 벤더가 성공적으로 삭제되었습니다`;
- const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : "";
- toast.success(successMessage + errorMessage);
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`);
}
// 선택 해제
@@ -395,9 +388,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
}
// 개별 벤더 삭제
- const { removeVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+ const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
- const result = await removeVendorFromTechSalesRfq({
+ const result = await removeTechVendorFromTechSalesRfq({
rfqId: selectedRfqId,
vendorId: vendor.vendorId
});
diff --git a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
index d58dbd00..0a6caa5c 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
@@ -31,6 +31,7 @@ import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react"
import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service"
import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions"
import { formatCurrency, formatDate } from "@/lib/utils"
+import { techSalesVendorQuotations } from "@/db/schema/techSales"
// 기술영업 견적 정보 타입
interface TechSalesVendorQuotation {
diff --git a/lib/techsales-rfq/table/project-detail-dialog.tsx b/lib/techsales-rfq/table/project-detail-dialog.tsx
index b8219d7f..68f13960 100644
--- a/lib/techsales-rfq/table/project-detail-dialog.tsx
+++ b/lib/techsales-rfq/table/project-detail-dialog.tsx
@@ -9,39 +9,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button"
-import { formatDateToQuarter } from "@/lib/utils"
-
-// 프로젝트 스냅샷 타입 정의
-interface ProjectSnapshot {
- scDt?: string
- klDt?: string
- lcDt?: string
- dlDt?: string
- dockNo?: string
- dockNm?: string
- projNo?: string
- projNm?: string
- ownerNm?: string
- kunnrNm?: string
- cls1Nm?: string
- projMsrm?: number
- ptypeNm?: string
- sector?: string
- estmPm?: string
-}
-
-// 시리즈 스냅샷 타입 정의
-interface SeriesSnapshot {
- sersNo?: string
- scDt?: string
- klDt?: string
- lcDt?: string
- dlDt?: string
- dockNo?: string
- dockNm?: string
-}
// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치)
interface TechSalesRfq {
@@ -64,8 +32,6 @@ interface TechSalesRfq {
updatedByName: string
sentBy: number | null
sentByName: string | null
- projectSnapshot: ProjectSnapshot | null
- seriesSnapshot: SeriesSnapshot[] | null
pspid: string
projNm: string
sector: string
@@ -90,9 +56,6 @@ export function ProjectDetailDialog({
return null
}
- const projectSnapshot = selectedRfq.projectSnapshot
- const seriesSnapshot = selectedRfq.seriesSnapshot
-
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col">
@@ -141,171 +104,6 @@ export function ProjectDetailDialog({
</div>
</div>
</div>
-
- <Separator />
-
- {/* 프로젝트 스냅샷 정보 */}
- {projectSnapshot && (
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">프로젝트 스냅샷</h3>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-4">
- {projectSnapshot.scDt && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">S/C</div>
- <div className="text-sm">{formatDateToQuarter(projectSnapshot.scDt)}</div>
- </div>
- )}
- {projectSnapshot.klDt && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">K/L</div>
- <div className="text-sm">{formatDateToQuarter(projectSnapshot.klDt)}</div>
- </div>
- )}
- {projectSnapshot.lcDt && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">L/C</div>
- <div className="text-sm">{formatDateToQuarter(projectSnapshot.lcDt)}</div>
- </div>
- )}
- {projectSnapshot.dlDt && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">D/L</div>
- <div className="text-sm">{formatDateToQuarter(projectSnapshot.dlDt)}</div>
- </div>
- )}
- {projectSnapshot.dockNo && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">도크번호</div>
- <div className="text-sm">{projectSnapshot.dockNo}</div>
- </div>
- )}
- {projectSnapshot.dockNm && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">도크명</div>
- <div className="text-sm">{projectSnapshot.dockNm}</div>
- </div>
- )}
- {projectSnapshot.projNo && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">공사번호</div>
- <div className="text-sm">{projectSnapshot.projNo}</div>
- </div>
- )}
- {projectSnapshot.projNm && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">공사명</div>
- <div className="text-sm">{projectSnapshot.projNm}</div>
- </div>
- )}
- {projectSnapshot.ownerNm && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">선주</div>
- <div className="text-sm">{projectSnapshot.ownerNm}</div>
- </div>
- )}
- {projectSnapshot.kunnrNm && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">선주명</div>
- <div className="text-sm">{projectSnapshot.kunnrNm}</div>
- </div>
- )}
- {projectSnapshot.cls1Nm && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">선급명</div>
- <div className="text-sm">{projectSnapshot.cls1Nm}</div>
- </div>
- )}
- {projectSnapshot.projMsrm && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">척수</div>
- <div className="text-sm">{projectSnapshot.projMsrm}</div>
- </div>
- )}
- {projectSnapshot.ptypeNm && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">선종명</div>
- <div className="text-sm">{projectSnapshot.ptypeNm}</div>
- </div>
- )}
- {projectSnapshot.sector && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">섹터</div>
- <div className="text-sm">{projectSnapshot.sector}</div>
- </div>
- )}
- {projectSnapshot.estmPm && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">견적 PM</div>
- <div className="text-sm">{projectSnapshot.estmPm}</div>
- </div>
- )}
- </div>
- </div>
- )}
-
- {/* 시리즈 스냅샷 정보 */}
- {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && (
- <>
- <Separator />
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">시리즈 정보 스냅샷</h3>
- <div className="space-y-4">
- {seriesSnapshot.map((series: SeriesSnapshot, index: number) => (
- <div key={index} className="border rounded-lg p-4 space-y-3">
- <div className="flex items-center gap-2">
- <Badge variant="secondary">시리즈 {series.sersNo || index + 1}</Badge>
- </div>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
- {series.scDt && (
- <div className="space-y-1">
- <div className="text-xs font-medium text-muted-foreground">S/C</div>
- <div className="text-sm">{formatDateToQuarter(series.scDt)}</div>
- </div>
- )}
- {series.klDt && (
- <div className="space-y-1">
- <div className="text-xs font-medium text-muted-foreground">K/L</div>
- <div className="text-sm">{formatDateToQuarter(series.klDt)}</div>
- </div>
- )}
- {series.lcDt && (
- <div className="space-y-1">
- <div className="text-xs font-medium text-muted-foreground">L/C</div>
- <div className="text-sm">{formatDateToQuarter(series.lcDt)}</div>
- </div>
- )}
- {series.dlDt && (
- <div className="space-y-1">
- <div className="text-xs font-medium text-muted-foreground">D/L</div>
- <div className="text-sm">{formatDateToQuarter(series.dlDt)}</div>
- </div>
- )}
- {series.dockNo && (
- <div className="space-y-1">
- <div className="text-xs font-medium text-muted-foreground">도크번호</div>
- <div className="text-sm">{series.dockNo}</div>
- </div>
- )}
- {series.dockNm && (
- <div className="space-y-1">
- <div className="text-xs font-medium text-muted-foreground">도크명</div>
- <div className="text-sm">{series.dockNm}</div>
- </div>
- )}
- </div>
- </div>
- ))}
- </div>
- </div>
- </>
- )}
-
- {/* 추가 정보가 없는 경우 */}
- {!projectSnapshot && !seriesSnapshot && (
- <div className="text-center py-8 text-muted-foreground">
- 추가 프로젝트 상세정보가 없습니다.
- </div>
- )}
</div>
{/* 닫기 버튼 */}
diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
index 6021699f..9b6acfb2 100644
--- a/lib/techsales-rfq/table/rfq-filter-sheet.tsx
+++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
@@ -409,17 +409,17 @@ export function RFQFilterSheet({
)}
/>
- {/* 자재코드 */}
+ {/* 자재그룹 */}
<FormField
control={form.control}
name="materialCode"
render={({ field }) => (
<FormItem>
- <FormLabel>{t("자재코드")}</FormLabel>
+ <FormLabel>{t("자재그룹")}</FormLabel>
<FormControl>
<div className="relative">
<Input
- placeholder={t("자재코드 입력")}
+ placeholder={t("자재그룹 입력")}
{...field}
className={cn(field.value && "pr-8", "bg-white")}
disabled={isInitializing}
diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
new file mode 100644
index 00000000..10bc9f1f
--- /dev/null
+++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
@@ -0,0 +1,198 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Package, FileText, X } from "lucide-react"
+import { getTechSalesRfqItems } from "../service"
+
+interface RfqItem {
+ id: number;
+ rfqId: number;
+ itemType: "SHIP" | "TOP" | "HULL";
+ itemCode: string;
+ itemList: string;
+ workType: string;
+ shipType?: string; // 조선용
+ subItemName?: string; // 해양용
+}
+
+interface RfqItemsViewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ rfq: {
+ id: number;
+ rfqCode?: string;
+ status?: string;
+ description?: string;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+ } | null;
+}
+
+export function RfqItemsViewDialog({
+ open,
+ onOpenChange,
+ rfq,
+}: RfqItemsViewDialogProps) {
+ const [items, setItems] = React.useState<RfqItem[]>([]);
+ const [loading, setLoading] = React.useState(false);
+
+ console.log("RfqItemsViewDialog render:", { open, rfq });
+
+ React.useEffect(() => {
+ console.log("RfqItemsViewDialog useEffect:", { open, rfqId: rfq?.id });
+ if (open && rfq?.id) {
+ loadItems();
+ }
+ }, [open, rfq?.id]);
+
+ const loadItems = async () => {
+ if (!rfq?.id) return;
+
+ console.log("Loading items for RFQ:", rfq.id);
+ setLoading(true);
+ try {
+ const result = await getTechSalesRfqItems(rfq.id);
+ console.log("Items loaded:", result);
+ if (result.data) {
+ setItems(result.data);
+ }
+ } catch (error) {
+ console.error("Failed to load items:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const getTypeLabel = (type: string) => {
+ switch (type) {
+ case "SHIP":
+ return "조선";
+ case "TOP":
+ return "해양TOP";
+ case "HULL":
+ return "해양HULL";
+ default:
+ return type;
+ }
+ };
+
+ const getTypeColor = (type: string) => {
+ switch (type) {
+ case "SHIP":
+ return "bg-blue-100 text-blue-800";
+ case "TOP":
+ return "bg-green-100 text-green-800";
+ case "HULL":
+ return "bg-purple-100 text-purple-800";
+ default:
+ return "bg-gray-100 text-gray-800";
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-none w-[1200px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ RFQ 아이템 조회
+ <Badge variant="outline" className="ml-2">
+ {rfq?.rfqCode || `RFQ #${rfq?.id}`}
+ </Badge>
+ </DialogTitle>
+ <DialogDescription>
+ RFQ에 등록된 아이템 목록을 확인할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="overflow-x-auto w-full">
+ <div className="space-y-4">
+ {loading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center space-y-2">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+ <p className="text-sm text-muted-foreground">아이템을 불러오는 중...</p>
+ </div>
+ </div>
+ ) : items.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <FileText className="h-12 w-12 text-muted-foreground mb-3" />
+ <h3 className="text-lg font-medium mb-1">아이템이 없습니다</h3>
+ <p className="text-sm text-muted-foreground">
+ 이 RFQ에 등록된 아이템이 없습니다.
+ </p>
+ </div>
+ ) : (
+ <>
+ {/* 헤더 행 (라벨) */}
+ <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm">
+ <div className="w-[50px] text-center">No.</div>
+ <div className="w-[120px] pl-2">타입</div>
+ <div className="w-[200px] ">자재 그룹</div>
+ <div className="w-[150px] ">공종</div>
+ <div className="w-[300px] ">자재명</div>
+ <div className="w-[150px] ">선종/자재명(상세)</div>
+ </div>
+
+ {/* 아이템 행들 */}
+ <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-2">
+ {items.map((item, index) => (
+ <div
+ key={item.id}
+ className="flex items-center gap-2 group hover:bg-gray-50 p-2 rounded-md transition-colors border"
+ >
+ <div className="w-[50px] text-center text-sm font-medium text-muted-foreground">
+ {index + 1}
+ </div>
+ <div className="w-[120px] pl-2">
+ <Badge variant="secondary" className={`text-xs ${getTypeColor(item.itemType)}`}>
+ {getTypeLabel(item.itemType)}
+ </Badge>
+ </div>
+ <div className="w-[200px] pl-2 font-mono text-sm">
+ {item.itemCode}
+ </div>
+ <div className="w-[150px] pl-2 text-sm">
+ {item.workType}
+ </div>
+ <div className="w-[300px] pl-2 font-medium">
+ {item.itemList}
+ </div>
+ <div className="w-[150px] pl-2 text-sm">
+ {item.itemType === 'SHIP' ? item.shipType : item.subItemName}
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div className="flex justify-between items-center pt-2 border-t">
+ <div className="flex items-center gap-2">
+ <Package className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">
+ 총 {items.length}개 아이템
+ </span>
+ </div>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter className="mt-6">
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ <X className="mr-2 h-4 w-4" />
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index 2740170b..51c143a4 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -6,16 +6,13 @@ import { formatDate, formatDateTime } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { DataTableRowAction } from "@/types/table"
-import { Paperclip } from "lucide-react"
+import { Paperclip, Package } from "lucide-react"
import { Button } from "@/components/ui/button"
// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함)
type TechSalesRfq = {
id: number
rfqCode: string | null
- itemId: number
- itemName: string | null
- materialCode: string | null
dueDate: Date
rfqSendDate: Date | null
status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
@@ -30,40 +27,6 @@ type TechSalesRfq = {
updatedByName: string
sentBy: number | null
sentByName: string | null
- // 스키마와 일치하도록 타입 수정
- projectSnapshot: {
- pspid: string;
- projNm?: string;
- sector?: string;
- projMsrm?: number;
- kunnr?: string;
- kunnrNm?: string;
- cls1?: string;
- cls1Nm?: string;
- ptype?: string;
- ptypeNm?: string;
- pmodelCd?: string;
- pmodelNm?: string;
- pmodelSz?: string;
- pmodelUom?: string;
- txt04?: string;
- txt30?: string;
- estmPm?: string;
- pspCreatedAt?: Date | string;
- pspUpdatedAt?: Date | string;
- } | Record<string, unknown> // legacy 호환성을 위해 유지
- seriesSnapshot: Array<{
- pspid: string;
- sersNo: string;
- scDt?: string;
- klDt?: string;
- lcDt?: string;
- dlDt?: string;
- dockNo?: string;
- dockNm?: string;
- projNo?: string;
- post1?: string;
- }> | Record<string, unknown> // legacy 호환성을 위해 유지
pspid: string
projNm: string
sector: string
@@ -71,6 +34,7 @@ type TechSalesRfq = {
ptypeNm: string
attachmentCount: number
quotationCount: number
+ itemCount: number
// 나머지 필드는 사용할 때마다 추가
[key: string]: unknown
}
@@ -78,11 +42,13 @@ type TechSalesRfq = {
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
openAttachmentsSheet: (rfqId: number) => void;
+ openItemsDialog: (rfq: TechSalesRfq) => void;
}
export function getColumns({
setRowAction,
openAttachmentsSheet,
+ openItemsDialog,
}: GetColumnsProps): ColumnDef<TechSalesRfq>[] {
return [
{
@@ -144,34 +110,6 @@ export function getColumns({
size: 120,
},
{
- accessorKey: "materialCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재코드" />
- ),
- cell: ({ row }) => <div>{row.getValue("materialCode")}</div>,
- meta: {
- excelHeader: "자재코드"
- },
- enableResizing: true,
- minSize: 80,
- size: 250,
- },
- {
- accessorKey: "itemName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재명" />
- ),
- cell: ({ row }) => {
- const itemName = row.getValue("itemName") as string | null;
- return <div>{itemName || "자재명 없음"}</div>;
- },
- meta: {
- excelHeader: "자재명"
- },
- enableResizing: true,
- size: 250,
- },
- {
accessorKey: "projNm",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트명" />
@@ -194,85 +132,43 @@ export function getColumns({
enableResizing: true,
size: 160,
},
- {
- accessorKey: "projMsrm",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="척수" />
- ),
- cell: ({ row }) => <div>{row.getValue("projMsrm")}</div>,
- meta: {
- excelHeader: "척수"
- },
- enableResizing: true,
- minSize: 60,
- size: 80,
- },
- {
- accessorKey: "ptypeNm",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="선종" />
- ),
- cell: ({ row }) => <div>{row.getValue("ptypeNm")}</div>,
- meta: {
- excelHeader: "선종"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "quotationCount",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="견적수" />
- ),
- cell: ({ row }) => <div>{row.getValue("quotationCount")}</div>,
- meta: {
- excelHeader: "견적수"
- },
- enableResizing: true,
- size: 80,
- },
- {
- id: "attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const attachmentCount = rfq.attachmentCount || 0
-
- const handleClick = () => {
- openAttachmentsSheet(rfq.id)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- attachmentCount > 0 ? `View ${attachmentCount} attachments` : "Add attachments"
- }
- >
- <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {attachmentCount > 0 && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
- {attachmentCount}
- </span>
- )}
- <span className="sr-only">
- {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 추가"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- enableResizing: true,
- size: 80,
- meta: {
- excelHeader: "첨부파일"
- },
- },
+ // {
+ // accessorKey: "projMsrm",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="척수" />
+ // ),
+ // cell: ({ row }) => <div>{row.getValue("projMsrm")}</div>,
+ // meta: {
+ // excelHeader: "척수"
+ // },
+ // enableResizing: true,
+ // minSize: 60,
+ // size: 80,
+ // },
+ // {
+ // accessorKey: "ptypeNm",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="선종" />
+ // ),
+ // cell: ({ row }) => <div>{row.getValue("ptypeNm")}</div>,
+ // meta: {
+ // excelHeader: "선종"
+ // },
+ // enableResizing: true,
+ // size: 120,
+ // },
+ // {
+ // accessorKey: "quotationCount",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="견적수" />
+ // ),
+ // cell: ({ row }) => <div>{row.getValue("quotationCount")}</div>,
+ // meta: {
+ // excelHeader: "견적수"
+ // },
+ // enableResizing: true,
+ // size: 80,
+ // },
{
accessorKey: "rfqSendDate",
header: ({ column }) => (
@@ -346,5 +242,88 @@ export function getColumns({
enableResizing: true,
size: 160,
},
+ // 우측 고정 컬럼들
+ {
+ id: "items",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const itemCount = rfq.itemCount || 0
+
+ const handleClick = () => {
+ openItemsDialog(rfq)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={`View ${itemCount} items`}
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {itemCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {itemCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "아이템"
+ },
+ },
+ {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const attachmentCount = rfq.attachmentCount || 0
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachmentCount > 0 ? `View ${attachmentCount} attachments` : "Add attachments"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ },
]
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
index da716eeb..a8c2d08c 100644
--- a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
+++ b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
@@ -7,16 +7,20 @@ import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
import { type Table } from "@tanstack/react-table"
-import { CreateRfqDialog } from "./create-rfq-dialog"
+import { CreateShipRfqDialog } from "./create-rfq-ship-dialog"
+import { CreateTopRfqDialog } from "./create-rfq-top-dialog"
+import { CreateHullRfqDialog } from "./create-rfq-hull-dialog"
interface RFQTableToolbarActionsProps<TData> {
selection: Table<TData>;
onRefresh?: () => void;
+ rfqType?: "SHIP" | "TOP" | "HULL";
}
export function RFQTableToolbarActions<TData>({
selection,
- onRefresh
+ onRefresh,
+ rfqType = "SHIP"
}: RFQTableToolbarActionsProps<TData>) {
// 데이터 새로고침
@@ -27,10 +31,23 @@ export function RFQTableToolbarActions<TData>({
}
}
+ // RFQ 타입에 따른 다이얼로그 렌더링
+ const renderRfqDialog = () => {
+ switch (rfqType) {
+ case "TOP":
+ return <CreateTopRfqDialog onCreated={onRefresh} />;
+ case "HULL":
+ return <CreateHullRfqDialog onCreated={onRefresh} />;
+ case "SHIP":
+ default:
+ return <CreateShipRfqDialog onCreated={onRefresh} />;
+ }
+ }
+
return (
<div className="flex items-center gap-2">
{/* RFQ 생성 다이얼로그 */}
- <CreateRfqDialog onCreated={onRefresh} />
+ {renderRfqDialog()}
{/* 새로고침 버튼 */}
<Button
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index f1570577..424ca70e 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -23,24 +23,22 @@ import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
import { getTechSalesRfqsWithJoin, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
import { toast } from "sonner"
import { useTablePresets } from "@/components/data-table/use-table-presets"
-import { TablePresetManager } from "@/components/data-table/data-table-preset"
import { RfqDetailTables } from "./detail-table/rfq-detail-table"
import { cn } from "@/lib/utils"
import { ProjectDetailDialog } from "./project-detail-dialog"
import { RFQFilterSheet } from "./rfq-filter-sheet"
import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet"
-
+import { RfqItemsViewDialog } from "./rfq-items-view-dialog"
// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
interface TechSalesRfq {
id: number
rfqCode: string | null
- itemId: number
- itemName: string | null
+ biddingProjectId: number | null
materialCode: string | null
dueDate: Date
rfqSendDate: Date | null
status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
- picCode: string | null
+ description: string | null
remark: string | null
cancelReason: string | null
createdAt: Date
@@ -51,40 +49,7 @@ interface TechSalesRfq {
updatedByName: string
sentBy: number | null
sentByName: string | null
- // 스키마와 일치하도록 타입 수정
- projectSnapshot: {
- pspid: string;
- projNm?: string;
- sector?: string;
- projMsrm?: number;
- kunnr?: string;
- kunnrNm?: string;
- cls1?: string;
- cls1Nm?: string;
- ptype?: string;
- ptypeNm?: string;
- pmodelCd?: string;
- pmodelNm?: string;
- pmodelSz?: string;
- pmodelUom?: string;
- txt04?: string;
- txt30?: string;
- estmPm?: string;
- pspCreatedAt?: Date | string;
- pspUpdatedAt?: Date | string;
- } | Record<string, unknown> // legacy 호환성을 위해 유지
- seriesSnapshot: Array<{
- pspid: string;
- sersNo: string;
- scDt?: string;
- klDt?: string;
- lcDt?: string;
- dlDt?: string;
- dockNo?: string;
- dockNm?: string;
- projNo?: string;
- post1?: string;
- }> | Record<string, unknown> // legacy 호환성을 위해 유지
+ // 조인된 프로젝트 정보
pspid: string
projNm: string
sector: string
@@ -100,12 +65,14 @@ interface RFQListTableProps {
promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
className?: string;
calculatedHeight?: string; // 계산된 높이 추가
+ rfqType: "SHIP" | "TOP" | "HULL";
}
export function RFQListTable({
promises,
className,
- calculatedHeight
+ calculatedHeight,
+ rfqType
}: RFQListTableProps) {
const searchParams = useSearchParams()
@@ -124,6 +91,10 @@ export function RFQListTable({
const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<TechSalesRfq | null>(null)
const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
+ // 아이템 다이얼로그 상태
+ const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
+ const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<TechSalesRfq | null>(null)
+
// 패널 collapse 상태
const [panelHeight, setPanelHeight] = React.useState<number>(55)
@@ -164,23 +135,23 @@ export function RFQListTable({
to: searchParams?.get('to') || undefined,
columnVisibility: {},
columnOrder: [],
- pinnedColumns: { left: [], right: [] },
+ pinnedColumns: { left: [], right: ["items", "attachments"] },
groupBy: [],
expandedRows: []
}), [searchParams])
// DB 기반 프리셋 훅 사용
const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
+ // presets,
+ // activePresetId,
+ // hasUnsavedChanges,
+ // isLoading: presetsLoading,
+ // createPreset,
+ // applyPreset,
+ // updatePreset,
+ // deletePreset,
+ // setDefaultPreset,
+ // renamePreset,
getCurrentSettings,
} = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
@@ -199,13 +170,12 @@ export function RFQListTable({
setSelectedRfq({
id: rfqData.id,
rfqCode: rfqData.rfqCode,
- itemId: rfqData.itemId,
- itemName: rfqData.itemName,
+ biddingProjectId: rfqData.biddingProjectId,
materialCode: rfqData.materialCode,
dueDate: rfqData.dueDate,
rfqSendDate: rfqData.rfqSendDate,
status: rfqData.status,
- picCode: rfqData.picCode,
+ description: rfqData.description,
remark: rfqData.remark,
cancelReason: rfqData.cancelReason,
createdAt: rfqData.createdAt,
@@ -216,8 +186,6 @@ export function RFQListTable({
updatedByName: rfqData.updatedByName,
sentBy: rfqData.sentBy,
sentByName: rfqData.sentByName,
- projectSnapshot: rfqData.projectSnapshot,
- seriesSnapshot: rfqData.seriesSnapshot,
pspid: rfqData.pspid,
projNm: rfqData.projNm,
sector: rfqData.sector,
@@ -233,13 +201,12 @@ export function RFQListTable({
setProjectDetailRfq({
id: projectRfqData.id,
rfqCode: projectRfqData.rfqCode,
- itemId: projectRfqData.itemId,
- itemName: projectRfqData.itemName,
+ biddingProjectId: projectRfqData.biddingProjectId,
materialCode: projectRfqData.materialCode,
dueDate: projectRfqData.dueDate,
rfqSendDate: projectRfqData.rfqSendDate,
status: projectRfqData.status,
- picCode: projectRfqData.picCode,
+ description: projectRfqData.description,
remark: projectRfqData.remark,
cancelReason: projectRfqData.cancelReason,
createdAt: projectRfqData.createdAt,
@@ -250,8 +217,6 @@ export function RFQListTable({
updatedByName: projectRfqData.updatedByName,
sentBy: projectRfqData.sentBy,
sentByName: projectRfqData.sentByName,
- projectSnapshot: projectRfqData.projectSnapshot || {},
- seriesSnapshot: projectRfqData.seriesSnapshot || {},
pspid: projectRfqData.pspid,
projNm: projectRfqData.projNm,
sector: projectRfqData.sector,
@@ -307,11 +272,7 @@ export function RFQListTable({
}))
setAttachmentsDefault(attachments)
- setSelectedRfqForAttachments({
- ...rfq,
- projectSnapshot: rfq.projectSnapshot || {},
- seriesSnapshot: Array.isArray(rfq.seriesSnapshot) ? rfq.seriesSnapshot : {},
- })
+ setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
setAttachmentsOpen(true)
} catch (error) {
console.error("첨부파일 조회 오류:", error)
@@ -332,12 +293,20 @@ export function RFQListTable({
}, 500)
}, [])
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: TechSalesRfq) => {
+ console.log("Opening items dialog for RFQ:", rfq.id, rfq)
+ setSelectedRfqForItems(rfq as unknown as TechSalesRfq)
+ setItemsDialogOpen(true)
+ }, [])
+
const columns = React.useMemo(
() => getColumns({
setRowAction,
- openAttachmentsSheet
+ openAttachmentsSheet,
+ openItemsDialog
}),
- [openAttachmentsSheet]
+ [openAttachmentsSheet, openItemsDialog]
)
// 고급 필터 필드 정의
@@ -348,13 +317,8 @@ export function RFQListTable({
type: "text",
},
{
- id: "materialCode",
- label: "자재코드",
- type: "text",
- },
- {
- id: "itemName",
- label: "자재명",
+ id: "description",
+ label: "설명",
type: "text",
},
{
@@ -363,11 +327,6 @@ export function RFQListTable({
type: "text",
},
{
- id: "ptypeNm",
- label: "선종명",
- type: "text",
- },
- {
id: "rfqSendDate",
label: "RFQ 전송일",
type: "date",
@@ -563,6 +522,7 @@ export function RFQListTable({
<RFQTableToolbarActions
selection={table}
onRefresh={() => {}}
+ rfqType={rfqType}
/>
</div>
</DataTableAdvancedToolbar>
@@ -603,6 +563,13 @@ export function RFQListTable({
rfq={selectedRfqForAttachments}
onAttachmentsUpdated={handleAttachmentsUpdated}
/>
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems}
+ />
</div>
)
} \ No newline at end of file