summaryrefslogtreecommitdiff
path: root/lib/rfq-last/table/create-general-rfq-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/table/create-general-rfq-dialog.tsx')
-rw-r--r--lib/rfq-last/table/create-general-rfq-dialog.tsx779
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