summaryrefslogtreecommitdiff
path: root/components/pq-input
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:18:16 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:18:16 +0000
commit748bb1720fd81e97a84c3e92f89d606e976b52e3 (patch)
treea7f7f377035cd04912fe0541368884f976f4ee6d /components/pq-input
parent9e280704988fdeffa05c1d8cbb731722f666c6af (diff)
(대표님) 컴포넌트 파트 커밋
Diffstat (limited to 'components/pq-input')
-rw-r--r--components/pq-input/pq-input-tabs.tsx895
-rw-r--r--components/pq-input/pq-review-wrapper.tsx330
2 files changed, 1225 insertions, 0 deletions
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx
new file mode 100644
index 00000000..2574a5b0
--- /dev/null
+++ b/components/pq-input/pq-input-tabs.tsx
@@ -0,0 +1,895 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+} from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { useToast } from "@/hooks/use-toast"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+
+// Form components
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+
+// Custom Dropzone, FileList components
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list"
+
+// Dialog components
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog"
+
+// Additional UI
+import { Separator } from "@/components/ui/separator"
+import { Badge } from "@/components/ui/badge"
+
+// Server actions
+import {
+ uploadFileAction,
+ savePQAnswersAction,
+ submitPQAction,
+ ProjectPQ,
+} from "@/lib/pq/service"
+import { PQGroupData } from "@/lib/pq/service"
+
+// ----------------------------------------------------------------------
+// 1) Define client-side file shapes
+// ----------------------------------------------------------------------
+interface UploadedFileState {
+ fileName: string
+ url: string
+ size?: number
+}
+
+interface LocalFileState {
+ fileObj: File
+ uploaded: boolean
+}
+
+// ----------------------------------------------------------------------
+// 2) Zod schema for the entire form
+// ----------------------------------------------------------------------
+const pqFormSchema = z.object({
+ answers: z.array(
+ z.object({
+ criteriaId: z.number(),
+ // Must have at least 1 char
+ answer: z.string().min(1, "Answer is required"),
+
+ // Existing, uploaded files
+ uploadedFiles: z
+ .array(
+ z.object({
+ fileName: z.string(),
+ url: z.string(),
+ size: z.number().optional(),
+ })
+ )
+ .min(1, "At least one file attachment is required"),
+
+ // Local (not-yet-uploaded) files
+ newUploads: z.array(
+ z.object({
+ fileObj: z.any(),
+ uploaded: z.boolean().default(false),
+ })
+ ),
+
+ // track saved state
+ saved: z.boolean().default(false),
+ })
+ ),
+})
+
+type PQFormValues = z.infer<typeof pqFormSchema>
+
+// ----------------------------------------------------------------------
+// 3) Main Component: PQInputTabs
+// ----------------------------------------------------------------------
+export function PQInputTabs({
+ data,
+ vendorId,
+ projectId,
+ projectData,
+ isReadOnly = false,
+ currentPQ, // 추가: 현재 PQ Submission 정보
+}: {
+ data: PQGroupData[]
+ vendorId: number
+ projectId?: number
+ projectData?: ProjectPQ | null
+ isReadOnly?: boolean
+ currentPQ?: { // PQ Submission 정보
+ id: number;
+ status: string;
+ type: string;
+ } | null
+}) {
+
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [allSaved, setAllSaved] = React.useState(false)
+ const [showConfirmDialog, setShowConfirmDialog] = React.useState(false)
+
+ const { toast } = useToast()
+
+ const shouldDisableInput = isReadOnly;
+
+ // ----------------------------------------------------------------------
+ // A) Create initial form values
+ // Mark items as "saved" if they have existing answer or attachments
+ // ----------------------------------------------------------------------
+ function createInitialFormValues(): PQFormValues {
+ const answers: PQFormValues["answers"] = []
+
+ data.forEach((group) => {
+ group.items.forEach((item) => {
+ // Check if the server item is already "complete"
+ const hasExistingAnswer = item.answer && item.answer.trim().length > 0
+ const hasExistingAttachments = item.attachments && item.attachments.length > 0
+
+ // If either is present, we consider it "saved" initially
+ const isAlreadySaved = hasExistingAnswer || hasExistingAttachments
+
+ answers.push({
+ criteriaId: item.criteriaId,
+ answer: item.answer || "",
+ uploadedFiles: item.attachments.map((attach) => ({
+ fileName: attach.fileName,
+ url: attach.filePath,
+ size: attach.fileSize,
+ })),
+ newUploads: [],
+ saved: isAlreadySaved,
+ })
+ })
+ })
+
+ return { answers }
+ }
+
+ // ----------------------------------------------------------------------
+ // B) Set up react-hook-form
+ // ----------------------------------------------------------------------
+ const form = useForm<PQFormValues>({
+ resolver: zodResolver(pqFormSchema),
+ defaultValues: createInitialFormValues(),
+ mode: "onChange",
+ })
+
+ // ----------------------------------------------------------------------
+ // C) Track if all items are saved => controls Submit PQ button
+ // ----------------------------------------------------------------------
+ React.useEffect(() => {
+ const values = form.getValues()
+ // We consider items "saved" if `saved===true` AND they have an answer or attachments
+ const allItemsSaved = values.answers.every(
+ (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0)
+ )
+ setAllSaved(allItemsSaved)
+ }, [form.watch()])
+
+ // Helper to find the array index by criteriaId
+ const getAnswerIndex = (criteriaId: number): number => {
+ return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId)
+ }
+
+ // ----------------------------------------------------------------------
+ // D) Handling File Drops, Removal
+ // ----------------------------------------------------------------------
+ const handleDropAccepted = (criteriaId: number, files: File[]) => {
+ const answerIndex = getAnswerIndex(criteriaId)
+ if (answerIndex === -1) return
+
+ // Convert each dropped file into a LocalFileState
+ const newLocalFiles: LocalFileState[] = files.map((f) => ({
+ fileObj: f,
+ uploaded: false,
+ }))
+
+ const current = form.getValues(`answers.${answerIndex}.newUploads`)
+ form.setValue(`answers.${answerIndex}.newUploads`, [...current, ...newLocalFiles], {
+ shouldDirty: true,
+ })
+
+ // Mark unsaved
+ form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true })
+ }
+
+ const handleDropRejected = () => {
+ toast({
+ title: "File upload rejected",
+ description: "Please check file size and type.",
+ variant: "destructive",
+ })
+ }
+
+ const removeNewUpload = (answerIndex: number, fileIndex: number) => {
+ const current = [...form.getValues(`answers.${answerIndex}.newUploads`)]
+ current.splice(fileIndex, 1)
+ form.setValue(`answers.${answerIndex}.newUploads`, current, { shouldDirty: true })
+
+ form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true })
+ }
+
+ const removeUploadedFile = (answerIndex: number, fileIndex: number) => {
+ const current = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)]
+ current.splice(fileIndex, 1)
+ form.setValue(`answers.${answerIndex}.uploadedFiles`, current, { shouldDirty: true })
+
+ form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true })
+ }
+
+ // ----------------------------------------------------------------------
+ // E) Saving a Single Item
+ // ----------------------------------------------------------------------
+ const handleSaveItem = async (answerIndex: number) => {
+ try {
+ const answerData = form.getValues(`answers.${answerIndex}`)
+
+ // Validation
+ if (!answerData.answer) {
+ toast({
+ title: "Validation Error",
+ description: "Answer is required",
+ variant: "destructive",
+ })
+ return
+ }
+
+ // Upload new files (if any)
+ if (answerData.newUploads.length > 0) {
+ setIsSaving(true)
+
+ for (const localFile of answerData.newUploads) {
+ try {
+ const uploadResult = await uploadFileAction(localFile.fileObj)
+ const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`)
+ currentUploaded.push({
+ fileName: uploadResult.fileName,
+ url: uploadResult.url,
+ size: uploadResult.size,
+ })
+ form.setValue(`answers.${answerIndex}.uploadedFiles`, currentUploaded, {
+ shouldDirty: true,
+ })
+ } catch (error) {
+ console.error("File upload error:", error)
+ toast({
+ title: "Upload Error",
+ description: "Failed to upload file",
+ variant: "destructive",
+ })
+ }
+ }
+
+ // Clear newUploads
+ form.setValue(`answers.${answerIndex}.newUploads`, [], { shouldDirty: true })
+ }
+
+ // Save to DB
+ const updatedAnswer = form.getValues(`answers.${answerIndex}`)
+ const saveResult = await savePQAnswersAction({
+ vendorId,
+ projectId, // 프로젝트 ID 전달
+ answers: [
+ {
+ criteriaId: updatedAnswer.criteriaId,
+ answer: updatedAnswer.answer,
+ attachments: updatedAnswer.uploadedFiles.map((f) => ({
+ fileName: f.fileName,
+ url: f.url,
+ size: f.size,
+ })),
+ },
+ ],
+ })
+
+ if (saveResult.ok) {
+ // Mark as saved
+ form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false })
+ toast({
+ title: "Saved",
+ description: "Item saved successfully",
+ })
+ }
+ } catch (error) {
+ console.error("Save error:", error)
+ toast({
+ title: "Save Error",
+ description: "Failed to save item",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // For convenience
+ const answers = form.getValues().answers
+ const dirtyFields = form.formState.dirtyFields.answers
+
+ // Check if any item is dirty or has new uploads
+ const isAnyItemDirty = answers.some((answer, i) => {
+ const itemDirty = !!dirtyFields?.[i]
+ const hasNewUploads = answer.newUploads.length > 0
+ return itemDirty || hasNewUploads
+ })
+
+ // ----------------------------------------------------------------------
+ // F) Save All Items
+ // ----------------------------------------------------------------------
+ const handleSaveAll = async () => {
+ try {
+ setIsSaving(true)
+ const answers = form.getValues().answers
+
+ // Only save items that are dirty or have new uploads
+ for (let i = 0; i < answers.length; i++) {
+ const itemDirty = !!dirtyFields?.[i]
+ const hasNewUploads = answers[i].newUploads.length > 0
+ if (!itemDirty && !hasNewUploads) continue
+
+ await handleSaveItem(i)
+ }
+
+ toast({
+ title: "All Saved",
+ description: "All items saved successfully",
+ })
+ } catch (error) {
+ console.error("Save all error:", error)
+ toast({
+ title: "Save Error",
+ description: "Failed to save all items",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // ----------------------------------------------------------------------
+ // G) Submission with Confirmation Dialog
+ // ----------------------------------------------------------------------
+ const handleSubmitPQ = () => {
+ if (!allSaved) {
+ toast({
+ title: "Cannot Submit",
+ description: "Please save all items before submitting",
+ variant: "destructive",
+ })
+ return
+ }
+ setShowConfirmDialog(true)
+ }
+
+ const handleConfirmSubmission = async () => {
+ try {
+ setIsSubmitting(true);
+ setShowConfirmDialog(false);
+
+ // pqSubmissionId가 있으면 포함하여 전달
+ const result = await submitPQAction({
+ vendorId,
+ projectId,
+ pqSubmissionId: currentPQ?.id, // 현재 PQ Submission ID 사용
+ });
+
+ if (result.ok) {
+ toast({
+ title: "PQ Submitted",
+ description: "Your PQ information has been submitted successfully",
+ });
+ // 제출 후 PQ 목록 페이지로 리디렉션
+ window.location.href = "/partners/pq";
+ } else {
+ toast({
+ title: "Submit Error",
+ description: result.error || "Failed to submit PQ",
+ variant: "destructive",
+ });
+ }
+ } catch (error) {
+ console.error("Submit error:", error);
+ toast({
+ title: "Submit Error",
+ description: "Failed to submit PQ information",
+ variant: "destructive",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+ // 프로젝트 정보 표시 섹션
+ const renderProjectInfo = () => {
+ if (!projectData) return null;
+
+ return (
+ <div className="mb-6 bg-muted p-4 rounded-md">
+ <div className="flex items-center justify-between mb-2">
+ <h3 className="text-lg font-semibold">프로젝트 정보</h3>
+ <Badge variant={getStatusVariant(projectData.status)}>
+ {getStatusLabel(projectData.status)}
+ </Badge>
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">프로젝트 코드</p>
+ <p>{projectData.projectCode}</p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">프로젝트명</p>
+ <p>{projectData.projectName}</p>
+ </div>
+ {projectData.submittedAt && (
+ <div className="col-span-1 md:col-span-2">
+ <p className="text-sm font-medium text-muted-foreground">제출일</p>
+ <p>{formatDate(projectData.submittedAt)}</p>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ };
+
+ // 상태 표시용 함수
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "REQUESTED": return "요청됨";
+ case "IN_PROGRESS": return "진행중";
+ case "SUBMITTED": return "제출됨";
+ case "APPROVED": return "승인됨";
+ case "REJECTED": return "반려됨";
+ default: return status;
+ }
+ };
+
+ const getStatusVariant = (status: string) => {
+ switch (status) {
+ case "REQUESTED": return "secondary";
+ case "IN_PROGRESS": return "default";
+ case "SUBMITTED": return "outline";
+ case "APPROVED": return "outline";
+ case "REJECTED": return "destructive";
+ default: return "secondary";
+ }
+ };
+
+ // 날짜 형식화 함수
+ const formatDate = (date: Date) => {
+ if (!date) return "-";
+ return new Date(date).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ };
+
+ // ----------------------------------------------------------------------
+ // H) Render
+ // ----------------------------------------------------------------------
+ return (
+ <Form {...form}>
+ <form>
+ {/* 프로젝트 정보 섹션 */}
+ {renderProjectInfo()}
+
+ <Tabs defaultValue={data[0]?.groupName || ""} className="w-full">
+ {/* Top Controls */}
+ <div className="flex justify-between items-center mb-4">
+ <TabsList className="grid grid-cols-4">
+ {data.map((group) => (
+ <TabsTrigger
+ key={group.groupName}
+ value={group.groupName}
+ className="truncate"
+ >
+ <div className="flex items-center gap-2">
+ {/* Mobile: truncated version */}
+ <span className="block sm:hidden">
+ {group.groupName.length > 5
+ ? group.groupName.slice(0, 5) + "..."
+ : group.groupName}
+ </span>
+ {/* Desktop: full text */}
+ <span className="hidden sm:block">{group.groupName}</span>
+ <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium">
+ {group.items.length}
+ </span>
+ </div>
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ <div className="flex gap-2">
+ {/* Save All button */}
+ <Button
+ type="button"
+ variant="outline"
+ disabled={isSaving || !isAnyItemDirty || shouldDisableInput}
+ onClick={handleSaveAll}
+ >
+ {isSaving ? "Saving..." : "Save All"}
+ <Save className="ml-2 h-4 w-4" />
+ </Button>
+
+ {/* Submit PQ button */}
+ <Button
+ type="button"
+ disabled={!allSaved || isSubmitting || shouldDisableInput}
+ onClick={handleSubmitPQ}
+ >
+ {isSubmitting ? "Submitting..." : "Submit PQ"}
+ <CheckCircle2 className="ml-2 h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+
+ {/* Render each group */}
+ {data.map((group) => (
+ <TabsContent key={group.groupName} value={group.groupName}>
+ {/* 2-column grid */}
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4">
+ {group.items.map((item) => {
+ const { criteriaId, code, checkPoint, description, contractInfo, additionalRequirement } = item
+ const answerIndex = getAnswerIndex(criteriaId)
+ if (answerIndex === -1) return null
+
+ const isSaved = form.watch(`answers.${answerIndex}.saved`)
+ const hasAnswer = form.watch(`answers.${answerIndex}.answer`)
+ const newUploads = form.watch(`answers.${answerIndex}.newUploads`)
+ const dirtyFieldsItem = form.formState.dirtyFields.answers?.[answerIndex]
+
+ const isItemDirty = !!dirtyFieldsItem
+ const hasNewUploads = newUploads.length > 0
+ const canSave = isItemDirty || hasNewUploads
+
+ // For "Not Saved" vs. "Saved" status label
+ const hasUploads =
+ form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 ||
+ newUploads.length > 0
+ const isValid = !!hasAnswer || hasUploads
+
+ return (
+ <Collapsible key={criteriaId} defaultOpen={!isSaved} className="w-full">
+ <Card className={isSaved ? "border-green-200" : ""}>
+ <CardHeader className="pb-1">
+ <div className="flex justify-between">
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <CollapsibleTrigger asChild>
+ <Button variant="ghost" size="sm" className="p-0 h-7 w-7">
+ <ChevronsUpDown className="h-4 w-4" />
+ <span className="sr-only">Toggle</span>
+ </Button>
+ </CollapsibleTrigger>
+ <CardTitle className="text-md">
+ {code} - {checkPoint}
+ </CardTitle>
+ </div>
+ {description && (
+ <CardDescription className="mt-1 whitespace-pre-wrap">
+ {description}
+ </CardDescription>
+ )}
+ </div>
+
+ {/* Save Status & Button */}
+ <div className="flex items-center gap-2">
+ {!isSaved && canSave && (
+ <span className="text-amber-600 text-xs flex items-center">
+ <AlertTriangle className="h-4 w-4 mr-1" />
+ Not Saved
+ </span>
+ )}
+ {isSaved && (
+ <span className="text-green-600 text-xs flex items-center">
+ <CheckCircle2 className="h-4 w-4 mr-1" />
+ Saved
+ </span>
+ )}
+
+ <Button
+ size="sm"
+ variant="outline"
+ disabled={isSaving || !canSave}
+ onClick={() => handleSaveItem(answerIndex)}
+ >
+ Save
+ </Button>
+ </div>
+ </div>
+ </CardHeader>
+
+ <CollapsibleContent>
+ <CardContent className="pt-3 space-y-3">
+ {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */}
+ {projectId && contractInfo && (
+ <div className="space-y-1">
+ <FormLabel className="text-sm font-medium">계약 정보</FormLabel>
+ <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap">
+ {contractInfo}
+ </div>
+ </div>
+ )}
+
+ {projectId && additionalRequirement && (
+ <div className="space-y-1">
+ <FormLabel className="text-sm font-medium">추가 요구사항</FormLabel>
+ <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap">
+ {additionalRequirement}
+ </div>
+ </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>
+ )}
+ />
+
+
+ {/* Attachments / Dropzone */}
+ <div className="grid gap-2 mt-3">
+ <FormLabel>Attachments</FormLabel>
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={(files) =>
+ handleDropAccepted(criteriaId, files)
+ }
+ onDropRejected={handleDropRejected}
+ >
+ {() => (
+ <FormItem>
+ <DropzoneZone className="flex justify-center h-24">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop files here</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: 600MB
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>
+ Or click to browse files
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ </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)
+ }
+ >
+ <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>
+ )}
+ <FileListAction
+ onClick={() =>
+ removeUploadedFile(answerIndex, fileIndex)
+ }
+ >
+ <X className="h-4 w-4" />
+ <span className="sr-only">Remove</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </div>
+ </CardContent>
+ </CollapsibleContent>
+ </Card>
+ </Collapsible>
+ )
+ })}
+ </div>
+ </TabsContent>
+ ))}
+ </Tabs>
+ </form>
+
+ {/* Confirmation Dialog */}
+ <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Submission</DialogTitle>
+ <DialogDescription>
+ {projectId
+ ? `${projectData?.projectCode} 프로젝트의 PQ 응답을 제출하시겠습니까?`
+ : "일반 PQ 응답을 제출하시겠습니까?"
+ } 제출 후에는 수정이 불가능합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 max-h-[600px] overflow-y-auto ">
+ {data.map((group) => (
+ <Collapsible key={group.groupName} defaultOpen>
+ <CollapsibleTrigger asChild>
+ <div className="flex justify-between items-center p-2 mb-1 cursor-pointer ">
+ <p className="font-semibold">{group.groupName}</p>
+ <ChevronsUpDown className="h-4 w-4 ml-2" />
+ </div>
+ </CollapsibleTrigger>
+
+ <CollapsibleContent>
+ {group.items.map((item) => {
+ const answerObj = form
+ .getValues()
+ .answers.find((a) => a.criteriaId === item.criteriaId)
+
+ if (!answerObj) return null
+
+ return (
+ <div key={item.criteriaId} className="mb-2 p-2 ml-2 border rounded-md text-sm">
+ {/* code & checkPoint */}
+ <p className="font-semibold">
+ {item.code} - {item.checkPoint}
+ </p>
+
+ {/* user's typed answer */}
+ <p className="text-sm font-medium mt-2">Answer:</p>
+ <p className="whitespace-pre-wrap text-sm">
+ {answerObj.answer || "(no answer)"}
+ </p>
+ {/* attachments */}
+ <p>Attachments:</p>
+ {answerObj.uploadedFiles.length > 0 ? (
+ <ul className="list-disc list-inside ml-4 text-xs">
+ {answerObj.uploadedFiles.map((file, idx) => (
+ <li key={idx}>{file.fileName}</li>
+ ))}
+ </ul>
+ ) : (
+ <p className="text-xs text-muted-foreground">(none)</p>
+ )}
+ </div>
+ )
+ })}
+ </CollapsibleContent>
+ </Collapsible>
+ ))}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setShowConfirmDialog(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+ </Button>
+ <Button onClick={handleConfirmSubmission} disabled={isSubmitting}>
+ {isSubmitting ? "Submitting..." : "Confirm Submit"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </Form>
+ )
+} \ No newline at end of file
diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx
new file mode 100644
index 00000000..216df422
--- /dev/null
+++ b/components/pq-input/pq-review-wrapper.tsx
@@ -0,0 +1,330 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardFooter
+} from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from "@/components/ui/dialog"
+import { useToast } from "@/hooks/use-toast"
+import { CheckCircle, AlertCircle, FileText, Paperclip } from "lucide-react"
+import { PQGroupData } from "@/lib/pq/service"
+import { approvePQAction, rejectPQAction } from "@/lib/pq/service"
+
+// PQ 제출 정보 타입
+interface PQSubmission {
+ id: number
+ vendorId: number
+ vendorName: string
+ vendorCode: string
+ type: string
+ status: string
+ projectId: number | null
+ projectName: string | null
+ projectCode: string | null
+ submittedAt: Date | null
+ approvedAt: Date | null
+ rejectedAt: Date | null
+ rejectReason: string | null
+}
+
+interface PQReviewWrapperProps {
+ pqData: PQGroupData[]
+ vendorId: number
+ pqSubmission: PQSubmission
+ canReview: boolean
+}
+
+export function PQReviewWrapper({
+ pqData,
+ vendorId,
+ pqSubmission,
+ canReview
+}: PQReviewWrapperProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [isApproving, setIsApproving] = React.useState(false)
+ const [isRejecting, setIsRejecting] = React.useState(false)
+ const [showApproveDialog, setShowApproveDialog] = React.useState(false)
+ const [showRejectDialog, setShowRejectDialog] = React.useState(false)
+ const [rejectReason, setRejectReason] = React.useState("")
+
+ // PQ 승인 처리
+ const handleApprove = async () => {
+ try {
+ setIsApproving(true)
+
+ const result = await approvePQAction({
+ pqSubmissionId: pqSubmission.id,
+ vendorId: vendorId
+ })
+
+ if (result.ok) {
+ toast({
+ title: "PQ 승인 완료",
+ description: "PQ가 성공적으로 승인되었습니다.",
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "승인 실패",
+ description: result.error || "PQ 승인 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ }
+ } catch (error) {
+ console.error("PQ 승인 오류:", error)
+ toast({
+ title: "승인 실패",
+ description: "PQ 승인 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ } finally {
+ setIsApproving(false)
+ setShowApproveDialog(false)
+ }
+ }
+
+ // PQ 거부 처리
+ const handleReject = async () => {
+ if (!rejectReason.trim()) {
+ toast({
+ title: "거부 사유 필요",
+ description: "거부 사유를 입력해주세요.",
+ variant: "destructive"
+ })
+ return
+ }
+
+ try {
+ setIsRejecting(true)
+
+ const result = await rejectPQAction({
+ pqSubmissionId: pqSubmission.id,
+ vendorId: vendorId,
+ rejectReason: rejectReason
+ })
+
+ if (result.ok) {
+ toast({
+ title: "PQ 거부 완료",
+ description: "PQ가 거부되었습니다.",
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "거부 실패",
+ description: result.error || "PQ 거부 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ }
+ } catch (error) {
+ console.error("PQ 거부 오류:", error)
+ toast({
+ title: "거부 실패",
+ description: "PQ 거부 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ } finally {
+ setIsRejecting(false)
+ setShowRejectDialog(false)
+ }
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 그룹별 PQ 항목 표시 */}
+ {pqData.map((group) => (
+ <div key={group.groupName} className="space-y-4">
+ <h3 className="text-lg font-medium">{group.groupName}</h3>
+
+ <div className="grid grid-cols-1 gap-4">
+ {group.items.map((item) => (
+ <Card key={item.criteriaId}>
+ <CardHeader>
+ <div className="flex justify-between items-start">
+ <div>
+ <CardTitle className="text-base">
+ {item.code} - {item.checkPoint}
+ </CardTitle>
+ {item.description && (
+ <CardDescription className="mt-1 whitespace-pre-wrap">
+ {item.description}
+ </CardDescription>
+ )}
+ </div>
+ {/* 항목 상태 표시 */}
+ {!!item.answer || item.attachments.length > 0 ? (
+ <Badge variant="outline" className="text-green-600 bg-green-50">
+ <CheckCircle className="h-3 w-3 mr-1" />
+ 답변 있음
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="text-amber-600 bg-amber-50">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ 답변 없음
+ </Badge>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 프로젝트별 추가 정보 */}
+ {pqSubmission.projectId && item.contractInfo && (
+ <div className="space-y-1">
+ <p className="text-sm font-medium">계약 정보</p>
+ <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap">
+ {item.contractInfo}
+ </div>
+ </div>
+ )}
+
+ {pqSubmission.projectId && item.additionalRequirement && (
+ <div className="space-y-1">
+ <p className="text-sm font-medium">추가 요구사항</p>
+ <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap">
+ {item.additionalRequirement}
+ </div>
+ </div>
+ )}
+
+ {/* 벤더 답변 */}
+ <div className="space-y-1">
+ <p className="text-sm font-medium flex items-center gap-1">
+ <FileText className="h-4 w-4" />
+ 벤더 답변
+ </p>
+ <div className="rounded-md border p-3 min-h-20 whitespace-pre-wrap">
+ {item.answer || <span className="text-muted-foreground">답변 없음</span>}
+ </div>
+ </div>
+
+ {/* 첨부 파일 */}
+ {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" />
+ 첨부 파일 ({item.attachments.length})
+ </p>
+ <div className="rounded-md border p-3">
+ <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"
+ >
+ {attachment.fileName}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </div>
+ ))}
+
+ {/* 검토 버튼 */}
+ {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={() => setShowRejectDialog(true)}
+ disabled={isRejecting}
+ >
+ {isRejecting ? "거부 중..." : "거부"}
+ </Button>
+ <Button
+ variant="default"
+ onClick={() => setShowApproveDialog(true)}
+ disabled={isApproving}
+ >
+ {isApproving ? "승인 중..." : "승인"}
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 승인 확인 다이얼로그 */}
+ <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>PQ 승인 확인</DialogTitle>
+ <DialogDescription>
+ {pqSubmission.vendorName}의 {pqSubmission.type === "GENERAL" ? "일반" : "프로젝트"} PQ를 승인하시겠습니까?
+ {pqSubmission.projectId && (
+ <span> 프로젝트: {pqSubmission.projectName}</span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShowApproveDialog(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleApprove} disabled={isApproving}>
+ {isApproving ? "승인 중..." : "승인"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 거부 확인 다이얼로그 */}
+ <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>PQ 거부</DialogTitle>
+ <DialogDescription>
+ {pqSubmission.vendorName}의 {pqSubmission.type === "GENERAL" ? "일반" : "프로젝트"} PQ를 거부하는 이유를 입력해주세요.
+ {pqSubmission.projectId && (
+ <span> 프로젝트: {pqSubmission.projectName}</span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <Textarea
+ value={rejectReason}
+ onChange={(e) => setRejectReason(e.target.value)}
+ placeholder="거부 사유를 입력하세요"
+ className="min-h-24"
+ />
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShowRejectDialog(false)}>
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleReject}
+ disabled={isRejecting || !rejectReason.trim()}
+ >
+ {isRejecting ? "거부 중..." : "거부"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+} \ No newline at end of file