"use client" import * as React from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Card, CardHeader, CardTitle, CardDescription, CardContent, } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown, Download, Loader2 } from "lucide-react" import prettyBytes from "pretty-bytes" import { useToast } from "@/hooks/use-toast" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" // Form components import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription, } from "@/components/ui/form" // Custom Dropzone, FileList components import { Dropzone, DropzoneDescription, DropzoneInput, DropzoneTitle, DropzoneUploadIcon, DropzoneZone, } from "@/components/ui/dropzone" import { FileList, FileListAction, FileListDescription, FileListHeader, FileListIcon, FileListInfo, FileListItem, FileListName, } from "@/components/ui/file-list" // Dialog components import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog" // Additional UI import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" // Server actions import { uploadVendorFileAction, savePQAnswersAction, submitPQAction, ProjectPQ, } from "@/lib/pq/service" import { PQGroupData } from "@/lib/pq/service" import { formatDate } from "@/lib/utils" // ---------------------------------------------------------------------- // 1) Define client-side file shapes // ---------------------------------------------------------------------- interface LocalFileState { fileObj: File uploaded: boolean } // ---------------------------------------------------------------------- // 2) Zod schema for the entire form // ---------------------------------------------------------------------- const pqFormSchema = z.object({ answers: z.array( z.object({ criteriaId: z.number(), // Must have at least 1 char answer: z.string().min(1, "Answer is required"), // SHI 코멘트와 벤더 답변 필드 추가 shiComment: z.string().optional(), vendorReply: z.string().optional(), // Existing, uploaded files uploadedFiles: z .array( z.object({ fileName: z.string(), url: z.string(), size: z.number().optional(), }) ) .min(1, "At least one file attachment is required"), // Local (not-yet-uploaded) files newUploads: z.array( z.object({ fileObj: z.any(), uploaded: z.boolean().default(false), }) ), // track saved state saved: z.boolean().default(false), }) ), }) type PQFormValues = z.infer // 통화 단위 옵션 const currencyUnits = [ "USD", "EUR", "GBP", "JPY", "CNY", "KRW", "AUD", "CAD", "CHF", "HKD", "SGD", "THB", "PHP", "IDR", "MYR", "VND", "INR", "BRL", "MXN", "RUB" ] // ---------------------------------------------------------------------- // 3) Main Component: PQInputTabs // ---------------------------------------------------------------------- export function PQInputTabs({ data, vendorId, projectId, projectData, isReadOnly = false, currentPQ, // 추가: 현재 PQ Submission 정보 }: { data: PQGroupData[] vendorId: number projectId?: number projectData?: ProjectPQ | null isReadOnly?: boolean currentPQ?: { // PQ Submission 정보 id: number; status: string; type: string; } | null }) { const [isSaving, setIsSaving] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) const [allSaved, setAllSaved] = React.useState(false) const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) // 필터 상태 관리 const [filterOptions, setFilterOptions] = React.useState({ showAll: true, showSaved: true, showNotSaved: true, }) const { toast } = useToast() const shouldDisableInput = isReadOnly; // 코드 순서로 정렬하는 함수 (1-1-1, 1-1-2, 1-2-1 순서) const sortByCode = (items: any[]) => { return [...items].sort((a, b) => { const parseCode = (code: string) => { return code.split('-').map(part => parseInt(part, 10)) } const aCode = parseCode(a.code) const bCode = parseCode(b.code) for (let i = 0; i < Math.max(aCode.length, bCode.length); i++) { const aPart = aCode[i] || 0 const bPart = bCode[i] || 0 if (aPart !== bPart) { return aPart - bPart } } return 0 }) } // 필터링 함수 const shouldShowItem = (isSaved: boolean) => { if (filterOptions.showAll) return true; if (isSaved && filterOptions.showSaved) return true; if (!isSaved && filterOptions.showNotSaved) return true; return false; } // ---------------------------------------------------------------------- // A) Create initial form values // Mark items as "saved" if they have existing answer or attachments // ---------------------------------------------------------------------- function createInitialFormValues(): PQFormValues { const answers: PQFormValues["answers"] = [] data.forEach((group) => { // 그룹 내 아이템들을 코드 순서로 정렬 const sortedItems = sortByCode(group.items) sortedItems.forEach((item) => { // Check if the server item is already "complete" const hasExistingAnswer = item.answer && item.answer.trim().length > 0 const hasExistingAttachments = item.attachments && item.attachments.length > 0 // If either is present, we consider it "saved" initially const isAlreadySaved = hasExistingAnswer || hasExistingAttachments answers.push({ criteriaId: item.criteriaId, answer: item.answer || "", shiComment: item.shiComment || "", vendorReply: item.vendorReply || "", uploadedFiles: item.attachments.map((attach) => ({ fileName: attach.fileName, url: attach.filePath, size: attach.fileSize, })), newUploads: [], saved: isAlreadySaved, }) }) }) return { answers } } // ---------------------------------------------------------------------- // B) Set up react-hook-form // ---------------------------------------------------------------------- const form = useForm({ resolver: zodResolver(pqFormSchema), defaultValues: createInitialFormValues(), mode: "onChange", }) // ---------------------------------------------------------------------- // C) Track if all items are saved => controls Submit PQ button // ---------------------------------------------------------------------- React.useEffect(() => { const values = form.getValues() // We consider items "saved" if `saved===true` AND they have an answer or attachments const allItemsSaved = values.answers.every( (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0) ) setAllSaved(allItemsSaved) }, [form.watch()]) // Helper to find the array index by criteriaId const getAnswerIndex = (criteriaId: number): number => { return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId) } // ---------------------------------------------------------------------- // D) Handling File Drops, Removal // ---------------------------------------------------------------------- const handleDropAccepted = (criteriaId: number, files: File[]) => { const answerIndex = getAnswerIndex(criteriaId) if (answerIndex === -1) return // Convert each dropped file into a LocalFileState const newLocalFiles: LocalFileState[] = files.map((f) => ({ fileObj: f, uploaded: false, })) const current = form.getValues(`answers.${answerIndex}.newUploads`) form.setValue(`answers.${answerIndex}.newUploads`, [...current, ...newLocalFiles], { shouldDirty: true, }) // Mark unsaved form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) } const handleDropRejected = () => { toast({ title: "File upload rejected", description: "Please check file size and type.", variant: "destructive", }) } const removeNewUpload = (answerIndex: number, fileIndex: number) => { const current = [...form.getValues(`answers.${answerIndex}.newUploads`)] current.splice(fileIndex, 1) form.setValue(`answers.${answerIndex}.newUploads`, current, { shouldDirty: true }) form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) } const removeUploadedFile = (answerIndex: number, fileIndex: number) => { const current = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)] current.splice(fileIndex, 1) form.setValue(`answers.${answerIndex}.uploadedFiles`, current, { shouldDirty: true }) form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) } // ---------------------------------------------------------------------- // E) Saving a Single Item // ---------------------------------------------------------------------- const handleSaveItem = async (answerIndex: number) => { try { const answerData = form.getValues(`answers.${answerIndex}`) const criteriaId = answerData.criteriaId const item = data.flatMap(group => group.items).find(item => item.criteriaId === criteriaId) const inputFormat = item?.inputFormat || "TEXT" // Validation // 모든 항목은 필수로 처리 (isRequired 제거됨) { if (inputFormat === "FILE") { // 파일 업로드 항목의 경우 첨부 파일이 있어야 함 const hasFiles = answerData.uploadedFiles.length > 0 || answerData.newUploads.length > 0 if (!hasFiles) { toast({ title: "필수 항목", description: "필수 항목입니다. 파일을 업로드해주세요.", variant: "destructive", }) return } } else if (inputFormat === "TEXT_FILE") { // 텍스트+파일 항목의 경우 텍스트 답변만 있어야 함 (파일은 선택적) if (!answerData.answer) { toast({ title: "필수 항목", description: "필수 항목입니다. 텍스트 답변을 입력해주세요.", variant: "destructive", }) return } } else if (!answerData.answer) { // 일반 텍스트 입력 항목의 경우 답변이 있어야 함 toast({ title: "필수 항목", description: "필수 항목입니다. 답변을 입력해주세요.", variant: "destructive", }) return } } // 입력 형식별 유효성 검사 if (answerData.answer) { switch (inputFormat) { case "EMAIL": const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ if (!emailRegex.test(answerData.answer)) { toast({ title: "이메일 형식 오류", description: "올바른 이메일 형식을 입력해주세요. (예: example@company.com)", variant: "destructive", }) return } break case "PHONE": case "FAX": // 전화번호/팩스번호는 숫자와 하이픈 허용 const phoneRegex = /^[\d-]+$/ if (!phoneRegex.test(answerData.answer)) { toast({ title: `${inputFormat === "PHONE" ? "전화번호" : "팩스번호"} 형식 오류`, description: `숫자와 하이픈(-)만 입력해주세요.`, variant: "destructive", }) return } break case "NUMBER": const numberRegex = /^-?\d+(\.\d+)?$/ if (!numberRegex.test(answerData.answer)) { toast({ title: "숫자 형식 오류", description: "올바른 숫자 형식을 입력해주세요. (예: 123, -123, 123.45)", variant: "destructive", }) return } break case "NUMBER_WITH_UNIT": // 숫자+단위는 별도 검증 없음 (숫자와 단위가 분리되어 있음) break case "TEXT": case "TEXT_FILE": case "FILE": // 텍스트 입력과 파일 업로드는 추가 검증 없음 break default: // 알 수 없는 입력 형식 break } } // Upload new files (if any) if (answerData.newUploads.length > 0) { setIsSaving(true) for (const localFile of answerData.newUploads) { try { const uploadResult = await uploadVendorFileAction(localFile.fileObj) const currentUploaded = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)] currentUploaded.push({ fileName: uploadResult.fileName, url: uploadResult.url, size: uploadResult.size, }) form.setValue(`answers.${answerIndex}.uploadedFiles`, currentUploaded, { shouldDirty: true, }) } catch (error) { console.error("File upload error:", error) toast({ title: "Upload Error", description: "Failed to upload file", variant: "destructive", }) } } // Clear newUploads form.setValue(`answers.${answerIndex}.newUploads`, [], { shouldDirty: true }) } // Save to DB const updatedAnswer = form.getValues(`answers.${answerIndex}`) const saveResult = await savePQAnswersAction({ vendorId, projectId, // 프로젝트 ID 전달 answers: [ { criteriaId: updatedAnswer.criteriaId, answer: updatedAnswer.answer, shiComment: updatedAnswer.shiComment, vendorReply: updatedAnswer.vendorReply, attachments: updatedAnswer.uploadedFiles.map((f) => ({ fileName: f.fileName, url: f.url, size: f.size, })), }, ], }) if (saveResult.ok) { // Mark as saved form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false }) // Individual save toast removed - only show toast in handleSaveAll } } catch (error) { console.error("Save error:", error) toast({ title: "Save Error", description: "Failed to save item", variant: "destructive", }) } finally { setIsSaving(false) } } // For convenience const answers = form.getValues().answers const dirtyFields = form.formState.dirtyFields.answers // Check if any item is dirty or has new uploads const isAnyItemDirty = answers.some((answer, i) => { const itemDirty = !!dirtyFields?.[i] const hasNewUploads = answer.newUploads.length > 0 return itemDirty || hasNewUploads }) // ---------------------------------------------------------------------- // F) Save All Items // ---------------------------------------------------------------------- const handleSaveAll = async () => { try { setIsSaving(true) const answers = form.getValues().answers let savedCount = 0 // Only save items that are dirty or have new uploads for (let i = 0; i < answers.length; i++) { const itemDirty = !!dirtyFields?.[i] const hasNewUploads = answers[i].newUploads.length > 0 if (!itemDirty && !hasNewUploads) continue await handleSaveItem(i) savedCount++ } // 저장된 항목이 있을 때만 토스트 메시지 표시 if (savedCount > 0) { toast({ title: "임시 저장 완료", description: `항목이 저장되었습니다.`, }) } else { toast({ title: "저장할 항목 없음", description: "변경된 항목이 없습니다.", }) } } catch (error) { console.error("Save all error:", error) toast({ title: "저장 실패", description: "일괄 저장 중 오류가 발생했습니다.", variant: "destructive", }) } finally { setIsSaving(false) } } // ---------------------------------------------------------------------- // G) Submission with Confirmation Dialog // ---------------------------------------------------------------------- const handleSubmitPQ = () => { if (!allSaved) { toast({ title: "Cannot Submit", description: "Please save all items before submitting", variant: "destructive", }) return } setShowConfirmDialog(true) } const handleConfirmSubmission = async () => { try { setIsSubmitting(true); setShowConfirmDialog(false); // pqSubmissionId가 있으면 포함하여 전달 const result = await submitPQAction({ vendorId, projectId, pqSubmissionId: currentPQ?.id, // 현재 PQ Submission ID 사용 }); if (result.ok) { toast({ title: "PQ Submitted", description: "Your PQ information has been submitted successfully", }); // 제출 후 PQ 목록 페이지로 리디렉션 window.location.href = "/partners/pq_new"; } else { toast({ title: "Submit Error", description: result.error || "Failed to submit PQ", variant: "destructive", }); } } catch (error) { console.error("Submit error:", error); toast({ title: "Submit Error", description: "Failed to submit PQ information", variant: "destructive", }); } finally { setIsSubmitting(false); } }; // 프로젝트 정보 표시 섹션 const renderProjectInfo = () => { if (!projectData) return null; return (

프로젝트 정보

{getStatusLabel(projectData.status)}

프로젝트 코드

{projectData.projectCode}

프로젝트명

{projectData.projectName}

{projectData.submittedAt && (

제출일

{formatDate(projectData.submittedAt, "kr")}

)}
); }; // 상태 표시용 함수 const getStatusLabel = (status: string) => { switch (status) { case "REQUESTED": return "요청됨"; case "IN_PROGRESS": return "진행중"; case "SUBMITTED": return "제출됨"; case "APPROVED": return "승인됨"; case "REJECTED": return "반려됨"; default: return status; } }; const getStatusVariant = (status: string) => { switch (status) { case "REQUESTED": return "secondary"; case "IN_PROGRESS": return "default"; case "SUBMITTED": return "outline"; case "APPROVED": return "outline"; case "REJECTED": return "destructive"; default: return "secondary"; } }; // ---------------------------------------------------------------------- // H) Render // ---------------------------------------------------------------------- return (
{/* 프로젝트 정보 섹션 */} {renderProjectInfo()} {/* Top Controls - Sticky Header */}
{/* Filter Controls */}
필터:
{ const newOptions = { ...filterOptions, showAll: !!checked }; if (!checked && !filterOptions.showSaved && !filterOptions.showNotSaved) { // 최소 하나는 체크되어 있어야 함 newOptions.showSaved = true; } setFilterOptions(newOptions); }} />
{ const newOptions = { ...filterOptions, showSaved: !!checked }; if (!checked && !filterOptions.showAll && !filterOptions.showNotSaved) { // 최소 하나는 체크되어 있어야 함 newOptions.showAll = true; } setFilterOptions(newOptions); }} />
{ const newOptions = { ...filterOptions, showNotSaved: !!checked }; if (!checked && !filterOptions.showAll && !filterOptions.showSaved) { // 최소 하나는 체크되어 있어야 함 newOptions.showAll = true; } setFilterOptions(newOptions); }} />
{data.map((group) => (
{/* Mobile: truncated version */} {group.groupName.length > 5 ? group.groupName.slice(0, 5) + "..." : group.groupName} {/* Desktop: full text */} {group.groupName} {group.items.length}
))}
{/* Save All button */} {/* Submit PQ button */}
{/* Render each group */} {data.map((group) => ( {/* 2-column grid */}
{sortByCode(group.items).map((item) => { const { criteriaId, code, checkPoint, remarks, description, contractInfo, additionalRequirement } = item const answerIndex = getAnswerIndex(criteriaId) if (answerIndex === -1) return null const isSaved = form.watch(`answers.${answerIndex}.saved`) const newUploads = form.watch(`answers.${answerIndex}.newUploads`) const dirtyFieldsItem = form.formState.dirtyFields.answers?.[answerIndex] const isItemDirty = !!dirtyFieldsItem const hasNewUploads = newUploads.length > 0 const canSave = isItemDirty || hasNewUploads // 면제된 항목은 입력 비활성화 const isDisabled = shouldDisableInput // 필터링 적용 if (!shouldShowItem(isSaved)) return null return (
{code} - {checkPoint}
{description && ( {description} )} {item.remarks && (

Remark:

{item.remarks}

)}
{/* Save Status & Button */}
{!isSaved && canSave && ( Not Saved )} {isSaved && ( Saved )} {/* 개별 저장 버튼 주석처리 */}
{/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */} {projectId && contractInfo && (
계약 정보
{contractInfo}
)} {projectId && additionalRequirement && (
추가 요구사항
{additionalRequirement}
)} {/* Answer Field - 입력 형식에 따라 다르게 렌더링 */} {item.inputFormat !== "FILE" && ( ( {(() => { const inputFormat = item.inputFormat || "TEXT"; switch (inputFormat) { case "EMAIL": return "이메일 주소"; case "PHONE": return "전화번호"; case "FAX": return "팩스번호"; case "NUMBER": return "숫자 값"; case "NUMBER_WITH_UNIT": return "숫자+단위"; case "TEXT_FILE": return "텍스트 답변"; default: return "답변"; } })()} {(() => { const inputFormat = item.inputFormat || "TEXT"; switch (inputFormat) { case "EMAIL": return ( { field.onChange(e) form.setValue( `answers.${answerIndex}.saved`, false, { shouldDirty: true } ) }} /> ); case "PHONE": case "FAX": return ( { // 전화번호 형식만 허용 (숫자, -, +, 공백) const value = e.target.value; const filteredValue = value.replace(/[^\d\-\+\s]/g, ''); field.onChange(filteredValue); form.setValue( `answers.${answerIndex}.saved`, false, { shouldDirty: true } ) }} /> ); case "NUMBER": return ( { // 숫자만 허용 const value = e.target.value; if (value === '' || /^-?\d*\.?\d*$/.test(value)) { field.onChange(value) form.setValue( `answers.${answerIndex}.saved`, false, { shouldDirty: true } ) } }} /> ); case "NUMBER_WITH_UNIT": return (
{ const unit = field.value?.split(' ')[1] || '' const newValue = e.target.value + (unit ? ` ${unit}` : '') field.onChange(newValue) form.setValue( `answers.${answerIndex}.saved`, false, { shouldDirty: true } ) }} />
); case "TEXT_FILE": return (