From cf8dac0c6490469dab88a560004b0c07dbd48612 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 18 Sep 2025 00:23:40 +0000 Subject: (대표님) rfq, 계약, 서명 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/itb/table/create-purchase-request-dialog.tsx | 995 +++++++++++++++++++++++ 1 file changed, 995 insertions(+) create mode 100644 lib/itb/table/create-purchase-request-dialog.tsx (limited to 'lib/itb/table/create-purchase-request-dialog.tsx') diff --git a/lib/itb/table/create-purchase-request-dialog.tsx b/lib/itb/table/create-purchase-request-dialog.tsx new file mode 100644 index 00000000..27b8a342 --- /dev/null +++ b/lib/itb/table/create-purchase-request-dialog.tsx @@ -0,0 +1,995 @@ +// components/purchase-requests/create-purchase-request-dialog.tsx +"use client"; + +import * as React from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ProjectSelector } from "@/components/ProjectSelector"; +import { PackageSelector } from "@/components/PackageSelector"; +import type { PackageItem } from "@/lib/items/service"; +import { MaterialGroupSelector } from "@/components/common/material/material-group-selector"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { Progress } from "@/components/ui/progress"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { CalendarIcon, X, Plus, FileText, Package, Trash2, Upload, FileIcon, AlertCircle, Paperclip } from "lucide-react"; +import { format } from "date-fns"; +import { cn } from "@/lib/utils"; +import { createPurchaseRequest } from "../service"; +import { toast } from "sonner"; +import { nanoid } from "nanoid"; + +const attachmentSchema = z.object({ + fileName: z.string(), + originalFileName: z.string(), + filePath: z.string(), + fileSize: z.number(), + fileType: z.string(), +}); + +const itemSchema = z.object({ + id: z.string(), + itemCode: z.string(), + itemName: z.string().min(1, "아이템명을 입력해주세요"), + specification: z.string().default(""), + quantity: z.coerce.number().min(1, "수량은 1 이상이어야 합니다"), + unit: z.string().min(1, "단위를 입력해주세요"), + estimatedUnitPrice: z.coerce.number().optional(), + remarks: z.string().optional(), +}); + +const formSchema = z.object({ + requestTitle: z.string().min(1, "요청 제목을 입력해주세요"), + requestDescription: z.string().optional(), + projectId: z.number({ + required_error: "프로젝트를 선택해주세요", + }), + projectCode: z.string().min(1), + projectName: z.string().min(1), + projectCompany: z.string().optional(), + projectSite: z.string().optional(), + classNo: z.string().optional(), + packageNo: z.string({ + required_error: "패키지를 선택해주세요", + }).min(1, "패키지를 선택해주세요"), + packageName: z.string().min(1), + majorItemMaterialCategory: z.string({ + required_error: "자재그룹을 선택해주세요", + }).min(1, "자재그룹을 선택해주세요"), + majorItemMaterialDescription: z.string().min(1), + smCode: z.string({ + required_error: "SM 코드가 필요합니다", + }).min(1, "SM 코드가 필요합니다"), + estimatedBudget: z.string().optional(), + requestedDeliveryDate: z.date().optional(), + items: z.array(itemSchema).optional().default([]), + attachments: z.array(attachmentSchema).min(1, "최소 1개 이상의 파일을 첨부해주세요"), +}); + +type FormData = z.infer; + +interface CreatePurchaseRequestDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function CreatePurchaseRequestDialog({ + open, + onOpenChange, + onSuccess, +}: CreatePurchaseRequestDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [activeTab, setActiveTab] = React.useState("basic"); + const [selectedPackage, setSelectedPackage] = React.useState(null); + const [selectedMaterials, setSelectedMaterials] = React.useState([]); + const [resetKey, setResetKey] = React.useState(0); + const [uploadedFiles, setUploadedFiles] = React.useState([]); + const [isUploading, setIsUploading] = React.useState(false); + const [uploadProgress, setUploadProgress] = React.useState(0); + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onChange", + criteriaMode: "all", + defaultValues: { + requestTitle: "", + requestDescription: "", + projectId: undefined, + projectCode: "", + projectName: "", + projectCompany: "", + projectSite: "", + classNo: "", + packageNo: "", + packageName: "", + majorItemMaterialCategory: "", + majorItemMaterialDescription: "", + smCode: "", + estimatedBudget: "", + requestedDeliveryDate: undefined, + items: [], + attachments: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + // 파일 업로드 핸들러 + const handleFileUpload = async (files: File[]) => { + if (files.length === 0) return; + + setIsUploading(true); + setUploadProgress(0); + + try { + const formData = new FormData(); + files.forEach(file => { + formData.append("files", file); + }); + + // 프로그레스 시뮬레이션 (실제로는 XMLHttpRequest 또는 fetch with progress를 사용) + const progressInterval = setInterval(() => { + setUploadProgress(prev => Math.min(prev + 10, 90)); + }, 200); + + const response = await fetch("/api/upload/purchase-request", { + method: "POST", + body: formData, + }); + + clearInterval(progressInterval); + setUploadProgress(100); + + if (!response.ok) { + throw new Error("파일 업로드 실패"); + } + + const data = await response.json(); + + // 업로드된 파일 정보를 상태에 추가 + const newFiles = [...uploadedFiles, ...data.files]; + setUploadedFiles(newFiles); + + // form의 attachments 필드 업데이트 + form.setValue("attachments", newFiles); + + toast.success(`${files.length}개 파일이 업로드되었습니다`); + } catch (error) { + console.error("Upload error:", error); + toast.error("파일 업로드 중 오류가 발생했습니다"); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + // 파일 삭제 핸들러 + const handleFileRemove = (fileName: string) => { + const updatedFiles = uploadedFiles.filter(f => f.fileName !== fileName); + setUploadedFiles(updatedFiles); + form.setValue("attachments", updatedFiles); + }; + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: any) => { + form.setValue("projectId", project.id); + form.setValue("projectCode", project.projectCode); + form.setValue("projectName", project.projectName); + form.setValue("projectCompany", project.projectCompany); + form.setValue("projectSite", project.projectSite); + + // 프로젝트 변경시 패키지 초기화 + setSelectedPackage(null); + form.setValue("packageNo", ""); + form.setValue("packageName", ""); + form.setValue("smCode", ""); + + // requestTitle 업데이트 + updateRequestTitle(project.projectName, "", ""); + }; + + // 패키지 선택 처리 + const handlePackageSelect = (packageItem: PackageItem) => { + setSelectedPackage(packageItem); + form.setValue("packageNo", packageItem.packageCode); + form.setValue("packageName", packageItem.description); + + // SM 코드가 패키지에 있으면 자동 설정 + if (packageItem.smCode) { + form.setValue("smCode", packageItem.smCode); + } + + // requestTitle 업데이트 + const projectName = form.getValues("projectName"); + const materialDesc = form.getValues("majorItemMaterialDescription"); + updateRequestTitle(projectName, packageItem.description, materialDesc); + }; + + // 자재그룹 선택 처리 + const handleMaterialsChange = (materials: any[]) => { + setSelectedMaterials(materials); + if (materials.length > 0) { + const material = materials[0]; // single select이므로 첫번째 항목만 + form.setValue("majorItemMaterialCategory", material.materialGroupCode); + form.setValue("majorItemMaterialDescription", material.materialGroupDescription); + + // requestTitle 업데이트 + const projectName = form.getValues("projectName"); + const packageName = form.getValues("packageName"); + updateRequestTitle(projectName, packageName, material.materialGroupDescription); + } else { + form.setValue("majorItemMaterialCategory", ""); + form.setValue("majorItemMaterialDescription", ""); + } + }; + + // requestTitle 자동 생성 + const updateRequestTitle = (projectName: string, packageName: string, materialDesc: string) => { + const parts = []; + if (projectName) parts.push(projectName); + if (packageName) parts.push(packageName); + if (materialDesc) parts.push(materialDesc); + + if (parts.length > 0) { + const title = `${parts.join(" - ")} 구매 요청`; + form.setValue("requestTitle", title); + } + }; + + const addItem = () => { + append({ + id: nanoid(), + itemCode: "", + itemName: "", + specification: "", + quantity: 1, + unit: "개", + estimatedUnitPrice: undefined, + remarks: "", + }); + }; + + const calculateTotal = () => { + const items = form.watch("items"); + return items?.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0) || 0; + }; + + const onSubmit = async (values: FormData) => { + try { + setIsLoading(true); + + const result = await createPurchaseRequest({ + ...values, + items: values.items, + attachments: uploadedFiles, // 첨부파일 포함 + }); + + if (result.error) { + toast.error(result.error); + return; + } + + toast.success("구매 요청이 등록되었습니다"); + handleClose(); + onSuccess?.(); + } catch (error) { + toast.error("구매 요청 등록에 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + form.reset({ + requestTitle: "", + requestDescription: "", + projectId: undefined, + projectCode: "", + projectName: "", + projectCompany: "", + projectSite: "", + classNo: "", + packageNo: "", + packageName: "", + majorItemMaterialCategory: "", + majorItemMaterialDescription: "", + smCode: "", + estimatedBudget: "", + requestedDeliveryDate: undefined, + items: [], + attachments: [], + }); + setSelectedPackage(null); + setSelectedMaterials([]); + setUploadedFiles([]); + setActiveTab("basic"); + setResetKey((k) => k + 1); + onOpenChange(false); + }; + + return ( + + + + 새 구매 요청 + + 구매가 필요한 품목과 관련 정보를 입력해주세요 + + + +
+ + + + + + 기본 정보 + + + + 품목 정보 + {fields.length > 0 && ( + + {fields.length} + + )} + + + + 첨부파일 + {uploadedFiles.length > 0 && ( + + {uploadedFiles.length} + + )} + + + +
+ + {/* 프로젝트 선택 */} + + + 프로젝트 정보 * + 프로젝트, 패키지, 자재그룹을 순서대로 선택하세요 + + + {/* 프로젝트 선택 */} + ( + + 프로젝트 * + + + + )} + /> + + {/* 패키지 선택 */} + ( + + 패키지 * + + + + )} + /> + + {/* 자재그룹 선택 */} + ( + + 자재그룹 * + + + + )} + /> + + {/* SM 코드 */} + ( + + SM 코드 * + + + + + + )} + /> + + {/* 선택된 정보 표시 */} + {form.watch("projectId") && ( +
+
+

프로젝트 코드

+

{form.watch("projectCode") || "-"}

+
+
+

프로젝트명

+

{form.watch("projectName") || "-"}

+
+
+

발주처

+

{form.watch("projectCompany") || "-"}

+
+
+

현장

+

{form.watch("projectSite") || "-"}

+
+
+ )} +
+
+ + {/* 기본 정보 */} +
+ ( + + 요청 제목 * + + + + + 자동 생성된 제목을 수정할 수 있습니다 + + + + )} + /> + + ( + + 요청 설명 + +