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