summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/table/create-rfq-dialog.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
commit5036cf2908792cef45f06256e71f10920f647f49 (patch)
tree3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /lib/techsales-rfq/table/create-rfq-dialog.tsx
parent7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff)
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'lib/techsales-rfq/table/create-rfq-dialog.tsx')
-rw-r--r--lib/techsales-rfq/table/create-rfq-dialog.tsx537
1 files changed, 537 insertions, 0 deletions
diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx
new file mode 100644
index 00000000..cc652b44
--- /dev/null
+++ b/lib/techsales-rfq/table/create-rfq-dialog.tsx
@@ -0,0 +1,537 @@
+"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 { createTechSalesRfq } 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,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// 실제 데이터 서비스 import
+import {
+ getShipbuildingItemsByWorkType,
+ searchShipbuildingItems,
+ getWorkTypes,
+ type ShipbuildingItem,
+ type WorkType
+} from "@/lib/items-tech/service"
+
+// 유효성 검증 스키마 - 자재코드(item_code) 배열로 변경
+const createRfqSchema = z.object({
+ biddingProjectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ materialCodes: z.array(z.string()).min(1, {
+ message: "적어도 하나의 자재코드를 선택해야 합니다.",
+ }),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+})
+
+// 폼 데이터 타입
+type CreateRfqFormValues = z.infer<typeof createRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: WorkType
+ name: string
+ description: string
+}
+
+interface CreateRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
+ 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 [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([])
+ const [isSearchingItems, setIsSearchingItems] = React.useState(false)
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [availableItems, setAvailableItems] = React.useState<ShipbuildingItem[]>([])
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+
+ // RFQ 생성 폼
+ const form = useForm<CreateRfqFormValues>({
+ resolver: zodResolver(createRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ materialCodes: [],
+ dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후
+ }
+ })
+
+ // 공종 목록 로드
+ React.useEffect(() => {
+ const loadWorkTypes = async () => {
+ const types = await getWorkTypes()
+ setWorkTypes(types)
+ }
+ loadWorkTypes()
+ }, [])
+
+ // 아이템 데이터 로드
+ const loadItems = React.useCallback(async () => {
+ setIsLoadingItems(true)
+ try {
+ let result
+ if (itemSearchQuery.trim()) {
+ result = await searchShipbuildingItems(itemSearchQuery, selectedWorkType || undefined)
+ } else {
+ result = await getShipbuildingItemsByWorkType(selectedWorkType || undefined)
+ }
+
+ if (result.error) {
+ toast.error(`아이템 로드 오류: ${result.error}`)
+ setAvailableItems([])
+ } else {
+ setAvailableItems(result.data || [])
+ }
+ } catch (error) {
+ console.error("아이템 로드 오류:", error)
+ toast.error("아이템을 불러오는 중 오류가 발생했습니다")
+ setAvailableItems([])
+ } finally {
+ setIsLoadingItems(false)
+ }
+ }, [itemSearchQuery, selectedWorkType])
+
+ // 아이템 검색 디바운스
+ React.useEffect(() => {
+ setIsSearchingItems(true)
+ const timer = setTimeout(() => {
+ loadItems()
+ setIsSearchingItems(false)
+ }, 300)
+
+ return () => clearTimeout(timer)
+ }, [loadItems])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ form.setValue("materialCodes", [])
+ }
+
+ // 아이템 선택/해제 처리
+ 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("materialCodes", newSelectedItems.map(item => item.itemCode))
+ } else {
+ // 아이템 선택 추가
+ const newSelectedItems = [...selectedItems, item]
+ setSelectedItems(newSelectedItems)
+ form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode))
+ }
+ }
+
+ // 아이템 제거 처리
+ 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) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 자재코드(item_code) 배열을 materialGroupCodes로 전달
+ const result = await createTechSalesRfq({
+ biddingProjectId: data.biddingProjectId,
+ materialGroupCodes: data.materialCodes, // item_code를 자재코드로 사용
+ createdBy: Number(session.user.id),
+ dueDate: data.dueDate,
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
+ toast.success(`${result.data?.length || 0}개의 RFQ가 성공적으로 생성되었습니다`)
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ materialCodes: [],
+ dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setAvailableItems([])
+
+ // 생성 후 콜백 실행
+ 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,
+ materialCodes: [],
+ dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setAvailableItems([])
+ }
+ }}
+ >
+ <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-4xl w-[90vw] h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>RFQ 생성</DialogTitle>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} 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>
+ <FormDescription>
+ 벤더가 견적을 제출해야 하는 마감일입니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {!selectedProject ? (
+ <div className="text-sm text-muted-foreground italic text-center py-8">
+ 먼저 프로젝트를 선택해주세요
+ </div>
+ ) : (
+ <div className="space-y-6">
+ {/* 아이템 선택 영역 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>조선 아이템 선택</FormLabel>
+ <FormDescription>
+ 공종별 아이템을 선택하세요
+ </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"
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ {isSearchingItems && (
+ <Loader2 className="absolute right-8 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" className="gap-1">
+ {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">
+ {isLoadingItems ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
+ 아이템을 불러오는 중...
+ </div>
+ ) : availableItems.length > 0 ? (
+ availableItems.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.itemName}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode} • {item.description || '설명 없음'}
+ </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>
+
+ {/* 선택된 아이템 목록 */}
+ <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.itemName} ({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>
+ )}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 안내 메시지 */}
+ {selectedProject && (
+ <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
+ <p>• 공종별 조선 아이템을 선택하세요.</p>
+ <p>• 선택된 아이템의 자재코드(item_code)별로 개별 RFQ가 생성됩니다.</p>
+ <p>• 아이템 코드가 자재 그룹 코드로 사용됩니다.</p>
+ <p>• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.</p>
+ </div>
+ )}
+
+ <div className="flex justify-end space-x-2 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isProcessing || !selectedProject || selectedItems.length === 0}
+ >
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 자재코드로 생성하기`}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file