From 748bb1720fd81e97a84c3e92f89d606e976b52e3 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 00:18:16 +0000 Subject: (대표님) 컴포넌트 파트 커밋 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/pq-input/pq-input-tabs.tsx | 895 ++++++++++++++++++++++++++++++++++ 1 file changed, 895 insertions(+) create mode 100644 components/pq-input/pq-input-tabs.tsx (limited to 'components/pq-input/pq-input-tabs.tsx') diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx new file mode 100644 index 00000000..2574a5b0 --- /dev/null +++ b/components/pq-input/pq-input-tabs.tsx @@ -0,0 +1,895 @@ +"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 { Textarea } from "@/components/ui/textarea" +import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown } 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 { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" + +// Server actions +import { + uploadFileAction, + savePQAnswersAction, + submitPQAction, + ProjectPQ, +} from "@/lib/pq/service" +import { PQGroupData } from "@/lib/pq/service" + +// ---------------------------------------------------------------------- +// 1) Define client-side file shapes +// ---------------------------------------------------------------------- +interface UploadedFileState { + fileName: string + url: string + size?: number +} + +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"), + + // 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; + + // ---------------------------------------------------------------------- + // 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) => { + group.items.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 || "", + 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}`) + + // Validation + if (!answerData.answer) { + toast({ + title: "Validation Error", + description: "Answer is required", + variant: "destructive", + }) + return + } + + // Upload new files (if any) + if (answerData.newUploads.length > 0) { + setIsSaving(true) + + for (const localFile of answerData.newUploads) { + try { + const uploadResult = await uploadFileAction(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, + 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"; + } 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)}

+
+ )} +
+
+ ); + }; + + // 상태 표시용 함수 + 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"; + } + }; + + // 날짜 형식화 함수 + const formatDate = (date: Date) => { + if (!date) return "-"; + return new Date(date).toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + // ---------------------------------------------------------------------- + // 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 */} +
+ {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 hasAnswer = form.watch(`answers.${answerIndex}.answer`) + 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 + + // For "Not Saved" vs. "Saved" status label + const hasUploads = + form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 || + newUploads.length > 0 + const isValid = !!hasAnswer || hasUploads + + return ( + + + +
+
+
+ + + + + {code} - {checkPoint} + +
+ {description && ( + + {description} + + )} +
+ + {/* Save Status & Button */} +
+ {!isSaved && canSave && ( + + + Not Saved + + )} + {isSaved && ( + + + Saved + + )} + + +
+
+
+ + + + {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */} + {projectId && contractInfo && ( +
+ 계약 정보 +
+ {contractInfo} +
+
+ )} + + {projectId && additionalRequirement && ( +
+ 추가 요구사항 +
+ {additionalRequirement} +
+
+ )} + + {/* Answer Field */} + ( + + Answer + +