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/README.md41
-rw-r--r--lib/techsales-rfq/table/create-rfq-hull-dialog.tsx1294
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx1450
-rw-r--r--lib/techsales-rfq/table/create-rfq-top-dialog.tsx1220
-rw-r--r--lib/techsales-rfq/table/delete-vendors-dialog.tsx236
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx946
-rw-r--r--lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx297
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx173
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx10
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx850
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx1483
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx1238
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx343
-rw-r--r--lib/techsales-rfq/table/project-detail-dialog.tsx238
-rw-r--r--lib/techsales-rfq/table/rfq-filter-sheet.tsx1516
-rw-r--r--lib/techsales-rfq/table/rfq-items-view-dialog.tsx6
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx831
-rw-r--r--lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx158
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx1223
-rw-r--r--lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx39
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx1118
21 files changed, 7682 insertions, 7028 deletions
diff --git a/lib/techsales-rfq/table/README.md b/lib/techsales-rfq/table/README.md
deleted file mode 100644
index 74d0005f..00000000
--- a/lib/techsales-rfq/table/README.md
+++ /dev/null
@@ -1,41 +0,0 @@
-
-# 기술영업 RFQ
-
-1. 마스터 테이블
----컬럼---
-상태
-견적프로젝트 이름
-rfqCode (RFQ-YYYY-001)
-프로젝트 상세보기 액션컬럼 >> 다이얼로그로 해당 프로젝트 정보 보여줌. (SHI/벤더 동일)
-
-- 견적 프로젝트명
-- 척수
-- 선주명
-- 선급코드(선급명)
-- 선종명
-- 선형명
-- 시리즈 상세보기 >> 시리즈별 K/L 연도분기 >> 2026.2Q 형식
-dueDate (마감일)
-sentDate (발송일)
-sentBy (발송자)
-createdBy (생성자)
-updatedBy (수정자)
-createdAt (생성일)
-updatedAt (수정일)
-첨부파일 첨부 테이블
-취소 이유 (삼중이 취소했을 때)
-데이터 없으면 취소하기 버튼으로 보여주기.
-코멘트 액션컬럼
----컬럼---
-
-2. 디테일 테이블
-디테일 테이블에서는 마스터 테이블의 레코드를 선택했을 때 해당 레코드의 상세내역을 보여줌.
-여기서는 벤더별 rfq 송신 및 현황 확인을 응답을 확인할 수 있도록, 발주용 견적과 유사하게 처리
----컬럼---
-벤더명
-상태
-응답 (가격)
-발송일
-발송자
-응답일
-응답자
diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
index 23c57491..5870c785 100644
--- a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
@@ -1,648 +1,648 @@
-"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
-}
-
-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="입찰 프로젝트를 선택하세요"
- pjtType="HULL"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Title</FormLabel>
- <FormControl>
- <Input
- placeholder="RFQ Title을 입력하세요 (선택사항)"
- {...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>
- )
+"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
+}
+
+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="입찰 프로젝트를 선택하세요"
+ pjtType="HULL"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...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 aCode = a.itemCode || 'zzz'
+ const bCode = b.itemCode || 'zzz'
+ return aCode.localeCompare(bCode, '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-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
index efa4e164..114bd04d 100644
--- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
@@ -1,726 +1,726 @@
-"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 { createTechSalesShipRfq } 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 {
- getWorkTypes,
- getAllShipbuildingItemsForCache,
- getShipTypes,
- type ShipbuildingItem,
- type ShipbuildingWorkType
-} from "@/lib/items-tech/service"
-
-
-// 유효성 검증 스키마
-const createShipRfqSchema = 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 CreateShipRfqFormValues = z.infer<typeof createShipRfqSchema>
-
-// 공종 타입 정의
-interface WorkTypeOption {
- code: ShipbuildingWorkType
- name: string
-}
-
-interface CreateShipRfqDialogProps {
- onCreated?: () => void;
-}
-
-export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
- 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<ShipbuildingWorkType | null>(null)
- const [selectedShipType, setSelectedShipType] = React.useState<string | null>(null)
- const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([])
-
- // 데이터 상태
- const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
- const [allItems, setAllItems] = React.useState<ShipbuildingItem[]>([])
- const [shipTypes, setShipTypes] = React.useState<string[]>([])
- 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(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
-
- const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([
- getWorkTypes(),
- getAllShipbuildingItemsForCache(),
- getShipTypes()
- ])
-
- console.log("Ship - WorkTypes 결과:", workTypesResult)
- console.log("Ship - Items 결과:", itemsResult)
- console.log("Ship - ShipTypes 결과:", shipTypesResult)
-
- // WorkTypes 설정
- if (Array.isArray(workTypesResult)) {
- setWorkTypes(workTypesResult)
- } else {
- throw new Error("공종 데이터를 불러올 수 없습니다.")
- }
-
- // Items 설정
- if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) {
- setAllItems(itemsResult.data)
- console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개")
- } else {
- throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.")
- }
-
- // ShipTypes 설정
- if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) {
- setShipTypes(shipTypesResult.data)
- console.log("선종 설정 완료:", shipTypesResult.data)
- } else {
- throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.")
- }
-
- // 성공 시 재시도 카운터 리셋
- setRetryCount(0)
- setDataLoadError(null)
- console.log("조선 RFQ 데이터 로딩 완료")
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
- console.error("조선 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<CreateShipRfqFormValues>({
- resolver: zodResolver(createShipRfqSchema),
- defaultValues: {
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- }
- })
-
- // 필터링된 아이템 목록 가져오기
- const availableItems = React.useMemo(() => {
- let filtered = [...allItems]
-
- // 선종 필터
- if (selectedShipType) {
- filtered = filtered.filter(item => item.shipTypes === selectedShipType)
- }
-
- // 공종 필터
- if (selectedWorkType) {
- filtered = filtered.filter(item => item.workType === selectedWorkType)
- }
-
- // 검색어 필터
- 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))
- )
- }
-
- return filtered
- }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType])
-
- // 사용 가능한 선종 목록 가져오기
- const availableShipTypes = React.useMemo(() => {
- return shipTypes
- }, [shipTypes])
-
- // 프로젝트 선택 처리
- const handleProjectSelect = (project: Project) => {
- setSelectedProject(project)
- form.setValue("biddingProjectId", project.id)
- // 선택 초기화
- setSelectedItems([])
- setSelectedShipType(null)
- setSelectedWorkType(null)
- setItemSearchQuery("")
- form.setValue("itemIds", [])
- }
-
- // 아이템 선택/해제 처리
- const handleItemToggle = (item: ShipbuildingItem) => {
- 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: CreateShipRfqFormValues) => {
- try {
- setIsProcessing(true)
-
- // 사용자 인증 확인
- if (!session?.user?.id) {
- throw new Error("로그인이 필요합니다")
- }
-
- // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성
- const result = await createTechSalesShipRfq({
- 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}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`)
-
- setIsDialogOpen(false)
- form.reset({
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- })
- setSelectedProject(null)
- setItemSearchQuery("")
- setSelectedWorkType(null)
- setSelectedShipType(null)
- setSelectedItems([])
- setDataLoadError(null)
- setRetryCount(0)
-
- // 생성 후 콜백 실행
- if (onCreated) {
- onCreated()
- }
-
- } catch (error) {
- console.error("조선 RFQ 생성 오류:", error)
- toast.error(`조선 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)
- setSelectedShipType(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">조선 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>조선 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="입찰 프로젝트를 선택하세요"
- pjtType="SHIP"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- {/* RFQ 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Title</FormLabel>
- <FormControl>
- <Input
- placeholder="RFQ Title을 입력하세요 (선택사항)"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- {/* 선종 선택 */}
- <div className="space-y-4">
- <div>
- <FormLabel>선종 선택</FormLabel>
- </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>
- )}
-
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- className="w-full justify-between"
- disabled={!selectedProject || isLoadingItems || dataLoadError !== null}
- >
- {isLoadingItems ? (
- <>
- <Loader2 className="h-4 w-4 animate-spin mr-2" />
- 데이터 로딩 중...
- </>
- ) : dataLoadError ? (
- "데이터 로딩 실패"
- ) : selectedShipType ? (
- selectedShipType
- ) : (
- "선종을 선택하세요"
- )}
- <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent className="w-full max-h-60 overflow-y-auto">
- <DropdownMenuCheckboxItem
- checked={selectedShipType === null}
- onCheckedChange={() => {
- setSelectedShipType(null)
- setSelectedItems([])
- form.setValue("itemIds", [])
- }}
- >
- 전체 선종
- </DropdownMenuCheckboxItem>
- {availableShipTypes.map(shipType => (
- <DropdownMenuCheckboxItem
- key={shipType}
- checked={selectedShipType === shipType}
- onCheckedChange={() => {
- setSelectedShipType(shipType)
- setSelectedItems([])
- form.setValue("itemIds", [])
- }}
- >
- {shipType}
- </DropdownMenuCheckboxItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- <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>
- {selectedShipType
- ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요`
- : "먼저 선종을 선택해주세요"
- }
- </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={!selectedShipType || isLoadingItems || dataLoadError !== null}
- />
- {itemSearchQuery && (
- <Button
- variant="ghost"
- size="sm"
- className="absolute right-0 top-0 h-full px-3"
- onClick={() => setItemSearchQuery("")}
- disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
-
- {/* 공종 필터 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- className="gap-1"
- disabled={!selectedShipType || 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 || '아이템명 없음'}
- </div>
- <div className="text-sm text-muted-foreground">
- {item.itemCode || '자재그룹코드 없음'}
- </div>
- <div className="text-xs text-muted-foreground">
- 공종: {item.workType} • 선종: {item.shipTypes}
- </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}개 아이템으로 조선 RFQ 생성하기`}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- )
+"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 { createTechSalesShipRfq } 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 {
+ getWorkTypes,
+ getAllShipbuildingItemsForCache,
+ getShipTypes,
+ type ShipbuildingItem,
+ type ShipbuildingWorkType
+} from "@/lib/items-tech/service"
+
+
+// 유효성 검증 스키마
+const createShipRfqSchema = 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 CreateShipRfqFormValues = z.infer<typeof createShipRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: ShipbuildingWorkType
+ name: string
+}
+
+interface CreateShipRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
+ 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<ShipbuildingWorkType | null>(null)
+ const [selectedShipType, setSelectedShipType] = React.useState<string | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([])
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [allItems, setAllItems] = React.useState<ShipbuildingItem[]>([])
+ const [shipTypes, setShipTypes] = React.useState<string[]>([])
+ 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(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+
+ const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([
+ getWorkTypes(),
+ getAllShipbuildingItemsForCache(),
+ getShipTypes()
+ ])
+
+ console.log("Ship - WorkTypes 결과:", workTypesResult)
+ console.log("Ship - Items 결과:", itemsResult)
+ console.log("Ship - ShipTypes 결과:", shipTypesResult)
+
+ // WorkTypes 설정
+ if (Array.isArray(workTypesResult)) {
+ setWorkTypes(workTypesResult)
+ } else {
+ throw new Error("공종 데이터를 불러올 수 없습니다.")
+ }
+
+ // Items 설정
+ if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) {
+ setAllItems(itemsResult.data)
+ console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개")
+ } else {
+ throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.")
+ }
+
+ // ShipTypes 설정
+ if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) {
+ setShipTypes(shipTypesResult.data)
+ console.log("선종 설정 완료:", shipTypesResult.data)
+ } else {
+ throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.")
+ }
+
+ // 성공 시 재시도 카운터 리셋
+ setRetryCount(0)
+ setDataLoadError(null)
+ console.log("조선 RFQ 데이터 로딩 완료")
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ console.error("조선 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<CreateShipRfqFormValues>({
+ resolver: zodResolver(createShipRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ }
+ })
+
+ // 필터링된 아이템 목록 가져오기
+ const availableItems = React.useMemo(() => {
+ let filtered = [...allItems]
+
+ // 선종 필터
+ if (selectedShipType) {
+ filtered = filtered.filter(item => item.shipTypes === selectedShipType)
+ }
+
+ // 공종 필터
+ if (selectedWorkType) {
+ filtered = filtered.filter(item => item.workType === selectedWorkType)
+ }
+
+ // 검색어 필터
+ 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))
+ )
+ }
+
+ return filtered
+ }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType])
+
+ // 사용 가능한 선종 목록 가져오기
+ const availableShipTypes = React.useMemo(() => {
+ return shipTypes
+ }, [shipTypes])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ setSelectedShipType(null)
+ setSelectedWorkType(null)
+ setItemSearchQuery("")
+ form.setValue("itemIds", [])
+ }
+
+ // 아이템 선택/해제 처리
+ const handleItemToggle = (item: ShipbuildingItem) => {
+ 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: CreateShipRfqFormValues) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesShipRfq({
+ 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}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`)
+
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedShipType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+
+ // 생성 후 콜백 실행
+ if (onCreated) {
+ onCreated()
+ }
+
+ } catch (error) {
+ console.error("조선 RFQ 생성 오류:", error)
+ toast.error(`조선 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)
+ setSelectedShipType(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">조선 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>조선 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="입찰 프로젝트를 선택하세요"
+ pjtType="SHIP"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* 선종 선택 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>선종 선택</FormLabel>
+ </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>
+ )}
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="w-full justify-between"
+ disabled={!selectedProject || isLoadingItems || dataLoadError !== null}
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ 데이터 로딩 중...
+ </>
+ ) : dataLoadError ? (
+ "데이터 로딩 실패"
+ ) : selectedShipType ? (
+ selectedShipType
+ ) : (
+ "선종을 선택하세요"
+ )}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-full max-h-60 overflow-y-auto">
+ <DropdownMenuCheckboxItem
+ checked={selectedShipType === null}
+ onCheckedChange={() => {
+ setSelectedShipType(null)
+ setSelectedItems([])
+ form.setValue("itemIds", [])
+ }}
+ >
+ 전체 선종
+ </DropdownMenuCheckboxItem>
+ {availableShipTypes.map(shipType => (
+ <DropdownMenuCheckboxItem
+ key={shipType}
+ checked={selectedShipType === shipType}
+ onCheckedChange={() => {
+ setSelectedShipType(shipType)
+ setSelectedItems([])
+ form.setValue("itemIds", [])
+ }}
+ >
+ {shipType}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <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>
+ {selectedShipType
+ ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요`
+ : "먼저 선종을 선택해주세요"
+ }
+ </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={!selectedShipType || isLoadingItems || dataLoadError !== null}
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={!selectedShipType || 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.itemCode || 'zzz'
+ const bName = b.itemCode || '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 || '아이템명 없음'}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode || '자재그룹코드 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType} • 선종: {item.shipTypes}
+ </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}개 아이템으로 조선 RFQ 생성하기`}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
index ef2229ac..49fb35ca 100644
--- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
@@ -1,611 +1,611 @@
-"use client"
-
-import * as React from "react"
-import { toast } from "sonner"
-import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
-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 { Input } from "@/components/ui/input"
-
-// 공종 타입 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
-}
-
-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="입찰 프로젝트를 선택하세요"
- pjtType="TOP"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
- {/* RFQ 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Title</FormLabel>
- <FormControl>
- <Input
- placeholder="RFQ Title을 입력하세요 (선택사항)"
- {...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>아이템 선택</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>
- )
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+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 { Input } from "@/components/ui/input"
+
+// 공종 타입 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
+}
+
+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="입찰 프로젝트를 선택하세요"
+ pjtType="TOP"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...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>아이템 선택</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.itemCode || 'zzz'
+ const bName = b.itemCode || '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/delete-vendors-dialog.tsx b/lib/techsales-rfq/table/delete-vendors-dialog.tsx
index 35c3b067..788ef1cc 100644
--- a/lib/techsales-rfq/table/delete-vendors-dialog.tsx
+++ b/lib/techsales-rfq/table/delete-vendors-dialog.tsx
@@ -1,119 +1,119 @@
-"use client"
-
-import * as React from "react"
-import { type RfqDetailView } from "./rfq-detail-column"
-import { Loader, Trash } from "lucide-react"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-
-interface DeleteVendorsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- vendors: RfqDetailView[]
- onConfirm: () => void
- isLoading?: boolean
-}
-
-export function DeleteVendorsDialog({
- vendors,
- onConfirm,
- isLoading = false,
- ...props
-}: DeleteVendorsDialogProps) {
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- const vendorNames = vendors.map(v => v.vendorName).filter(Boolean).join(", ")
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>벤더 삭제 확인</DialogTitle>
- <DialogDescription>
- 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
- <br />
- <br />
- 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
- <br />
- <br />
- 이 작업은 되돌릴 수 없습니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline" disabled={isLoading}>취소</Button>
- </DialogClose>
- <Button
- aria-label="선택한 벤더들 삭제"
- variant="destructive"
- onClick={onConfirm}
- disabled={isLoading}
- >
- {isLoading && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>벤더 삭제 확인</DrawerTitle>
- <DrawerDescription>
- 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
- <br />
- <br />
- 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
- <br />
- <br />
- 이 작업은 되돌릴 수 없습니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline" disabled={isLoading}>취소</Button>
- </DrawerClose>
- <Button
- aria-label="선택한 벤더들 삭제"
- variant="destructive"
- onClick={onConfirm}
- disabled={isLoading}
- >
- {isLoading && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
+"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./detail-table/rfq-detail-column"
+import { Loader } from "lucide-react"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+
+interface DeleteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: RfqDetailView[]
+ onConfirm: () => void
+ isLoading?: boolean
+}
+
+export function DeleteVendorsDialog({
+ vendors,
+ onConfirm,
+ isLoading = false,
+ ...props
+}: DeleteVendorsDialogProps) {
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ const vendorNames = vendors.map(v => v.vendorName).filter(Boolean).join(", ")
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>벤더 삭제 확인</DialogTitle>
+ <DialogDescription>
+ 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
+ <br />
+ <br />
+ 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
+ <br />
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline" disabled={isLoading}>취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 벤더들 삭제"
+ variant="destructive"
+ onClick={onConfirm}
+ disabled={isLoading}
+ >
+ {isLoading && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>벤더 삭제 확인</DrawerTitle>
+ <DrawerDescription>
+ 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
+ <br />
+ <br />
+ 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
+ <br />
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline" disabled={isLoading}>취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 벤더들 삭제"
+ variant="destructive"
+ onClick={onConfirm}
+ disabled={isLoading}
+ >
+ {isLoading && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
} \ 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 8f2fe948..69953217 100644
--- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
@@ -1,474 +1,474 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useCallback } from "react"
-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, Star } from "lucide-react"
-import { useSession } from "next-auth/react"
-
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Badge } from "@/components/ui/badge"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service"
-
-// 폼 유효성 검증 스키마 - 간단화
-const vendorFormSchema = z.object({
- vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"),
-})
-
-type VendorFormValues = z.infer<typeof vendorFormSchema>
-
-// 기술영업 RFQ 타입 정의
-type TechSalesRfq = {
- id: number
- rfqCode: string | null
- rfqType: "SHIP" | "TOP" | "HULL" | null
- ptypeNm: string | null // 프로젝트 타입명 추가
- status: string
- [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
-}
-
-// 벤더 검색 결과 타입 (techVendor 기반)
-type VendorSearchResult = {
- id: number
- vendorName: string
- vendorCode: string | null
- status: string
- country: string | null
- techVendorType?: string | null
- matchedItemCount?: number // 후보 벤더 정보
-}
-
-interface AddVendorDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: TechSalesRfq | null
- onSuccess?: () => void
- existingVendorIds?: number[]
-}
-
-export function AddVendorDialog({
- open,
- onOpenChange,
- selectedRfq,
- onSuccess,
- existingVendorIds = [],
-}: AddVendorDialogProps) {
- const { data: session } = useSession()
- 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),
- defaultValues: {
- vendorIds: [],
- },
- })
-
- 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()) {
- setSearchResults([])
- setHasSearched(false)
- return
- }
-
- setIsSearching(true)
- try {
- // 선택된 RFQ의 타입을 기반으로 벤더 검색
- const rfqType = selectedRfq?.rfqType || undefined;
- console.log("rfqType", rfqType) // 디버깅용
- const results = await searchTechVendors(term, 100, rfqType)
-
- // 이미 추가된 벤더 제외
- const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id))
- setSearchResults(filteredResults)
- setHasSearched(true)
- } catch (error) {
- console.error("벤더 검색 오류:", error)
- toast.error("벤더 검색 중 오류가 발생했습니다")
- setSearchResults([])
- } finally {
- setIsSearching(false)
- }
- },
- [existingVendorIds, selectedRfq?.rfqType]
- )
-
- // 검색어 변경 시 디바운스 적용
- useEffect(() => {
- const timer = setTimeout(() => {
- searchVendorsDebounced(searchTerm)
- }, 300)
-
- 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")
- const isSelected = currentIds.includes(vendor.id)
-
- if (isSelected) {
- // 선택 해제
- const newIds = currentIds.filter(id => id !== vendor.id)
- const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id)
- form.setValue("vendorIds", newIds, { shouldValidate: true })
- setSelectedVendorData(newSelectedData)
- } else {
- // 선택 추가
- const newIds = [...currentIds, vendor.id]
- const newSelectedData = [...selectedVendorData, vendor]
- form.setValue("vendorIds", newIds, { shouldValidate: true })
- setSelectedVendorData(newSelectedData)
- }
- }
-
- // 선택된 벤더 제거 핸들러
- const handleRemoveVendor = (vendorId: number) => {
- const currentIds = form.getValues("vendorIds")
- const newIds = currentIds.filter(id => id !== vendorId)
- const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId)
- form.setValue("vendorIds", newIds, { shouldValidate: true })
- setSelectedVendorData(newSelectedData)
- }
-
- // 폼 제출 핸들러
- async function onSubmit(values: VendorFormValues) {
- if (!selectedRfq) {
- toast.error("선택된 RFQ가 없습니다")
- return
- }
-
- if (!session?.user?.id) {
- toast.error("로그인이 필요합니다")
- return
- }
-
- try {
- setIsSubmitting(true)
-
- // 새로운 서비스 함수 호출
- const result = await addTechVendorsToTechSalesRfq({
- rfqId: selectedRfq.id,
- vendorIds: values.vendorIds,
- createdBy: Number(session.user.id),
- })
-
- if (result.error) {
- toast.error(result.error)
- } else {
- const successCount = result.data?.length || 0
- toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`)
-
- onOpenChange(false)
- form.reset()
- setSearchTerm("")
- setSearchResults([])
- setCandidateVendors([])
- setHasSearched(false)
- setHasCandidatesLoaded(false)
- setSelectedVendorData([])
- onSuccess?.()
- }
- } catch (error) {
- console.error("벤더 추가 오류:", error)
- toast.error("벤더 추가 중 오류가 발생했습니다")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 다이얼로그 닫기 시 폼 리셋
- React.useEffect(() => {
- if (!open) {
- 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-[800px] max-h-[80vh] flex flex-col">
- {/* 헤더 */}
- <DialogHeader>
- <DialogTitle>벤더 추가</DialogTitle>
- <DialogDescription>
- {selectedRfq ? (
- <>
- <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
- </>
- ) : (
- "RFQ에 벤더를 추가합니다."
- )}
- </DialogDescription>
- </DialogHeader>
-
- {/* 콘텐츠 */}
- <div className="flex-1 overflow-y-auto">
- <Form {...form}>
- <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 탭 메뉴 */}
- <Tabs value={activeTab} onValueChange={setActiveTab}>
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="candidates">
- 후보 벤더 ({candidateVendors.length})
- </TabsTrigger>
- <TabsTrigger value="search">
- 벤더 검색
- </TabsTrigger>
- </TabsList>
-
- {/* 후보 벤더 탭 */}
- <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>
- </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
- control={form.control}
- name="vendorIds"
- render={() => (
- <FormItem>
- <div className="space-y-2">
- <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel>
- <div className="min-h-[60px] p-3 border rounded-md bg-muted/50">
- {selectedVendorData.length > 0 ? (
- <div className="flex flex-wrap gap-2">
- {selectedVendorData.map((vendor) => (
- <Badge
- key={vendor.id}
- variant="secondary"
- className="flex items-center gap-1"
- >
- {vendor.vendorName} ({vendor.vendorCode || 'N/A'})
- <X
- className="h-3 w-3 cursor-pointer hover:text-destructive"
- onClick={() => handleRemoveVendor(vendor.id)}
- />
- </Badge>
- ))}
- </div>
- ) : (
- <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
- 선택된 벤더가 없습니다
- </div>
- )}
- </div>
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 안내 메시지 */}
- <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
- <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p>
- <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
- <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
- </div>
- </form>
- </Form>
- </div>
-
- {/* 푸터 */}
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- form="vendor-form"
- disabled={isSubmitting || selectedVendorIds.length === 0}
- >
- {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+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, Star } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service"
+
+// 폼 유효성 검증 스키마 - 간단화
+const vendorFormSchema = z.object({
+ vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"),
+})
+
+type VendorFormValues = z.infer<typeof vendorFormSchema>
+
+// 기술영업 RFQ 타입 정의
+type TechSalesRfq = {
+ id: number
+ rfqCode: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm: string | null // 프로젝트 타입명 추가
+ status: string
+ [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
+}
+
+// 벤더 검색 결과 타입 (techVendor 기반)
+type VendorSearchResult = {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ status: string
+ country: string | null
+ techVendorType?: string | null
+ matchedItemCount?: number // 후보 벤더 정보
+}
+
+interface AddVendorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: TechSalesRfq | null
+ onSuccess?: () => void
+ existingVendorIds?: number[]
+}
+
+export function AddVendorDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+ onSuccess,
+ existingVendorIds = [],
+}: AddVendorDialogProps) {
+ const { data: session } = useSession()
+ 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),
+ defaultValues: {
+ vendorIds: [],
+ },
+ })
+
+ 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()) {
+ setSearchResults([])
+ setHasSearched(false)
+ return
+ }
+
+ setIsSearching(true)
+ try {
+ // 선택된 RFQ의 타입을 기반으로 벤더 검색
+ const rfqType = selectedRfq?.rfqType || undefined;
+ console.log("rfqType", rfqType) // 디버깅용
+ const results = await searchTechVendors(term, 100, rfqType)
+
+ // 이미 추가된 벤더 제외
+ const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id))
+ setSearchResults(filteredResults)
+ setHasSearched(true)
+ } catch (error) {
+ console.error("벤더 검색 오류:", error)
+ toast.error("벤더 검색 중 오류가 발생했습니다")
+ setSearchResults([])
+ } finally {
+ setIsSearching(false)
+ }
+ },
+ [existingVendorIds, selectedRfq?.rfqType]
+ )
+
+ // 검색어 변경 시 디바운스 적용
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ searchVendorsDebounced(searchTerm)
+ }, 300)
+
+ 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")
+ const isSelected = currentIds.includes(vendor.id)
+
+ if (isSelected) {
+ // 선택 해제
+ const newIds = currentIds.filter(id => id !== vendor.id)
+ const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id)
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ } else {
+ // 선택 추가
+ const newIds = [...currentIds, vendor.id]
+ const newSelectedData = [...selectedVendorData, vendor]
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ }
+ }
+
+ // 선택된 벤더 제거 핸들러
+ const handleRemoveVendor = (vendorId: number) => {
+ const currentIds = form.getValues("vendorIds")
+ const newIds = currentIds.filter(id => id !== vendorId)
+ const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId)
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ }
+
+ // 폼 제출 핸들러
+ async function onSubmit(values: VendorFormValues) {
+ if (!selectedRfq) {
+ toast.error("선택된 RFQ가 없습니다")
+ return
+ }
+
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+
+ // 새로운 서비스 함수 호출
+ const result = await addTechVendorsToTechSalesRfq({
+ rfqId: selectedRfq.id,
+ vendorIds: values.vendorIds,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`)
+
+ onOpenChange(false)
+ form.reset()
+ setSearchTerm("")
+ setSearchResults([])
+ setCandidateVendors([])
+ setHasSearched(false)
+ setHasCandidatesLoaded(false)
+ setSelectedVendorData([])
+ onSuccess?.()
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 다이얼로그 닫기 시 폼 리셋
+ React.useEffect(() => {
+ if (!open) {
+ 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-[800px] max-h-[80vh] flex flex-col">
+ {/* 헤더 */}
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 콘텐츠 */}
+ <div className="flex-1 overflow-y-auto">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 탭 메뉴 */}
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="candidates">
+ 후보 벤더 ({candidateVendors.length})
+ </TabsTrigger>
+ <TabsTrigger value="search">
+ 벤더 검색
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 후보 벤더 탭 */}
+ <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>
+ </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
+ control={form.control}
+ name="vendorIds"
+ render={() => (
+ <FormItem>
+ <div className="space-y-2">
+ <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel>
+ <div className="min-h-[60px] p-3 border rounded-md bg-muted/50">
+ {selectedVendorData.length > 0 ? (
+ <div className="flex flex-wrap gap-2">
+ {selectedVendorData.map((vendor) => (
+ <Badge
+ key={vendor.id}
+ variant="secondary"
+ className="flex items-center gap-1"
+ >
+ {vendor.vendorName} ({vendor.vendorCode || 'N/A'})
+ <X
+ className="h-3 w-3 cursor-pointer hover:text-destructive"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ />
+ </Badge>
+ ))}
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ 선택된 벤더가 없습니다
+ </div>
+ )}
+ </div>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 안내 메시지
+ <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
+ <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p>
+ <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
+ <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
+ </div> */}
+ </form>
+ </Form>
+ </div>
+
+ {/* 푸터 */}
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting || selectedVendorIds.length === 0}
+ >
+ {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx
index d7e3403b..d86dcea2 100644
--- a/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx
@@ -1,150 +1,149 @@
-"use client"
-
-import * as React from "react"
-import { type RfqDetailView } from "./rfq-detail-column"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
-
-
-interface DeleteRfqDetailDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- detail: RfqDetailView | null
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteVendorDialog({
- detail,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteRfqDetailDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- if (!detail) return
-
- startDeleteTransition(async () => {
- try {
- const result = await deleteRfqDetail(detail.id)
-
- if (!result.success) {
- toast.error(result.message || "삭제 중 오류가 발생했습니다")
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("RFQ 벤더 정보가 삭제되었습니다")
- onSuccess?.()
- } catch (error) {
- console.error("RFQ 벤더 삭제 오류:", error)
- toast.error("삭제 중 오류가 발생했습니다")
- }
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="destructive" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="선택한 RFQ 벤더 정보 삭제"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="destructive" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="선택한 RFQ 벤더 정보 삭제"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
+"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./rfq-detail-column"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
+
+
+interface DeleteRfqDetailDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ detail: RfqDetailView | null
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteVendorDialog({
+ detail,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRfqDetailDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ if (!detail) return
+
+ startDeleteTransition(async () => {
+ try {
+ const result = await deleteRfqDetail(detail.id)
+
+ if (!result.success) {
+ toast.error(result.message || "삭제 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 삭제되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 삭제 오류:", error)
+ toast.error("삭제 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx
new file mode 100644
index 00000000..3e793b62
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx
@@ -0,0 +1,173 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Mail, Phone, User, Users } from "lucide-react"
+import { getQuotationContacts } from "../../service"
+
+interface QuotationContact {
+ id: number
+ contactId: number
+ contactName: string
+ contactPosition: string | null
+ contactEmail: string
+ contactPhone: string | null
+ contactCountry: string | null
+ isPrimary: boolean
+ createdAt: Date
+}
+
+interface QuotationContactsViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationId: number | null
+ vendorName?: string
+}
+
+export function QuotationContactsViewDialog({
+ open,
+ onOpenChange,
+ quotationId,
+ vendorName
+}: QuotationContactsViewDialogProps) {
+ const [contacts, setContacts] = useState<QuotationContact[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+
+ // 담당자 정보 로드
+ const loadQuotationContacts = useCallback(async () => {
+ if (!quotationId) return
+
+ setIsLoading(true)
+ try {
+ const result = await getQuotationContacts(quotationId)
+ if (result.success) {
+ setContacts(result.data || [])
+ } else {
+ console.error("담당자 정보 로드 실패:", result.error)
+ setContacts([])
+ }
+ } catch (error) {
+ console.error("담당자 정보 로드 오류:", error)
+ setContacts([])
+ } finally {
+ setIsLoading(false)
+ }
+ }, [quotationId])
+
+ // Dialog가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotationId) {
+ loadQuotationContacts()
+ }
+ }, [open, quotationId, loadQuotationContacts])
+
+ // Dialog가 닫힐 때 상태 초기화
+ useEffect(() => {
+ if (!open) {
+ setContacts([])
+ }
+ }, [open])
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl max-h-[70vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="size-5" />
+ RFQ 발송 담당자 목록
+ </DialogTitle>
+ <DialogDescription>
+ {vendorName && (
+ <span className="font-medium">{vendorName}</span>
+ )} 에게 발송된 RFQ의 담당자 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto">
+ {isLoading ? (
+ <div className="space-y-3">
+ {[1, 2, 3].map((i) => (
+ <Skeleton key={i} className="h-20 w-full" />
+ ))}
+ </div>
+ ) : contacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Mail className="size-12 mx-auto mb-2 opacity-50" />
+ <p>발송된 담당자 정보가 없습니다.</p>
+ <p className="text-sm">아직 RFQ가 발송되지 않았거나 담당자 정보가 기록되지 않았습니다.</p>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {contacts.map((contact) => (
+ <div
+ key={contact.id}
+ className="flex items-center justify-between p-4 border rounded-lg bg-gray-50"
+ >
+ <div className="flex items-center gap-3">
+ <User className="size-4 text-muted-foreground" />
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ {contact.isPrimary && (
+ <Badge variant="secondary" className="text-xs">
+ 주담당자
+ </Badge>
+ )}
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactPosition}
+ </p>
+ )}
+ {contact.contactCountry && (
+ <p className="text-xs text-muted-foreground">
+ {contact.contactCountry}
+ </p>
+ )}
+ </div>
+ </div>
+
+ <div className="flex flex-col items-end gap-1 text-sm">
+ <div className="flex items-center gap-1">
+ <Mail className="size-4 text-muted-foreground" />
+ <span>{contact.contactEmail}</span>
+ </div>
+ {contact.contactPhone && (
+ <div className="flex items-center gap-1">
+ <Phone className="size-4 text-muted-foreground" />
+ <span>{contact.contactPhone}</span>
+ </div>
+ )}
+ <div className="text-xs text-muted-foreground">
+ 발송일: {new Date(contact.createdAt).toLocaleDateString('ko-KR')}
+ </div>
+ </div>
+ </div>
+ ))}
+
+ <div className="text-center pt-4 text-sm text-muted-foreground border-t">
+ 총 {contacts.length}명의 담당자에게 발송됨
+ </div>
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end pt-4">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
index ce701e13..0f5158d9 100644
--- a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
-import { Clock, User, FileText, AlertCircle, Paperclip } from "lucide-react"
+import { Clock, User, AlertCircle, Paperclip } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { toast } from "sonner"
@@ -91,7 +91,6 @@ function QuotationCard({
data,
version,
isCurrent = false,
- changeReason,
revisedBy,
revisedAt,
attachments
@@ -99,7 +98,6 @@ function QuotationCard({
data: QuotationSnapshot | QuotationHistoryData["current"]
version: number
isCurrent?: boolean
- changeReason?: string | null
revisedBy?: string | null
revisedAt?: Date
attachments?: QuotationAttachment[]
@@ -137,7 +135,7 @@ function QuotationCard({
<div>
<p className="text-sm font-medium text-muted-foreground">유효 기한</p>
<p className="text-sm">
- {data.validUntil ? formatDate(data.validUntil, "KR") : "미설정"}
+ {data.validUntil ? formatDate(data.validUntil) : "미설정"}
</p>
</div>
</div>
@@ -187,8 +185,8 @@ function QuotationCard({
<Clock className="size-3" />
<span>
{isCurrent
- ? `수정: ${data.updatedAt ? formatDate(data.updatedAt, "KR") : "N/A"}`
- : `변경: ${revisedAt ? formatDate(revisedAt, "KR") : "N/A"}`
+ ? `수정: ${data.updatedAt ? formatDate(data.updatedAt) : "N/A"}`
+ : `변경: ${revisedAt ? formatDate(revisedAt) : "N/A"}`
}
</span>
</div>
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index e921fcaa..e4141520 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -1,401 +1,451 @@
-"use client"
-
-import * as React from "react"
-import type { ColumnDef, Row } from "@tanstack/react-table";
-import { formatDate } from "@/lib/utils"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2, Paperclip } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-
-export interface DataTableRowAction<TData> {
- row: Row<TData>;
- type: "communicate" | "delete";
-}
-
-// 벤더 견적 데이터 타입 정의
-export interface RfqDetailView {
- id: number
- rfqId: number
- vendorId?: number | null
- vendorName: string | null
- vendorCode: string | null
- totalPrice: string | number | null
- currency: string | null
- validUntil: Date | null
- status: string | null
- remark: string | null
- submittedAt: Date | null
- acceptedAt: Date | null
- rejectionReason: string | null
- createdAt: Date | null
- updatedAt: Date | null
- createdByName: string | null
- quotationCode?: string | null
- rfqCode?: string | null
- quotationAttachments?: Array<{
- id: number
- revisionId: number
- fileName: string
- fileSize: number
- filePath: string
- description?: string | null
- }>
-}
-
-// 견적서 정보 타입 (Sheet용)
-export interface QuotationInfo {
- id: number
- quotationCode: string | null
- vendorName?: string
- rfqCode?: string
-}
-
-interface GetColumnsProps<TData> {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<TData> | null>
- >;
- unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
- onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
- openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
-}
-
-export function getRfqDetailColumns({
- setRowAction,
- unreadMessages = {},
- onQuotationClick,
- openQuotationAttachmentsSheet
-}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
- return [
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="모두 선택"
- />
- ),
- cell: ({ row }) => {
- const status = row.original.status;
- const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true;
-
- return (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- disabled={!isSelectable}
- aria-label="행 선택"
- className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""}
- />
- );
- },
- enableSorting: false,
- enableHiding: false,
- size: 40,
- },
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="견적 상태" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("status") as string;
- // 상태에 따른 배지 색상 설정
- let variant: "default" | "secondary" | "outline" | "destructive" = "outline";
-
- if (status === "Submitted") {
- variant = "default"; // 제출됨 - 기본 색상
- } else if (status === "Accepted") {
- variant = "secondary"; // 승인됨 - 보조 색상
- } else if (status === "Rejected") {
- variant = "destructive"; // 거부됨 - 위험 색상
- }
-
- return (
- <Badge variant={variant}>{status || "Draft"}</Badge>
- );
- },
- meta: {
- excelHeader: "견적 상태"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
- ),
- cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
- meta: {
- excelHeader: "벤더 코드"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더명" />
- ),
- cell: ({ row }) => {
- const vendorName = row.getValue("vendorName") as string | null;
- const vendorId = row.original.vendorId;
-
- if (!vendorName) return <div>-</div>;
-
- if (vendorId) {
- return (
- <Button
- variant="link"
- className="p-0 h-auto font-normal text-left justify-start hover:underline"
- onClick={() => {
- window.open(`/ko/evcp/tech-vendors/${vendorId}/info`, '_blank');
- }}
- >
- {vendorName}
- </Button>
- );
- }
-
- return <div>{vendorName}</div>;
- },
- meta: {
- excelHeader: "벤더명"
- },
- enableResizing: true,
- size: 160,
- },
- {
- accessorKey: "totalPrice",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="견적 금액" />
- ),
- cell: ({ row }) => {
- const value = row.getValue("totalPrice") as string | number | null;
- const currency = row.getValue("currency") as string | null;
- const quotationId = row.original.id;
-
- if (value === null || value === undefined) return "-";
-
- // 숫자로 변환 시도
- const numValue = typeof value === 'string' ? parseFloat(value) : value;
- const displayValue = isNaN(numValue) ? value : numValue.toLocaleString();
-
- // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시
- if (onQuotationClick && quotationId) {
- return (
- <Button
- variant="link"
- className="p-0 h-auto font-medium text-left justify-start hover:underline"
- onClick={() => onQuotationClick(quotationId)}
- title="견적 히스토리 보기"
- >
- {displayValue} {currency}
- </Button>
- );
- }
-
- return (
- <div className="font-medium">
- {displayValue} {currency}
- </div>
- );
- },
- meta: {
- excelHeader: "견적 금액"
- },
- enableResizing: true,
- size: 140,
- },
- {
- accessorKey: "quotationAttachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
- ),
- cell: ({ row }) => {
- const attachments = row.original.quotationAttachments || [];
- const attachmentCount = attachments.length;
-
- if (attachmentCount === 0) {
- return <div className="text-muted-foreground">-</div>;
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={() => {
- // 견적서 첨부파일 sheet 열기
- if (openQuotationAttachmentsSheet) {
- const quotation = row.original;
- openQuotationAttachmentsSheet(quotation.id, {
- id: quotation.id,
- quotationCode: quotation.quotationCode || null,
- vendorName: quotation.vendorName || undefined,
- rfqCode: quotation.rfqCode || undefined,
- });
- }
- }}
- title={
- attachmentCount === 1
- ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)`
- : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}`
- }
- >
- <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>
- )}
- </Button>
- );
- },
- meta: {
- excelHeader: "첨부파일"
- },
- enableResizing: false,
- size: 80,
- },
- {
- accessorKey: "currency",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="통화" />
- ),
- cell: ({ row }) => <div>{row.getValue("currency")}</div>,
- meta: {
- excelHeader: "통화"
- },
- enableResizing: true,
- size: 80,
- },
- {
- accessorKey: "validUntil",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유효기간" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue() as Date | null;
- return value ? formatDate(value, "KR") : "-";
- },
- meta: {
- excelHeader: "유효기간"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "submittedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="제출일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue() as Date | null;
- return value ? formatDate(value, "KR") : "-";
- },
- meta: {
- excelHeader: "제출일"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "createdByName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="등록자" />
- ),
- cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
- meta: {
- excelHeader: "등록자"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "remark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="비고" />
- ),
- cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>,
- meta: {
- excelHeader: "비고"
- },
- enableResizing: true,
- size: 200,
- },
- {
- id: "actions",
- header: () => <div className="text-right">동작</div>,
- cell: function Cell({ row }) {
- const vendorId = row.original.vendorId;
- const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0;
- const status = row.original.status;
- const isDraft = status === "Draft";
-
- return (
- <div className="text-right flex items-center justify-end gap-1">
- {/* 커뮤니케이션 버튼 */}
- <div className="relative">
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- onClick={() => setRowAction({ row, type: "communicate" })}
- title="벤더와 커뮤니케이션"
- >
- <MessageCircle className="h-4 w-4" />
- </Button>
- {unreadCount > 0 && (
- <Badge
- variant="destructive"
- className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center"
- >
- {unreadCount > 9 ? '9+' : unreadCount}
- </Badge>
- )}
- </div>
-
- {/* 컨텍스트 메뉴 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- title="더 많은 작업"
- >
- <MoreHorizontal className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "delete" })}
- disabled={!isDraft}
- className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"}
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 벤더 삭제
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- );
- },
- enableResizing: false,
- size: 120,
- },
- ];
+"use client"
+
+import * as React from "react"
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { formatDate } from "@/lib/utils"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Checkbox } from "@/components/ui/checkbox";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+export interface DataTableRowAction<TData> {
+ row: Row<TData>;
+ type: "communicate" | "delete";
+}
+
+// 벤더 견적 데이터 타입 정의
+export interface RfqDetailView {
+ id: number
+ rfqId: number
+ vendorId?: number | null
+ vendorName: string | null
+ vendorCode: string | null
+ totalPrice: string | number | null
+ currency: string | null
+ validUntil: Date | null
+ status: string | null
+ remark: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ rejectionReason: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+ createdByName: string | null
+ quotationCode?: string | null
+ rfqCode?: string | null
+ quotationAttachments?: Array<{
+ id: number
+ revisionId: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+}
+
+// 견적서 정보 타입 (Sheet용)
+export interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface GetColumnsProps<TData> {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
+ onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
+ openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
+ openContactsDialog?: (quotationId: number, vendorName?: string) => void; // 담당자 조회 다이얼로그 열기
+}
+
+export function getRfqDetailColumns({
+ setRowAction,
+ unreadMessages = {},
+ onQuotationClick,
+ openQuotationAttachmentsSheet,
+ openContactsDialog
+}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.status;
+ const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true;
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ disabled={!isSelectable}
+ aria-label="행 선택"
+ className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+ // 상태에 따른 배지 색상 설정
+ let variant: "default" | "secondary" | "outline" | "destructive" = "outline";
+
+ if (status === "Submitted") {
+ variant = "default"; // 제출됨 - 기본 색상
+ } else if (status === "Accepted") {
+ variant = "secondary"; // 승인됨 - 보조 색상
+ } else if (status === "Rejected") {
+ variant = "destructive"; // 거부됨 - 위험 색상
+ }
+
+ return (
+ <Badge variant={variant}>{status || "Draft"}</Badge>
+ );
+ },
+ meta: {
+ excelHeader: "견적 상태"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
+ meta: {
+ excelHeader: "벤더 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => {
+ const vendorName = row.getValue("vendorName") as string | null;
+ const vendorId = row.original.vendorId;
+
+ if (!vendorName) return <div>-</div>;
+
+ if (vendorId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-normal text-left justify-start hover:underline"
+ onClick={() => {
+ window.open(`/ko/evcp/tech-vendors/${vendorId}/info`, '_blank');
+ }}
+ >
+ {vendorName}
+ </Button>
+ );
+ }
+
+ return <div>{vendorName}</div>;
+ },
+ meta: {
+ excelHeader: "벤더명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 금액" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("totalPrice") as string | number | null;
+ const currency = row.getValue("currency") as string | null;
+ const quotationId = row.original.id;
+
+ if (value === null || value === undefined) return "-";
+
+ // 숫자로 변환 시도
+ const numValue = typeof value === 'string' ? parseFloat(value) : value;
+ const displayValue = isNaN(numValue) ? value : numValue.toLocaleString();
+
+ // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시
+ if (onQuotationClick && quotationId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-medium text-left justify-start hover:underline"
+ onClick={() => onQuotationClick(quotationId)}
+ title="견적 히스토리 보기"
+ >
+ {displayValue} {currency}
+ </Button>
+ );
+ }
+
+ return (
+ <div className="font-medium">
+ {displayValue} {currency}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "견적 금액"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "quotationAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const attachments = row.original.quotationAttachments || [];
+ const attachmentCount = attachments.length;
+
+ if (attachmentCount === 0) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={() => {
+ // 견적서 첨부파일 sheet 열기
+ if (openQuotationAttachmentsSheet) {
+ const quotation = row.original;
+ openQuotationAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ }
+ }}
+ title={
+ attachmentCount === 1
+ ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)`
+ : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}`
+ }
+ >
+ <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>
+ )}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ id: "contacts",
+ header: "담당자",
+ cell: ({ row }) => {
+ const quotation = row.original;
+
+ const handleClick = () => {
+ if (openContactsDialog) {
+ openContactsDialog(quotation.id, quotation.vendorName || undefined);
+ }
+ };
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="담당자 정보 보기"
+ >
+ <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ <span className="sr-only">담당자 정보 보기</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>RFQ 발송 담당자 보기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "담당자"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("currency")}</div>,
+ meta: {
+ excelHeader: "통화"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "유효기간"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "제출일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "등록자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>,
+ meta: {
+ excelHeader: "비고"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
+ id: "actions",
+ header: () => <div className="text-right">동작</div>,
+ cell: function Cell({ row }) {
+ const vendorId = row.original.vendorId;
+ const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0;
+ const status = row.original.status;
+ const isDraft = status === "Draft";
+
+ return (
+ <div className="text-right flex items-center justify-end gap-1">
+ {/* 커뮤니케이션 버튼 */}
+ <div className="relative">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ onClick={() => setRowAction({ row, type: "communicate" })}
+ title="벤더와 커뮤니케이션"
+ >
+ <MessageCircle className="h-4 w-4" />
+ </Button>
+ {unreadCount > 0 && (
+ <Badge
+ variant="destructive"
+ className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center"
+ >
+ {unreadCount > 9 ? '9+' : unreadCount}
+ </Badge>
+ )}
+ </div>
+
+ {/* 컨텍스트 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ title="더 많은 작업"
+ >
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "delete" })}
+ disabled={!isDraft}
+ className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 벤더 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ );
+ },
+ enableResizing: false,
+ size: 120,
+ },
+ ];
} \ No newline at end of file
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 1d701bd5..41572a93 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -1,710 +1,775 @@
-"use client"
-
-import * as React from "react"
-import { useEffect, useState, useCallback, useMemo } from "react"
-import {
- DataTableRowAction,
- getRfqDetailColumns,
- RfqDetailView
-} from "./rfq-detail-column"
-import { toast } from "sonner"
-
-import { Skeleton } from "@/components/ui/skeleton"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import { AddVendorDialog } from "./add-vendor-dialog"
-import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
-import { DeleteVendorsDialog } from "../delete-vendors-dialog"
-import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
-import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
-import type { QuotationInfo } from "./rfq-detail-column"
-
-// 기본적인 RFQ 타입 정의
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- status: string
- materialCode?: string | null
- itemName?: string | null
- remark?: string | null
- rfqSendDate?: Date | null
- dueDate?: Date | null
- createdByName?: string | null
- rfqType: "SHIP" | "TOP" | "HULL" | null
- ptypeNm?: string | null
-}
-
-// 프로퍼티 정의
-interface RfqDetailTablesProps {
- selectedRfq: TechSalesRfq | null
- maxHeight?: string | number
-}
-
-
-export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
- // console.log("selectedRfq", selectedRfq)
-
- // 상태 관리
- const [isLoading, setIsLoading] = useState(false)
- const [details, setDetails] = useState<RfqDetailView[]>([])
- const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
-
- const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
-
- // 벤더 커뮤니케이션 상태 관리
- const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
- const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
-
- // 읽지 않은 메시지 개수
- const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
-
- // 테이블 선택 상태 관리
- const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
- const [isSendingRfq, setIsSendingRfq] = useState(false)
- const [isDeletingVendors, setIsDeletingVendors] = useState(false)
-
- // 벤더 삭제 확인 다이얼로그 상태 추가
- const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
-
- // 견적 히스토리 다이얼로그 상태 관리
- const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
- const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
-
- // 견적서 첨부파일 sheet 상태 관리
- const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
- const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
- const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
- const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
-
- // selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
- const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
-
- // existingVendorIds 메모이제이션
- const existingVendorIds = useMemo(() => {
- return details.map(detail => Number(detail.vendorId)).filter(Boolean);
- }, [details]);
-
- // 읽지 않은 메시지 로드 함수 메모이제이션
- const loadUnreadMessages = useCallback(async () => {
- if (!selectedRfqId) return;
-
- try {
- // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현
- const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service");
- const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId);
- setUnreadMessages(unreadData);
- } catch (error) {
- console.error("읽지 않은 메시지 로드 오류:", error);
- setUnreadMessages({});
- }
- }, [selectedRfqId]);
-
- // 데이터 새로고침 함수 메모이제이션
- const handleRefreshData = useCallback(async () => {
- if (!selectedRfqId) return
-
- try {
- // 실제 벤더 견적 데이터 다시 로딩
- const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service")
-
- const result = await getTechSalesRfqTechVendors(selectedRfqId)
-
- // 데이터 변환
- const transformedData = result.data?.map((item: any) => ({
- ...item,
- detailId: item.id,
- rfqId: selectedRfqId,
- rfqCode: selectedRfq?.rfqCode || null,
- rfqType: selectedRfq?.rfqType || null,
- ptypeNm: selectedRfq?.ptypeNm || null,
- vendorId: item.vendorId ? Number(item.vendorId) : undefined,
- })) || []
-
- setDetails(transformedData)
-
- // 읽지 않은 메시지 개수 업데이트
- await loadUnreadMessages();
-
- toast.success("데이터를 성공적으로 새로고침했습니다")
- } catch (error) {
- console.error("데이터 새로고침 오류:", error)
- toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
- }
- }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
-
- // 벤더 추가 핸들러 메모이제이션
- const handleAddVendor = useCallback(async () => {
- try {
- setIsAdddialogLoading(true)
- setVendorDialogOpen(true)
- } catch (error) {
- console.error("데이터 로드 오류:", error)
- toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
- } finally {
- setIsAdddialogLoading(false)
- }
- }, [])
-
- // RFQ 발송 핸들러 메모이제이션
- const handleSendRfq = useCallback(async () => {
- if (selectedRows.length === 0) {
- toast.warning("발송할 벤더를 선택해주세요.");
- return;
- }
-
- if (!selectedRfqId) {
- toast.error("선택된 RFQ가 없습니다.");
- return;
- }
-
- try {
- setIsSendingRfq(true);
-
- // 기술영업 RFQ 발송 서비스 함수 호출
- const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean);
- const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service");
-
- const result = await sendTechSalesRfqToVendors({
- rfqId: selectedRfqId,
- vendorIds: vendorIds as number[]
- });
-
- if (result.success) {
- toast.success(result.message || `${selectedRows.length}개 벤더에게 RFQ가 발송되었습니다.`);
- } else {
- toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다.");
- }
-
- // 선택 해제
- setSelectedRows([]);
-
- // 데이터 새로고침
- await handleRefreshData();
-
- } catch (error) {
- console.error("RFQ 발송 오류:", error);
- toast.error("RFQ 발송 중 오류가 발생했습니다.");
- } finally {
- setIsSendingRfq(false);
- }
- }, [selectedRows, selectedRfqId, handleRefreshData]);
-
- // 벤더 선택 핸들러 추가
- const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
-
- const handleAcceptVendors = useCallback(async () => {
- if (selectedRows.length === 0) {
- toast.warning("선택할 벤더를 선택해주세요.");
- return;
- }
-
- if (selectedRows.length > 1) {
- toast.warning("하나의 벤더만 선택할 수 있습니다.");
- return;
- }
-
- const selectedQuotation = selectedRows[0];
- if (selectedQuotation.status !== "Submitted") {
- toast.warning("제출된 견적서만 선택할 수 있습니다.");
- return;
- }
-
- try {
- setIsAcceptingVendors(true);
-
- // 벤더 견적 승인 서비스 함수 호출
- const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions");
-
- const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id);
-
- if (result.success) {
- toast.success(result.message || "벤더가 성공적으로 선택되었습니다.");
- } else {
- toast.error(result.error || "벤더 선택 중 오류가 발생했습니다.");
- }
-
- // 선택 해제
- setSelectedRows([]);
-
- // 데이터 새로고침
- await handleRefreshData();
-
- } catch (error) {
- console.error("벤더 선택 오류:", error);
- toast.error("벤더 선택 중 오류가 발생했습니다.");
- } finally {
- setIsAcceptingVendors(false);
- }
- }, [selectedRows, handleRefreshData]);
-
- // 벤더 삭제 핸들러 메모이제이션
- const handleDeleteVendors = useCallback(async () => {
- if (selectedRows.length === 0) {
- toast.warning("삭제할 벤더를 선택해주세요.");
- return;
- }
-
- if (!selectedRfqId) {
- toast.error("선택된 RFQ가 없습니다.");
- return;
- }
-
- try {
- setIsDeletingVendors(true);
-
- const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[];
-
- if (vendorIds.length === 0) {
- toast.error("유효한 벤더 ID가 없습니다.");
- return;
- }
-
- // 서비스 함수 호출
- const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
-
- const result = await removeTechVendorsFromTechSalesRfq({
- rfqId: selectedRfqId,
- vendorIds: vendorIds
- });
-
- if (result.error) {
- toast.error(result.error);
- } else {
- const successCount = result.data?.length || 0
- toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`);
- }
-
- // 선택 해제
- setSelectedRows([]);
-
- // 데이터 새로고침
- await handleRefreshData();
-
- } catch (error) {
- console.error("벤더 삭제 오류:", error);
- toast.error("벤더 삭제 중 오류가 발생했습니다.");
- } finally {
- setIsDeletingVendors(false);
- }
- }, [selectedRows, selectedRfqId, handleRefreshData]);
-
- // 벤더 삭제 확인 핸들러
- const handleDeleteVendorsConfirm = useCallback(() => {
- if (selectedRows.length === 0) {
- toast.warning("삭제할 벤더를 선택해주세요.");
- return;
- }
- setDeleteConfirmDialogOpen(true);
- }, [selectedRows]);
-
- // 벤더 삭제 확정 실행
- const executeDeleteVendors = useCallback(async () => {
- setDeleteConfirmDialogOpen(false);
- await handleDeleteVendors();
- }, [handleDeleteVendors]);
-
-
- // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
- const handleOpenHistoryDialog = useCallback((quotationId: number) => {
- setSelectedQuotationId(quotationId);
- setHistoryDialogOpen(true);
- }, [])
-
- // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션
- const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
- try {
- setIsLoadingAttachments(true);
- setSelectedQuotationInfo(quotationInfo);
- setQuotationAttachmentsSheetOpen(true);
-
- // 견적서 첨부파일 조회
- const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service");
- const result = await getTechSalesVendorQuotationAttachments(quotationId);
-
- if (result.error) {
- toast.error(result.error);
- setQuotationAttachments([]);
- } else {
- setQuotationAttachments(result.data || []);
- }
- } catch (error) {
- console.error("견적서 첨부파일 조회 오류:", error);
- toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다.");
- setQuotationAttachments([]);
- } finally {
- setIsLoadingAttachments(false);
- }
- }, [])
-
- // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
- const columns = useMemo(() =>
- getRfqDetailColumns({
- setRowAction,
- unreadMessages,
- onQuotationClick: handleOpenHistoryDialog,
- openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet
- }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet])
-
- // 필터 필드 정의 (메모이제이션)
- const advancedFilterFields = useMemo(
- () => [
- {
- id: "vendorName",
- label: "벤더명",
- type: "text",
- },
- {
- id: "vendorCode",
- label: "벤더 코드",
- type: "text",
- },
- {
- id: "currency",
- label: "통화",
- type: "text",
- },
- ],
- []
- )
-
- // 계산된 값들 메모이제이션
- const vendorsWithQuotations = useMemo(() =>
- details.filter(detail => detail.status === "Submitted").length,
- [details]
- );
-
- // RFQ ID가 변경될 때 데이터 로드
- useEffect(() => {
- async function loadRfqDetails() {
- if (!selectedRfqId) {
- setDetails([])
- return
- }
-
- try {
- setIsLoading(true)
-
- // 실제 벤더 견적 데이터 로딩
- const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
-
- const result = await getTechSalesVendorQuotationsWithJoin({
- rfqId: selectedRfqId,
- page: 1,
- perPage: 1000, // 모든 데이터 가져오기
- })
-
- // 데이터 변환 (procurement 패턴에 맞게)
- const transformedData = result.data?.map(item => ({
- ...item,
- detailId: item.id,
- rfqId: selectedRfqId,
- rfqCode: selectedRfq?.rfqCode || null,
- vendorId: item.vendorId ? Number(item.vendorId) : undefined,
- // 기타 필요한 필드 변환
- })) || []
-
- setDetails(transformedData)
-
- // 읽지 않은 메시지 개수 로드
- await loadUnreadMessages();
-
- } catch (error) {
- console.error("RFQ 디테일 로드 오류:", error)
- setDetails([])
- toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
- } finally {
- setIsLoading(false)
- }
- }
-
- loadRfqDetails()
- }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
-
- // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용
- useEffect(() => {
- if (!selectedRfqId) return;
-
- const intervalId = setInterval(() => {
- loadUnreadMessages();
- }, 60000); // 60초마다 갱신
-
- return () => clearInterval(intervalId);
- }, [selectedRfqId, loadUnreadMessages]);
-
- // rowAction 처리 - procurement 패턴 적용 (메모이제이션)
- useEffect(() => {
- if (!rowAction) return
-
- const handleRowAction = async () => {
- try {
- // 통신 액션인 경우 드로어 열기
- if (rowAction.type === "communicate") {
- setSelectedVendor(rowAction.row.original);
- setCommunicationDrawerOpen(true);
-
- // rowAction 초기화
- setRowAction(null);
- return;
- }
-
- // 삭제 액션인 경우 개별 벤더 삭제
- if (rowAction.type === "delete") {
- const vendor = rowAction.row.original;
-
- if (!vendor.vendorId || !selectedRfqId) {
- toast.error("벤더 정보가 없습니다.");
- setRowAction(null);
- return;
- }
-
- // Draft 상태 체크
- if (vendor.status !== "Draft") {
- toast.error("Draft 상태의 벤더만 삭제할 수 있습니다.");
- setRowAction(null);
- return;
- }
-
- // 개별 벤더 삭제
- const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
-
- const result = await removeTechVendorFromTechSalesRfq({
- rfqId: selectedRfqId,
- vendorId: vendor.vendorId
- });
-
- if (result.error) {
- toast.error(result.error);
- } else {
- toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`);
- // 데이터 새로고침
- await handleRefreshData();
- }
-
- // rowAction 초기화
- setRowAction(null);
- return;
- }
- } catch (error) {
- console.error("액션 처리 오류:", error);
- toast.error("작업을 처리하는 중 오류가 발생했습니다");
- }
- };
-
- handleRowAction();
- }, [rowAction, selectedRfqId, handleRefreshData])
-
- // 선택된 행 변경 핸들러 메모이제이션
- const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => {
- setSelectedRows(selectedRowsData);
- }, []);
-
- // 커뮤니케이션 드로어 변경 핸들러 메모이제이션
- const handleCommunicationDrawerChange = useCallback((open: boolean) => {
- setCommunicationDrawerOpen(open);
- // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신
- if (!open && selectedVendor?.vendorId && selectedRfqId) {
- // 메시지를 읽음으로 처리
- import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => {
- markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => {
- console.error("메시지 읽음 처리 오류:", error);
- });
- });
-
- // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트
- setUnreadMessages(prev => ({
- ...prev,
- [selectedVendor.vendorId!]: 0
- }));
-
- // 전체 읽지 않은 메시지 개수 갱신
- loadUnreadMessages();
- }
- }, [selectedVendor, selectedRfqId, loadUnreadMessages]);
-
- if (!selectedRfq) {
- return (
- <div className="flex items-center justify-center h-full text-muted-foreground">
- RFQ를 선택하세요
- </div>
- )
- }
-
- // 로딩 중인 경우
- if (isLoading) {
- return (
- <div className="p-4 space-y-4">
- <Skeleton className="h-8 w-1/2" />
- <Skeleton className="h-24 w-full" />
- <Skeleton className="h-48 w-full" />
- </div>
- )
- }
-
- return (
- <div className="h-full overflow-hidden pt-4">
- {/* 테이블 또는 빈 상태 표시 */}
- {details.length > 0 ? (
- <ClientDataTable
- columns={columns}
- data={details}
- advancedFilterFields={advancedFilterFields}
- maxHeight={maxHeight}
- onSelectedRowsChange={handleSelectedRowsChange}
- >
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2 mr-2">
- {selectedRows.length > 0 && (
- <Badge variant="default" className="h-6">
- {selectedRows.length}개 선택됨
- </Badge>
- )}
- {/* {totalUnreadMessages > 0 && (
- <Badge variant="destructive" className="h-6">
- 읽지 않은 메시지: {totalUnreadMessages}건
- </Badge>
- )} */}
- {vendorsWithQuotations > 0 && (
- <Badge variant="outline" className="h-6">
- 견적 제출: {vendorsWithQuotations}개 벤더
- </Badge>
- )}
- </div>
- <div className="flex gap-2">
- {/* 벤더 선택 버튼 */}
- <Button
- variant="default"
- size="sm"
- onClick={handleAcceptVendors}
- disabled={selectedRows.length === 0 || isAcceptingVendors}
- className="gap-2"
- >
- {isAcceptingVendors ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <CheckCircle className="size-4" aria-hidden="true" />
- )}
- <span>벤더 선택</span>
- </Button>
-
- {/* RFQ 발송 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleSendRfq}
- disabled={selectedRows.length === 0 || isSendingRfq}
- className="gap-2"
- >
- {isSendingRfq ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <Send className="size-4" aria-hidden="true" />
- )}
- <span>RFQ 발송</span>
- </Button>
-
- {/* 벤더 삭제 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleDeleteVendorsConfirm}
- disabled={selectedRows.length === 0 || isDeletingVendors}
- className="gap-2"
- >
- {isDeletingVendors ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <Trash2 className="size-4" aria-hidden="true" />
- )}
- <span>벤더 삭제</span>
- </Button>
-
- {/* 벤더 추가 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleAddVendor}
- disabled={isAdddialogLoading}
- className="gap-2"
- >
- {isAdddialogLoading ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <UserPlus className="size-4" aria-hidden="true" />
- )}
- <span>벤더 추가</span>
- </Button>
- </div>
- </div>
- </ClientDataTable>
- ) : (
- <div className="flex h-full items-center justify-center text-muted-foreground">
- <div className="text-center">
- <p className="text-lg font-medium">벤더가 없습니다</p>
- <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p>
- <Button
- variant="outline"
- size="sm"
- onClick={handleAddVendor}
- disabled={isAdddialogLoading}
- className="mt-4 gap-2"
- >
- {isAdddialogLoading ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <UserPlus className="size-4" aria-hidden="true" />
- )}
- <span>벤더 추가</span>
- </Button>
- </div>
- </div>
- )}
-
- {/* 다이얼로그들 */}
- <AddVendorDialog
- open={vendorDialogOpen}
- onOpenChange={setVendorDialogOpen}
- selectedRfq={selectedRfq as unknown as TechSalesRfq}
- existingVendorIds={existingVendorIds}
- onSuccess={handleRefreshData}
- />
-
- {/* 벤더 커뮤니케이션 드로어 */}
- <VendorCommunicationDrawer
- open={communicationDrawerOpen}
- onOpenChange={handleCommunicationDrawerChange}
- selectedRfq={selectedRfq}
- selectedVendor={selectedVendor}
- onSuccess={handleRefreshData}
- />
-
- {/* 다중 벤더 삭제 확인 다이얼로그 */}
- <DeleteVendorsDialog
- open={deleteConfirmDialogOpen}
- onOpenChange={setDeleteConfirmDialogOpen}
- vendors={selectedRows}
- onConfirm={executeDeleteVendors}
- isLoading={isDeletingVendors}
- />
-
- {/* 견적 히스토리 다이얼로그 */}
- <QuotationHistoryDialog
- open={historyDialogOpen}
- onOpenChange={setHistoryDialogOpen}
- quotationId={selectedQuotationId}
- />
-
- {/* 견적서 첨부파일 Sheet */}
- <TechSalesQuotationAttachmentsSheet
- open={quotationAttachmentsSheetOpen}
- onOpenChange={setQuotationAttachmentsSheetOpen}
- quotation={selectedQuotationInfo}
- attachments={quotationAttachments}
- isLoading={isLoadingAttachments}
- />
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { useEffect, useState, useCallback, useMemo } from "react"
+import {
+ DataTableRowAction,
+ getRfqDetailColumns,
+ RfqDetailView
+} from "./rfq-detail-column"
+import { toast } from "sonner"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
+import { DeleteVendorDialog } from "./delete-vendors-dialog"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import type { QuotationInfo } from "./rfq-detail-column"
+import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
+import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
+
+// 기본적인 RFQ 타입 정의
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ materialCode?: string | null
+ itemName?: string | null
+ remark?: string | null
+ rfqSendDate?: Date | null
+ dueDate?: Date | null
+ createdByName?: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm?: string | null
+}
+
+// 프로퍼티 정의
+interface RfqDetailTablesProps {
+ selectedRfq: TechSalesRfq | null
+ maxHeight?: string | number
+}
+
+
+export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
+ // console.log("selectedRfq", selectedRfq)
+
+ // 상태 관리
+ const [isLoading, setIsLoading] = useState(false)
+ const [details, setDetails] = useState<RfqDetailView[]>([])
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
+
+ const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
+
+ // 벤더 커뮤니케이션 상태 관리
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+ const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
+
+ // 읽지 않은 메시지 개수
+ const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
+
+ // 테이블 선택 상태 관리
+ const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
+ const [isSendingRfq, setIsSendingRfq] = useState(false)
+ const [isDeletingVendors, setIsDeletingVendors] = useState(false)
+
+ // 벤더 삭제 확인 다이얼로그 상태 추가
+ const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
+
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+ const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
+
+ // 견적서 첨부파일 sheet 상태 관리
+ const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
+ const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
+ const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+
+ // 벤더 contact 선택 다이얼로그 상태 관리
+ const [contactSelectionDialogOpen, setContactSelectionDialogOpen] = useState(false)
+
+ // 담당자 조회 다이얼로그 상태 관리
+ const [contactsDialogOpen, setContactsDialogOpen] = useState(false)
+ const [selectedQuotationForContacts, setSelectedQuotationForContacts] = useState<{ id: number; vendorName?: string } | null>(null)
+
+ // selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
+ const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
+
+ // existingVendorIds 메모이제이션
+ const existingVendorIds = useMemo(() => {
+ return details.map(detail => Number(detail.vendorId)).filter(Boolean);
+ }, [details]);
+
+ // 읽지 않은 메시지 로드 함수 메모이제이션
+ const loadUnreadMessages = useCallback(async () => {
+ if (!selectedRfqId) return;
+
+ try {
+ // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현
+ const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service");
+ const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId);
+ setUnreadMessages(unreadData);
+ } catch (error) {
+ console.error("읽지 않은 메시지 로드 오류:", error);
+ setUnreadMessages({});
+ }
+ }, [selectedRfqId]);
+
+ // 데이터 새로고침 함수 메모이제이션
+ const handleRefreshData = useCallback(async () => {
+ if (!selectedRfqId) return
+
+ try {
+ // 실제 벤더 견적 데이터 다시 로딩
+ const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesRfqTechVendors(selectedRfqId)
+
+ // 데이터 변환
+ const transformedData = result.data?.map((item: any) => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ rfqType: selectedRfq?.rfqType || null,
+ ptypeNm: selectedRfq?.ptypeNm || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 업데이트
+ await loadUnreadMessages();
+
+ toast.success("데이터를 성공적으로 새로고침했습니다")
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error)
+ toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
+ }
+ }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
+
+ // 벤더 추가 핸들러 메모이제이션
+ const handleAddVendor = useCallback(async () => {
+ try {
+ setIsAdddialogLoading(true)
+ setVendorDialogOpen(true)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsAdddialogLoading(false)
+ }
+ }, [])
+
+ // RFQ 발송 핸들러 메모이제이션 - contact selection dialog 사용
+ const handleSendRfq = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("발송할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ // 선택된 벤더들의 status가 모두 'Assigned'인지 확인
+ const nonAssignedVendors = selectedRows.filter(row => row.status !== "Assigned");
+ if (nonAssignedVendors.length > 0) {
+ toast.warning("Assigned 상태의 벤더만 RFQ를 발송할 수 있습니다.");
+ return;
+ }
+
+ // contact selection dialog 열기
+ setContactSelectionDialogOpen(true);
+ }, [selectedRows, selectedRfqId]);
+
+ // contact 기반 RFQ 발송 핸들러
+ const handleSendRfqWithContacts = useCallback(async (selectedContacts: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>) => {
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsSendingRfq(true);
+
+ // 기술영업 RFQ 발송 서비스 함수 호출 (contact 정보 포함)
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean);
+ const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service");
+
+ const result = await sendTechSalesRfqToVendors({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds as number[],
+ selectedContacts: selectedContacts
+ });
+
+ if (result.success) {
+ toast.success(result.message || `${selectedContacts.length}명의 연락처에게 RFQ가 발송되었습니다.`);
+ } else {
+ toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("RFQ 발송 오류:", error);
+ toast.error("RFQ 발송 중 오류가 발생했습니다.");
+ } finally {
+ setIsSendingRfq(false);
+ }
+ }, [selectedRfqId, selectedRows, handleRefreshData]);
+
+ // 벤더 선택 핸들러 추가
+ const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
+
+ const handleAcceptVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("선택할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (selectedRows.length > 1) {
+ toast.warning("하나의 벤더만 선택할 수 있습니다.");
+ return;
+ }
+
+ const selectedQuotation = selectedRows[0];
+ if (selectedQuotation.status !== "Submitted") {
+ toast.warning("제출된 견적서만 선택할 수 있습니다.");
+ return;
+ }
+
+ try {
+ setIsAcceptingVendors(true);
+
+ // 벤더 견적 승인 서비스 함수 호출
+ const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions");
+
+ const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id);
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 성공적으로 선택되었습니다.");
+ } else {
+ toast.error(result.error || "벤더 선택 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 선택 오류:", error);
+ toast.error("벤더 선택 중 오류가 발생했습니다.");
+ } finally {
+ setIsAcceptingVendors(false);
+ }
+ }, [selectedRows, handleRefreshData]);
+
+ // 벤더 삭제 핸들러 메모이제이션
+ const handleDeleteVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsDeletingVendors(true);
+
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[];
+
+ if (vendorIds.length === 0) {
+ toast.error("유효한 벤더 ID가 없습니다.");
+ return;
+ }
+
+ // 서비스 함수 호출
+ const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeTechVendorsFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`);
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 삭제 오류:", error);
+ toast.error("벤더 삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsDeletingVendors(false);
+ }
+ }, [selectedRows, selectedRfqId, handleRefreshData]);
+
+ // 벤더 삭제 확인 핸들러
+ const handleDeleteVendorsConfirm = useCallback(() => {
+ if (selectedRows.length === 0) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+ setDeleteConfirmDialogOpen(true);
+ }, [selectedRows]);
+
+ // 벤더 삭제 확정 실행
+ const executeDeleteVendors = useCallback(async () => {
+ setDeleteConfirmDialogOpen(false);
+ await handleDeleteVendors();
+ }, [handleDeleteVendors]);
+
+
+ // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
+ const handleOpenHistoryDialog = useCallback((quotationId: number) => {
+ setSelectedQuotationId(quotationId);
+ setHistoryDialogOpen(true);
+ }, [])
+
+ // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션
+ const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingAttachments(true);
+ setSelectedQuotationInfo(quotationInfo);
+ setQuotationAttachmentsSheetOpen(true);
+
+ // 견적서 첨부파일 조회
+ const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service");
+ const result = await getTechSalesVendorQuotationAttachments(quotationId);
+
+ if (result.error) {
+ toast.error(result.error);
+ setQuotationAttachments([]);
+ } else {
+ setQuotationAttachments(result.data || []);
+ }
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다.");
+ setQuotationAttachments([]);
+ } finally {
+ setIsLoadingAttachments(false);
+ }
+ }, [])
+
+ // 담당자 조회 다이얼로그 열기 함수
+ const handleOpenContactsDialog = useCallback((quotationId: number, vendorName?: string) => {
+ setSelectedQuotationForContacts({ id: quotationId, vendorName })
+ setContactsDialogOpen(true)
+ }, [])
+
+ // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
+ const columns = useMemo(() =>
+ getRfqDetailColumns({
+ setRowAction,
+ unreadMessages,
+ onQuotationClick: handleOpenHistoryDialog,
+ openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet,
+ openContactsDialog: handleOpenContactsDialog
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog])
+
+ // 필터 필드 정의 (메모이제이션)
+ const advancedFilterFields = useMemo(
+ () => [
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "text",
+ },
+ ],
+ []
+ )
+
+ // 계산된 값들 메모이제이션
+ const vendorsWithQuotations = useMemo(() =>
+ details.filter(detail => detail.status === "Submitted").length,
+ [details]
+ );
+
+ // RFQ ID가 변경될 때 데이터 로드
+ useEffect(() => {
+ async function loadRfqDetails() {
+ if (!selectedRfqId) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ // 실제 벤더 견적 데이터 로딩
+ const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId: selectedRfqId,
+ page: 1,
+ perPage: 1000, // 모든 데이터 가져오기
+ })
+
+ // 데이터 변환 (procurement 패턴에 맞게)
+ const transformedData = result.data?.map(item => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // 기타 필요한 필드 변환
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 로드
+ await loadUnreadMessages();
+
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ setDetails([])
+ toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadRfqDetails()
+ }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+
+ // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용
+ useEffect(() => {
+ if (!selectedRfqId) return;
+
+ const intervalId = setInterval(() => {
+ loadUnreadMessages();
+ }, 60000); // 60초마다 갱신
+
+ return () => clearInterval(intervalId);
+ }, [selectedRfqId, loadUnreadMessages]);
+
+ // rowAction 처리 - procurement 패턴 적용 (메모이제이션)
+ useEffect(() => {
+ if (!rowAction) return
+
+ const handleRowAction = async () => {
+ try {
+ // 통신 액션인 경우 드로어 열기
+ if (rowAction.type === "communicate") {
+ setSelectedVendor(rowAction.row.original);
+ setCommunicationDrawerOpen(true);
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+
+ // 삭제 액션인 경우 개별 벤더 삭제
+ if (rowAction.type === "delete") {
+ const vendor = rowAction.row.original;
+
+ if (!vendor.vendorId || !selectedRfqId) {
+ toast.error("벤더 정보가 없습니다.");
+ setRowAction(null);
+ return;
+ }
+
+ // Draft 상태 체크
+ if (vendor.status !== "Draft") {
+ toast.error("Draft 상태의 벤더만 삭제할 수 있습니다.");
+ setRowAction(null);
+ return;
+ }
+
+ // 개별 벤더 삭제
+ const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeTechVendorFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorId: vendor.vendorId
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`);
+ // 데이터 새로고침
+ await handleRefreshData();
+ }
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+ } catch (error) {
+ console.error("액션 처리 오류:", error);
+ toast.error("작업을 처리하는 중 오류가 발생했습니다");
+ }
+ };
+
+ handleRowAction();
+ }, [rowAction, selectedRfqId, handleRefreshData])
+
+ // 선택된 행 변경 핸들러 메모이제이션
+ const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => {
+ setSelectedRows(selectedRowsData);
+ }, []);
+
+ // 커뮤니케이션 드로어 변경 핸들러 메모이제이션
+ const handleCommunicationDrawerChange = useCallback((open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신
+ if (!open && selectedVendor?.vendorId && selectedRfqId) {
+ // 메시지를 읽음으로 처리
+ import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => {
+ markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => {
+ console.error("메시지 읽음 처리 오류:", error);
+ });
+ });
+
+ // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트
+ setUnreadMessages(prev => ({
+ ...prev,
+ [selectedVendor.vendorId!]: 0
+ }));
+
+ // 전체 읽지 않은 메시지 개수 갱신
+ loadUnreadMessages();
+ }
+ }, [selectedVendor, selectedRfqId, loadUnreadMessages]);
+
+ if (!selectedRfq) {
+ return (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ RFQ를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="h-full overflow-hidden pt-4">
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ maxHeight={maxHeight}
+ onSelectedRowsChange={handleSelectedRowsChange}
+ >
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {selectedRows.length > 0 && (
+ <Badge variant="default" className="h-6">
+ {selectedRows.length}개 선택됨
+ </Badge>
+ )}
+ {/* {totalUnreadMessages > 0 && (
+ <Badge variant="destructive" className="h-6">
+ 읽지 않은 메시지: {totalUnreadMessages}건
+ </Badge>
+ )} */}
+ {vendorsWithQuotations > 0 && (
+ <Badge variant="outline" className="h-6">
+ 견적 제출: {vendorsWithQuotations}개 벤더
+ </Badge>
+ )}
+ </div>
+ <div className="flex gap-2">
+ {/* 벤더 선택 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAcceptVendors}
+ disabled={
+ selectedRows.length === 0 ||
+ isAcceptingVendors ||
+ selectedRows.length > 1 ||
+ selectedRows.some(row => row.status !== "Submitted")
+ }
+ className="gap-2"
+ >
+ {isAcceptingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <CheckCircle className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 선택</span>
+ </Button>
+
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendRfq}
+ disabled={
+ selectedRows.length === 0 ||
+ isSendingRfq ||
+ selectedRows.some(row => row.status !== "Assigned")
+ }
+ className="gap-2"
+ >
+ {isSendingRfq ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Send className="size-4" aria-hidden="true" />
+ )}
+ <span>RFQ 발송</span>
+ </Button>
+
+ {/* 벤더 삭제 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDeleteVendorsConfirm}
+ disabled={selectedRows.length === 0 || isDeletingVendors}
+ className="gap-2"
+ >
+ {isDeletingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Trash2 className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 삭제</span>
+ </Button>
+
+ {/* 벤더 추가 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ </ClientDataTable>
+ ) : (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ <div className="text-center">
+ <p className="text-lg font-medium">벤더가 없습니다</p>
+ <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="mt-4 gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 다이얼로그들 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={setVendorDialogOpen}
+ selectedRfq={selectedRfq as unknown as TechSalesRfq}
+ existingVendorIds={existingVendorIds}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 다중 벤더 삭제 확인 다이얼로그 */}
+ <DeleteVendorDialog
+ open={deleteConfirmDialogOpen}
+ onOpenChange={setDeleteConfirmDialogOpen}
+ vendors={selectedRows}
+ onConfirm={executeDeleteVendors}
+ isLoading={isDeletingVendors}
+ />
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={selectedQuotationId}
+ />
+
+ {/* 견적서 첨부파일 Sheet */}
+ <TechSalesQuotationAttachmentsSheet
+ open={quotationAttachmentsSheetOpen}
+ onOpenChange={setQuotationAttachmentsSheetOpen}
+ quotation={selectedQuotationInfo}
+ attachments={quotationAttachments}
+ isLoading={isLoadingAttachments}
+ />
+
+ {/* 벤더 contact 선택 다이얼로그 */}
+ <VendorContactSelectionDialog
+ open={contactSelectionDialogOpen}
+ onOpenChange={setContactSelectionDialogOpen}
+ vendorIds={selectedRows.map(row => row.vendorId).filter(Boolean) as number[]}
+ onSendRfq={handleSendRfqWithContacts}
+ />
+
+ {/* 담당자 조회 다이얼로그 */}
+ <QuotationContactsViewDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ quotationId={selectedQuotationForContacts?.id || null}
+ vendorName={selectedQuotationForContacts?.vendorName}
+ />
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
index 0312451d..5b60ef0f 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
@@ -1,619 +1,621 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { RfqDetailView } from "./rfq-detail-column"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-import { Badge } from "@/components/ui/badge"
-import { toast } from "sonner"
-import {
- Send,
- Paperclip,
- DownloadCloud,
- File,
- FileText,
- Image as ImageIcon,
- AlertCircle,
- X
-} from "lucide-react"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { formatDateTime } from "@/lib/utils"
-import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
-import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service"
-
-// 타입 정의
-interface Comment {
- id: number;
- rfqId: number;
- vendorId: number | null // null 허용으로 변경
- userId?: number | null // null 허용으로 변경
- content: string;
- isVendorComment: boolean | null; // null 허용으로 변경
- createdAt: Date;
- updatedAt: Date;
- userName?: string | null // null 허용으로 변경
- vendorName?: string | null // null 허용으로 변경
- attachments: Attachment[];
- isRead: boolean | null // null 허용으로 변경
-}
-
-interface Attachment {
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null;
- filePath: string;
- uploadedAt: Date;
-}
-
-// 프롭스 정의
-interface VendorCommunicationDrawerProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- selectedRfq: {
- id: number;
- rfqCode: string | null;
- status: string;
- [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
- } | null;
- selectedVendor: RfqDetailView | null;
- onSuccess?: () => void;
-}
-
-async function sendComment(params: {
- rfqId: number;
- vendorId: number;
- content: string;
- attachments?: File[];
-}): Promise<Comment> {
- try {
- // 폼 데이터 생성 (파일 첨부를 위해)
- const formData = new FormData();
- formData.append('rfqId', params.rfqId.toString());
- formData.append('vendorId', params.vendorId.toString());
- formData.append('content', params.content);
- formData.append('isVendorComment', 'false');
-
- // 첨부파일 추가
- if (params.attachments && params.attachments.length > 0) {
- params.attachments.forEach((file) => {
- formData.append(`attachments`, file);
- });
- }
-
- // API 엔드포인트 구성 - techSales용으로 변경
- const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
-
- // API 호출
- const response = await fetch(url, {
- method: 'POST',
- body: formData, // multipart/form-data 형식 사용
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
- }
-
- // 응답 데이터 파싱
- const result = await response.json();
-
- if (!result.success || !result.data) {
- throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
- }
-
- return result.data.comment;
- } catch (error) {
- console.error('코멘트 전송 오류:', error);
- throw error;
- }
-}
-
-export function VendorCommunicationDrawer({
- open,
- onOpenChange,
- selectedRfq,
- selectedVendor,
- onSuccess
-}: VendorCommunicationDrawerProps) {
- // 상태 관리
- const [comments, setComments] = useState<Comment[]>([]);
- const [newComment, setNewComment] = useState("");
- const [attachments, setAttachments] = useState<File[]>([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const fileInputRef = useRef<HTMLInputElement>(null);
- const messagesEndRef = useRef<HTMLDivElement>(null);
-
- // 자동 새로고침 관련 상태
- const [autoRefresh, setAutoRefresh] = useState(true);
- const [lastMessageCount, setLastMessageCount] = useState(0);
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
-
- // 첨부파일 관련 상태
- const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
-
- // 드로어가 열릴 때 데이터 로드
- useEffect(() => {
- if (open && selectedRfq && selectedVendor) {
- loadComments();
- // 자동 새로고침 시작
- if (autoRefresh) {
- startAutoRefresh();
- }
- } else {
- // 드로어가 닫히면 자동 새로고침 중지
- stopAutoRefresh();
- }
-
- // 컴포넌트 언마운트 시 정리
- return () => {
- stopAutoRefresh();
- };
- }, [open, selectedRfq, selectedVendor, autoRefresh]);
-
- // 스크롤 최하단으로 이동
- useEffect(() => {
- if (messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, [comments]);
-
- // 자동 새로고침 시작
- const startAutoRefresh = () => {
- stopAutoRefresh(); // 기존 interval 정리
- intervalRef.current = setInterval(() => {
- if (open && selectedRfq && selectedVendor && !isSubmitting) {
- loadComments(true); // 자동 새로고침임을 표시
- }
- }, 60000); // 60초마다 새로고침
- };
-
- // 자동 새로고침 중지
- const stopAutoRefresh = () => {
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
- };
-
- // 자동 새로고침 토글
- const toggleAutoRefresh = () => {
- setAutoRefresh(prev => {
- const newValue = !prev;
- if (newValue && open) {
- startAutoRefresh();
- } else {
- stopAutoRefresh();
- }
- return newValue;
- });
- };
-
- // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
- const loadComments = async (isAutoRefresh = false) => {
- if (!selectedRfq || !selectedVendor) return;
-
- try {
- // 자동 새로고침일 때는 로딩 표시하지 않음
- if (!isAutoRefresh) {
- setIsLoading(true);
- }
-
- // Server Action을 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
-
- // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
- if (isAutoRefresh) {
- const newMessageCount = commentsData.length;
- if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
- // 새 메시지 알림 (선택사항)
- toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
- }
- setLastMessageCount(newMessageCount);
- } else {
- setLastMessageCount(commentsData.length);
- }
-
- setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
-
- // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
- await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
- } catch (error) {
- console.error("코멘트 로드 오류:", error);
- if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
- toast.error("메시지를 불러오는 중 오류가 발생했습니다");
- }
- } finally {
- // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
- if (!isAutoRefresh) {
- setTimeout(() => {
- setIsLoading(false);
- }, 200);
- }
- }
- };
-
- // 파일 선택 핸들러
- const handleFileSelect = () => {
- fileInputRef.current?.click();
- };
-
- // 파일 변경 핸들러
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files && e.target.files.length > 0) {
- const newFiles = Array.from(e.target.files);
- setAttachments(prev => [...prev, ...newFiles]);
- }
- };
-
- // 파일 제거 핸들러
- const handleRemoveFile = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index));
- };
-
- console.log(newComment)
-
- // 코멘트 전송 핸들러
- const handleSubmitComment = async () => {
- console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
- console.log(!newComment.trim() && attachments.length === 0)
-
- if (!newComment.trim() && attachments.length === 0) return;
- if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
-
- console.log("버튼 클릭")
-
- try {
- setIsSubmitting(true);
-
- // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
- const newCommentObj = await sendComment({
- rfqId: selectedRfq.id,
- vendorId: selectedVendor.vendorId,
- content: newComment,
- attachments: attachments
- });
-
- // 상태 업데이트
- setComments(prev => [...prev, newCommentObj]);
- setNewComment("");
- setAttachments([]);
-
- toast.success("메시지가 전송되었습니다");
-
- // 데이터 새로고침
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("코멘트 전송 오류:", error);
- toast.error("메시지 전송 중 오류가 발생했습니다");
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // 첨부파일 미리보기
- const handleAttachmentPreview = (attachment: Attachment) => {
- setSelectedAttachment(attachment);
- setPreviewDialogOpen(true);
- };
-
- // 첨부파일 다운로드
- const handleAttachmentDownload = (attachment: Attachment) => {
- // TODO: 실제 다운로드 구현
- window.open(attachment.filePath, '_blank');
- };
-
- // 파일 아이콘 선택
- const getFileIcon = (fileType: string) => {
- if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
- if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
- if (fileType.includes("spreadsheet") || fileType.includes("excel"))
- return <FileText className="h-5 w-5 text-green-500" />;
- if (fileType.includes("document") || fileType.includes("word"))
- return <FileText className="h-5 w-5 text-blue-500" />;
- return <File className="h-5 w-5 text-gray-500" />;
- };
-
- // 첨부파일 미리보기 다이얼로그
- const renderAttachmentPreviewDialog = () => {
- if (!selectedAttachment) return null;
-
- const isImage = selectedAttachment.fileType?.startsWith("image/");
- const isPdf = selectedAttachment.fileType?.includes("pdf");
-
- return (
- <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
- <DialogContent className="max-w-3xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- {getFileIcon(selectedAttachment.fileType || '')}
- {selectedAttachment.fileName}
- </DialogTitle>
- <DialogDescription>
- {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")}
- </DialogDescription>
- </DialogHeader>
-
- <div className="min-h-[300px] flex items-center justify-center p-4">
- {isImage ? (
- <img
- src={selectedAttachment.filePath}
- alt={selectedAttachment.fileName}
- className="max-h-[500px] max-w-full object-contain"
- />
- ) : isPdf ? (
- <iframe
- src={`${selectedAttachment.filePath}#toolbar=0`}
- className="w-full h-[500px]"
- title={selectedAttachment.fileName}
- />
- ) : (
- <div className="flex flex-col items-center gap-4 p-8">
- {getFileIcon(selectedAttachment.fileType || '')}
- <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
- <Button
- variant="outline"
- onClick={() => handleAttachmentDownload(selectedAttachment)}
- >
- <DownloadCloud className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
- );
- };
-
- if (!selectedRfq || !selectedVendor) {
- return null;
- }
-
- return (
- <Drawer open={open} onOpenChange={onOpenChange}>
- <DrawerContent className="max-h-[80vh] flex flex-col">
- <DrawerHeader className="border-b flex-shrink-0">
- <DrawerTitle className="flex items-center gap-2">
- <Avatar className="h-8 w-8">
- <AvatarFallback className="bg-primary/10">
- {selectedVendor.vendorName?.[0] || 'V'}
- </AvatarFallback>
- </Avatar>
- <div>
- <span>{selectedVendor.vendorName}</span>
- <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
- </div>
- </DrawerTitle>
- <DrawerDescription>
- RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="flex flex-col flex-1 min-h-0">
- {/* 메시지 목록 */}
- <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
- {isLoading && comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <p className="text-muted-foreground">메시지 로딩 중...</p>
- </div>
- ) : comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <div className="flex flex-col items-center gap-2">
- <AlertCircle className="h-6 w-6 text-muted-foreground" />
- <p className="text-muted-foreground">아직 메시지가 없습니다</p>
- </div>
- </div>
- ) : (
- <div className="space-y-4 relative">
- {isLoading && (
- <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
- <div className="flex items-center gap-2">
- <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
- <span className="text-xs text-muted-foreground">새로고침 중...</span>
- </div>
- </div>
- )}
- {comments.map(comment => (
- <div
- key={comment.id}
- className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
- >
- {comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/10">
- {comment.vendorName?.[0] || 'V'}
- </AvatarFallback>
- </Avatar>
- )}
-
- <div className={`rounded-lg p-3 max-w-[80%] ${
- comment.isVendorComment
- ? 'bg-muted'
- : 'bg-primary text-primary-foreground'
- }`}>
- <div className="text-sm font-medium mb-1">
- {comment.isVendorComment ? comment.vendorName : comment.userName}
- </div>
-
- {comment.content && (
- <div className="text-sm whitespace-pre-wrap break-words">
- {comment.content}
- </div>
- )}
-
- {/* 첨부파일 표시 */}
- {comment.attachments.length > 0 && (
- <div className={`mt-2 pt-2 ${
- comment.isVendorComment
- ? 'border-t border-t-border/30'
- : 'border-t border-t-primary-foreground/20'
- }`}>
- {comment.attachments.map(attachment => (
- <div
- key={attachment.id}
- className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
- onClick={() => handleAttachmentPreview(attachment)}
- >
- {getFileIcon(attachment.fileType || '')}
- <span className="flex-1 truncate">{attachment.fileName}</span>
- <span className="text-xs opacity-70">
- {formatFileSize(attachment.fileSize)}
- </span>
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 rounded-full"
- onClick={(e) => {
- e.stopPropagation();
- handleAttachmentDownload(attachment);
- }}
- >
- <DownloadCloud className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- )}
-
- <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
- {formatDateTime(comment.createdAt)}
- </div>
- </div>
-
- {!comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/20">
- {comment.userName?.[0] || 'U'}
- </AvatarFallback>
- </Avatar>
- )}
- </div>
- ))}
- <div ref={messagesEndRef} />
- </div>
- )}
- </div>
-
- {/* 선택된 첨부파일 표시 */}
- {attachments.length > 0 && (
- <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
- <div className="text-xs font-medium mb-1">첨부파일</div>
- <div className="flex flex-wrap gap-2">
- {attachments.map((file, index) => (
- <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
- {file.type.startsWith("image/") ? (
- <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
- ) : (
- <File className="h-4 w-4 mr-1 text-gray-500" />
- )}
- <span className="truncate max-w-[100px]">{file.name}</span>
- <Button
- variant="ghost"
- size="icon"
- className="h-4 w-4 ml-1 p-0"
- onClick={() => handleRemoveFile(index)}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 메시지 입력 영역 */}
- <div className="p-4 border-t flex-shrink-0">
- <div className="flex gap-2 items-end">
- <div className="flex-1">
- <Textarea
- placeholder="메시지를 입력하세요..."
- className="min-h-[80px] resize-none"
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- />
- </div>
- <div className="flex flex-col gap-2">
- <input
- type="file"
- ref={fileInputRef}
- className="hidden"
- multiple
- onChange={handleFileChange}
- />
- <Button
- variant="outline"
- size="icon"
- onClick={handleFileSelect}
- title="파일 첨부"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- <Button
- onClick={handleSubmitComment}
- disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
- >
- <Send className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- </div>
-
- <DrawerFooter className="border-t flex-shrink-0">
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2">
- <Button variant="outline" onClick={() => loadComments()}>
- 새로고침
- </Button>
- <Button
- variant={autoRefresh ? "default" : "outline"}
- size="sm"
- onClick={toggleAutoRefresh}
- className="gap-2"
- >
- {autoRefresh ? (
- <>
- <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
- 자동 새로고침 ON
- </>
- ) : (
- <>
- <div className="w-2 h-2 bg-gray-400 rounded-full" />
- 자동 새로고침 OFF
- </>
- )}
- </Button>
- </div>
- <DrawerClose asChild>
- <Button variant="outline">닫기</Button>
- </DrawerClose>
- </div>
- </DrawerFooter>
- </DrawerContent>
-
- {renderAttachmentPreviewDialog()}
- </Drawer>
- );
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { RfqDetailView } from "./rfq-detail-column"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X
+} from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
+import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service"
+
+// 타입 정의
+interface Comment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string;
+ isVendorComment: boolean | null; // null 허용으로 변경
+ createdAt: Date;
+ updatedAt: Date;
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[];
+ isRead: boolean | null // null 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface VendorCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: {
+ id: number;
+ rfqCode: string | null;
+ status: string;
+ [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ } | null;
+ selectedVendor: RfqDetailView | null;
+ onSuccess?: () => void;
+}
+
+async function sendComment(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ try {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'false');
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 - techSales용으로 변경
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ const response = await fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
+ }
+
+ // 응답 데이터 파싱
+ const result = await response.json();
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ return result.data.comment;
+ } catch (error) {
+ console.error('코멘트 전송 오류:', error);
+ throw error;
+ }
+}
+
+export function VendorCommunicationDrawer({
+ open,
+ onOpenChange,
+ selectedRfq,
+ selectedVendor,
+ onSuccess
+}: VendorCommunicationDrawerProps) {
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 자동 새로고침 관련 상태
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ const [lastMessageCount, setLastMessageCount] = useState(0);
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && selectedRfq && selectedVendor) {
+ loadComments();
+ // 자동 새로고침 시작
+ if (autoRefresh) {
+ startAutoRefresh();
+ }
+ } else {
+ // 드로어가 닫히면 자동 새로고침 중지
+ stopAutoRefresh();
+ }
+
+ // 컴포넌트 언마운트 시 정리
+ return () => {
+ stopAutoRefresh();
+ };
+ }, [open, selectedRfq, selectedVendor, autoRefresh]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 자동 새로고침 시작
+ const startAutoRefresh = () => {
+ stopAutoRefresh(); // 기존 interval 정리
+ intervalRef.current = setInterval(() => {
+ if (open && selectedRfq && selectedVendor && !isSubmitting) {
+ loadComments(true); // 자동 새로고침임을 표시
+ }
+ }, 60000); // 60초마다 새로고침
+ };
+
+ // 자동 새로고침 중지
+ const stopAutoRefresh = () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+
+ // 자동 새로고침 토글
+ const toggleAutoRefresh = () => {
+ setAutoRefresh(prev => {
+ const newValue = !prev;
+ if (newValue && open) {
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+ return newValue;
+ });
+ };
+
+ // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
+ const loadComments = async (isAutoRefresh = false) => {
+ if (!selectedRfq || !selectedVendor) return;
+
+ try {
+ // 자동 새로고침일 때는 로딩 표시하지 않음
+ if (!isAutoRefresh) {
+ setIsLoading(true);
+ }
+
+ // Server Action을 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
+
+ // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
+ if (isAutoRefresh) {
+ const newMessageCount = commentsData.length;
+ if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
+ // 새 메시지 알림 (선택사항)
+ toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
+ }
+ setLastMessageCount(newMessageCount);
+ } else {
+ setLastMessageCount(commentsData.length);
+ }
+
+ setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
+
+ // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
+ await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ }
+ } finally {
+ // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
+ if (!isAutoRefresh) {
+ setTimeout(() => {
+ setIsLoading(false);
+ }, 200);
+ }
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ console.log(newComment)
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
+ console.log(!newComment.trim() && attachments.length === 0)
+
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
+
+ console.log("버튼 클릭")
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendComment({
+ rfqId: selectedRfq.id,
+ vendorId: selectedVendor.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // TODO: 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType?.startsWith("image/");
+ const isPdf = selectedAttachment.fileType?.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType || '')}
+ {selectedAttachment.originalFileName || selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType || '')}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!selectedRfq || !selectedVendor) {
+ return null;
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader className="border-b flex-shrink-0">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ {selectedVendor.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{selectedVendor.vendorName}</span>
+ <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="flex flex-col flex-1 min-h-0">
+ {/* 메시지 목록 */}
+ <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
+ {isLoading && comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4 relative">
+ {isLoading && (
+ <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
+ <span className="text-xs text-muted-foreground">새로고침 중...</span>
+ </div>
+ </div>
+ )}
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
+ >
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ {comment.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${
+ comment.isVendorComment
+ ? 'bg-muted'
+ : 'bg-primary text-primary-foreground'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? comment.vendorName : comment.userName}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${
+ comment.isVendorComment
+ ? 'border-t border-t-border/30'
+ : 'border-t border-t-primary-foreground/20'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType || '')}
+ <span className="flex-1 truncate">{attachment.originalFileName || attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ {comment.userName?.[0] || 'U'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t flex-shrink-0">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t flex-shrink-0">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <Button
+ variant={autoRefresh ? "default" : "outline"}
+ size="sm"
+ onClick={toggleAutoRefresh}
+ className="gap-2"
+ >
+ {autoRefresh ? (
+ <>
+ <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+ 자동 새로고침 ON
+ </>
+ ) : (
+ <>
+ <div className="w-2 h-2 bg-gray-400 rounded-full" />
+ 자동 새로고침 OFF
+ </>
+ )}
+ </Button>
+ </div>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
new file mode 100644
index 00000000..aa6f6c2f
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
@@ -0,0 +1,343 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Mail, Phone, User, Send, Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+interface VendorContact {
+ id: number
+ contactName: string
+ contactPosition: string | null
+ contactEmail: string
+ contactPhone: string | null
+ isPrimary: boolean
+}
+
+interface VendorWithContacts {
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ }
+ contacts: VendorContact[]
+}
+
+interface SelectedContact {
+ vendorId: number
+ contactId: number
+ contactEmail: string
+ contactName: string
+}
+
+interface VendorContactSelectionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorIds: number[]
+ onSendRfq: (selectedContacts: SelectedContact[]) => Promise<void>
+}
+
+export function VendorContactSelectionDialog({
+ open,
+ onOpenChange,
+ vendorIds,
+ onSendRfq
+}: VendorContactSelectionDialogProps) {
+ const [vendorsWithContacts, setVendorsWithContacts] = useState<Record<number, VendorWithContacts>>({})
+ const [selectedContacts, setSelectedContacts] = useState<SelectedContact[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSending, setIsSending] = useState(false)
+
+ // 벤더 contact 정보 조회
+ useEffect(() => {
+ if (open && vendorIds.length > 0) {
+ loadVendorsContacts()
+ }
+ }, [open, vendorIds])
+
+ // 다이얼로그 닫힐 때 상태 초기화
+ useEffect(() => {
+ if (!open) {
+ setVendorsWithContacts({})
+ setSelectedContacts([])
+ setIsLoading(false)
+ }
+ }, [open])
+
+ const loadVendorsContacts = useCallback(async () => {
+ try {
+ setIsLoading(true)
+ const { getTechVendorsContacts } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechVendorsContacts(vendorIds)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ setVendorsWithContacts(result.data)
+
+ // 기본 선택: 모든 contact 선택
+ const defaultSelected: SelectedContact[] = []
+ Object.values(result.data).forEach(vendorData => {
+ vendorData.contacts.forEach(contact => {
+ defaultSelected.push({
+ vendorId: vendorData.vendor.id,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ })
+ })
+ })
+ setSelectedContacts(defaultSelected)
+
+ } catch (error) {
+ console.error("벤더 contact 조회 오류:", error)
+ toast.error("벤더 연락처를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }, [vendorIds])
+
+ // contact 선택/해제 핸들러
+ const handleContactToggle = (vendorId: number, contact: VendorContact) => {
+ const isSelected = selectedContacts.some(
+ sc => sc.vendorId === vendorId && sc.contactId === contact.id
+ )
+
+ if (isSelected) {
+ // 선택 해제
+ setSelectedContacts(prev =>
+ prev.filter(sc => !(sc.vendorId === vendorId && sc.contactId === contact.id))
+ )
+ } else {
+ // 선택 추가
+ setSelectedContacts(prev => [
+ ...prev,
+ {
+ vendorId,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ }
+ ])
+ }
+ }
+
+ // 벤더별 전체 선택/해제
+ const handleVendorToggle = (vendorId: number, vendorData: VendorWithContacts) => {
+ const vendorContacts = vendorData.contacts
+ const selectedVendorContacts = selectedContacts.filter(sc => sc.vendorId === vendorId)
+
+ if (selectedVendorContacts.length === vendorContacts.length) {
+ // 전체 해제
+ setSelectedContacts(prev => prev.filter(sc => sc.vendorId !== vendorId))
+ } else {
+ // 전체 선택
+ const newSelected = vendorContacts.map(contact => ({
+ vendorId,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ }))
+
+ setSelectedContacts(prev => [
+ ...prev.filter(sc => sc.vendorId !== vendorId),
+ ...newSelected
+ ])
+ }
+ }
+
+ // RFQ 발송 핸들러
+ const handleSendRfq = async () => {
+ if (selectedContacts.length === 0) {
+ toast.warning("발송할 연락처를 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsSending(true)
+ await onSendRfq(selectedContacts)
+ onOpenChange(false)
+ } catch (error) {
+ console.error("RFQ 발송 오류:", error)
+ } finally {
+ setIsSending(false)
+ }
+ }
+
+ // 선택된 contact가 있는지 확인
+ const isContactSelected = (vendorId: number, contactId: number) => {
+ return selectedContacts.some(sc => sc.vendorId === vendorId && sc.contactId === contactId)
+ }
+
+ // 벤더별 선택 상태 확인
+ const getVendorSelectionState = (vendorId: number, vendorData: VendorWithContacts) => {
+ const selectedVendorContacts = selectedContacts.filter(sc => sc.vendorId === vendorId)
+ const totalContacts = vendorData.contacts.length
+
+ if (selectedVendorContacts.length === 0) return "none"
+ if (selectedVendorContacts.length === totalContacts) return "all"
+ return "partial"
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>RFQ 발송 대상 선택</DialogTitle>
+ <DialogDescription>
+ 각 벤더의 연락처를 선택하여 RFQ를 발송하세요. 기본적으로 모든 연락처가 선택되어 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto space-y-4">
+ {isLoading ? (
+ <div className="space-y-4">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="space-y-3">
+ <Skeleton className="h-6 w-40" />
+ <div className="space-y-2 pl-4">
+ <Skeleton className="h-16 w-full" />
+ <Skeleton className="h-16 w-full" />
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : Object.keys(vendorsWithContacts).length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Mail className="size-12 mx-auto mb-2 opacity-50" />
+ <p>연락처 정보가 없습니다.</p>
+ <p className="text-sm">벤더의 연락처를 먼저 등록해주세요.</p>
+ </div>
+ ) : (
+ Object.entries(vendorsWithContacts).map(([vendorId, vendorData]) => {
+ const selectionState = getVendorSelectionState(Number(vendorId), vendorData)
+
+ return (
+ <div key={vendorId} className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={selectionState === "all"}
+ ref={(el) => {
+ if (el) {
+ const input = el.querySelector('input[type="checkbox"]') as HTMLInputElement
+ if (input) {
+ input.indeterminate = selectionState === "partial"
+ }
+ }
+ }}
+ onCheckedChange={() => handleVendorToggle(Number(vendorId), vendorData)}
+ />
+ <div>
+ <h3 className="font-medium">{vendorData.vendor.vendorName}</h3>
+ {vendorData.vendor.vendorCode && (
+ <p className="text-sm text-muted-foreground">
+ 코드: {vendorData.vendor.vendorCode}
+ </p>
+ )}
+ </div>
+ </div>
+ <Badge variant="outline">
+ {selectedContacts.filter(sc => sc.vendorId === Number(vendorId)).length} / {vendorData.contacts.length} 선택됨
+ </Badge>
+ </div>
+
+ <div className="space-y-2 pl-6">
+ {vendorData.contacts.map((contact) => (
+ <div
+ key={contact.id}
+ className={`flex items-center justify-between p-3 rounded border ${
+ isContactSelected(Number(vendorId), contact.id)
+ ? "bg-blue-50 border-blue-200"
+ : "bg-gray-50 border-gray-200"
+ }`}
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={isContactSelected(Number(vendorId), contact.id)}
+ onCheckedChange={() => handleContactToggle(Number(vendorId), contact)}
+ />
+ <div className="flex items-center gap-2">
+ <User className="size-4 text-muted-foreground" />
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactPosition}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-4 text-sm">
+ <div className="flex items-center gap-1">
+ <Mail className="size-4 text-muted-foreground" />
+ <span>{contact.contactEmail}</span>
+ </div>
+ {contact.contactPhone && (
+ <div className="flex items-center gap-1">
+ <Phone className="size-4 text-muted-foreground" />
+ <span>{contact.contactPhone}</span>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+ })
+ )}
+ </div>
+
+ <DialogFooter>
+ <div className="flex items-center justify-between w-full">
+ <div className="text-sm text-muted-foreground">
+ 총 {selectedContacts.length}명의 연락처가 선택됨
+ </div>
+ <div className="flex gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSendRfq}
+ disabled={selectedContacts.length === 0 || isSending}
+ className="flex items-center gap-2"
+ >
+ {isSending ? (
+ <>
+ <Loader2 className="size-4 animate-spin" />
+ 발송 중...
+ </>
+ ) : (
+ <>
+ <Send className="size-4" />
+ RFQ 발송 ({selectedContacts.length}명)
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/project-detail-dialog.tsx b/lib/techsales-rfq/table/project-detail-dialog.tsx
index 68f13960..00202501 100644
--- a/lib/techsales-rfq/table/project-detail-dialog.tsx
+++ b/lib/techsales-rfq/table/project-detail-dialog.tsx
@@ -1,120 +1,120 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-
-// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치)
-interface 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"
- picCode: string | null
- remark: string | null
- cancelReason: string | null
- createdAt: Date
- updatedAt: Date
- createdBy: number | null
- createdByName: string
- updatedBy: number | null
- updatedByName: string
- sentBy: number | null
- sentByName: string | null
- pspid: string
- projNm: string
- sector: string
- projMsrm: number
- ptypeNm: string
- attachmentCount: number
- quotationCount: number
-}
-
-interface ProjectDetailDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: TechSalesRfq | null
-}
-
-export function ProjectDetailDialog({
- open,
- onOpenChange,
- selectedRfq,
-}: ProjectDetailDialogProps) {
- if (!selectedRfq) {
- return null
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col">
- <DialogHeader className="border-b pb-4">
- <DialogTitle className="flex items-center gap-2">
- 프로젝트 상세정보
- <Badge variant="outline">{selectedRfq.pspid}</Badge>
- </DialogTitle>
- <DialogDescription className="space-y-1">
- <div className="flex items-center gap-2 text-base font-medium">
- <span>RFQ:</span>
- <Badge variant="secondary">{selectedRfq.rfqCode || "미할당"}</Badge>
- <span>|</span>
- <span>자재:</span>
- <span className="text-foreground">{selectedRfq.materialCode || "N/A"}</span>
- </div>
- <div className="text-sm text-muted-foreground">
- {selectedRfq.projNm} - {selectedRfq.ptypeNm} ({selectedRfq.itemName || "자재명 없음"})
- </div>
- </DialogDescription>
- </DialogHeader>
- <div className="space-y-6 p-1 overflow-y-auto">
- {/* 기본 프로젝트 정보 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">기본 정보</h3>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
- <div className="text-sm">{selectedRfq.pspid}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
- <div className="text-sm">{selectedRfq.projNm}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">선종</div>
- <div className="text-sm">{selectedRfq.ptypeNm}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">척수</div>
- <div className="text-sm">{selectedRfq.projMsrm}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">섹터</div>
- <div className="text-sm">{selectedRfq.sector}</div>
- </div>
- </div>
- </div>
- </div>
-
- {/* 닫기 버튼 */}
- <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
- <div className="flex justify-end">
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 닫기
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+
+// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치)
+interface 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"
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ quotationCount: number
+}
+
+interface ProjectDetailDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: TechSalesRfq | null
+}
+
+export function ProjectDetailDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: ProjectDetailDialogProps) {
+ if (!selectedRfq) {
+ return null
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle className="flex items-center gap-2">
+ 프로젝트 상세정보
+ <Badge variant="outline">{selectedRfq.pspid}</Badge>
+ </DialogTitle>
+ <DialogDescription className="space-y-1">
+ <div className="flex items-center gap-2 text-base font-medium">
+ <span>RFQ:</span>
+ <Badge variant="secondary">{selectedRfq.rfqCode || "미할당"}</Badge>
+ <span>|</span>
+ <span>자재:</span>
+ <span className="text-foreground">{selectedRfq.materialCode || "N/A"}</span>
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {selectedRfq.projNm} - {selectedRfq.ptypeNm} ({selectedRfq.itemName || "자재명 없음"})
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 p-1 overflow-y-auto">
+ {/* 기본 프로젝트 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{selectedRfq.pspid}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
+ <div className="text-sm">{selectedRfq.projNm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선종</div>
+ <div className="text-sm">{selectedRfq.ptypeNm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">척수</div>
+ <div className="text-sm">{selectedRfq.projMsrm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">섹터</div>
+ <div className="text-sm">{selectedRfq.sector}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 닫기 버튼 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
index 9b6acfb2..a03e6167 100644
--- a/lib/techsales-rfq/table/rfq-filter-sheet.tsx
+++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
@@ -1,759 +1,759 @@
-"use client"
-
-import { useEffect, useTransition, useState, useRef } from "react"
-import { useRouter, useParams } from "next/navigation"
-import { z } from "zod"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Search, X } from "lucide-react"
-import { customAlphabet } from "nanoid"
-import { parseAsStringEnum, useQueryState } from "nuqs"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { DateRangePicker } from "@/components/date-range-picker"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { useTranslation } from '@/i18n/client'
-import { getFiltersStateParser } from "@/lib/parsers"
-
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
-
-// 필터 스키마 정의 (TechSales RFQ에 맞게 수정)
-const filterSchema = z.object({
- rfqCode: z.string().optional(),
- materialCode: z.string().optional(),
- itemName: z.string().optional(),
- pspid: z.string().optional(),
- projNm: z.string().optional(),
- ptypeNm: z.string().optional(),
- createdByName: z.string().optional(),
- status: z.string().optional(),
- dateRange: z.object({
- from: z.date().optional(),
- to: z.date().optional(),
- }).optional(),
-})
-
-// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정)
-const statusOptions = [
- { value: "RFQ Created", label: "RFQ Created" },
- { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
- { value: "RFQ Sent", label: "RFQ Sent" },
- { value: "Quotation Analysis", label: "Quotation Analysis" },
- { value: "Closed", label: "Closed" },
-]
-
-type FilterFormValues = z.infer<typeof filterSchema>
-
-interface RFQFilterSheetProps {
- isOpen: boolean;
- onClose: () => void;
- onSearch?: () => void;
- isLoading?: boolean;
-}
-
-// Updated component for inline use (not a sheet anymore)
-export function RFQFilterSheet({
- isOpen,
- onClose,
- onSearch,
- isLoading = false
-}: RFQFilterSheetProps) {
- const router = useRouter()
- const params = useParams();
- const lng = params ? (params.lng as string) : 'ko';
- const { t } = useTranslation(lng);
-
- const [isPending, startTransition] = useTransition()
-
- // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
- const [isInitializing, setIsInitializing] = useState(false)
- // 마지막으로 적용된 필터를 추적하기 위한 ref
- const lastAppliedFilters = useRef<string>("")
-
- // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
- const [filters, setFilters] = useQueryState(
- "basicFilters",
- getFiltersStateParser().withDefault([])
- )
-
- // joinOperator 설정
- const [joinOperator, setJoinOperator] = useQueryState(
- "basicJoinOperator",
- parseAsStringEnum(["and", "or"]).withDefault("and")
- )
-
- // 현재 URL의 페이지 파라미터도 가져옴
- const [page, setPage] = useQueryState("page", { defaultValue: "1" })
-
- // 폼 상태 초기화
- const form = useForm<FilterFormValues>({
- resolver: zodResolver(filterSchema),
- defaultValues: {
- rfqCode: "",
- materialCode: "",
- itemName: "",
- pspid: "",
- projNm: "",
- ptypeNm: "",
- createdByName: "",
- status: "",
- dateRange: {
- from: undefined,
- to: undefined,
- },
- },
- })
-
- // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
- useEffect(() => {
- // 현재 필터를 문자열로 직렬화
- const currentFiltersString = JSON.stringify(filters);
-
- // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
- if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
- setIsInitializing(true);
-
- const formValues = { ...form.getValues() };
- let formUpdated = false;
-
- filters.forEach(filter => {
- if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
- formValues.dateRange = {
- from: filter.value[0] ? new Date(filter.value[0]) : undefined,
- to: filter.value[1] ? new Date(filter.value[1]) : undefined,
- };
- formUpdated = true;
- } else if (filter.id in formValues) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (formValues as any)[filter.id] = filter.value;
- formUpdated = true;
- }
- });
-
- // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
- if (formUpdated) {
- form.reset(formValues);
- lastAppliedFilters.current = currentFiltersString;
- }
-
- setIsInitializing(false);
- }
- }, [filters, isOpen, form]) // form 의존성 추가
-
- // 현재 적용된 필터 카운트
- const getActiveFilterCount = () => {
- return filters?.length || 0
- }
-
- // 조회 버튼 클릭 핸들러
- const handleSearch = () => {
- // 필터 패널 닫기 로직이 있다면 여기에 추가
- if (onSearch) {
- onSearch();
- }
- }
-
- // 폼 제출 핸들러 - 개선된 버전
- async function onSubmit(data: FilterFormValues) {
- // 초기화 중이면 제출 방지
- if (isInitializing) return;
-
- startTransition(async () => {
- try {
- // 필터 배열 생성
- const newFilters = []
-
- if (data.rfqCode?.trim()) {
- newFilters.push({
- id: "rfqCode",
- value: data.rfqCode.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.materialCode?.trim()) {
- newFilters.push({
- id: "materialCode",
- value: data.materialCode.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.itemName?.trim()) {
- newFilters.push({
- id: "itemName",
- value: data.itemName.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.pspid?.trim()) {
- newFilters.push({
- id: "pspid",
- value: data.pspid.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.projNm?.trim()) {
- newFilters.push({
- id: "projNm",
- value: data.projNm.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.ptypeNm?.trim()) {
- newFilters.push({
- id: "ptypeNm",
- value: data.ptypeNm.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.createdByName?.trim()) {
- newFilters.push({
- id: "createdByName",
- value: data.createdByName.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.status?.trim()) {
- newFilters.push({
- id: "status",
- value: data.status.trim(),
- type: "select" as const,
- operator: "eq" as const,
- rowId: generateId()
- })
- }
-
- // Add date range to params if it exists
- if (data.dateRange?.from) {
- newFilters.push({
- id: "rfqSendDate",
- value: [
- data.dateRange.from.toISOString().split('T')[0],
- data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
- ].filter(Boolean) as string[],
- type: "date" as const,
- operator: "isBetween" as const,
- rowId: generateId()
- })
- }
-
- console.log("기본 필터 적용:", newFilters);
-
- // 마지막 적용된 필터 업데이트
- lastAppliedFilters.current = JSON.stringify(newFilters);
-
- // 먼저 필터를 설정
- await setFilters(newFilters.length > 0 ? newFilters : null);
-
- // 그 다음 페이지를 1로 설정
- await setPage("1");
-
- // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
- handleSearch();
-
- // 페이지 새로고침으로 서버 데이터 다시 가져오기
- setTimeout(() => {
- window.location.reload();
- }, 100);
- } catch (error) {
- console.error("필터 적용 오류:", error);
- }
- })
- }
-
- // 필터 초기화 핸들러 - 개선된 버전
- async function handleReset() {
- try {
- setIsInitializing(true);
-
- form.reset({
- rfqCode: "",
- materialCode: "",
- itemName: "",
- pspid: "",
- projNm: "",
- ptypeNm: "",
- createdByName: "",
- status: "",
- dateRange: { from: undefined, to: undefined },
- });
-
- // 필터와 조인 연산자를 초기화
- await setFilters(null);
- await setJoinOperator("and");
- await setPage("1");
-
- // 마지막 적용된 필터 초기화
- lastAppliedFilters.current = "";
-
- console.log("필터 초기화 완료");
- setIsInitializing(false);
-
- // 페이지 새로고침으로 서버 데이터 다시 가져오기
- setTimeout(() => {
- window.location.reload();
- }, 100);
- } catch (error) {
- console.error("필터 초기화 오류:", error);
- setIsInitializing(false);
- }
- }
-
- // Don't render if not open (for side panel use)
- if (!isOpen) {
- return null;
- }
-
- return (
- <div className="flex flex-col h-full max-h-full p-4">
- {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */}
- <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
- <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
- </div>
-
- {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */}
- <div className="px-6 shrink-0">
- <label className="text-sm font-medium">조건 결합 방식</label>
- <Select
- value={joinOperator}
- onValueChange={(value: "and" | "or") => setJoinOperator(value)}
- disabled={isInitializing}
- >
- <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
- <SelectValue placeholder="조건 결합 방식" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
- <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
- {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
- <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
- <div className="space-y-6 pt-4">
- {/* RFQ NO. */}
- <FormField
- control={form.control}
- name="rfqCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("RFQ NO.")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("RFQ 번호 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("rfqCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 자재그룹 */}
- <FormField
- control={form.control}
- name="materialCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("자재그룹")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("자재그룹 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("materialCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 자재명 */}
- <FormField
- control={form.control}
- name="itemName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("자재명")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("자재명 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("itemName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트 ID */}
- <FormField
- control={form.control}
- name="pspid"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("프로젝트 ID")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("프로젝트 ID 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("pspid", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트명 */}
- <FormField
- control={form.control}
- name="projNm"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("프로젝트명")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("프로젝트명 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("projNm", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 선종명 */}
- <FormField
- control={form.control}
- name="ptypeNm"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("선종명")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("선종명 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("ptypeNm", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 요청자 */}
- <FormField
- control={form.control}
- name="createdByName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("요청자")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("요청자 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("createdByName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Status */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("Status")}</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isInitializing}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder={t("Select status")} />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("status", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {statusOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ 전송일 */}
- <FormField
- control={form.control}
- name="dateRange"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("RFQ 전송일")}</FormLabel>
- <FormControl>
- <div className="relative">
- <DateRangePicker
- triggerSize="default"
- triggerClassName="w-full bg-white"
- align="start"
- showClearButton={true}
- placeholder={t("RFQ 전송일 범위를 고르세요")}
- date={field.value || undefined}
- onDateChange={field.onChange}
- disabled={isInitializing}
- />
- {(field.value?.from || field.value?.to) && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-10 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("dateRange", { from: undefined, to: undefined });
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */}
- <div className="p-4 shrink-0">
- <div className="flex gap-2 justify-end">
- <Button
- type="button"
- variant="outline"
- onClick={handleReset}
- disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
- className="px-4"
- >
- {t("초기화")}
- </Button>
- <Button
- type="submit"
- variant="samsung"
- disabled={isPending || isLoading || isInitializing}
- className="px-4"
- >
- <Search className="size-4 mr-2" />
- {isPending || isLoading ? t("조회 중...") : t("조회")}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- )
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { DateRangePicker } from "@/components/date-range-picker"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { useTranslation } from '@/i18n/client'
+import { getFiltersStateParser } from "@/lib/parsers"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (TechSales RFQ에 맞게 수정)
+const filterSchema = z.object({
+ rfqCode: z.string().optional(),
+ materialCode: z.string().optional(),
+ itemName: z.string().optional(),
+ pspid: z.string().optional(),
+ projNm: z.string().optional(),
+ ptypeNm: z.string().optional(),
+ createdByName: z.string().optional(),
+ status: z.string().optional(),
+ dateRange: z.object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ }).optional(),
+})
+
+// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정)
+const statusOptions = [
+ { value: "RFQ Created", label: "RFQ Created" },
+ { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
+ { value: "RFQ Sent", label: "RFQ Sent" },
+ { value: "Quotation Analysis", label: "Quotation Analysis" },
+ { value: "Closed", label: "Closed" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface RFQFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+// Updated component for inline use (not a sheet anymore)
+export function RFQFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: RFQFilterSheetProps) {
+ const router = useRouter()
+ const params = useParams();
+ const lng = params ? (params.lng as string) : 'ko';
+ const { t } = useTranslation(lng);
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
+ const [filters, setFilters] = useQueryState(
+ "basicFilters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "basicJoinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 현재 URL의 페이지 파라미터도 가져옴
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" })
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ rfqCode: "",
+ materialCode: "",
+ itemName: "",
+ pspid: "",
+ projNm: "",
+ ptypeNm: "",
+ createdByName: "",
+ status: "",
+ dateRange: {
+ from: undefined,
+ to: undefined,
+ },
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
+ useEffect(() => {
+ // 현재 필터를 문자열로 직렬화
+ const currentFiltersString = JSON.stringify(filters);
+
+ // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
+ formValues.dateRange = {
+ from: filter.value[0] ? new Date(filter.value[0]) : undefined,
+ to: filter.value[1] ? new Date(filter.value[1]) : undefined,
+ };
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (formValues as any)[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen, form]) // form 의존성 추가
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ // 필터 패널 닫기 로직이 있다면 여기에 추가
+ if (onSearch) {
+ onSearch();
+ }
+ }
+
+ // 폼 제출 핸들러 - 개선된 버전
+ async function onSubmit(data: FilterFormValues) {
+ // 초기화 중이면 제출 방지
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ // 필터 배열 생성
+ const newFilters = []
+
+ if (data.rfqCode?.trim()) {
+ newFilters.push({
+ id: "rfqCode",
+ value: data.rfqCode.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.materialCode?.trim()) {
+ newFilters.push({
+ id: "materialCode",
+ value: data.materialCode.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.itemName?.trim()) {
+ newFilters.push({
+ id: "itemName",
+ value: data.itemName.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.pspid?.trim()) {
+ newFilters.push({
+ id: "pspid",
+ value: data.pspid.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.projNm?.trim()) {
+ newFilters.push({
+ id: "projNm",
+ value: data.projNm.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.ptypeNm?.trim()) {
+ newFilters.push({
+ id: "ptypeNm",
+ value: data.ptypeNm.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.createdByName?.trim()) {
+ newFilters.push({
+ id: "createdByName",
+ value: data.createdByName.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select" as const,
+ operator: "eq" as const,
+ rowId: generateId()
+ })
+ }
+
+ // Add date range to params if it exists
+ if (data.dateRange?.from) {
+ newFilters.push({
+ id: "rfqSendDate",
+ value: [
+ data.dateRange.from.toISOString().split('T')[0],
+ data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
+ ].filter(Boolean) as string[],
+ type: "date" as const,
+ operator: "isBetween" as const,
+ rowId: generateId()
+ })
+ }
+
+ console.log("기본 필터 적용:", newFilters);
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ // 먼저 필터를 설정
+ await setFilters(newFilters.length > 0 ? newFilters : null);
+
+ // 그 다음 페이지를 1로 설정
+ await setPage("1");
+
+ // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
+ handleSearch();
+
+ // 페이지 새로고침으로 서버 데이터 다시 가져오기
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+ } catch (error) {
+ console.error("필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러 - 개선된 버전
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ rfqCode: "",
+ materialCode: "",
+ itemName: "",
+ pspid: "",
+ projNm: "",
+ ptypeNm: "",
+ createdByName: "",
+ status: "",
+ dateRange: { from: undefined, to: undefined },
+ });
+
+ // 필터와 조인 연산자를 초기화
+ await setFilters(null);
+ await setJoinOperator("and");
+ await setPage("1");
+
+ // 마지막 적용된 필터 초기화
+ lastAppliedFilters.current = "";
+
+ console.log("필터 초기화 완료");
+ setIsInitializing(false);
+
+ // 페이지 새로고침으로 서버 데이터 다시 가져오기
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+ } catch (error) {
+ console.error("필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ // Don't render if not open (for side panel use)
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full p-4">
+ {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
+ </div>
+
+ {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-6 pt-4">
+ {/* RFQ NO. */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ NO.")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("RFQ 번호 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("rfqCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재그룹 */}
+ <FormField
+ control={form.control}
+ name="materialCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재그룹")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재그룹 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("materialCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재명 */}
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("itemName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 ID */}
+ <FormField
+ control={form.control}
+ name="pspid"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트 ID")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트 ID 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("pspid", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트명 */}
+ <FormField
+ control={form.control}
+ name="projNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("projNm", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선종명 */}
+ <FormField
+ control={form.control}
+ name="ptypeNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("선종명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("선종명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("ptypeNm", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 요청자 */}
+ <FormField
+ control={form.control}
+ name="createdByName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("요청자")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("요청자 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("createdByName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("Status")}</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder={t("Select status")} />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 전송일 */}
+ <FormField
+ control={form.control}
+ name="dateRange"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ 전송일")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <DateRangePicker
+ triggerSize="default"
+ triggerClassName="w-full bg-white"
+ align="start"
+ showClearButton={true}
+ placeholder={t("RFQ 전송일 범위를 고르세요")}
+ date={field.value || undefined}
+ onDateChange={field.onChange}
+ disabled={isInitializing}
+ />
+ {(field.value?.from || field.value?.to) && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-10 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("dateRange", { from: undefined, to: undefined });
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */}
+ <div className="p-4 shrink-0">
+ <div className="flex gap-2 justify-end">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ className="px-4"
+ >
+ {t("초기화")}
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading || isInitializing}
+ className="px-4"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? t("조회 중...") : t("조회")}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
index 289ad312..c0aaf477 100644
--- a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
+++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
@@ -21,8 +21,8 @@ interface RfqItem {
itemCode: string;
itemList: string;
workType: string;
- shipType?: string; // 조선용
- subItemName?: string; // 해양용
+ shipTypes?: string; // 조선용
+ subItemList?: string; // 해양용
}
interface RfqItemsViewDialogProps {
@@ -167,7 +167,7 @@ export function RfqItemsViewDialog({
{item.itemList}
</div>
<div className="w-[150px] pl-2 text-sm">
- {item.itemType === 'SHIP' ? item.shipType : item.subItemName}
+ {item.itemType === 'SHIP' ? item.shipTypes : item.subItemList}
</div>
</div>
))}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index 89054d0e..f41857cd 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -1,420 +1,413 @@
-"use client"
-
-import * as React from "react"
-import { ColumnDef } from "@tanstack/react-table"
-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, Package, FileText, BarChart3 } from "lucide-react"
-import { Button } from "@/components/ui/button"
-
-// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함)
-type TechSalesRfq = {
- id: number
- rfqCode: string | null
- description: string | null
- dueDate: Date
- rfqSendDate: Date | null
- status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
- picCode: string | null
- remark: string | null
- cancelReason: string | null
- createdAt: Date
- updatedAt: Date
- createdBy: number | null
- createdByName: string
- updatedBy: number | null
- updatedByName: string
- sentBy: number | null
- sentByName: string | null
- pspid: string
- projNm: string
- sector: string
- projMsrm: number
- ptypeNm: string
- attachmentCount: number
- hasTbeAttachments: boolean
- hasCbeAttachments: boolean
- quotationCount: number
- itemCount: number
- // 나머지 필드는 사용할 때마다 추가
- [key: string]: unknown
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
- openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void;
- openItemsDialog: (rfq: TechSalesRfq) => void;
-}
-
-export function getColumns({
- setRowAction,
- openAttachmentsSheet,
- openItemsDialog,
-}: GetColumnsProps): ColumnDef<TechSalesRfq>[] {
- return [
- {
- id: "select",
- // Remove the "Select all" checkbox in header since we're doing single-select
- header: () => <span className="sr-only">Select</span>,
- cell: ({ row, table }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => {
- // If selecting this row
- if (value) {
- // First deselect all rows (to ensure single selection)
- table.toggleAllRowsSelected(false)
- // Then select just this row
- row.toggleSelected(true)
- // Trigger the same action that was in the "Select" button
- setRowAction({ row, type: "select" as const })
- } else {
- // Just deselect this row
- row.toggleSelected(false)
- }
- }}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- enableResizing: false,
- size: 40,
- minSize: 40,
- maxSize: 40,
- },
-
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="진행상태" />
- ),
- cell: ({ row }) => <div>{row.getValue("status")}</div>,
- meta: {
- excelHeader: "진행상태"
- },
- enableResizing: true,
- minSize: 80,
- size: 100,
- },
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ No." />
- ),
- cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
- meta: {
- excelHeader: "RFQ No."
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "description",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
- ),
- cell: ({ row }) => <div>{row.getValue("description")}</div>,
- meta: {
- excelHeader: "RFQ Title"
- },
- enableResizing: true,
- size: 200,
- },
- {
- accessorKey: "projNm",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
- ),
- cell: ({ row }) => {
- const projNm = row.getValue("projNm") as string;
- return (
- <Button
- variant="link"
- className="p-0 h-auto font-normal text-left justify-start hover:underline"
- onClick={() => setRowAction({ row, type: "view" as const })}
- >
- {projNm}
- </Button>
- );
- },
- meta: {
- excelHeader: "프로젝트명"
- },
- 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,
- // },
- {
- accessorKey: "rfqSendDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최초 전송일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDate(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "최초 전송일"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDate(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "RFQ 마감일"
- },
- enableResizing: true,
- minSize: 80,
- size: 120,
- },
- {
- accessorKey: "createdByName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="요청자" />
- ),
- cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
- meta: {
- excelHeader: "요청자"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="등록일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDateTime(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "등록일"
- },
- enableResizing: true,
- size: 160,
- },
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDateTime(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "수정일"
- },
- 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="RFQ 첨부파일" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const attachmentCount = rfq.attachmentCount || 0
-
- const handleClick = () => {
- openAttachmentsSheet(rfq.id, 'RFQ_COMMON')
- }
-
- 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: "첨부파일"
- },
- },
- {
- id: "tbe-attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const hasTbeAttachments = rfq.hasTbeAttachments
-
- const handleClick = () => {
- openAttachmentsSheet(rfq.id, 'TBE_RESULT')
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
- >
- <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" />
- {hasTbeAttachments && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
- )}
- <span className="sr-only">
- {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- enableResizing: false,
- size: 80,
- meta: {
- excelHeader: "TBE 결과"
- },
- },
- {
- id: "cbe-attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="CBE 결과" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const hasCbeAttachments = rfq.hasCbeAttachments
-
- const handleClick = () => {
- openAttachmentsSheet(rfq.id, 'CBE_RESULT')
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
- >
- <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" />
- {hasCbeAttachments && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
- )}
- <span className="sr-only">
- {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- enableResizing: false,
- size: 80,
- meta: {
- excelHeader: "CBE 결과"
- },
- },
- ]
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+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, Package, FileText, BarChart3 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { TechSalesRfq } from "./rfq-table"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
+ openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void;
+ openItemsDialog: (rfq: TechSalesRfq) => void;
+}
+
+export function getColumns({
+ setRowAction,
+ openAttachmentsSheet,
+ openItemsDialog,
+}: GetColumnsProps): ColumnDef<TechSalesRfq>[] {
+ return [
+ {
+ id: "select",
+ // Remove the "Select all" checkbox in header since we're doing single-select
+ header: () => <span className="sr-only">Select</span>,
+ cell: ({ row, table }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // If selecting this row
+ if (value) {
+ // First deselect all rows (to ensure single selection)
+ table.toggleAllRowsSelected(false)
+ // Then select just this row
+ row.toggleSelected(true)
+ // Trigger the same action that was in the "Select" button
+ setRowAction({ row, type: "select" as const })
+ } else {
+ // Just deselect this row
+ row.toggleSelected(false)
+ }
+ }}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="진행상태" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("status")}</div>,
+ meta: {
+ excelHeader: "진행상태"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ No." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
+ meta: {
+ excelHeader: "RFQ No."
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("description")}</div>,
+ meta: {
+ excelHeader: "RFQ Title"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => {
+ const projNm = row.getValue("projNm") as string;
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-normal text-left justify-start hover:underline"
+ onClick={() => setRowAction({ row, type: "view" as const })}
+ >
+ {projNm}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "프로젝트명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "workTypes",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => {
+ const workTypes = row.getValue("workTypes") as string | null;
+ return (
+ <div className="max-w-[150px]">
+ {workTypes ? (
+ <span className="text-sm truncate block" title={workTypes}>
+ {workTypes}
+ </span>
+ ) : (
+ <span className="text-muted-foreground text-sm">-</span>
+ )}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "공종"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ // {
+ // 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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="최초 전송일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "최초 전송일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 마감일"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="요청자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "요청자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "등록일"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "수정일"
+ },
+ 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="RFQ 첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const attachmentCount = rfq.attachmentCount || 0
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'RFQ_COMMON')
+ }
+
+ 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: "첨부파일"
+ },
+ },
+ {
+ id: "tbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasTbeAttachments = rfq.hasTbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'TBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ >
+ <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" />
+ {hasTbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "TBE 결과"
+ },
+ },
+ {
+ id: "cbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="CBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasCbeAttachments = rfq.hasCbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'CBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ >
+ <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" />
+ {hasCbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "CBE 결과"
+ },
+ },
+ ]
} \ 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 a8c2d08c..3ccca4eb 100644
--- a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
+++ b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
@@ -1,80 +1,80 @@
-"use client"
-
-import * as React from "react"
-import { Download, RefreshCw } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { type Table } from "@tanstack/react-table"
-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,
- rfqType = "SHIP"
-}: RFQTableToolbarActionsProps<TData>) {
-
- // 데이터 새로고침
- const handleRefresh = () => {
- if (onRefresh) {
- onRefresh();
- toast.success("데이터를 새로고침했습니다");
- }
- }
-
- // 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 생성 다이얼로그 */}
- {renderRfqDialog()}
-
- {/* 새로고침 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefresh}
- className="gap-2"
- >
- <RefreshCw className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">새로고침</span>
- </Button>
-
- {/* 내보내기 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(selection, {
- filename: "tech_sales_rfq",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">내보내기</span>
- </Button>
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { Download, RefreshCw } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { type Table } from "@tanstack/react-table"
+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,
+ rfqType = "SHIP"
+}: RFQTableToolbarActionsProps<TData>) {
+
+ // 데이터 새로고침
+ const handleRefresh = () => {
+ if (onRefresh) {
+ onRefresh();
+ toast.success("데이터를 새로고침했습니다");
+ }
+ }
+
+ // 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 생성 다이얼로그 */}
+ {renderRfqDialog()}
+
+ {/* 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ className="gap-2"
+ >
+ <RefreshCw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">새로고침</span>
+ </Button>
+
+ {/* 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(selection, {
+ filename: "tech_sales_rfq",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">내보내기</span>
+ </Button>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index 615753cd..e3551625 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -1,589 +1,636 @@
-"use client"
-
-import * as React from "react"
-import { useSearchParams } from "next/navigation"
-import { Button } from "@/components/ui/button"
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
-import type {
- DataTableAdvancedFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import {
- ResizablePanelGroup,
- ResizablePanel,
- ResizableHandle,
-} from "@/components/ui/resizable"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { getColumns } from "./rfq-table-column"
-import { useEffect, useMemo } from "react"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-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 { 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
- biddingProjectId: number | null
- materialCode: string | null
- dueDate: Date
- rfqSendDate: Date | null
- status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
- description: string | null
- remark: string | null
- cancelReason: string | null
- createdAt: Date
- updatedAt: Date
- createdBy: number | null
- createdByName: string
- updatedBy: number | null
- updatedByName: string
- sentBy: number | null
- sentByName: string | null
- // 조인된 프로젝트 정보
- pspid: string
- projNm: string
- sector: string
- projMsrm: number
- ptypeNm: string
- attachmentCount: number
- quotationCount: number
- rfqType: "SHIP" | "TOP" | "HULL" | null
- // 필요에 따라 다른 필드들 추가
- [key: string]: unknown
-}
-
-interface RFQListTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
- className?: string;
- calculatedHeight?: string; // 계산된 높이 추가
- rfqType: "SHIP" | "TOP" | "HULL";
-}
-
-export function RFQListTable({
- promises,
- className,
- calculatedHeight,
- rfqType
-}: RFQListTableProps) {
- const searchParams = useSearchParams()
-
- // 필터 패널 상태
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
-
- // 선택된 RFQ 상태
- const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null)
-
- // 프로젝트 상세정보 다이얼로그 상태
- const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false)
- const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null)
-
- // 첨부파일 시트 상태
- const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
- 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)
-
- // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
- const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
- const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
- const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
- const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비
-
- // 높이 계산
- // 필터 패널 높이 - Layout Header와 Footer 사이
- const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)`
-
- console.log(calculatedHeight)
-
- // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
- const FIXED_TABLE_HEIGHT = calculatedHeight
- ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
- : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
-
- // Suspense 방식으로 데이터 처리
- const [promiseData] = React.use(promises)
- const tableData = promiseData
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null)
-
- // 초기 설정 정의
- const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams?.get('page') || '1'),
- perPage: parseInt(searchParams?.get('perPage') || '10'),
- sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
- filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams?.get('search') || '',
- from: searchParams?.get('from') || undefined,
- to: searchParams?.get('to') || undefined,
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
- groupBy: [],
- expandedRows: []
- }), [searchParams])
-
- // DB 기반 프리셋 훅 사용
- const {
- // presets,
- // activePresetId,
- // hasUnsavedChanges,
- // isLoading: presetsLoading,
- // createPreset,
- // applyPreset,
- // updatePreset,
- // deletePreset,
- // setDefaultPreset,
- // renamePreset,
- getCurrentSettings,
- } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
-
- // 조회 버튼 클릭 핸들러
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- // 행 액션 처리
- useEffect(() => {
- if (rowAction) {
- switch (rowAction.type) {
- case "select":
- // 객체 참조 안정화를 위해 필요한 필드만 추출
- const rfqData = rowAction.row.original;
- setSelectedRfq({
- id: rfqData.id,
- rfqCode: rfqData.rfqCode,
- rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
- biddingProjectId: rfqData.biddingProjectId,
- materialCode: rfqData.materialCode,
- dueDate: rfqData.dueDate,
- rfqSendDate: rfqData.rfqSendDate,
- status: rfqData.status,
- description: rfqData.description,
- remark: rfqData.remark,
- cancelReason: rfqData.cancelReason,
- createdAt: rfqData.createdAt,
- updatedAt: rfqData.updatedAt,
- createdBy: rfqData.createdBy,
- createdByName: rfqData.createdByName,
- updatedBy: rfqData.updatedBy,
- updatedByName: rfqData.updatedByName,
- sentBy: rfqData.sentBy,
- sentByName: rfqData.sentByName,
- pspid: rfqData.pspid,
- projNm: rfqData.projNm,
- sector: rfqData.sector,
- projMsrm: rfqData.projMsrm,
- ptypeNm: rfqData.ptypeNm,
- attachmentCount: rfqData.attachmentCount,
- quotationCount: rfqData.quotationCount,
- });
- break;
- case "view":
- // 프로젝트 상세정보 다이얼로그 열기
- const projectRfqData = rowAction.row.original;
- setProjectDetailRfq({
- id: projectRfqData.id,
- rfqCode: projectRfqData.rfqCode,
- rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
- biddingProjectId: projectRfqData.biddingProjectId,
- materialCode: projectRfqData.materialCode,
- dueDate: projectRfqData.dueDate,
- rfqSendDate: projectRfqData.rfqSendDate,
- status: projectRfqData.status,
- description: projectRfqData.description,
- remark: projectRfqData.remark,
- cancelReason: projectRfqData.cancelReason,
- createdAt: projectRfqData.createdAt,
- updatedAt: projectRfqData.updatedAt,
- createdBy: projectRfqData.createdBy,
- createdByName: projectRfqData.createdByName,
- updatedBy: projectRfqData.updatedBy,
- updatedByName: projectRfqData.updatedByName,
- sentBy: projectRfqData.sentBy,
- sentByName: projectRfqData.sentByName,
- pspid: projectRfqData.pspid,
- projNm: projectRfqData.projNm,
- sector: projectRfqData.sector,
- projMsrm: projectRfqData.projMsrm,
- ptypeNm: projectRfqData.ptypeNm,
- attachmentCount: projectRfqData.attachmentCount,
- quotationCount: projectRfqData.quotationCount,
- });
- setIsProjectDetailOpen(true);
- break;
- case "update":
- console.log("Update rfq:", rowAction.row.original)
- break;
- case "delete":
- console.log("Delete rfq:", rowAction.row.original)
- break;
- }
- setRowAction(null)
- }
- }, [rowAction])
-
- // 첨부파일 시트 상태에 타입 추가
- const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
-
- // 첨부파일 시트 열기 함수
- const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
- try {
- // 선택된 RFQ 찾기
- const rfq = tableData?.data?.find(r => r.id === rfqId)
- if (!rfq) {
- toast.error("RFQ를 찾을 수 없습니다.")
- return
- }
-
- // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
- const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
-
- // 실제 첨부파일 목록 조회 API 호출
- const result = await getTechSalesRfqAttachments(rfqId)
-
- if (result.error) {
- toast.error(result.error)
- return
- }
-
- // 해당 타입의 첨부파일만 필터링
- const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
-
- // API 응답을 ExistingTechSalesAttachment 형식으로 변환
- const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- }))
-
- setAttachmentType(validAttachmentType)
- setAttachmentsDefault(attachments)
- setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
- setAttachmentsOpen(true)
- } catch (error) {
- console.error("첨부파일 조회 오류:", error)
- toast.error("첨부파일 조회 중 오류가 발생했습니다.")
- }
- }, [tableData?.data])
-
- // 첨부파일 업데이트 콜백
- const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => {
- // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨
- console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`)
-
- // 성공 피드백
- setTimeout(() => {
- toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, {
- duration: 3000
- })
- }, 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,
- openItemsDialog
- }),
- [openAttachmentsSheet, openItemsDialog]
- )
-
- // 고급 필터 필드 정의
- const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [
- {
- id: "rfqCode",
- label: "RFQ No.",
- type: "text",
- },
- {
- id: "description",
- label: "설명",
- type: "text",
- },
- {
- id: "projNm",
- label: "프로젝트명",
- type: "text",
- },
- {
- id: "rfqSendDate",
- label: "RFQ 전송일",
- type: "date",
- },
- {
- id: "dueDate",
- label: "RFQ 마감일",
- type: "date",
- },
- {
- id: "createdByName",
- label: "요청자",
- type: "text",
- },
- {
- id: "status",
- label: "상태",
- type: "text",
- },
- ]
-
- // 현재 설정 가져오기
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
-
- // useDataTable 초기 상태 설정
- const initialState = useMemo(() => {
- return {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- sorting: initialSettings.sort.filter((sortItem: any) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id)
- return columnExists
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- // useDataTable 훅 설정
- const { table } = useDataTable({
- data: tableData?.data || [],
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- columns: columns as any,
- pageCount: tableData?.pageCount || 0,
- rowCount: tableData?.total || 0,
- filterFields: [],
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- columnResizeMode: "onEnd",
- })
-
- // Get active basic filter count
- const getActiveBasicFilterCount = () => {
- try {
- const basicFilters = searchParams?.get('basicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch {
- return 0
- }
- }
-
- console.log(panelHeight)
-
- return (
- <div
- className={cn("flex flex-col relative", className)}
- style={{ height: calculatedHeight }}
- >
- {/* Filter Panel - 계산된 높이 적용 */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- top: `${LAYOUT_HEADER_HEIGHT*2}px`,
- height: FIXED_FILTER_HEIGHT
- }}
- >
- {/* Filter Content */}
- <div className="h-full">
- <RFQFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false}
- />
- </div>
- </div>
-
- {/* Main Content */}
- <div
- className="flex flex-col transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- height: '100%'
- }}
- >
- {/* Header Bar - 고정 높이 */}
- <div
- className="flex items-center justify-between p-4 bg-background border-b"
- style={{
- height: `${LOCAL_HEADER_HEIGHT}px`,
- flexShrink: 0
- }}
- >
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- type='button'
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
- {getActiveBasicFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveBasicFilterCount()}
- </span>
- )}
- </Button>
- </div>
-
- {/* Right side info */}
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || 0}건</span>
- )}
- </div>
- </div>
-
- {/* Table Content Area - 계산된 높이 사용 */}
- <div
- className="relative bg-background"
- style={{
- height: FIXED_TABLE_HEIGHT,
- display: 'grid',
- gridTemplateRows: '1fr',
- gridTemplateColumns: '1fr'
- }}
- >
- <ResizablePanelGroup
- direction="vertical"
- className="w-full h-full"
- >
- <ResizablePanel
- defaultSize={60}
- minSize={25}
- maxSize={75}
- collapsible={false}
- onResize={(size) => {
- setPanelHeight(size)
- }}
- className="flex flex-col overflow-hidden"
- >
- {/* 상단 테이블 영역 */}
- <div className="flex-1 min-h-0 overflow-hidden">
- <DataTable
- table={table}
- maxHeight={`${panelHeight*0.5}vh`}
- >
- <DataTableAdvancedToolbar
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- table={table as any}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <div className="flex items-center gap-2">
- {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */}
- {/* <TablePresetManager<TechSalesRfq>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- /> */}
-
- <RFQTableToolbarActions
- selection={table}
- onRefresh={() => {}}
- rfqType={rfqType}
- />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- minSize={25}
- defaultSize={40}
- collapsible={false}
- className="flex flex-col overflow-hidden"
- >
- {/* 하단 상세 테이블 영역 */}
- <div className="flex-1 min-h-0 overflow-hidden bg-background">
- <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/>
- </div>
- </ResizablePanel>
- </ResizablePanelGroup>
- </div>
- </div>
-
- {/* 프로젝트 상세정보 다이얼로그 */}
- <ProjectDetailDialog
- open={isProjectDetailOpen}
- onOpenChange={setIsProjectDetailOpen}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- selectedRfq={projectDetailRfq as any}
- />
-
- {/* 첨부파일 관리 시트 */}
- <TechSalesRfqAttachmentsSheet
- open={attachmentsOpen}
- onOpenChange={setAttachmentsOpen}
- defaultAttachments={attachmentsDefault}
- rfq={selectedRfqForAttachments}
- attachmentType={attachmentType}
- onAttachmentsUpdated={handleAttachmentsUpdated}
- />
-
- {/* 아이템 보기 다이얼로그 */}
- <RfqItemsViewDialog
- open={itemsDialogOpen}
- onOpenChange={setItemsDialogOpen}
- rfq={selectedRfqForItems}
- />
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns } from "./rfq-table-column"
+import { useEffect, useMemo } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+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 { 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 반환 타입에 맞춤)
+export interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ biddingProjectId: number | null
+ materialCode: string | null
+ dueDate: Date
+ rfqSendDate: Date | null
+ status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
+ description: string | null
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ // 조인된 프로젝트 정보
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ hasTbeAttachments: boolean
+ hasCbeAttachments: boolean
+ quotationCount: number
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ itemCount: number
+ workTypes: string | null
+ // 필요에 따라 다른 필드들 추가
+ [key: string]: unknown
+}
+
+interface RFQListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
+ className?: string;
+ calculatedHeight?: string; // 계산된 높이 추가
+ rfqType: "SHIP" | "TOP" | "HULL";
+}
+
+export function RFQListTable({
+ promises,
+ className,
+ calculatedHeight,
+ rfqType
+}: RFQListTableProps) {
+ const searchParams = useSearchParams()
+
+ // 필터 패널 상태
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // 선택된 RFQ 상태
+ const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null)
+
+ // 프로젝트 상세정보 다이얼로그 상태
+ const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false)
+ const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null)
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ 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)
+
+ // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
+ const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
+ const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
+ const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
+ const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비
+
+ // 높이 계산
+ // 필터 패널 높이 - Layout Header와 Footer 사이
+ const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)`
+
+ console.log(calculatedHeight)
+
+ // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
+ const FIXED_TABLE_HEIGHT = calculatedHeight
+ ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
+ : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
+
+ // Suspense 방식으로 데이터 처리
+ const [promiseData] = React.use(promises)
+ const tableData = promiseData
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null)
+
+ // 초기 설정 정의
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || undefined,
+ to: searchParams?.get('to') || undefined,
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
+
+ // DB 기반 프리셋 훅 사용
+ const {
+ // presets,
+ // activePresetId,
+ // hasUnsavedChanges,
+ // isLoading: presetsLoading,
+ // createPreset,
+ // applyPreset,
+ // updatePreset,
+ // deletePreset,
+ // setDefaultPreset,
+ // renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ setIsFilterPanelOpen(false)
+ }
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ switch (rowAction.type) {
+ case "select":
+ // 객체 참조 안정화를 위해 필요한 필드만 추출
+ const rfqData = rowAction.row.original;
+ setSelectedRfq({
+ id: rfqData.id,
+ rfqCode: rfqData.rfqCode,
+ rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
+ biddingProjectId: rfqData.biddingProjectId,
+ hasTbeAttachments: rfqData.hasTbeAttachments,
+ hasCbeAttachments: rfqData.hasCbeAttachments,
+ materialCode: rfqData.materialCode,
+ dueDate: rfqData.dueDate,
+ rfqSendDate: rfqData.rfqSendDate,
+ status: rfqData.status,
+ description: rfqData.description,
+ picCode: rfqData.picCode,
+ remark: rfqData.remark,
+ cancelReason: rfqData.cancelReason,
+ createdAt: rfqData.createdAt,
+ updatedAt: rfqData.updatedAt,
+ createdBy: rfqData.createdBy,
+ createdByName: rfqData.createdByName,
+ updatedBy: rfqData.updatedBy,
+ updatedByName: rfqData.updatedByName,
+ sentBy: rfqData.sentBy,
+ sentByName: rfqData.sentByName,
+ pspid: rfqData.pspid,
+ projNm: rfqData.projNm,
+ sector: rfqData.sector,
+ projMsrm: rfqData.projMsrm,
+ ptypeNm: rfqData.ptypeNm,
+ attachmentCount: rfqData.attachmentCount,
+ quotationCount: rfqData.quotationCount,
+ itemCount: rfqData.itemCount,
+ workTypes: rfqData.workTypes,
+ });
+ break;
+ case "view":
+ // 프로젝트 상세정보 다이얼로그 열기
+ const projectRfqData = rowAction.row.original;
+ setProjectDetailRfq({
+ id: projectRfqData.id,
+ rfqCode: projectRfqData.rfqCode,
+ rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
+ biddingProjectId: projectRfqData.biddingProjectId,
+ hasTbeAttachments: projectRfqData.hasTbeAttachments,
+ hasCbeAttachments: projectRfqData.hasCbeAttachments,
+ materialCode: projectRfqData.materialCode,
+ dueDate: projectRfqData.dueDate,
+ rfqSendDate: projectRfqData.rfqSendDate,
+ status: projectRfqData.status,
+ description: projectRfqData.description,
+ picCode: projectRfqData.picCode,
+ remark: projectRfqData.remark,
+ cancelReason: projectRfqData.cancelReason,
+ createdAt: projectRfqData.createdAt,
+ updatedAt: projectRfqData.updatedAt,
+ createdBy: projectRfqData.createdBy,
+ createdByName: projectRfqData.createdByName,
+ updatedBy: projectRfqData.updatedBy,
+ updatedByName: projectRfqData.updatedByName,
+ sentBy: projectRfqData.sentBy,
+ sentByName: projectRfqData.sentByName,
+ pspid: projectRfqData.pspid,
+ projNm: projectRfqData.projNm,
+ sector: projectRfqData.sector,
+ projMsrm: projectRfqData.projMsrm,
+ ptypeNm: projectRfqData.ptypeNm,
+ attachmentCount: projectRfqData.attachmentCount,
+ quotationCount: projectRfqData.quotationCount,
+ itemCount: projectRfqData.itemCount,
+ workTypes: projectRfqData.workTypes,
+ });
+ setIsProjectDetailOpen(true);
+ break;
+ case "update":
+ console.log("Update rfq:", rowAction.row.original)
+ break;
+ case "delete":
+ console.log("Delete rfq:", rowAction.row.original)
+ break;
+ }
+ setRowAction(null)
+ }
+ }, [rowAction])
+
+ // 첨부파일 시트 상태에 타입 추가
+ const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
+
+ // 첨부파일 시트 열기 함수
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
+ try {
+ // 선택된 RFQ 찾기
+ const rfq = tableData?.data?.find(r => r.id === rfqId)
+ if (!rfq) {
+ toast.error("RFQ를 찾을 수 없습니다.")
+ return
+ }
+
+ // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
+ const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // 해당 타입의 첨부파일만 필터링
+ const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환
+ const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentType(validAttachmentType)
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [tableData?.data])
+
+ // // 첨부파일 업데이트 콜백
+ // const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => {
+ // // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨
+ // console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`)
+
+ // // 성공 피드백
+ // setTimeout(() => {
+ // toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, {
+ // duration: 3000
+ // })
+ // }, 500)
+ // }, [])
+
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: TechSalesRfq) => {
+ console.log("Opening items dialog for RFQ:", rfq.id, rfq)
+ setSelectedRfqForItems(rfq)
+ setItemsDialogOpen(true)
+ }, [])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ openAttachmentsSheet,
+ openItemsDialog
+ }),
+ [openAttachmentsSheet, openItemsDialog, setRowAction]
+ )
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ No.",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "설명",
+ type: "text",
+ },
+ {
+ id: "projNm",
+ label: "프로젝트명",
+ type: "text",
+ },
+ {
+ id: "rfqSendDate",
+ label: "RFQ 전송일",
+ type: "date",
+ },
+ {
+ id: "dueDate",
+ label: "RFQ 마감일",
+ type: "date",
+ },
+ {
+ id: "createdByName",
+ label: "요청자",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "text",
+ },
+ {
+ id: "workTypes",
+ label: "Work Type",
+ type: "multi-select",
+ options: [
+ // 조선 workTypes
+ { label: "기장", value: "기장" },
+ { label: "전장", value: "전장" },
+ { label: "선실", value: "선실" },
+ { label: "배관", value: "배관" },
+ { label: "철의", value: "철의" },
+ { label: "선체", value: "선체" },
+ // 해양TOP workTypes
+ { label: "TM", value: "TM" },
+ { label: "TS", value: "TS" },
+ { label: "TE", value: "TE" },
+ { label: "TP", value: "TP" },
+ // 해양HULL workTypes
+ { label: "HA", value: "HA" },
+ { label: "HE", value: "HE" },
+ { label: "HH", value: "HH" },
+ { label: "HM", value: "HM" },
+ { label: "NC", value: "NC" },
+ { label: "HO", value: "HO" },
+ { label: "HP", value: "HP" },
+ ],
+ },
+ ]
+
+ // 현재 설정 가져오기
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ // useDataTable 초기 상태 설정
+ const initialState = useMemo(() => {
+ return {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ sorting: initialSettings.sort.filter((sortItem: any) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id)
+ return columnExists
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ // useDataTable 훅 설정
+ const { table } = useDataTable({
+ data: tableData?.data || [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ columns: columns as any,
+ pageCount: tableData?.pageCount || 0,
+ rowCount: tableData?.total || 0,
+ filterFields: [],
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ // Get active basic filter count
+ const getActiveBasicFilterCount = () => {
+ try {
+ const basicFilters = searchParams?.get('basicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch {
+ return 0
+ }
+ }
+
+ console.log(panelHeight)
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+ {/* Filter Panel - 계산된 높이 적용 */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${LAYOUT_HEADER_HEIGHT*2}px`,
+ height: FIXED_FILTER_HEIGHT
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <RFQFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
+ >
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+
+ {/* Right side info */}
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || 0}건</span>
+ )}
+ </div>
+ </div>
+
+ {/* Table Content Area - 계산된 높이 사용 */}
+ <div
+ className="relative bg-background"
+ style={{
+ height: FIXED_TABLE_HEIGHT,
+ display: 'grid',
+ gridTemplateRows: '1fr',
+ gridTemplateColumns: '1fr'
+ }}
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="w-full h-full"
+ >
+ <ResizablePanel
+ defaultSize={60}
+ minSize={25}
+ maxSize={75}
+ collapsible={false}
+ onResize={(size) => {
+ setPanelHeight(size)
+ }}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 상단 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <DataTable
+ table={table}
+ maxHeight={`${panelHeight*0.5}vh`}
+ >
+ <DataTableAdvancedToolbar
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ table={table as any}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */}
+ {/* <TablePresetManager<TechSalesRfq>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ /> */}
+
+ <RFQTableToolbarActions
+ selection={table}
+ onRefresh={() => {}}
+ rfqType={rfqType}
+ />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ minSize={25}
+ defaultSize={40}
+ collapsible={false}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 하단 상세 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden bg-background">
+ <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/>
+ </div>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </div>
+ </div>
+
+ {/* 프로젝트 상세정보 다이얼로그 */}
+ <ProjectDetailDialog
+ open={isProjectDetailOpen}
+ onOpenChange={setIsProjectDetailOpen}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ selectedRfq={projectDetailRfq as any}
+ />
+
+ {/* 첨부파일 관리 시트 */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ attachmentType={attachmentType}
+ />
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems ? {
+ id: selectedRfqForItems.id,
+ rfqCode: selectedRfqForItems.rfqCode,
+ status: selectedRfqForItems.status,
+ description: selectedRfqForItems.description || undefined,
+ rfqType: selectedRfqForItems.rfqType
+ } : null}
+ />
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
index 08363535..a03839c1 100644
--- a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
@@ -14,7 +14,6 @@ import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { formatDate } from "@/lib/utils"
import prettyBytes from "pretty-bytes"
-import { downloadFile } from "@/lib/file-download"
// 견적서 첨부파일 타입 정의
export interface QuotationAttachment {
@@ -80,20 +79,26 @@ export function TechSalesQuotationAttachmentsSheet({
// 기본 파일
return <File className="h-5 w-5 text-gray-500" />;
};
-
- // 파일 다운로드 처리
- const handleDownload = (attachment: QuotationAttachment) => {
- downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName)
- /*
- const link = document.createElement('a');
- link.href = attachment.filePath;
- link.download = attachment.originalFileName || attachment.fileName;
- link.target = '_blank';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- */
- };
+
+ // 파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (attachment: QuotationAttachment) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download');
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error);
+ // TODO: toast 에러 메시지 추가 (sonner import 필요)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`);
+ }
+ });
+ } catch (error) {
+ console.error('다운로드 오류:', error);
+ // TODO: toast 에러 메시지 추가 (sonner import 필요)
+ }
+ }, []);
// 리비전별로 첨부파일 그룹핑
const groupedAttachments = React.useMemo(() => {
@@ -176,7 +181,7 @@ export function TechSalesQuotationAttachmentsSheet({
className="flex items-start gap-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors ml-4"
>
<div className="mt-1">
- {getFileIcon(attachment.fileName)}
+ {getFileIcon(attachment.originalFileName || attachment.fileName)}
</div>
<div className="flex-1 min-w-0">
@@ -211,7 +216,7 @@ export function TechSalesQuotationAttachmentsSheet({
variant="ghost"
size="icon"
className="h-8 w-8"
- onClick={() => handleDownload(attachment)}
+ onClick={() => handleDownloadClick(attachment)}
title="다운로드"
>
<Download className="h-4 w-4" />
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
index fccedf0a..f2ae1084 100644
--- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
@@ -1,550 +1,570 @@
-"use client"
-
-import * as React from "react"
-import { z } from "zod"
-import { useForm, useFieldArray } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { cn } from "@/lib/utils"
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- SheetDescription,
- SheetFooter,
- SheetClose,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription
-} from "@/components/ui/form"
-import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
-import { toast } from "sonner"
-import { Badge } from "@/components/ui/badge"
-
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
-} from "@/components/ui/file-list"
-
-import prettyBytes from "pretty-bytes"
-import { formatDate } from "@/lib/utils"
-import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
-import { useSession } from "next-auth/react"
-import { downloadFile } from "@/lib/file-download"
-
-const MAX_FILE_SIZE = 6e8 // 600MB
-
-/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */
-export interface ExistingTechSalesAttachment {
- id: number
- techSalesRfqId: number
- fileName: string
- originalFileName: string
- filePath: string
- fileSize?: number
- fileType?: string
- attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
- description?: string
- createdBy: number
- createdAt: Date
-}
-
-/** 새로 업로드할 파일 */
-const newUploadSchema = z.object({
- fileObj: z.any().optional(), // 실제 File
- attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
- description: z.string().optional(),
-})
-
-/** 기존 첨부 (react-hook-form에서 관리) */
-const existingAttachSchema = z.object({
- id: z.number(),
- techSalesRfqId: z.number(),
- fileName: z.string(),
- originalFileName: z.string(),
- filePath: z.string(),
- fileSize: z.number().optional(),
- fileType: z.string().optional(),
- attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
- description: z.string().optional(),
- createdBy: z.number(),
- createdAt: z.custom<Date>(),
-})
-
-/** RHF 폼 전체 스키마 */
-const attachmentsFormSchema = z.object({
- techSalesRfqId: z.number().int(),
- existing: z.array(existingAttachSchema),
- newUploads: z.array(newUploadSchema),
-})
-
-type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
-
-// TechSalesRfq 타입 (간단 버전)
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- status: string
- // 필요한 다른 필드들...
-}
-
-interface TechSalesRfqAttachmentsSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- defaultAttachments?: ExistingTechSalesAttachment[]
- rfq: TechSalesRfq | null
- /** 첨부파일 타입 */
- attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
- /** 읽기 전용 모드 (벤더용) */
- readOnly?: boolean
- /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
- // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
-
-}
-
-export function TechSalesRfqAttachmentsSheet({
- defaultAttachments = [],
- // onAttachmentsUpdated,
- rfq,
- attachmentType = "RFQ_COMMON",
- readOnly = false,
- ...props
-}: TechSalesRfqAttachmentsSheetProps) {
- const [isPending, setIsPending] = React.useState(false)
- const session = useSession()
-
- // 첨부파일 타입별 제목과 설명 설정
- const attachmentConfig = React.useMemo(() => {
- switch (attachmentType) {
- case "TBE_RESULT":
- return {
- title: "TBE 결과 첨부파일",
- description: "기술 평가(TBE) 결과 파일을 관리합니다.",
- fileTypeLabel: "TBE 결과",
- canEdit: !readOnly
- }
- case "CBE_RESULT":
- return {
- title: "CBE 결과 첨부파일",
- description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
- fileTypeLabel: "CBE 결과",
- canEdit: !readOnly
- }
- default: // RFQ_COMMON
- return {
- title: "RFQ 첨부파일",
- description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.",
- fileTypeLabel: "공통",
- canEdit: !readOnly
- }
- }
- }, [attachmentType, readOnly])
-
- // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
- // const isEditable = React.useMemo(() => {
- // if (!rfq) return false
- // return attachmentConfig.canEdit
- // }, [rfq, attachmentConfig.canEdit])
-
- const form = useForm<AttachmentsFormValues>({
- resolver: zodResolver(attachmentsFormSchema),
- defaultValues: {
- techSalesRfqId: rfq?.id || 0,
- existing: defaultAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType,
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- })),
- newUploads: [],
- },
- })
-
- // useFieldArray for existing and new uploads
- const {
- fields: existingFields,
- remove: removeExisting,
- } = useFieldArray({
- control: form.control,
- name: "existing",
- })
-
- const {
- fields: newUploadFields,
- append: appendNewUpload,
- remove: removeNewUpload,
- } = useFieldArray({
- control: form.control,
- name: "newUploads",
- })
-
- // Reset form when defaultAttachments changes
- React.useEffect(() => {
- if (defaultAttachments) {
- form.reset({
- techSalesRfqId: rfq?.id || 0,
- existing: defaultAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType,
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- })),
- newUploads: [],
- })
- }
- }, [defaultAttachments, rfq?.id, form])
-
- // Handle dropzone accept
- const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
- acceptedFiles.forEach((file) => {
- appendNewUpload({
- fileObj: file,
- attachmentType: "RFQ_COMMON",
- description: "",
- })
- })
- }, [appendNewUpload])
-
- // Handle dropzone reject
- const handleDropRejected = React.useCallback(() => {
- toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.")
- }, [])
-
- // Handle remove existing attachment
- const handleRemoveExisting = React.useCallback((index: number) => {
- removeExisting(index)
- }, [removeExisting])
-
- // Handle form submission
- const onSubmit = async (data: AttachmentsFormValues) => {
- if (!rfq) {
- toast.error("RFQ 정보를 찾을 수 없습니다.")
- return
- }
-
- setIsPending(true)
- try {
- // 삭제할 첨부파일 ID 수집
- const deleteAttachmentIds = defaultAttachments
- .filter((original) => !data.existing.find(existing => existing.id === original.id))
- .map(attachment => attachment.id)
-
- // 새 파일 정보 수집
- const newFiles = data.newUploads
- .filter(upload => upload.fileObj)
- .map(upload => ({
- file: upload.fileObj as File,
- attachmentType: attachmentType,
- description: upload.description,
- }))
-
- // 실제 API 호출
- const result = await processTechSalesRfqAttachments({
- techSalesRfqId: rfq.id,
- newFiles,
- deleteAttachmentIds,
- createdBy: parseInt(session.data?.user.id || "0"),
- })
-
- if (result.error) {
- toast.error(result.error)
- return
- }
-
- // 성공 메시지 표시 (업로드된 파일 수 포함)
- const uploadedCount = newFiles.length
- const deletedCount = deleteAttachmentIds.length
-
- let successMessage = "첨부파일이 저장되었습니다."
- if (uploadedCount > 0 && deletedCount > 0) {
- successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료`
- } else if (uploadedCount > 0) {
- successMessage = `${uploadedCount}개 파일이 업로드되었습니다.`
- } else if (deletedCount > 0) {
- successMessage = `${deletedCount}개 파일이 삭제되었습니다.`
- }
-
- toast.success(successMessage)
-
- // // 즉시 첨부파일 목록 새로고침
- // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
- // if (refreshResult.error) {
- // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
- // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
- // } else {
- // // 새로운 첨부파일 목록으로 폼 업데이트
- // const refreshedAttachments = refreshResult.data.map(att => ({
- // id: att.id,
- // techSalesRfqId: att.techSalesRfqId || rfq.id,
- // fileName: att.fileName,
- // originalFileName: att.originalFileName,
- // filePath: att.filePath,
- // fileSize: att.fileSize,
- // fileType: att.fileType,
- // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- // description: att.description,
- // createdBy: att.createdBy,
- // createdAt: att.createdAt,
- // }))
-
- // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
- // form.reset({
- // techSalesRfqId: rfq.id,
- // existing: refreshedAttachments.map(att => ({
- // ...att,
- // fileSize: att.fileSize || undefined,
- // fileType: att.fileType || undefined,
- // description: att.description || undefined,
- // })),
- // newUploads: [],
- // })
-
- // // 즉시 UI 업데이트를 위한 추가 피드백
- // if (uploadedCount > 0) {
- // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
- // }
- // }
-
- // // 콜백으로 상위 컴포넌트에 변경사항 알림
- // const newAttachmentCount = refreshResult.error ?
- // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
- // refreshResult.data.length
- // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
-
- } catch (error) {
- console.error("첨부파일 저장 오류:", error)
- toast.error("첨부파일 저장 중 오류가 발생했습니다.")
- } finally {
- setIsPending(false)
- }
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
- <SheetHeader className="text-left">
- <SheetTitle>{attachmentConfig.title}</SheetTitle>
- <SheetDescription>
- <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
- <div className="mt-1">{attachmentConfig.description}</div>
- {!attachmentConfig.canEdit && (
- <div className="mt-2 flex items-center gap-2 text-amber-600">
- <AlertCircle className="h-4 w-4" />
- <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
- </div>
- )}
- </SheetDescription>
- </SheetHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
- {/* 1) Existing attachments */}
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- 기존 첨부파일 ({existingFields.length}개)
- </h6>
- {existingFields.map((field, index) => {
- const typeLabel = attachmentConfig.fileTypeLabel
- const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
- const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
-
- return (
- <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
- <div className="flex-1 min-w-0 overflow-hidden">
- <div className="flex items-center gap-2 mb-1 flex-wrap">
- <p className="text-sm font-medium break-words leading-tight">
- {field.originalFileName || field.fileName}
- </p>
- <Badge variant="outline" className="text-xs shrink-0">
- {typeLabel}
- </Badge>
- </div>
- <p className="text-xs text-muted-foreground">
- {sizeText} • {dateText}
- </p>
- {field.description && (
- <p className="text-xs text-muted-foreground mt-1 break-words">
- {field.description}
- </p>
- )}
- </div>
-
- <div className="flex items-center gap-1 shrink-0">
- {/* Download button */}
- {field.filePath && (
- <a
- // href={`/api/tech-sales-rfq-download?path=${encodeURIComponent(field.filePath)}`}
- // download={field.originalFileName || field.fileName}
- onClick={() => downloadFile(field.filePath, field.originalFileName || field.fileName)}
- className="inline-block"
- >
- <Button variant="ghost" size="icon" type="button" className="h-8 w-8">
- <Download className="h-4 w-4" />
- </Button>
- </a>
- )}
- {/* Remove button - 편집 가능할 때만 표시 */}
- {attachmentConfig.canEdit && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={() => handleRemoveExisting(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
- )
- })}
- </div>
-
- {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
- {attachmentConfig.canEdit ? (
- <>
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={handleDropRejected}
- >
- {({ maxSize }) => (
- <FormField
- control={form.control}
- name="newUploads"
- render={() => (
- <FormItem>
- <FormLabel>새 파일 업로드</FormLabel>
- <DropzoneZone className="flex justify-center">
- <FormControl>
- <DropzoneInput />
- </FormControl>
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
- <DropzoneDescription>
- 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- </Dropzone>
-
- {/* newUpload fields -> FileList */}
- {newUploadFields.length > 0 && (
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- 새 파일 ({newUploadFields.length}개)
- </h6>
- <FileList>
- {newUploadFields.map((field, idx) => {
- const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
- if (!fileObj) return null
-
- const fileName = fileObj.name
- const fileSize = fileObj.size
- return (
- <FileListItem key={field.id}>
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{fileName}</FileListName>
- <FileListDescription>
- {prettyBytes(fileSize)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction onClick={() => removeNewUpload(idx)}>
- <X />
- <span className="sr-only">제거</span>
- </FileListAction>
- </FileListHeader>
-
- </FileListItem>
- )
- })}
- </FileList>
- </div>
- )}
- </>
- ) : (
- <div className="p-3 bg-muted rounded-md flex items-center justify-center">
- <div className="text-center text-sm text-muted-foreground">
- <Eye className="h-4 w-4 mx-auto mb-2" />
- <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
- </div>
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- {attachmentConfig.canEdit ? "취소" : "닫기"}
- </Button>
- </SheetClose>
- {attachmentConfig.canEdit && (
- <Button
- type="submit"
- disabled={
- isPending ||
- (
- form.getValues().newUploads.length === 0 &&
- form.getValues().existing.length === defaultAttachments.length &&
- form.getValues().existing.every(existing =>
- defaultAttachments.some(original => original.id === existing.id)
- )
- )
- }
- >
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- {isPending ? "저장 중..." : "저장"}
- </Button>
- )}
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
+"use client"
+
+import * as React from "react"
+import { z } from "zod"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription
+} from "@/components/ui/form"
+import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
+import { toast } from "sonner"
+import { Badge } from "@/components/ui/badge"
+
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list"
+
+import prettyBytes from "pretty-bytes"
+import { formatDate } from "@/lib/utils"
+import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */
+export interface ExistingTechSalesAttachment {
+ id: number
+ techSalesRfqId: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize?: number
+ fileType?: string
+ attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+ description?: string
+ createdBy: number
+ createdAt: Date
+}
+
+/** 새로 업로드할 파일 */
+const newUploadSchema = z.object({
+ fileObj: z.any().optional(), // 실제 File
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
+ description: z.string().optional(),
+})
+
+/** 기존 첨부 (react-hook-form에서 관리) */
+const existingAttachSchema = z.object({
+ id: z.number(),
+ techSalesRfqId: z.number(),
+ fileName: z.string(),
+ originalFileName: z.string(),
+ filePath: z.string(),
+ fileSize: z.number().optional(),
+ fileType: z.string().optional(),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
+ description: z.string().optional(),
+ createdBy: z.number(),
+ createdAt: z.custom<Date>(),
+})
+
+/** RHF 폼 전체 스키마 */
+const attachmentsFormSchema = z.object({
+ techSalesRfqId: z.number().int(),
+ existing: z.array(existingAttachSchema),
+ newUploads: z.array(newUploadSchema),
+})
+
+type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
+
+// TechSalesRfq 타입 (간단 버전)
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ // 필요한 다른 필드들...
+}
+
+interface TechSalesRfqAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ defaultAttachments?: ExistingTechSalesAttachment[]
+ rfq: TechSalesRfq | null
+ /** 첨부파일 타입 */
+ attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+ /** 읽기 전용 모드 (벤더용) */
+ readOnly?: boolean
+ /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
+ // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
+
+}
+
+export function TechSalesRfqAttachmentsSheet({
+ defaultAttachments = [],
+ // onAttachmentsUpdated,
+ rfq,
+ attachmentType = "RFQ_COMMON",
+ readOnly = false,
+ ...props
+}: TechSalesRfqAttachmentsSheetProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const session = useSession()
+
+ // 파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error(error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }, [])
+ // 첨부파일 타입별 제목과 설명 설정
+ const attachmentConfig = React.useMemo(() => {
+ switch (attachmentType) {
+ case "TBE_RESULT":
+ return {
+ title: "TBE 결과 첨부파일",
+ description: "기술 평가(TBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "TBE 결과",
+ canEdit: !readOnly
+ }
+ case "CBE_RESULT":
+ return {
+ title: "CBE 결과 첨부파일",
+ description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "CBE 결과",
+ canEdit: !readOnly
+ }
+ default: // RFQ_COMMON
+ return {
+ title: "RFQ 첨부파일",
+ description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.",
+ fileTypeLabel: "공통",
+ canEdit: !readOnly
+ }
+ }
+ }, [attachmentType, readOnly])
+
+ // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
+ // const isEditable = React.useMemo(() => {
+ // if (!rfq) return false
+ // return attachmentConfig.canEdit
+ // }, [rfq, attachmentConfig.canEdit])
+
+ const form = useForm<AttachmentsFormValues>({
+ resolver: zodResolver(attachmentsFormSchema),
+ defaultValues: {
+ techSalesRfqId: rfq?.id || 0,
+ existing: defaultAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType,
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ })),
+ newUploads: [],
+ },
+ })
+
+ // useFieldArray for existing and new uploads
+ const {
+ fields: existingFields,
+ remove: removeExisting,
+ } = useFieldArray({
+ control: form.control,
+ name: "existing",
+ })
+
+ const {
+ fields: newUploadFields,
+ append: appendNewUpload,
+ remove: removeNewUpload,
+ } = useFieldArray({
+ control: form.control,
+ name: "newUploads",
+ })
+
+ // Reset form when defaultAttachments changes
+ React.useEffect(() => {
+ if (defaultAttachments) {
+ form.reset({
+ techSalesRfqId: rfq?.id || 0,
+ existing: defaultAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType,
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ })),
+ newUploads: [],
+ })
+ }
+ }, [defaultAttachments, rfq?.id, form])
+
+ // Handle dropzone accept
+ const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
+ acceptedFiles.forEach((file) => {
+ appendNewUpload({
+ fileObj: file,
+ attachmentType: "RFQ_COMMON",
+ description: "",
+ })
+ })
+ }, [appendNewUpload])
+
+ // Handle dropzone reject
+ const handleDropRejected = React.useCallback(() => {
+ toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.")
+ }, [])
+
+ // Handle remove existing attachment
+ const handleRemoveExisting = React.useCallback((index: number) => {
+ removeExisting(index)
+ }, [removeExisting])
+
+ // Handle form submission
+ const onSubmit = async (data: AttachmentsFormValues) => {
+ if (!rfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ setIsPending(true)
+ try {
+ // 삭제할 첨부파일 ID 수집
+ const deleteAttachmentIds = defaultAttachments
+ .filter((original) => !data.existing.find(existing => existing.id === original.id))
+ .map(attachment => attachment.id)
+
+ // 새 파일 정보 수집
+ const newFiles = data.newUploads
+ .filter(upload => upload.fileObj)
+ .map(upload => ({
+ file: upload.fileObj as File,
+ attachmentType: attachmentType,
+ description: upload.description,
+ }))
+
+ // 실제 API 호출
+ const result = await processTechSalesRfqAttachments({
+ techSalesRfqId: rfq.id,
+ newFiles,
+ deleteAttachmentIds,
+ createdBy: parseInt(session.data?.user.id || "0"),
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // 성공 메시지 표시 (업로드된 파일 수 포함)
+ const uploadedCount = newFiles.length
+ const deletedCount = deleteAttachmentIds.length
+
+ let successMessage = "첨부파일이 저장되었습니다."
+ if (uploadedCount > 0 && deletedCount > 0) {
+ successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료`
+ } else if (uploadedCount > 0) {
+ successMessage = `${uploadedCount}개 파일이 업로드되었습니다.`
+ } else if (deletedCount > 0) {
+ successMessage = `${deletedCount}개 파일이 삭제되었습니다.`
+ }
+
+ toast.success(successMessage)
+
+ // 다이얼로그 자동 닫기
+ props.onOpenChange?.(false)
+
+ // // 즉시 첨부파일 목록 새로고침
+ // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
+ // if (refreshResult.error) {
+ // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
+ // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
+ // } else {
+ // // 새로운 첨부파일 목록으로 폼 업데이트
+ // const refreshedAttachments = refreshResult.data.map(att => ({
+ // id: att.id,
+ // techSalesRfqId: att.techSalesRfqId || rfq.id,
+ // fileName: att.fileName,
+ // originalFileName: att.originalFileName,
+ // filePath: att.filePath,
+ // fileSize: att.fileSize,
+ // fileType: att.fileType,
+ // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ // description: att.description,
+ // createdBy: att.createdBy,
+ // createdAt: att.createdAt,
+ // }))
+
+ // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
+ // form.reset({
+ // techSalesRfqId: rfq.id,
+ // existing: refreshedAttachments.map(att => ({
+ // ...att,
+ // fileSize: att.fileSize || undefined,
+ // fileType: att.fileType || undefined,
+ // description: att.description || undefined,
+ // })),
+ // newUploads: [],
+ // })
+
+ // // 즉시 UI 업데이트를 위한 추가 피드백
+ // if (uploadedCount > 0) {
+ // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
+ // }
+ // }
+
+ // // 콜백으로 상위 컴포넌트에 변경사항 알림
+ // const newAttachmentCount = refreshResult.error ?
+ // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
+ // refreshResult.data.length
+ // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
+
+ } catch (error) {
+ console.error("첨부파일 저장 오류:", error)
+ toast.error("첨부파일 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
+ <SheetDescription>
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
+ <div className="mt-2 flex items-center gap-2 text-amber-600">
+ <AlertCircle className="h-4 w-4" />
+ <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
+ </div>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
+ {/* 1) Existing attachments */}
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 기존 첨부파일 ({existingFields.length}개)
+ </h6>
+ {existingFields.map((field, index) => {
+ const typeLabel = attachmentConfig.fileTypeLabel
+ const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
+ const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
+
+ return (
+ <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <p className="text-sm font-medium break-words leading-tight">
+ {field.originalFileName || field.fileName}
+ </p>
+ <Badge variant="outline" className="text-xs shrink-0">
+ {typeLabel}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {sizeText} • {dateText}
+ </p>
+ {field.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {field.description}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ {/* Download button */}
+ {field.filePath && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ className="h-8 w-8"
+ onClick={() => handleDownloadClick(field.filePath, field.originalFileName || field.fileName)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {/* Remove button - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleRemoveExisting(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit ? (
+ <>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={form.control}
+ name="newUploads"
+ render={() => (
+ <FormItem>
+ <FormLabel>새 파일 업로드</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {/* newUpload fields -> FileList */}
+ {newUploadFields.length > 0 && (
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 새 파일 ({newUploadFields.length}개)
+ </h6>
+ <FileList>
+ {newUploadFields.map((field, idx) => {
+ const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
+ if (!fileObj) return null
+
+ const fileName = fileObj.name
+ const fileSize = fileObj.size
+ return (
+ <FileListItem key={field.id}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{fileName}</FileListName>
+ <FileListDescription>
+ {prettyBytes(fileSize)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => removeNewUpload(idx)}>
+ <X />
+ <span className="sr-only">제거</span>
+ </FileListAction>
+ </FileListHeader>
+
+ </FileListItem>
+ )
+ })}
+ </FileList>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="p-3 bg-muted rounded-md flex items-center justify-center">
+ <div className="text-center text-sm text-muted-foreground">
+ <Eye className="h-4 w-4 mx-auto mb-2" />
+ <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
+ </div>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
+ </Button>
+ </SheetClose>
+ {attachmentConfig.canEdit && (
+ <Button
+ type="submit"
+ disabled={
+ isPending ||
+ (
+ form.getValues().newUploads.length === 0 &&
+ form.getValues().existing.length === defaultAttachments.length &&
+ form.getValues().existing.every(existing =>
+ defaultAttachments.some(original => original.id === existing.id)
+ )
+ )
+ }
+ >
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ )}
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
} \ No newline at end of file