diff options
Diffstat (limited to 'lib/rfq-last/table/create-general-rfq-dialog.tsx')
| -rw-r--r-- | lib/rfq-last/table/create-general-rfq-dialog.tsx | 779 |
1 files changed, 779 insertions, 0 deletions
diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx new file mode 100644 index 00000000..14564686 --- /dev/null +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -0,0 +1,779 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { format } from "date-fns" +import { CalendarIcon, Plus, Loader2, Trash2, PlusCircle, Check, ChevronsUpDown } from "lucide-react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Calendar } from "@/components/ui/calendar" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } from "../service" + +// 아이템 스키마 +const itemSchema = z.object({ + itemCode: z.string().min(1, "자재코드를 입력해주세요"), + itemName: z.string().min(1, "자재명을 입력해주세요"), + quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), + uom: z.string().min(1, "단위를 입력해주세요"), + remark: z.string().optional(), +}) + +// 일반견적 생성 폼 스키마 +const createGeneralRfqSchema = z.object({ + rfqType: z.string().min(1, "견적 종류를 선택해주세요"), + customRfqType: z.string().optional(), + rfqTitle: z.string().min(1, "견적명을 입력해주세요"), + dueDate: z.date({ + required_error: "마감일을 선택해주세요", + }), + picUserId: z.number().min(1, "구매 담당자를 선택해주세요"), + remark: z.string().optional(), + items: z.array(itemSchema).min(1, "최소 하나의 아이템을 추가해주세요"), +}).refine((data) => { + if (data.rfqType === "기타") { + return data.customRfqType && data.customRfqType.trim().length > 0 + } + return true +}, { + message: "견적 종류를 직접 입력해주세요", + path: ["customRfqType"], +}) + +type CreateGeneralRfqFormValues = z.infer<typeof createGeneralRfqSchema> + +interface CreateGeneralRfqDialogProps { + onSuccess?: () => void; +} + +export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [users, setUsers] = React.useState<Array<{ id: number; name: string }>>([]) + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false) + const [userPopoverOpen, setUserPopoverOpen] = React.useState(false) + const [userSearchTerm, setUserSearchTerm] = React.useState("") + const [previewCode, setPreviewCode] = React.useState("") + const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) + const userOptionIdsRef = React.useRef<Record<number, string>>({}) + const router = useRouter() + const { data: session } = useSession() + + // 고유 ID 생성 + const buttonId = React.useMemo(() => `user-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + const popoverContentId = React.useMemo(() => `user-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + const commandId = React.useMemo(() => `user-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null; + }, [session]); + + const form = useForm<CreateGeneralRfqFormValues>({ + resolver: zodResolver(createGeneralRfqSchema), + defaultValues: { + rfqType: "", + customRfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: userId || undefined, + remark: "", + items: [ + { + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }) + + // 견적 종류 변경 시 customRfqType 필드 초기화 + const handleRfqTypeChange = (value: string) => { + form.setValue("rfqType", value) + if (value !== "기타") { + form.setValue("customRfqType", "") + } + } + + // RFQ 코드 미리보기 생성 + const generatePreviewCode = React.useCallback(async () => { + const picUserId = form.watch("picUserId") + + if (!picUserId) { + setPreviewCode("") + return + } + + setIsLoadingPreview(true) + try { + const code = await previewGeneralRfqCode(picUserId) + setPreviewCode(code) + } catch (error) { + console.error("코드 미리보기 오류:", error) + setPreviewCode("") + } finally { + setIsLoadingPreview(false) + } + }, [form]) + + // 필드 변경 감지해서 미리보기 업데이트 + React.useEffect(() => { + const subscription = form.watch((value, { name }) => { + if (name === "picUserId") { + generatePreviewCode() + } + }) + return () => subscription.unsubscribe() + }, [form, generatePreviewCode]) + + // 사용자 목록 로드 + React.useEffect(() => { + const loadUsers = async () => { + setIsLoadingUsers(true) + try { + const userList = await getPUsersForFilter() + setUsers(userList) + } catch (error) { + console.log("사용자 목록 로드 오류:", error) + toast.error("사용자 목록을 불러오는데 실패했습니다") + } finally { + setIsLoadingUsers(false) + } + } + loadUsers() + }, []) + + // 세션 사용자 ID로 기본값 설정 + React.useEffect(() => { + if (userId && !form.getValues("picUserId")) { + form.setValue("picUserId", userId) + } + }, [userId, form]) + + // 사용자 검색 필터링 + const userOptions = React.useMemo(() => { + return users.filter((user) => + user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) + ) + }, [users, userSearchTerm]) + + // 선택된 사용자 찾기 + const selectedUser = React.useMemo(() => { + const picUserId = form.watch("picUserId") + return users.find(user => user.id === picUserId) + }, [users, form.watch("picUserId")]) + + // 사용자 선택 핸들러 + const handleSelectUser = (user: { id: number; name: string }) => { + form.setValue("picUserId", user.id) + setUserPopoverOpen(false) + setUserSearchTerm("") + } + + // 다이얼로그 열림/닫힘 처리 및 폼 리셋 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + + // 다이얼로그가 닫힐 때 폼과 상태 초기화 + if (!newOpen) { + form.reset({ + rfqType: "", + customRfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: userId || undefined, + remark: "", + items: [ + { + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }) + setUserSearchTerm("") + setUserPopoverOpen(false) + setPreviewCode("") + setIsLoadingPreview(false) + } + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + const onSubmit = async (data: CreateGeneralRfqFormValues) => { + if (!userId) { + toast.error("로그인이 필요합니다") + return + } + + setIsLoading(true) + + try { + // 견적 종류가 "기타"인 경우 customRfqType 사용 + const finalRfqType = data.rfqType === "기타" ? data.customRfqType || "기타" : data.rfqType + + // 서버 액션 호출 + const result = await createGeneralRfqAction({ + rfqType: finalRfqType, + rfqTitle: data.rfqTitle, + dueDate: data.dueDate, + picUserId: data.picUserId, + remark: data.remark || "", + items: data.items, + createdBy: userId, + updatedBy: userId, + }) + + if (result.success) { + toast.success(result.message, { + description: `RFQ 코드: ${result.data?.rfqCode}`, + }) + + // 다이얼로그 닫기 + setOpen(false) + + // 성공 콜백 실행 + if (onSuccess) { + onSuccess() + } + + // RFQ 상세 페이지로 이동 (선택사항) + // router.push(`/rfq/${result.data?.rfqCode}`) + + } else { + toast.error(result.error || "일반견적 생성에 실패했습니다") + } + + } catch (error) { + console.error('일반견적 생성 오류:', error) + toast.error("일반견적 생성에 실패했습니다", { + description: "알 수 없는 오류가 발생했습니다", + }) + } finally { + setIsLoading(false) + } + } + + // 아이템 추가 + const handleAddItem = () => { + append({ + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }) + } + + const isCustomRfqType = form.watch("rfqType") === "기타" + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="samsung" size="sm" className="h-8 px-2 lg:px-3"> + <Plus className="mr-2 h-4 w-4" /> + 일반견적 생성 + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl h-[90vh] flex flex-col"> + {/* 고정된 헤더 */} + <DialogHeader className="flex-shrink-0"> + <DialogTitle>일반견적 생성</DialogTitle> + <DialogDescription> + 새로운 일반견적을 생성합니다. 필수 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + + {/* 스크롤 가능한 컨텐츠 영역 */} + <ScrollArea className="flex-1 px-1"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2"> + + {/* 기본 정보 섹션 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">기본 정보</h3> + + <div className="grid grid-cols-2 gap-4"> + {/* 견적 종류 */} + <div className="space-y-2"> + <FormField + control={form.control} + name="rfqType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적 종류 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={handleRfqTypeChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="견적 종류 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="정기견적">정기견적</SelectItem> + <SelectItem value="긴급견적">긴급견적</SelectItem> + <SelectItem value="단가계약">단가계약</SelectItem> + <SelectItem value="기술견적">기술견적</SelectItem> + <SelectItem value="예산견적">예산견적</SelectItem> + <SelectItem value="기타">기타</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 기타 견적 종류 입력 필드 */} + {isCustomRfqType && ( + <FormField + control={form.control} + name="customRfqType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적 종류 직접 입력 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="견적 종류를 입력해주세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 마감일 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel> + 마감일 <span className="text-red-500">*</span> + </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, "yyyy-MM-dd") + ) : ( + <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> + )} + /> + </div> + + {/* 견적명 */} + <FormField + control={form.control} + name="rfqTitle" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="예: 2025년 1분기 사무용품 구매 견적" + {...field} + /> + </FormControl> + <FormDescription> + 견적의 목적이나 내용을 간단명료하게 입력해주세요 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구매 담당자 - 검색 가능한 셀렉터로 변경 */} + <FormField + control={form.control} + name="picUserId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel> + 구매 담당자 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between relative h-9" + disabled={isLoadingUsers} + > + {isLoadingUsers ? ( + <> + <span>담당자 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {selectedUser ? selectedUser.name : "구매 담당자를 선택하세요"} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-[300px] p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder="담당자 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList + key={`${commandId}-list`} + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + <CommandEmpty key={`${commandId}-empty`}>담당자를 찾을 수 없습니다.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {userOptions.map((user, userIndex) => { + if (!userOptionIdsRef.current[user.id]) { + userOptionIdsRef.current[user.id] = + `user-${user.id}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = userOptionIdsRef.current[user.id] + + return ( + <CommandItem + key={`${optionId}-${userIndex}`} + onSelect={() => handleSelectUser(user)} + value={user.name} + className="truncate" + title={user.name} + > + <span className="truncate">{user.name}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + {/* RFQ 코드 미리보기 */} + {previewCode && ( + <div className="flex items-center gap-2 mt-2"> + <Badge variant="secondary" className="font-mono text-sm"> + 예상 RFQ 코드: {previewCode} + </Badge> + {isLoadingPreview && ( + <Loader2 className="h-3 w-3 animate-spin" /> + )} + </div> + )} + <FormMessage /> + </FormItem> + )} + /> + + {/* 비고 */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + placeholder="추가 비고사항을 입력하세요" + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <Separator /> + + {/* 아이템 정보 섹션 - 컴팩트한 UI */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">아이템 정보</h3> + <Button + type="button" + variant="outline" + size="sm" + onClick={handleAddItem} + > + <PlusCircle className="mr-2 h-4 w-4" /> + 아이템 추가 + </Button> + </div> + + <div className="space-y-3"> + {fields.map((field, index) => ( + <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50"> + <div className="flex items-center justify-between mb-3"> + <span className="text-sm font-medium text-gray-700"> + 아이템 #{index + 1} + </span> + {fields.length > 1 && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => remove(index)} + className="h-6 w-6 p-0 text-destructive hover:text-destructive" + > + <Trash2 className="h-3 w-3" /> + </Button> + )} + </div> + + <div className="grid grid-cols-4 gap-3"> + {/* 자재코드 */} + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 자재코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="MAT-001" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재명 */} + <FormField + control={form.control} + name={`items.${index}.itemName`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 자재명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="A4 용지" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 수량 */} + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 수량 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="number" + min="1" + placeholder="1" + className="h-8 text-sm" + {...field} + onChange={(e) => field.onChange(Number(e.target.value))} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 단위 */} + <FormField + control={form.control} + name={`items.${index}.uom`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 단위 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="EA" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 비고 - 별도 행에 배치 */} + <div className="mt-3"> + <FormField + control={form.control} + name={`items.${index}.remark`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs">비고</FormLabel> + <FormControl> + <Input + placeholder="아이템별 비고사항" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + ))} + </div> + </div> + </form> + </Form> + </ScrollArea> + + {/* 고정된 푸터 */} + <DialogFooter className="flex-shrink-0"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "일반견적 생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
