diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:39:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:39:21 +0000 |
| commit | 53ad72732f781e6c6d5ddb3776ea47aec010af8e (patch) | |
| tree | e676287827f8634be767a674b8ad08b6ed7eb3e6 /components | |
| parent | 3e4d15271322397764601dee09441af8a5b3adf5 (diff) | |
(최겸) PQ/실사 수정 및 개발
Diffstat (limited to 'components')
| -rw-r--r-- | components/pq-input/pq-delete-dialog.tsx | 122 | ||||
| -rw-r--r-- | components/pq-input/pq-input-tabs.tsx | 524 | ||||
| -rw-r--r-- | components/pq-input/pq-review-wrapper.tsx | 392 | ||||
| -rw-r--r-- | components/pq/pq-input-tabs.tsx | 6 |
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"> + "파일 업로드는 첨부 파일 섹션에서 진행해주세요." + </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> 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", |
