summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/pq-input/pq-delete-dialog.tsx122
-rw-r--r--components/pq-input/pq-input-tabs.tsx524
-rw-r--r--components/pq-input/pq-review-wrapper.tsx392
-rw-r--r--components/pq/pq-input-tabs.tsx6
4 files changed, 897 insertions, 147 deletions
diff --git a/components/pq-input/pq-delete-dialog.tsx b/components/pq-input/pq-delete-dialog.tsx
new file mode 100644
index 00000000..2cb7c1d8
--- /dev/null
+++ b/components/pq-input/pq-delete-dialog.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { useToast } from "@/hooks/use-toast"
+import { deletePQSubmissionAction } from "@/lib/pq/service"
+import { useRouter } from "next/navigation"
+
+interface PQDeleteDialogProps {
+ pqSubmissionId: number
+ status: string
+ children: React.ReactNode
+}
+
+export function PQDeleteDialog({
+ pqSubmissionId,
+ status,
+ children
+}: PQDeleteDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isDeleting, setIsDeleting] = React.useState(false)
+ const { toast } = useToast()
+ const router = useRouter()
+
+ // REQUESTED 상태가 아니면 삭제 버튼 비활성화
+ const canDelete = status === "REQUESTED"
+
+ const handleDelete = async () => {
+ if (!canDelete) {
+ toast({
+ title: "삭제 불가",
+ description: "요청됨 상태가 아닌 PQ는 삭제할 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ try {
+ setIsDeleting(true)
+ const result = await deletePQSubmissionAction(pqSubmissionId)
+
+ if (result.success) {
+ toast({
+ title: "삭제 완료",
+ description: "PQ가 성공적으로 삭제되었습니다.",
+ })
+ setOpen(false)
+ router.refresh()
+ } else {
+ toast({
+ title: "삭제 실패",
+ description: result.error || "PQ 삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("PQ 삭제 오류:", error)
+ toast({
+ title: "삭제 실패",
+ description: "PQ 삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <div>
+ {children}
+ </div>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ PQ 삭제
+ </DialogTitle>
+ <DialogDescription>
+ 다음 PQ를 삭제하시겠습니까? <br />
+ 협력업체가 입력한 답변이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {!canDelete && (
+ <div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
+ <p className="text-sm text-amber-800">
+ 요청됨 상태가 아닌 PQ는 삭제할 수 없습니다.
+ </p>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isDeleting}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={!canDelete || isDeleting}
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
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>
diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx
index 216df422..1056189e 100644
--- a/components/pq-input/pq-review-wrapper.tsx
+++ b/components/pq-input/pq-review-wrapper.tsx
@@ -7,8 +7,7 @@ import {
CardContent,
CardHeader,
CardTitle,
- CardDescription,
- CardFooter
+ CardDescription
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
@@ -22,16 +21,18 @@ import {
DialogTitle
} from "@/components/ui/dialog"
import { useToast } from "@/hooks/use-toast"
-import { CheckCircle, AlertCircle, FileText, Paperclip } from "lucide-react"
+import { CheckCircle, AlertCircle, Paperclip } from "lucide-react"
import { PQGroupData } from "@/lib/pq/service"
-import { approvePQAction, rejectPQAction } from "@/lib/pq/service"
+import { approvePQAction, rejectPQAction, updateSHICommentAction } from "@/lib/pq/service"
+// import * as ExcelJS from 'exceljs';
+// import { saveAs } from "file-saver";
// PQ 제출 정보 타입
interface PQSubmission {
id: number
vendorId: number
- vendorName: string
- vendorCode: string
+ vendorName: string | null
+ vendorCode: string | null
type: string
status: string
projectId: number | null
@@ -63,6 +64,21 @@ export function PQReviewWrapper({
const [showApproveDialog, setShowApproveDialog] = React.useState(false)
const [showRejectDialog, setShowRejectDialog] = React.useState(false)
const [rejectReason, setRejectReason] = React.useState("")
+ const [shiComments, setShiComments] = React.useState<Record<number, string>>({})
+ const [isUpdatingComment, setIsUpdatingComment] = React.useState<number | null>(null)
+
+ // 기존 SHI 코멘트를 로컬 상태에 초기화
+ React.useEffect(() => {
+ const initialComments: Record<number, string> = {}
+ pqData.forEach(group => {
+ group.items.forEach(item => {
+ if (item.answerId && item.shiComment) {
+ initialComments[item.answerId] = item.shiComment
+ }
+ })
+ })
+ setShiComments(initialComments)
+ }, [pqData])
// PQ 승인 처리
const handleApprove = async () => {
@@ -101,6 +117,178 @@ export function PQReviewWrapper({
}
}
+ // SHI 코멘트 업데이트 처리
+ const handleSHICommentUpdate = async (answerId: number) => {
+ const comment = shiComments[answerId] || ""
+
+ try {
+ setIsUpdatingComment(answerId)
+ const result = await updateSHICommentAction({
+ answerId,
+ shiComment: comment,
+ })
+
+ if (result.ok) {
+ toast({
+ title: "SHI 코멘트 저장 완료",
+ description: "SHI 코멘트가 저장되었습니다.",
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "저장 실패",
+ description: result.error || "SHI 코멘트 저장 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ }
+ } catch (error) {
+ console.error("SHI 코멘트 저장 오류:", error)
+ toast({
+ title: "저장 실패",
+ description: "SHI 코멘트 저장 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ } finally {
+ setIsUpdatingComment(null)
+ }
+ }
+
+ // // Excel export 처리
+ // const handleExportToExcel = async () => {
+ // try {
+ // setIsExporting(true)
+
+ // // 워크북 생성
+ // const workbook = new ExcelJS.Workbook()
+ // workbook.creator = 'PQ Management System'
+ // workbook.created = new Date()
+
+ // // 메인 시트 생성
+ // const worksheet = workbook.addWorksheet("PQ 항목")
+
+ // // 헤더 정의
+ // const headers = [
+ // "그룹명",
+ // "코드",
+ // "체크포인트",
+ // "설명",
+ // "입력형식",
+ // "필수여부",
+ // "벤더답변",
+ // "SHI 코멘트",
+ // "벤더 답변",
+ // ]
+
+ // // 헤더 추가
+ // worksheet.addRow(headers)
+
+ // // 헤더 스타일 적용
+ // const headerRow = worksheet.getRow(1)
+ // headerRow.font = { bold: true }
+ // headerRow.fill = {
+ // type: 'pattern',
+ // pattern: 'solid',
+ // fgColor: { argb: 'FFE0E0E0' }
+ // }
+ // headerRow.alignment = { vertical: 'middle', horizontal: 'center' }
+
+ // // 컬럼 너비 설정
+ // worksheet.columns = [
+ // { header: "그룹명", key: "groupName", width: 15 },
+ // { header: "코드", key: "code", width: 12 },
+ // { header: "체크포인트", key: "checkPoint", width: 30 },
+ // { header: "설명", key: "description", width: 40 },
+ // { header: "입력형식", key: "inputFormat", width: 12 },
+
+ // { header: "벤더답변", key: "answer", width: 30 },
+ // { header: "SHI 코멘트", key: "shiComment", width: 30 },
+ // { header: "벤더 답변", key: "vendorReply", width: 30 },
+ // ]
+
+ // // 데이터 추가
+ // pqData.forEach(group => {
+ // group.items.forEach(item => {
+ // const rowData = [
+ // group.groupName,
+ // item.code,
+ // item.checkPoint,
+ // item.description || "",
+ // item.inputFormat || "",
+
+ // item.answer || "",
+ // item.shiComment || "",
+ // item.vendorReply || "",
+ // ]
+ // worksheet.addRow(rowData)
+ // })
+ // })
+
+ // // 전체 셀에 테두리 추가
+ // worksheet.eachRow((row, rowNumber) => {
+ // row.eachCell((cell) => {
+ // cell.border = {
+ // top: { style: 'thin' },
+ // left: { style: 'thin' },
+ // bottom: { style: 'thin' },
+ // right: { style: 'thin' }
+ // }
+ // // 긴 텍스트는 자동 줄바꿈
+ // cell.alignment = {
+ // vertical: 'top',
+ // horizontal: 'left',
+ // wrapText: true
+ // }
+ // })
+ // })
+
+ // // 정보 시트 생성
+ // const infoSheet = workbook.addWorksheet("정보")
+ // infoSheet.addRow(["벤더명", pqSubmission.vendorName])
+ // if (pqSubmission.projectName) {
+ // infoSheet.addRow(["프로젝트명", pqSubmission.projectName])
+ // }
+ // infoSheet.addRow(["생성일", new Date().toLocaleDateString('ko-KR')])
+ // infoSheet.addRow(["총 항목 수", pqData.reduce((total, group) => total + group.items.length, 0)])
+
+ // // 정보 시트 스타일링
+ // infoSheet.columns = [
+ // { header: "항목", key: "item", width: 20 },
+ // { header: "값", key: "value", width: 40 }
+ // ]
+
+ // const infoHeaderRow = infoSheet.getRow(1)
+ // infoHeaderRow.font = { bold: true }
+ // infoHeaderRow.fill = {
+ // type: 'pattern',
+ // pattern: 'solid',
+ // fgColor: { argb: 'FFE6F3FF' }
+ // }
+
+ // // 파일명 생성
+ // const defaultFilename = pqSubmission.projectName
+ // ? `${pqSubmission.vendorName}_${pqSubmission.projectName}_PQ_${new Date().toISOString().slice(0, 10)}`
+ // : `${pqSubmission.vendorName}_PQ_${new Date().toISOString().slice(0, 10)}`
+ // const finalFilename = defaultFilename
+
+ // // 파일 다운로드
+ // const buffer = await workbook.xlsx.writeBuffer()
+ // const blob = new Blob([buffer], {
+ // type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ // })
+ // saveAs(blob, `${finalFilename}.xlsx`)
+ // } catch (error) {
+ // console.error("Excel export 오류:", error)
+ // toast({
+ // title: "내보내기 실패",
+ // description: "Excel 내보내기 중 오류가 발생했습니다.",
+ // variant: "destructive"
+ // })
+ // } finally {
+ // setIsExporting(false)
+ // }
+ // }
+
// PQ 거부 처리
const handleReject = async () => {
if (!rejectReason.trim()) {
@@ -163,12 +351,20 @@ export function PQReviewWrapper({
<div>
<CardTitle className="text-base">
{item.code} - {item.checkPoint}
+
+
</CardTitle>
{item.description && (
<CardDescription className="mt-1 whitespace-pre-wrap">
{item.description}
</CardDescription>
)}
+ {/* <div className="text-sm text-muted-foreground">
+ 생성일: {item.createdAt?.toLocaleString('ko-KR')}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ 수정일: {item.updatedAt?.toLocaleString('ko-KR')}
+ </div> */}
</div>
{/* 항목 상태 표시 */}
{!!item.answer || item.attachments.length > 0 ? (
@@ -182,6 +378,7 @@ export function PQReviewWrapper({
답변 없음
</Badge>
)}
+
</div>
</CardHeader>
<CardContent className="space-y-4">
@@ -204,19 +401,134 @@ export function PQReviewWrapper({
</div>
)}
- {/* 벤더 답변 */}
+
+
+ {/* 벤더 답변 - 입력 형식에 따라 다르게 표시 */}
<div className="space-y-1">
<p className="text-sm font-medium flex items-center gap-1">
- <FileText className="h-4 w-4" />
벤더 답변
+ {item.inputFormat && (
+ <Badge variant="outline" className="ml-2 text-xs">
+ {item.inputFormat === "TEXT" && "텍스트"}
+ {item.inputFormat === "EMAIL" && "이메일"}
+ {item.inputFormat === "PHONE" && "전화번호"}
+ {item.inputFormat === "NUMBER" && "숫자"}
+ {item.inputFormat === "FILE" && "파일"}
+ {item.inputFormat === "TEXT_FILE" && "텍스트+파일"}
+ </Badge>
+ )}
+ </p>
+ <div className="rounded-md border p-3 min-h-20">
+ {(() => {
+ const inputFormat = item.inputFormat || "TEXT";
+
+ switch (inputFormat) {
+ case "EMAIL":
+ return (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">이메일 주소:</div>
+ <div className="whitespace-pre-wrap">
+ {item.answer || <span className="text-muted-foreground">답변 없음</span>}
+ </div>
+ </div>
+ );
+ case "PHONE":
+ return (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">전화번호:</div>
+ <div className="whitespace-pre-wrap">
+ {item.answer || <span className="text-muted-foreground">답변 없음</span>}
+ </div>
+ </div>
+ );
+ case "NUMBER":
+ return (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">숫자 값:</div>
+ <div className="whitespace-pre-wrap">
+ {item.answer || <span className="text-muted-foreground">답변 없음</span>}
+ </div>
+ </div>
+ );
+ case "FILE":
+ return (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">파일 업로드 항목:</div>
+ <div className="text-sm text-muted-foreground">
+ {item.attachments.length > 0 ? "파일이 업로드되었습니다." : "파일이 업로드되지 않았습니다."}
+ </div>
+ </div>
+ );
+ case "TEXT_FILE":
+ return (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">텍스트 답변:</div>
+ <div className="whitespace-pre-wrap">
+ {item.answer || <span className="text-muted-foreground">텍스트 답변 없음</span>}
+ </div>
+ <div className="text-sm font-medium text-muted-foreground">파일 업로드:</div>
+ <div className="text-sm text-muted-foreground">
+ {item.attachments.length > 0 ? "파일이 업로드되었습니다." : "파일이 업로드되지 않았습니다."}
+ </div>
+ </div>
+ );
+ default: // TEXT
+ return (
+ <div className="whitespace-pre-wrap">
+ {item.answer || <span className="text-muted-foreground">답변 없음</span>}
+ </div>
+ );
+ }
+ })()}
+ </div>
+ </div>
+ {/* SHI 코멘트 필드 (편집 가능) */}
+ <div className="space-y-1">
+ <p className="text-sm font-medium flex items-center gap-1">
+ SHI 코멘트
</p>
- <div className="rounded-md border p-3 min-h-20 whitespace-pre-wrap">
- {item.answer || <span className="text-muted-foreground">답변 없음</span>}
+ <div className="rounded-md border p-3 min-h-20">
+ <Textarea
+ value={shiComments[item.answerId || 0] ?? item.shiComment ?? ""}
+ onChange={(e) => {
+ if (item.answerId) {
+ setShiComments(prev => ({
+ ...prev,
+ [item.answerId!]: e.target.value
+ }))
+ }
+ }}
+ placeholder="SHI 코멘트를 입력하세요."
+ className="min-h-20"
+ />
+ {item.answerId && (
+ <div className="mt-2 flex justify-end">
+ <Button
+ size="sm"
+ onClick={() => handleSHICommentUpdate(item.answerId!)}
+ disabled={isUpdatingComment === item.answerId}
+ >
+ {isUpdatingComment === item.answerId ? "저장 중..." : "저장"}
+ </Button>
+ </div>
+ )}
</div>
</div>
+ {/* 벤더 답변 필드 (읽기 전용) */}
+ <div className="space-y-1">
+ <p className="text-sm font-medium flex items-center gap-1">
+ 벤더 reply
+ </p>
+ <div className="rounded-md border p-3 min-h-20 bg-muted/30">
+ <div className="whitespace-pre-wrap">
+ {item.vendorReply || <span className="text-muted-foreground">벤더 reply 없음</span>}
+ </div>
+ </div>
+ </div>
+
- {/* 첨부 파일 */}
- {item.attachments.length > 0 && (
+ {/* 첨부 파일 - FILE 또는 TEXT_FILE 형식에서만 표시 */}
+ {(item.inputFormat === "FILE" || item.inputFormat === "TEXT_FILE") && item.attachments.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium flex items-center gap-1">
<Paperclip className="h-4 w-4" />
@@ -226,15 +538,37 @@ export function PQReviewWrapper({
<ul className="space-y-1">
{item.attachments.map((attachment, idx) => (
<li key={idx} className="flex items-center gap-2">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <a
- href={attachment.filePath}
- target="_blank"
- rel="noopener noreferrer"
- className="text-sm text-blue-600 hover:underline"
+ <button
+ onClick={async () => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(attachment.filePath, attachment.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"
+ })
+ }
+ }}
+ className="text-sm text-blue-600 hover:underline cursor-pointer"
>
{attachment.fileName}
- </a>
+ </button>
</li>
))}
</ul>
@@ -252,6 +586,14 @@ export function PQReviewWrapper({
{canReview && (
<div className="fixed bottom-4 right-4 bg-background p-4 rounded-lg shadow-md border">
<div className="flex gap-2">
+ {/* <Button
+ variant="outline"
+ onClick={handleExportToExcel}
+ disabled={isExporting}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ {isExporting ? "내보내기 중..." : "Excel 내보내기"}
+ </Button> */}
<Button
variant="outline"
onClick={() => setShowRejectDialog(true)}
@@ -276,7 +618,11 @@ export function PQReviewWrapper({
<DialogHeader>
<DialogTitle>PQ 승인 확인</DialogTitle>
<DialogDescription>
- {pqSubmission.vendorName}의 {pqSubmission.type === "GENERAL" ? "일반" : "프로젝트"} PQ를 승인하시겠습니까?
+ {pqSubmission.vendorName || "알 수 없는 업체"}의 {
+ pqSubmission.type === "GENERAL" ? "일반" :
+ pqSubmission.type === "PROJECT" ? "프로젝트" :
+ pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반"
+ } PQ를 승인하시겠습니까?
{pqSubmission.projectId && (
<span> 프로젝트: {pqSubmission.projectName}</span>
)}
@@ -299,7 +645,11 @@ export function PQReviewWrapper({
<DialogHeader>
<DialogTitle>PQ 거부</DialogTitle>
<DialogDescription>
- {pqSubmission.vendorName}의 {pqSubmission.type === "GENERAL" ? "일반" : "프로젝트"} PQ를 거부하는 이유를 입력해주세요.
+ {pqSubmission.vendorName || "알 수 없는 업체"}의 {
+ pqSubmission.type === "GENERAL" ? "일반" :
+ pqSubmission.type === "PROJECT" ? "프로젝트" :
+ pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반"
+ } PQ를 거부하는 이유를 입력해주세요.
{pqSubmission.projectId && (
<span> 프로젝트: {pqSubmission.projectName}</span>
)}
diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx
index b84d9167..d72eff92 100644
--- a/components/pq/pq-input-tabs.tsx
+++ b/components/pq/pq-input-tabs.tsx
@@ -76,6 +76,7 @@ import {
ProjectPQ,
} from "@/lib/pq/service"
import { PQGroupData } from "@/lib/pq/service"
+import { useRouter } from "next/navigation"
// ----------------------------------------------------------------------
// 1) Define client-side file shapes
@@ -148,7 +149,7 @@ export function PQInputTabs({
const [showConfirmDialog, setShowConfirmDialog] = React.useState(false)
const { toast } = useToast()
-
+ const router = useRouter()
// ----------------------------------------------------------------------
// A) Create initial form values
// Mark items as "saved" if they have existing answer or attachments
@@ -414,7 +415,8 @@ export function PQInputTabs({
description: "Your PQ information has been submitted successfully",
})
// 제출 후 페이지 새로고침 또는 리디렉션 처리
- window.location.reload()
+ router.refresh()
+ // window.location.reload()
} else {
toast({
title: "Submit Error",