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