summaryrefslogtreecommitdiff
path: root/components/pq-input/pq-input-tabs.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/pq-input/pq-input-tabs.tsx')
-rw-r--r--components/pq-input/pq-input-tabs.tsx524
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">
+ &quot;파일 업로드는 첨부 파일 섹션에서 진행해주세요.&quot;
+ </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">
+ &quot;파일을 업로드해주세요.&quot;
+ </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>