diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
| commit | 14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch) | |
| tree | 317c501d64662d05914330628f867467fba78132 /lib/techsales-rfq/table/create-rfq-top-dialog.tsx | |
| parent | 194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff) | |
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'lib/techsales-rfq/table/create-rfq-top-dialog.tsx')
| -rw-r--r-- | lib/techsales-rfq/table/create-rfq-top-dialog.tsx | 1220 |
1 files changed, 610 insertions, 610 deletions
diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx index ef2229ac..49fb35ca 100644 --- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx @@ -1,611 +1,611 @@ -"use client" - -import * as React from "react" -import { toast } from "sonner" -import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" -import { Calendar } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { CalendarIcon } from "lucide-react" -import { format } from "date-fns" -import { ko } from "date-fns/locale" - -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import * as z from "zod" -import { EstimateProjectSelector } from "@/components/BidProjectSelector" -import { type Project } from "@/lib/rfqs/service" -import { createTechSalesTopRfq } from "@/lib/techsales-rfq/service" -import { useSession } from "next-auth/react" -import { Separator } from "@/components/ui/separator" -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { cn } from "@/lib/utils" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Input } from "@/components/ui/input" - -// 공종 타입 import -import { - getOffshoreTopWorkTypes, - getAllOffshoreTopItemsForCache, - type OffshoreTopWorkType, - type OffshoreTopTechItem -} from "@/lib/items-tech/service" - -// 해양 TOP 아이템 타입 정의 (이미 service에서 import하므로 제거) - -// 유효성 검증 스키마 -const createTopRfqSchema = z.object({ - biddingProjectId: z.number({ - required_error: "프로젝트를 선택해주세요.", - }), - itemIds: z.array(z.number()).min(1, { - message: "적어도 하나의 아이템을 선택해야 합니다.", - }), - dueDate: z.date({ - required_error: "마감일을 선택해주세요.", - }), - description: z.string().optional(), -}) - -// 폼 데이터 타입 -type CreateTopRfqFormValues = z.infer<typeof createTopRfqSchema> - -// 공종 타입 정의 -interface WorkTypeOption { - code: OffshoreTopWorkType - name: string -} - -interface CreateTopRfqDialogProps { - onCreated?: () => void; -} - -export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) { - const { data: session } = useSession() - const [isProcessing, setIsProcessing] = React.useState(false) - const [isDialogOpen, setIsDialogOpen] = React.useState(false) - const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) - - // 검색 및 필터링 상태 - const [itemSearchQuery, setItemSearchQuery] = React.useState("") - const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreTopWorkType | null>(null) - const [selectedItems, setSelectedItems] = React.useState<OffshoreTopTechItem[]>([]) - - // 데이터 상태 - const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([]) - const [allItems, setAllItems] = React.useState<OffshoreTopTechItem[]>([]) - const [isLoadingItems, setIsLoadingItems] = React.useState(false) - const [dataLoadError, setDataLoadError] = React.useState<string | null>(null) - const [retryCount, setRetryCount] = React.useState(0) - - // 데이터 로딩 함수 - const loadData = React.useCallback(async (isRetry = false) => { - try { - if (!isRetry) { - setIsLoadingItems(true) - setDataLoadError(null) - } - - console.log(`해양 TOP RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) - - const [workTypesResult, topItemsResult] = await Promise.all([ - getOffshoreTopWorkTypes(), - getAllOffshoreTopItemsForCache() - ]) - - console.log("TOP - WorkTypes 결과:", workTypesResult) - console.log("TOP - Items 결과:", topItemsResult) - - // WorkTypes 설정 - if (Array.isArray(workTypesResult)) { - setWorkTypes(workTypesResult) - } else { - throw new Error("공종 데이터를 불러올 수 없습니다.") - } - - // TOP Items 설정 - if (topItemsResult.data && Array.isArray(topItemsResult.data)) { - setAllItems(topItemsResult.data as OffshoreTopTechItem[]) - console.log("TOP 아이템 설정 완료:", topItemsResult.data.length, "개") - } else { - throw new Error("TOP 아이템 데이터를 불러올 수 없습니다.") - } - - // 성공 시 재시도 카운터 리셋 - setRetryCount(0) - setDataLoadError(null) - console.log("해양 TOP RFQ 데이터 로딩 완료") - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' - console.error("해양 TOP RFQ 데이터 로딩 오류:", errorMessage) - - setDataLoadError(errorMessage) - - // 3회까지 자동 재시도 (500ms 간격) - if (retryCount < 2) { - console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) - setTimeout(() => { - setRetryCount(prev => prev + 1) - loadData(true) - }, 500 * (retryCount + 1)) - } else { - // 재시도 실패 시 사용자에게 알림 - toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) - } - } finally { - if (!isRetry) { - setIsLoadingItems(false) - } - } - }, [retryCount]) - - // 다이얼로그가 열릴 때마다 데이터 로딩 - React.useEffect(() => { - if (isDialogOpen) { - setDataLoadError(null) - setRetryCount(0) - loadData() - } - }, [isDialogOpen, loadData]) - - // 수동 새로고침 함수 - const handleRefreshData = React.useCallback(() => { - setDataLoadError(null) - setRetryCount(0) - loadData() - }, [loadData]) - - // RFQ 생성 폼 - const form = useForm<CreateTopRfqFormValues>({ - resolver: zodResolver(createTopRfqSchema), - defaultValues: { - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - } - }) - - // 필터링된 아이템 목록 가져오기 - const availableItems = React.useMemo(() => { - let filtered = [...allItems] - - // 공종 필터 - if (selectedWorkType) { - filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreTopTechItem['workType']) - } - - // 검색어 필터 - if (itemSearchQuery && itemSearchQuery.trim()) { - const query = itemSearchQuery.toLowerCase().trim() - filtered = filtered.filter(item => - item.itemCode.toLowerCase().includes(query) || - (item.itemList && item.itemList.toLowerCase().includes(query)) || - (item.subItemList && item.subItemList.toLowerCase().includes(query)) - ) - } - - return filtered - }, [allItems, itemSearchQuery, selectedWorkType]) - - // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project) => { - setSelectedProject(project) - form.setValue("biddingProjectId", project.id) - // 선택 초기화 - setSelectedItems([]) - setSelectedWorkType(null) - setItemSearchQuery("") - form.setValue("itemIds", []) - } - - // 아이템 선택/해제 처리 - const handleItemToggle = (item: OffshoreTopTechItem) => { - const isSelected = selectedItems.some(selected => selected.id === item.id) - - if (isSelected) { - const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) - setSelectedItems(newSelectedItems) - form.setValue("itemIds", newSelectedItems.map(item => item.id)) - } else { - const newSelectedItems = [...selectedItems, item] - setSelectedItems(newSelectedItems) - form.setValue("itemIds", newSelectedItems.map(item => item.id)) - } - } - - // RFQ 생성 함수 - const handleCreateRfq = async (data: CreateTopRfqFormValues) => { - try { - setIsProcessing(true) - - // 사용자 인증 확인 - if (!session?.user?.id) { - throw new Error("로그인이 필요합니다") - } - - // 해양 TOP RFQ 생성 - 1:N 관계로 한 번에 생성 - const result = await createTechSalesTopRfq({ - biddingProjectId: data.biddingProjectId, - itemIds: data.itemIds, - dueDate: data.dueDate, - description: data.description, - createdBy: Number(session.user.id), - }) - - if (result.error) { - throw new Error(result.error) - } - - // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 - toast.success(`${selectedItems.length}개 아이템으로 해양 TOP RFQ가 성공적으로 생성되었습니다`) - - setIsDialogOpen(false) - form.reset({ - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedItems([]) - setDataLoadError(null) - setRetryCount(0) - - // 생성 후 콜백 실행 - if (onCreated) { - onCreated() - } - - } catch (error) { - console.error("해양 TOP RFQ 생성 오류:", error) - toast.error(`해양 TOP RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) - } finally { - setIsProcessing(false) - } - } - - return ( - <Dialog - open={isDialogOpen} - onOpenChange={(open) => { - setIsDialogOpen(open) - if (!open) { - form.reset({ - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedItems([]) - setDataLoadError(null) - setRetryCount(0) - } - }} - > - <DialogTrigger asChild> - <Button - variant="default" - size="sm" - className="gap-2" - disabled={isProcessing} - > - <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">해양 TOP RFQ 생성</span> - </Button> - </DialogTrigger> - <DialogContent - className="max-w-none h-[90vh] overflow-y-auto flex flex-col" - style={{ width: '1200px' }} - > - <DialogHeader className="border-b pb-4"> - <DialogTitle>해양 TOP RFQ 생성</DialogTitle> - </DialogHeader> - - <div className="space-y-6 p-1 overflow-y-auto"> - <Form {...form}> - <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6"> - {/* 프로젝트 선택 */} - <div className="space-y-4"> - <FormField - control={form.control} - name="biddingProjectId" - render={({ field }) => ( - <FormItem> - <FormLabel>입찰 프로젝트</FormLabel> - <FormControl> - <EstimateProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="입찰 프로젝트를 선택하세요" - pjtType="TOP" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Separator className="my-4" /> - {/* RFQ 설명 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Title</FormLabel> - <FormControl> - <Input - placeholder="RFQ Title을 입력하세요 (선택사항)" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <Separator className="my-4" /> - {/* 마감일 설정 */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>마감일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - format(field.value, "PPP", { locale: ko }) - ) : ( - <span>마감일을 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <Separator className="my-4" /> - - <div className="space-y-6"> - {/* 아이템 선택 영역 */} - <div className="space-y-4"> - <div> - <FormLabel>아이템 선택</FormLabel> - <FormDescription> - 해양 TOP RFQ를 생성하려면 아이템을 선택하세요 - </FormDescription> - </div> - - {/* 아이템 검색 및 필터 */} - <div className="space-y-2"> - <div className="flex space-x-2"> - <div className="relative flex-1"> - <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> - <Input - placeholder="아이템 검색..." - value={itemSearchQuery} - onChange={(e) => setItemSearchQuery(e.target.value)} - className="pl-8 pr-8" - disabled={isLoadingItems || dataLoadError !== null} - /> - {itemSearchQuery && ( - <Button - variant="ghost" - size="sm" - className="absolute right-0 top-0 h-full px-3" - onClick={() => setItemSearchQuery("")} - disabled={isLoadingItems || dataLoadError !== null} - > - <X className="h-4 w-4" /> - </Button> - )} - </div> - - {/* 공종 필터 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - className="gap-1" - disabled={isLoadingItems || dataLoadError !== null} - > - {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"} - <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuCheckboxItem - checked={selectedWorkType === null} - onCheckedChange={() => setSelectedWorkType(null)} - > - 전체 공종 - </DropdownMenuCheckboxItem> - {workTypes.map(workType => ( - <DropdownMenuCheckboxItem - key={workType.code} - checked={selectedWorkType === workType.code} - onCheckedChange={() => setSelectedWorkType(workType.code)} - > - {workType.name} - </DropdownMenuCheckboxItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - - {/* 아이템 목록 */} - <div className="border rounded-md"> - <ScrollArea className="h-[300px]"> - <div className="p-2 space-y-1"> - {dataLoadError ? ( - <div className="text-center py-8"> - <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4"> - <div className="flex flex-col items-center gap-3"> - <X className="h-8 w-8 text-destructive" /> - <div className="text-center"> - <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p> - <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p> - </div> - <Button - variant="outline" - size="sm" - onClick={handleRefreshData} - disabled={isLoadingItems} - className="h-8" - > - {isLoadingItems ? ( - <> - <Loader2 className="h-3 w-3 animate-spin mr-1" /> - 재시도 중... - </> - ) : ( - "다시 시도" - )} - </Button> - </div> - </div> - </div> - ) : isLoadingItems ? ( - <div className="text-center py-8 text-muted-foreground"> - <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" /> - 아이템을 불러오는 중... - {retryCount > 0 && ( - <p className="text-xs mt-1">재시도 {retryCount}회</p> - )} - </div> - ) : availableItems.length > 0 ? ( - [...availableItems] - .sort((a, b) => { - const aName = a.itemList || 'zzz' - const bName = b.itemList || 'zzz' - return aName.localeCompare(bName, 'ko', { numeric: true }) - }) - .map((item) => { - const isSelected = selectedItems.some(selected => selected.id === item.id) - - return ( - <div - key={item.id} - className={cn( - "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted", - isSelected && "bg-muted" - )} - onClick={() => handleItemToggle(item)} - > - <div className="flex items-center space-x-2 flex-1"> - {isSelected ? ( - <CheckSquare className="h-4 w-4" /> - ) : ( - <Square className="h-4 w-4" /> - )} - <div className="flex-1"> - <div className="font-medium"> - {item.itemList || '아이템명 없음'} - {item.subItemList && ` / ${item.subItemList}`} - </div> - <div className="text-sm text-muted-foreground"> - {item.itemCode || '아이템코드 없음'} - </div> - <div className="text-xs text-muted-foreground"> - 공종: {item.workType} - </div> - </div> - </div> - </div> - ) - }) - ) : ( - <div className="text-center py-8 text-muted-foreground"> - {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} - </div> - )} - </div> - </ScrollArea> - </div> - </div> - </div> - </div> - </form> - </Form> - </div> - - {/* Footer - Sticky 버튼 영역 */} - <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4"> - <div className="flex justify-end space-x-2"> - <Button - type="button" - variant="outline" - onClick={() => setIsDialogOpen(false)} - disabled={isProcessing} - > - 취소 - </Button> - <Button - type="button" - onClick={form.handleSubmit(handleCreateRfq)} - disabled={ - isProcessing || - !selectedProject || - selectedItems.length === 0 - } - > - {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 TOP RFQ 생성하기`} - </Button> - </div> - </div> - </DialogContent> - </Dialog> - ) +"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
+import { type Project } from "@/lib/rfqs/service"
+import { createTechSalesTopRfq } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+import { Separator } from "@/components/ui/separator"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Input } from "@/components/ui/input"
+
+// 공종 타입 import
+import {
+ getOffshoreTopWorkTypes,
+ getAllOffshoreTopItemsForCache,
+ type OffshoreTopWorkType,
+ type OffshoreTopTechItem
+} from "@/lib/items-tech/service"
+
+// 해양 TOP 아이템 타입 정의 (이미 service에서 import하므로 제거)
+
+// 유효성 검증 스키마
+const createTopRfqSchema = z.object({
+ biddingProjectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ itemIds: z.array(z.number()).min(1, {
+ message: "적어도 하나의 아이템을 선택해야 합니다.",
+ }),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+ description: z.string().optional(),
+})
+
+// 폼 데이터 타입
+type CreateTopRfqFormValues = z.infer<typeof createTopRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: OffshoreTopWorkType
+ name: string
+}
+
+interface CreateTopRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
+ const { data: session } = useSession()
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ // 검색 및 필터링 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState("")
+ const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreTopWorkType | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<OffshoreTopTechItem[]>([])
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [allItems, setAllItems] = React.useState<OffshoreTopTechItem[]>([])
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+ const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
+ const [retryCount, setRetryCount] = React.useState(0)
+
+ // 데이터 로딩 함수
+ const loadData = React.useCallback(async (isRetry = false) => {
+ try {
+ if (!isRetry) {
+ setIsLoadingItems(true)
+ setDataLoadError(null)
+ }
+
+ console.log(`해양 TOP RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+
+ const [workTypesResult, topItemsResult] = await Promise.all([
+ getOffshoreTopWorkTypes(),
+ getAllOffshoreTopItemsForCache()
+ ])
+
+ console.log("TOP - WorkTypes 결과:", workTypesResult)
+ console.log("TOP - Items 결과:", topItemsResult)
+
+ // WorkTypes 설정
+ if (Array.isArray(workTypesResult)) {
+ setWorkTypes(workTypesResult)
+ } else {
+ throw new Error("공종 데이터를 불러올 수 없습니다.")
+ }
+
+ // TOP Items 설정
+ if (topItemsResult.data && Array.isArray(topItemsResult.data)) {
+ setAllItems(topItemsResult.data as OffshoreTopTechItem[])
+ console.log("TOP 아이템 설정 완료:", topItemsResult.data.length, "개")
+ } else {
+ throw new Error("TOP 아이템 데이터를 불러올 수 없습니다.")
+ }
+
+ // 성공 시 재시도 카운터 리셋
+ setRetryCount(0)
+ setDataLoadError(null)
+ console.log("해양 TOP RFQ 데이터 로딩 완료")
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ console.error("해양 TOP RFQ 데이터 로딩 오류:", errorMessage)
+
+ setDataLoadError(errorMessage)
+
+ // 3회까지 자동 재시도 (500ms 간격)
+ if (retryCount < 2) {
+ console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
+ setTimeout(() => {
+ setRetryCount(prev => prev + 1)
+ loadData(true)
+ }, 500 * (retryCount + 1))
+ } else {
+ // 재시도 실패 시 사용자에게 알림
+ toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
+ }
+ } finally {
+ if (!isRetry) {
+ setIsLoadingItems(false)
+ }
+ }
+ }, [retryCount])
+
+ // 다이얼로그가 열릴 때마다 데이터 로딩
+ React.useEffect(() => {
+ if (isDialogOpen) {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }
+ }, [isDialogOpen, loadData])
+
+ // 수동 새로고침 함수
+ const handleRefreshData = React.useCallback(() => {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }, [loadData])
+
+ // RFQ 생성 폼
+ const form = useForm<CreateTopRfqFormValues>({
+ resolver: zodResolver(createTopRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ }
+ })
+
+ // 필터링된 아이템 목록 가져오기
+ const availableItems = React.useMemo(() => {
+ let filtered = [...allItems]
+
+ // 공종 필터
+ if (selectedWorkType) {
+ filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreTopTechItem['workType'])
+ }
+
+ // 검색어 필터
+ if (itemSearchQuery && itemSearchQuery.trim()) {
+ const query = itemSearchQuery.toLowerCase().trim()
+ filtered = filtered.filter(item =>
+ item.itemCode.toLowerCase().includes(query) ||
+ (item.itemList && item.itemList.toLowerCase().includes(query)) ||
+ (item.subItemList && item.subItemList.toLowerCase().includes(query))
+ )
+ }
+
+ return filtered
+ }, [allItems, itemSearchQuery, selectedWorkType])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ setSelectedWorkType(null)
+ setItemSearchQuery("")
+ form.setValue("itemIds", [])
+ }
+
+ // 아이템 선택/해제 처리
+ const handleItemToggle = (item: OffshoreTopTechItem) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ if (isSelected) {
+ const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ } else {
+ const newSelectedItems = [...selectedItems, item]
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ }
+ }
+
+ // RFQ 생성 함수
+ const handleCreateRfq = async (data: CreateTopRfqFormValues) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 해양 TOP RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesTopRfq({
+ biddingProjectId: data.biddingProjectId,
+ itemIds: data.itemIds,
+ dueDate: data.dueDate,
+ description: data.description,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
+ toast.success(`${selectedItems.length}개 아이템으로 해양 TOP RFQ가 성공적으로 생성되었습니다`)
+
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+
+ // 생성 후 콜백 실행
+ if (onCreated) {
+ onCreated()
+ }
+
+ } catch (error) {
+ console.error("해양 TOP RFQ 생성 오류:", error)
+ toast.error(`해양 TOP RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ return (
+ <Dialog
+ open={isDialogOpen}
+ onOpenChange={(open) => {
+ setIsDialogOpen(open)
+ if (!open) {
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+ }
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">해양 TOP RFQ 생성</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent
+ className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
+ style={{ width: '1200px' }}
+ >
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle>해양 TOP RFQ 생성</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6 p-1 overflow-y-auto">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
+ {/* 프로젝트 선택 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="biddingProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰 프로젝트</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="입찰 프로젝트를 선택하세요"
+ pjtType="TOP"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <Separator className="my-4" />
+ {/* 마감일 설정 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ <div className="space-y-6">
+ {/* 아이템 선택 영역 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>아이템 선택</FormLabel>
+ <FormDescription>
+ 해양 TOP RFQ를 생성하려면 아이템을 선택하세요
+ </FormDescription>
+ </div>
+
+ {/* 아이템 검색 및 필터 */}
+ <div className="space-y-2">
+ <div className="flex space-x-2">
+ <div className="relative flex-1">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템 검색..."
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-8 pr-8"
+ disabled={isLoadingItems || dataLoadError !== null}
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuCheckboxItem
+ checked={selectedWorkType === null}
+ onCheckedChange={() => setSelectedWorkType(null)}
+ >
+ 전체 공종
+ </DropdownMenuCheckboxItem>
+ {workTypes.map(workType => (
+ <DropdownMenuCheckboxItem
+ key={workType.code}
+ checked={selectedWorkType === workType.code}
+ onCheckedChange={() => setSelectedWorkType(workType.code)}
+ >
+ {workType.name}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-[300px]">
+ <div className="p-2 space-y-1">
+ {dataLoadError ? (
+ <div className="text-center py-8">
+ <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
+ <div className="flex flex-col items-center gap-3">
+ <X className="h-8 w-8 text-destructive" />
+ <div className="text-center">
+ <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
+ <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : isLoadingItems ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
+ 아이템을 불러오는 중...
+ {retryCount > 0 && (
+ <p className="text-xs mt-1">재시도 {retryCount}회</p>
+ )}
+ </div>
+ ) : availableItems.length > 0 ? (
+ [...availableItems]
+ .sort((a, b) => {
+ const aName = a.itemCode || 'zzz'
+ const bName = b.itemCode || 'zzz'
+ return aName.localeCompare(bName, 'ko', { numeric: true })
+ })
+ .map((item) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ return (
+ <div
+ key={item.id}
+ className={cn(
+ "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
+ isSelected && "bg-muted"
+ )}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ {isSelected ? (
+ <CheckSquare className="h-4 w-4" />
+ ) : (
+ <Square className="h-4 w-4" />
+ )}
+ <div className="flex-1">
+ <div className="font-medium">
+ {item.itemList || '아이템명 없음'}
+ {item.subItemList && ` / ${item.subItemList}`}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode || '아이템코드 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ })
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* Footer - Sticky 버튼 영역 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(handleCreateRfq)}
+ disabled={
+ isProcessing ||
+ !selectedProject ||
+ selectedItems.length === 0
+ }
+ >
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 TOP RFQ 생성하기`}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
}
\ No newline at end of file |
