// 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") || "-"}

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