"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 { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown, Download } 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" // 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 // ---------------------------------------------------------------------- // 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 { 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 }) } // ---------------------------------------------------------------------- // 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") { // 텍스트+파일 항목의 경우 텍스트 답변과 파일이 모두 있어야 함 const hasFiles = answerData.uploadedFiles.length > 0 || answerData.newUploads.length > 0 if (!answerData.answer || !hasFiles) { 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 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!emailRegex.test(answerData.answer)) { toast({ title: "이메일 형식 오류", description: "올바른 이메일 형식을 입력해주세요. (예: example@company.com)", variant: "destructive", }) return } break case "PHONE": const phoneRegex = /^[\d-]+$/ if (!phoneRegex.test(answerData.answer)) { toast({ title: "전화번호 형식 오류", description: "올바른 전화번호 형식을 입력해주세요. (예: 02-1234-5678)", variant: "destructive", }) return } break case "NUMBER": const numberRegex = /^-?\d*\.?\d*$/ if (!numberRegex.test(answerData.answer)) { toast({ title: "숫자 형식 오류", description: "숫자만 입력해주세요. (소수점, 음수 허용)", variant: "destructive", }) return } 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 }) toast({ title: "Saved", description: "Item saved successfully", }) } } 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 // 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) } toast({ title: "All Saved", description: "All items saved successfully", }) } catch (error) { console.error("Save all error:", error) toast({ title: "Save Error", description: "Failed to save all items", 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 */}
{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, 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 return (
{code} - {checkPoint}
{description && ( {description} )}
{/* 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 "NUMBER": 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": return ( { field.onChange(e) 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 "TEXT_FILE": return (