diff options
Diffstat (limited to 'components/pq-input/pq-input-tabs.tsx')
| -rw-r--r-- | components/pq-input/pq-input-tabs.tsx | 524 |
1 files changed, 400 insertions, 124 deletions
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx index 2574a5b0..1bc2fc38 100644 --- a/components/pq-input/pq-input-tabs.tsx +++ b/components/pq-input/pq-input-tabs.tsx @@ -13,8 +13,9 @@ import { 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 } from "lucide-react" +import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown, Download } from "lucide-react" import prettyBytes from "pretty-bytes" import { useToast } from "@/hooks/use-toast" import { @@ -65,12 +66,12 @@ import { } from "@/components/ui/dialog" // Additional UI -import { Separator } from "@/components/ui/separator" + import { Badge } from "@/components/ui/badge" // Server actions import { - uploadFileAction, + uploadVendorFileAction, savePQAnswersAction, submitPQAction, ProjectPQ, @@ -80,12 +81,6 @@ 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 @@ -101,6 +96,10 @@ const pqFormSchema = z.object({ // 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( @@ -179,6 +178,8 @@ export function PQInputTabs({ 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, @@ -271,15 +272,90 @@ export function PQInputTabs({ 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 - if (!answerData.answer) { - toast({ - title: "Validation Error", - description: "Answer is required", - variant: "destructive", - }) - return + // 모든 항목은 필수로 처리 (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) @@ -288,7 +364,7 @@ export function PQInputTabs({ for (const localFile of answerData.newUploads) { try { - const uploadResult = await uploadFileAction(localFile.fileObj) + const uploadResult = await uploadVendorFileAction(localFile.fileObj) const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`) currentUploaded.push({ fileName: uploadResult.fileName, @@ -321,6 +397,8 @@ export function PQInputTabs({ { criteriaId: updatedAnswer.criteriaId, answer: updatedAnswer.answer, + shiComment: updatedAnswer.shiComment, + vendorReply: updatedAnswer.vendorReply, attachments: updatedAnswer.uploadedFiles.map((f) => ({ fileName: f.fileName, url: f.url, @@ -583,19 +661,13 @@ export function PQInputTabs({ 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 ( <Collapsible key={criteriaId} defaultOpen={!isSaved} className="w-full"> @@ -612,6 +684,7 @@ export function PQInputTabs({ </CollapsibleTrigger> <CardTitle className="text-md"> {code} - {checkPoint} + </CardTitle> </div> {description && ( @@ -669,44 +742,159 @@ export function PQInputTabs({ </div> )} - {/* Answer Field */} - <FormField - control={form.control} - name={`answers.${answerIndex}.answer`} - render={({ field }) => ( - <FormItem className="mt-2"> - <FormLabel>Answer</FormLabel> - <FormControl> - <Textarea - {...field} - disabled={shouldDisableInput} - className="min-h-24" - placeholder="Enter your answer here" - onChange={(e) => { - field.onChange(e) - form.setValue( - `answers.${answerIndex}.saved`, - false, - { shouldDirty: true } - ) - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* Answer Field - 입력 형식에 따라 다르게 렌더링 */} + {item.inputFormat !== "FILE" && ( + <FormField + control={form.control} + name={`answers.${answerIndex}.answer`} + render={({ field }) => ( + <FormItem className="mt-2"> + <FormLabel> + {(() => { + const inputFormat = item.inputFormat || "TEXT"; + switch (inputFormat) { + case "EMAIL": + return "이메일 주소"; + case "PHONE": + return "전화번호"; + case "NUMBER": + return "숫자 값"; + case "TEXT_FILE": + return "텍스트 답변"; + default: + return "답변"; + } + })()} + </FormLabel> + <FormControl> + {(() => { + const inputFormat = item.inputFormat || "TEXT"; + + switch (inputFormat) { + case "EMAIL": + return ( + <Input + {...field} + type="email" + disabled={shouldDisableInput} + placeholder="example@company.com" + onChange={(e) => { + field.onChange(e) + form.setValue( + `answers.${answerIndex}.saved`, + false, + { shouldDirty: true } + ) + }} + /> + ); + case "PHONE": + return ( + <Input + {...field} + type="tel" + disabled={shouldDisableInput} + placeholder="02-1234-5678" + onChange={(e) => { + field.onChange(e) + form.setValue( + `answers.${answerIndex}.saved`, + false, + { shouldDirty: true } + ) + }} + /> + ); + case "NUMBER": + return ( + <Input + {...field} + type="text" + disabled={shouldDisableInput} + placeholder="숫자를 입력하세요" + onChange={(e) => { + // 숫자만 허용 + 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 ( + <div className="space-y-2"> + <Textarea + {...field} + disabled={shouldDisableInput} + className="min-h-24" + placeholder="텍스트 답변을 입력하세요" + onChange={(e) => { + field.onChange(e) + form.setValue( + `answers.${answerIndex}.saved`, + false, + { shouldDirty: true } + ) + }} + /> + <div className="text-sm text-muted-foreground"> + "파일 업로드는 첨부 파일 섹션에서 진행해주세요." + </div> + </div> + ); + default: // TEXT + return ( + <Textarea + {...field} + disabled={shouldDisableInput} + className="min-h-24" + placeholder="답변을 입력해주세요." + onChange={(e) => { + field.onChange(e) + form.setValue( + `answers.${answerIndex}.saved`, + false, + { shouldDirty: true } + ) + }} + /> + ); + } + })()} + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + {/* FILE 형식일 때 안내 메시지 */} + {item.inputFormat === "FILE" && ( + <div className="mt-2"> + <FormLabel>파일 업로드</FormLabel> + <div className="text-sm text-muted-foreground"> + "파일을 업로드해주세요." + </div> + </div> + )} - {/* Attachments / Dropzone */} - <div className="grid gap-2 mt-3"> - <FormLabel>Attachments</FormLabel> + {/* Attachments / Dropzone - FILE 또는 TEXT_FILE 형식에서만 활성화 */} + {(item.inputFormat === "FILE" || item.inputFormat === "TEXT_FILE") && ( + <div className="grid gap-2 mt-3"> + <FormLabel>첨부 파일</FormLabel> <Dropzone maxSize={6e8} // 600MB onDropAccepted={(files) => handleDropAccepted(criteriaId, files) } onDropRejected={handleDropRejected} + disabled={shouldDisableInput} > {() => ( <FormItem> @@ -717,15 +905,15 @@ export function PQInputTabs({ <div className="flex items-center gap-6"> <DropzoneUploadIcon /> <div className="grid gap-0.5"> - <DropzoneTitle>Drop files here</DropzoneTitle> + <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> <DropzoneDescription> - Max size: 600MB + PDF, Word, Excel, 이미지 파일 (최대 600MB) </DropzoneDescription> </div> </div> </DropzoneZone> <FormDescription> - Or click to browse files + 또는 클릭하여 파일 선택 </FormDescription> <FormMessage /> </FormItem> @@ -733,75 +921,163 @@ export function PQInputTabs({ </Dropzone> </div> - {/* Existing + Pending Files */} - <div className="mt-4 space-y-4"> - {/* 1) Not-yet-uploaded files */} - {newUploads.length > 0 && ( - <div className="grid gap-2"> - <h6 className="text-sm font-medium"> - Pending Files ({newUploads.length}) - </h6> - <FileList> - {newUploads.map((f, fileIndex) => { - const fileObj = f.fileObj - if (!fileObj) return null - - return ( - <FileListItem key={fileIndex}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileObj.name}</FileListName> - <FileListDescription> - {prettyBytes(fileObj.size)} - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={() => - removeNewUpload(answerIndex, fileIndex) + )} + + {/* Existing + Pending Files - FILE 또는 TEXT_FILE 형식에서만 활성화 */} + {(item.inputFormat === "FILE" || item.inputFormat === "TEXT_FILE") && ( + <div className="mt-4 space-y-4"> + {/* 1) Not-yet-uploaded files */} + {newUploads.length > 0 && ( + <div className="grid gap-2"> + <h6 className="text-sm font-medium"> + 업로드 대기 중인 파일 ({newUploads.length}) + </h6> + <FileList> + {newUploads.map((f, fileIndex) => { + const fileObj = f.fileObj + if (!fileObj) return null + + return ( + <FileListItem key={fileIndex}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileObj.name}</FileListName> + <FileListDescription> + {prettyBytes(fileObj.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => + removeNewUpload(answerIndex, fileIndex) + } + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ) + })} + </FileList> + </div> + )} + + {/* 2) Already uploaded files */} + {form + .watch(`answers.${answerIndex}.uploadedFiles`) + .map((file, fileIndex) => ( + <FileListItem key={fileIndex}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.fileName}</FileListName> + {/* If you want to display the path: + <FileListDescription>{file.url}</FileListDescription> + */} + </FileListInfo> + {file.size && ( + <span className="text-xs text-muted-foreground"> + {prettyBytes(file.size)} + </span> + )} + <div className="flex gap-1"> + <FileListAction + onClick={async () => { + try { + const { downloadFile } = await import('@/lib/file-download') + await downloadFile(file.url, file.fileName, { + showToast: true, + onError: (error) => { + console.error('다운로드 오류:', error) + toast({ + title: "다운로드 실패", + description: error, + variant: "destructive" + }) + }, + onSuccess: (fileName, fileSize) => { + console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`) + } + }) + } catch (error) { + console.error('다운로드 오류:', error) + toast({ + title: "다운로드 실패", + description: "파일 다운로드 중 오류가 발생했습니다.", + variant: "destructive" + }) } - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ) - })} - </FileList> - </div> + }} + > + <Download className="h-4 w-4" /> + <span className="sr-only">Download</span> + </FileListAction> + <FileListAction + onClick={() => + removeUploadedFile(answerIndex, fileIndex) + } + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ))} + </div> + )} + + {/* SHI 코멘트 필드 (읽기 전용) */} + {item.shiComment && ( + <FormField + control={form.control} + name={`answers.${answerIndex}.shiComment`} + render={({ field }) => ( + <FormItem className="mt-2"> + <FormLabel className="text-amber-600">SHI 코멘트</FormLabel> + <FormControl> + <Textarea + {...field} + disabled={true} + className="min-h-20 bg-muted/50" + placeholder="SHI 코멘트가 없습니다." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* Vendor Reply 필드 */} + <FormField + control={form.control} + name={`answers.${answerIndex}.vendorReply`} + render={({ field }) => ( + <FormItem className="mt-2"> + <FormLabel className="text-blue-600">벤더 Reply</FormLabel> + <FormControl> + <Textarea + {...field} + disabled={shouldDisableInput} + className="min-h-20 bg-muted/50" + placeholder="벤더 Reply를 입력하세요." + onChange={(e) => { + field.onChange(e) + form.setValue( + `answers.${answerIndex}.saved`, + false, + { shouldDirty: true } + ) + }} + /> + </FormControl> + <FormMessage /> + </FormItem> )} + /> - {/* 2) Already uploaded files */} - {form - .watch(`answers.${answerIndex}.uploadedFiles`) - .map((file, fileIndex) => ( - <FileListItem key={fileIndex}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.fileName}</FileListName> - {/* If you want to display the path: - <FileListDescription>{file.url}</FileListDescription> - */} - </FileListInfo> - {file.size && ( - <span className="text-xs text-muted-foreground"> - {prettyBytes(file.size)} - </span> - )} - <FileListAction - onClick={() => - removeUploadedFile(answerIndex, fileIndex) - } - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ))} - </div> </CardContent> </CollapsibleContent> </Card> |
