summaryrefslogtreecommitdiff
path: root/components/pq
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-13 11:05:09 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-13 11:05:09 +0000
commit33be47506f0aa62b969d82521580a29e95080268 (patch)
tree6b7e232f2d78ef8775944ea085a36b3ccbce7d95 /components/pq
parent2ac95090157c355ea1bd0b8eb1e1e5e2bd56faf4 (diff)
(대표님) 입찰, 법무검토, EDP 변경사항 대응, dolce 개선, form-data 개선, 정규업체 등록관리 추가
(최겸) pq 미사용 컴포넌트 및 페이지 제거, 파일 라우트에 pq 적용
Diffstat (limited to 'components/pq')
-rw-r--r--components/pq/client-pq-input-wrapper.tsx90
-rw-r--r--components/pq/pq-input-tabs.tsx884
-rw-r--r--components/pq/pq-review-detail.tsx888
-rw-r--r--components/pq/pq-review-table.tsx344
-rw-r--r--components/pq/project-select-wrapper.tsx35
-rw-r--r--components/pq/project-select.tsx173
6 files changed, 0 insertions, 2414 deletions
diff --git a/components/pq/client-pq-input-wrapper.tsx b/components/pq/client-pq-input-wrapper.tsx
deleted file mode 100644
index 42d2420d..00000000
--- a/components/pq/client-pq-input-wrapper.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Shell } from "@/components/shell"
-import { Skeleton } from "@/components/ui/skeleton"
-import { PQInputTabs } from "@/components/pq/pq-input-tabs"
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { PQGroupData, ProjectPQ } from "@/lib/pq/service"
-import { useRouter, useSearchParams } from "next/navigation"
-
-interface ClientPQWrapperProps {
- pqData: PQGroupData[] // 변경: allPQData → pqData (현재 선택된 PQ 데이터)
- projectPQs: ProjectPQ[]
- vendorId: number
- rawSearchParams: {
- projectId?: string
- }
-}
-
-export function ClientPQWrapper({
- pqData,
- projectPQs,
- vendorId,
- rawSearchParams
-}: ClientPQWrapperProps) {
- const searchParams = useSearchParams()
- const projectIdParam = searchParams?.get('projectId')
-
- // 클라이언트 측에서 projectId 파싱
- const projectId = projectIdParam ? parseInt(projectIdParam, 10) : undefined
-
- // 현재 프로젝트 정보 찾기
- const currentProject = projectId
- ? projectPQs.find(p => p.projectId === projectId)
- : null
-
- return (
- <Shell className="gap-2">
- {/* 헤더 - 프로젝트 정보 포함 */}
- <div className="space-y-2">
- <h2 className="text-2xl font-bold tracking-tight">
- Pre-Qualification Check Sheet
- {currentProject && (
- <span className="ml-2 text-muted-foreground">
- - {currentProject.projectCode}
- </span>
- )}
- </h2>
- <p className="text-muted-foreground">
- PQ에 적절한 응답을 제출하시기 바랍니다.
- </p>
- </div>
-
- {/* 일반/프로젝트 PQ 선택 탭 */}
- {projectPQs.length > 0 && (
- <div className="border-b">
- <Tabs defaultValue={projectId ? `project-${projectId}` : "general"}>
- <TabsList>
- <TabsTrigger value="general" asChild>
- <a href="/partners/pq">일반 PQ</a>
- </TabsTrigger>
-
- {projectPQs.map(project => (
- <TabsTrigger
- key={project.projectId}
- value={`project-${project.projectId}`}
- asChild
- >
- <a href={`/partners/pq?projectId=${project.projectId}`}>
- {project.projectCode}
- </a>
- </TabsTrigger>
- ))}
- </TabsList>
- </Tabs>
- </div>
- )}
-
- {/* PQ 입력 탭 */}
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- <PQInputTabs
- data={pqData}
- vendorId={vendorId}
- projectId={projectId}
- projectData={currentProject}
- />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx
deleted file mode 100644
index d72eff92..00000000
--- a/components/pq/pq-input-tabs.tsx
+++ /dev/null
@@ -1,884 +0,0 @@
-"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"
-import { useRouter } from "next/navigation"
-
-// ----------------------------------------------------------------------
-// 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,
-}: {
- data: PQGroupData[]
- vendorId: number
- projectId?: number
- projectData?: ProjectPQ | 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 router = useRouter()
- // ----------------------------------------------------------------------
- // 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)
-
- const result = await submitPQAction({
- vendorId,
- projectId, // 프로젝트 ID 전달
- })
-
- if (result.ok) {
- toast({
- title: "PQ Submitted",
- description: "Your PQ information has been submitted successfully",
- })
- // 제출 후 페이지 새로고침 또는 리디렉션 처리
- router.refresh()
- // window.location.reload()
- } 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}
- onClick={handleSaveAll}
- >
- {isSaving ? "Saving..." : "Save All"}
- <Save className="ml-2 h-4 w-4" />
- </Button>
-
- {/* Submit PQ button */}
- <Button
- type="button"
- disabled={!allSaved || isSubmitting}
- 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}
- 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/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx
deleted file mode 100644
index 4f897a2b..00000000
--- a/components/pq/pq-review-detail.tsx
+++ /dev/null
@@ -1,888 +0,0 @@
-"use client"
-
-import React from "react"
-import { Button } from "@/components/ui/button"
-import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
-import { Textarea } from "@/components/ui/textarea"
-import { useToast } from "@/hooks/use-toast"
-import {
- PQGroupData,
- requestPqChangesAction,
- updateVendorStatusAction,
- updateProjectPQStatusAction,
- getItemReviewLogsAction
-} from "@/lib/pq/service"
-import { Vendor } from "@/db/schema/vendors"
-import { Separator } from "@/components/ui/separator"
-import { Badge } from "@/components/ui/badge"
-import { ChevronsUpDown, MessagesSquare, Download, Loader2, X } from "lucide-react"
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { Card } from "@/components/ui/card"
-import { formatDate } from "@/lib/utils"
-import { downloadFileAction } from "@/lib/downloadFile"
-import { useSession } from "next-auth/react" // Importando o hook do next-auth
-
-// 코멘트 상태를 위한 인터페이스 정의
-interface PendingComment {
- answerId: number;
- checkPoint: string;
- code: string;
- comment: string;
- createdAt: Date;
-}
-
-interface ReviewLog {
- id: number
- reviewerComment: string
- reviewerName: string | null
- createdAt: Date
-}
-
-// Updated props interface to support both general and project PQs
-interface VendorPQAdminReviewProps {
- data: PQGroupData[]
- vendor: Vendor
- projectId?: number
- projectName?: string
- projectStatus?: string
- // loadData: () => Promise<PQGroupData[]>
- loadData: (vendorId: number, projectId?: number) => Promise<PQGroupData[]>
-
- pqType: 'general' | 'project'
-}
-
-export default function VendorPQAdminReview({
- data,
- vendor,
- projectId,
- projectName,
- projectStatus,
- loadData,
- pqType
-}: VendorPQAdminReviewProps) {
- const { toast } = useToast()
- const { data: session } = useSession()
- const reviewerName = session?.user?.name || "Unknown Reviewer"
- const reviewerId = session?.user?.id
-
-
- // State for dynamically loaded data
- const [pqData, setPqData] = React.useState<PQGroupData[]>(data)
- const [isDataLoading, setIsDataLoading] = React.useState(false)
-
- // Load data if not provided initially (for tab switching)
- React.useEffect(() => {
- if (data.length === 0) {
- const fetchData = async () => {
- setIsDataLoading(true)
- try {
- const freshData = await loadData(vendor.id, projectId)
-
- setPqData(freshData)
- } catch (error) {
- console.error("Error loading PQ data:", error)
- toast({
- title: "Error",
- description: "Failed to load PQ data",
- variant: "destructive"
- })
- } finally {
- setIsDataLoading(false)
- }
- }
- fetchData()
- } else {
- setPqData(data)
- }
- }, [data, loadData, vendor.id, projectId, toast])
-
- // 다이얼로그 상태들
- const [showRequestDialog, setShowRequestDialog] = React.useState(false)
- const [showApproveDialog, setShowApproveDialog] = React.useState(false)
- const [showRejectDialog, setShowRejectDialog] = React.useState(false)
-
- // 코멘트 상태들
- const [requestComment, setRequestComment] = React.useState("")
- const [approveComment, setApproveComment] = React.useState("")
- const [rejectComment, setRejectComment] = React.useState("")
- const [isLoading, setIsLoading] = React.useState(false)
-
- // 항목별 코멘트 상태 추적 (메모리에만 저장)
- const [pendingComments, setPendingComments] = React.useState<PendingComment[]>([])
-
- // 코멘트 추가 핸들러 - 실제 서버 저장이 아닌 메모리에 저장
- const handleCommentAdded = (newComment: PendingComment) => {
- setPendingComments(prev => [...prev, newComment]);
- toast({
- title: "Comment Added",
- description: `Comment added for ${newComment.code}. Please "Request Changes" to save.`
- });
- }
-
- // 코멘트 삭제 핸들러
- const handleRemoveComment = (index: number) => {
- setPendingComments(prev => prev.filter((_, i) => i !== index));
- }
-
- // 1) 승인 다이얼로그 표시
- const handleApprove = () => {
- // 코멘트가 있는데 승인하려고 하면 경고
- if (pendingComments.length > 0) {
- if (!confirm('You have unsaved comments. Are you sure you want to approve without requesting changes?')) {
- return;
- }
- }
- setShowApproveDialog(true)
- }
-
- // 실제 승인 처리 - 일반 PQ와 프로젝트 PQ 분리
- const handleSubmitApprove = async () => {
- try {
- setIsLoading(true)
- setShowApproveDialog(false)
-
- let res;
-
- if (pqType === 'general') {
- // 일반 PQ 승인
- res = await updateVendorStatusAction(vendor.id, "PQ_APPROVED")
- } else if (projectId) {
- // 프로젝트 PQ 승인
- res = await updateProjectPQStatusAction({
- vendorId: vendor.id,
- projectId,
- status: "APPROVED",
- comment: approveComment.trim() || undefined
- })
- }
-
- if (res?.ok) {
- toast({
- title: "Approved",
- description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been approved.`
- })
- // 코멘트 초기화
- setPendingComments([]);
- } else {
- toast({
- title: "Error",
- description: res?.error || "An error occurred",
- variant: "destructive"
- })
- }
- } catch (error) {
- toast({ title: "Error", description: String(error), variant: "destructive" })
- } finally {
- setIsLoading(false)
- setApproveComment("")
- }
- }
-
- // 2) 거부 다이얼로그 표시
- const handleReject = () => {
- // 코멘트가 있는데 거부하려고 하면 경고
- if (pendingComments.length > 0) {
- if (!confirm('You have unsaved comments. Are you sure you want to reject without requesting changes?')) {
- return;
- }
- }
- setShowRejectDialog(true)
- }
-
- // 실제 거부 처리 - 일반 PQ와 프로젝트 PQ 분리
- const handleSubmitReject = async () => {
- try {
- setIsLoading(true)
- setShowRejectDialog(false)
-
- if (!rejectComment.trim()) {
- toast({
- title: "Error",
- description: "Please provide a reason for rejection",
- variant: "destructive"
- })
- return;
- }
-
- let res;
-
- if (pqType === 'general') {
- // 일반 PQ 거부
- res = await updateVendorStatusAction(vendor.id, "REJECTED")
- } else if (projectId) {
- // 프로젝트 PQ 거부
- res = await updateProjectPQStatusAction({
- vendorId: vendor.id,
- projectId,
- status: "REJECTED",
- comment: rejectComment
- })
- }
-
- if (res?.ok) {
- toast({
- title: "Rejected",
- description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been rejected.`
- })
- // 코멘트 초기화
- setPendingComments([]);
- } else {
- toast({
- title: "Error",
- description: res?.error || "An error occurred",
- variant: "destructive"
- })
- }
- } catch (error) {
- toast({ title: "Error", description: String(error), variant: "destructive" })
- } finally {
- setIsLoading(false)
- setRejectComment("")
- }
- }
-
- // 3) 변경 요청 다이얼로그 표시
- const handleRequestChanges = () => {
- setShowRequestDialog(true)
- }
-
- // 4) 변경 요청 처리 - 이제 프로젝트 ID 포함
- const handleSubmitRequestChanges = async () => {
- try {
- setIsLoading(true);
- setShowRequestDialog(false);
-
- // 항목별 코멘트 준비 - answerId와 함께 checkPoint와 code도 전송
- const itemComments = pendingComments.map(pc => ({
- answerId: pc.answerId,
- checkPoint: pc.checkPoint,
- code: pc.code,
- comment: pc.comment
- }));
-
- // 서버 액션 호출 (프로젝트 ID 추가)
- const res = await requestPqChangesAction({
- vendorId: vendor.id,
- projectId: pqType === 'project' ? projectId : undefined,
- comment: itemComments,
- generalComment: requestComment || undefined,
- reviewerName,
- reviewerId
- });
-
- if (res.ok) {
- toast({
- title: "Changes Requested",
- description: `${pqType === 'general' ? 'Vendor' : 'Project'} was notified of your comments.`,
- });
- // 코멘트 초기화
- setPendingComments([]);
- } else {
- toast({
- title: "Error",
- description: res.error,
- variant: "destructive"
- });
- }
- } catch (error) {
- toast({
- title: "Error",
- description: String(error),
- variant: "destructive"
- });
- } finally {
- setIsLoading(false);
- setRequestComment("");
- }
- };
-
- // 현재 상태에 따른 액션 버튼 비활성화 여부 판단
- const getDisabledState = () => {
- if (pqType === 'general') {
- // 일반 PQ는 vendor 상태에 따라 결정
- return vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED';
- } else if (pqType === 'project' && projectStatus) {
- // 프로젝트 PQ는 project 상태에 따라 결정
- return projectStatus === 'APPROVED' || projectStatus === 'REJECTED';
- }
- return false;
- };
-
- const areActionsDisabled = getDisabledState();
-
- return (
- <div className="space-y-4">
- {/* PQ Type indicators and status */}
- {pqType === 'project' && projectName && (
- <div className="flex flex-col space-y-1 mb-4">
- <div className="flex items-center gap-2">
- <Badge variant="outline">{projectName}</Badge>
- {projectStatus && (
- <Badge className={
- projectStatus === 'APPROVED' ? 'bg-green-100 text-green-800' :
- projectStatus === 'REJECTED' ? 'bg-red-100 text-red-800' :
- 'bg-blue-100 text-blue-800'
- }>
- {projectStatus}
- </Badge>
- )}
- </div>
- {areActionsDisabled && (
- <p className="text-sm text-muted-foreground">
- This PQ has already been {
- pqType !== 'project'
- ? (vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED' ? 'approved' : 'rejected')
- : (projectStatus === 'APPROVED' ? 'approved' : 'rejected')
- }. No further actions can be taken.
- </p>
- )}
- </div>
- )}
-
- {/* Loading indicator */}
- {isDataLoading && (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-8 w-8 animate-spin text-primary" />
- </div>
- )}
-
- {!isDataLoading && (
- <>
- {/* Top header */}
- <div className="flex items-center justify-between">
- <h2 className="text-2xl font-bold">
- {vendor.vendorCode} - {vendor.vendorName} {pqType === 'project' ? 'Project' : 'General'} PQ Review
- </h2>
- <div className="flex gap-2">
- <Button
- variant="outline"
- disabled={isLoading || areActionsDisabled}
- onClick={handleReject}
- >
- Reject
- </Button>
- <Button
- variant={pendingComments.length > 0 ? "default" : "outline"}
- disabled={isLoading || areActionsDisabled}
- onClick={handleRequestChanges}
- >
- Request Changes
- {pendingComments.length > 0 && (
- <span className="ml-2 bg-white text-primary rounded-full h-5 min-w-5 inline-flex items-center justify-center text-xs px-1">
- {pendingComments.length}
- </span>
- )}
- </Button>
- <Button
- disabled={isLoading || areActionsDisabled}
- onClick={handleApprove}
- >
- Approve
- </Button>
- </div>
- </div>
-
- <p className="text-sm text-muted-foreground">
- Review the submitted PQ items below, then approve, reject, or request more info.
- </p>
-
- {/* 코멘트가 있을 때 알림 표시 */}
- {pendingComments.length > 0 && (
- <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-yellow-800">
- <p className="text-sm font-medium flex items-center">
- <span className="mr-2">⚠️</span>
- You have {pendingComments.length} pending comments. Click "Request Changes" to save them.
- </p>
- </div>
- )}
-
- <Separator />
-
- {/* PQ 데이터 표시 */}
- {pqData.length > 0 ? (
- <VendorPQReviewPageIntegrated
- data={pqData}
- onCommentAdded={handleCommentAdded}
- />
- ) : (
- <div className="text-center py-10">
- <p className="text-muted-foreground">No PQ data available for review.</p>
- </div>
- )}
- </>
- )}
-
- {/* 변경 요청 다이얼로그 */}
- <Dialog open={showRequestDialog} onOpenChange={setShowRequestDialog}>
- <DialogContent className="max-w-3xl">
- <DialogHeader>
- <DialogTitle>Request PQ Changes</DialogTitle>
- <DialogDescription>
- Review your comments and add any additional notes. The vendor will receive these changes.
- </DialogDescription>
- </DialogHeader>
-
- {/* 항목별 코멘트 목록 */}
- {pendingComments.length > 0 && (
- <div className="border rounded-md p-2 space-y-2 max-h-[300px] overflow-y-auto">
- <h3 className="font-medium text-sm">Item Comments:</h3>
- {pendingComments.map((comment, index) => (
- <div key={index} className="flex items-start gap-2 p-2 border rounded-md bg-muted/50">
- <div className="flex-1">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium">{comment.code}</span>
- <span className="text-sm">{comment.checkPoint}</span>
- </div>
- <p className="text-sm mt-1">{comment.comment}</p>
- <p className="text-xs text-muted-foreground mt-1">
- {formatDate(comment.createdAt, "KR")}
- </p>
- </div>
- <Button
- variant="ghost"
- size="sm"
- className="p-0 h-8 w-8"
- onClick={() => handleRemoveComment(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- )}
-
- {/* 추가 코멘트 입력 */}
- <div className="space-y-2 mt-2">
- <label className="text-sm font-medium">
- {pendingComments.length > 0
- ? "Additional comments (optional):"
- : "Enter details about what should be modified:"}
- </label>
- <Textarea
- value={requestComment}
- onChange={(e) => setRequestComment(e.target.value)}
- placeholder={pendingComments.length > 0
- ? "Add any additional notes..."
- : "Please correct item #1, etc..."}
- className="min-h-[100px]"
- />
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setShowRequestDialog(false)}
- disabled={isLoading}
- >
- Cancel
- </Button>
- <Button
- onClick={handleSubmitRequestChanges}
- disabled={isLoading || (pendingComments.length === 0 && !requestComment.trim())}
- >
- Submit Changes
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-
- {/* 승인 확인 다이얼로그 */}
- <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Confirm Approval</DialogTitle>
- <DialogDescription>
- Are you sure you want to approve this {pqType === 'project' ? 'project' : 'vendor'} PQ? You can add a comment if needed.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-2">
- <Textarea
- value={approveComment}
- onChange={(e) => setApproveComment(e.target.value)}
- placeholder="Optional: Add any comments about this approval"
- className="min-h-[100px]"
- />
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setShowApproveDialog(false)}
- disabled={isLoading}
- >
- Cancel
- </Button>
- <Button
- onClick={handleSubmitApprove}
- disabled={isLoading}
- >
- Confirm Approval
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-
- {/* 거부 확인 다이얼로그 */}
- <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Confirm Rejection</DialogTitle>
- <DialogDescription>
- Are you sure you want to reject this {pqType === 'project' ? 'project' : 'vendor'} PQ? Please provide a reason.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-2">
- <Textarea
- value={rejectComment}
- onChange={(e) => setRejectComment(e.target.value)}
- placeholder="Required: Provide reason for rejection"
- className="min-h-[150px]"
- />
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setShowRejectDialog(false)}
- disabled={isLoading}
- >
- Cancel
- </Button>
- <Button
- onClick={handleSubmitReject}
- disabled={isLoading || !rejectComment.trim()}
- variant="destructive"
- >
- Confirm Rejection
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </div>
- )
-}
-
-// 코멘트 추가 함수 인터페이스
-interface VendorPQReviewPageIntegratedProps {
- data: PQGroupData[];
- onCommentAdded: (comment: PendingComment) => void;
-}
-
-// 통합된 VendorPQReviewPage 컴포넌트
-function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPageIntegratedProps) {
- const { toast } = useToast();
-
- // 파일 다운로드 함수 - 서버 액션 사용
- const handleFileDownload = async (filePath: string, fileName: string) => {
- try {
- toast({
- title: "Download Started",
- description: `Preparing ${fileName} for download...`,
- });
-
- // 서버 액션 호출
- const result = await downloadFileAction(filePath);
-
- if (!result.ok || !result.data) {
- throw new Error(result.error || 'Failed to download file');
- }
-
- // Base64 디코딩하여 Blob 생성
- const binaryString = atob(result.data.content);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
-
- // Blob 생성 및 다운로드
- const blob = new Blob([bytes.buffer], { type: result.data.mimeType });
- const url = URL.createObjectURL(blob);
-
- // 다운로드 링크 생성 및 클릭
- const a = document.createElement('a');
- a.href = url;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
-
- // 정리
- URL.revokeObjectURL(url);
- document.body.removeChild(a);
-
- toast({
- title: "Download Complete",
- description: `${fileName} downloaded successfully`,
- });
- } catch (error) {
- console.error('Download error:', error);
- toast({
- title: "Download Error",
- description: error instanceof Error ? error.message : "Failed to download file",
- variant: "destructive"
- });
- }
- };
-
- return (
- <div className="space-y-4">
- {data.map((group) => (
- <Collapsible key={group.groupName} defaultOpen>
- <CollapsibleTrigger asChild>
- <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded">
- <h2 className="font-semibold text-lg">{group.groupName}</h2>
- <Button variant="ghost" size="sm" className="p-0 h-7 w-7">
- <ChevronsUpDown className="h-4 w-4" />
- </Button>
- </div>
- </CollapsibleTrigger>
-
- <CollapsibleContent>
- <Card className="mt-2 p-4">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[60px]">Code</TableHead>
- <TableHead>Check Point</TableHead>
- <TableHead>Answer</TableHead>
- <TableHead className="w-[180px]">Attachments</TableHead>
- <TableHead className="w-[60px] text-center">Comments</TableHead>
- </TableRow>
- </TableHeader>
-
- <TableBody>
- {group.items.map((item) => (
- <TableRow key={item.criteriaId}>
- <TableCell className="font-medium">{item.code}</TableCell>
- <TableCell>{item.checkPoint}</TableCell>
-
- <TableCell>
- {item.answer ? (
- <p className="whitespace-pre-wrap text-sm">
- {item.answer}
- </p>
- ) : (
- <p className="text-sm text-muted-foreground">(no answer)</p>
- )}
- </TableCell>
-
- <TableCell>
- {item.attachments.length > 0 ? (
- <ul className="list-none space-y-1">
- {item.attachments.map((file) => (
- <li key={file.attachId} className="text-sm flex items-center">
- <button
- className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]"
- onClick={() => handleFileDownload(file.filePath, file.fileName)}
- >
- <Download className="h-3 w-3 mr-1 flex-shrink-0" />
- <span className="truncate">{file.fileName}</span>
- </button>
- </li>
- ))}
- </ul>
- ) : (
- <p className="text-sm text-muted-foreground">(none)</p>
- )}
- </TableCell>
-
- <TableCell className="text-center">
- <ItemCommentButton
- item={item}
- onCommentAdded={onCommentAdded}
- />
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </Card>
- </CollapsibleContent>
- </Collapsible>
- ))}
- </div>
- );
-}
-
-// 항목 코멘트 버튼 컴포넌트 props
-interface ItemCommentButtonProps {
- item: any; // 항목 데이터
- onCommentAdded: (comment: PendingComment) => void;
-}
-
-// 항목별 코멘트 버튼 컴포넌트 (기존 로그 표시 + 메모리에 새 코멘트 저장)
-function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) {
- const { toast } = useToast();
- const [open, setOpen] = React.useState(false);
- const [logs, setLogs] = React.useState<ReviewLog[]>([]);
- const [newComment, setNewComment] = React.useState("");
- const [isLoading, setIsLoading] = React.useState(false);
- const [hasComments, setHasComments] = React.useState(false);
-
- // If there's no answerId, item wasn't answered
- if (!item.answerId) {
- return <p className="text-xs text-muted-foreground">N/A</p>;
- }
-
- // 기존 로그 가져오기
- const fetchLogs = React.useCallback(async () => {
- try {
- setIsLoading(true);
- const res = await getItemReviewLogsAction({ answerId: item.answerId });
-
- if (res.ok && res.data) {
- setLogs(res.data);
- // 코멘트 존재 여부 설정
- setHasComments(res.data.length > 0);
- } else {
- console.error("Error response:", res.error);
- toast({ title: "Error", description: res.error, variant: "destructive" });
- }
- } catch (error) {
- console.error("Fetch error:", error);
- toast({ title: "Error", description: String(error), variant: "destructive" });
- } finally {
- setIsLoading(false);
- }
- }, [item.answerId, toast]);
-
- // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용)
- React.useEffect(() => {
- const checkComments = async () => {
- try {
- const res = await getItemReviewLogsAction({ answerId: item.answerId });
- if (res.ok && res.data) {
- setHasComments(res.data.length > 0);
- }
- } catch (error) {
- console.error("Error checking comments:", error);
- }
- };
-
- checkComments();
- }, [item.answerId]);
-
- // open 상태가 변경될 때 로그 가져오기
- React.useEffect(() => {
- if (open) {
- fetchLogs();
- }
- }, [open, fetchLogs]);
-
- // 다이얼로그 열기
- const handleButtonClick = React.useCallback(() => {
- setOpen(true);
- }, []);
-
- // 다이얼로그 상태 변경
- const handleOpenChange = React.useCallback((nextOpen: boolean) => {
- setOpen(nextOpen);
- }, []);
-
- // 코멘트 추가 처리 (메모리에만 저장)
- const handleAddComment = React.useCallback(() => {
- if (!newComment.trim()) return;
-
- setIsLoading(true);
-
- // 새 코멘트 생성
- const pendingComment: PendingComment = {
- answerId: item.answerId,
- checkPoint: item.checkPoint,
- code: item.code,
- comment: newComment.trim(),
- createdAt: new Date()
- };
-
- // 부모 컴포넌트에 전달
- onCommentAdded(pendingComment);
-
- // 상태 초기화
- setNewComment("");
- setOpen(false);
- setIsLoading(false);
- }, [item, newComment, onCommentAdded]);
-
- return (
- <>
- <Button variant="ghost" size="sm" onClick={handleButtonClick}>
- <MessagesSquare
- className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`}
- />
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>{item.checkPoint}</DialogTitle>
- <DialogDescription>
- Review existing comments and add new ones
- </DialogDescription>
- </DialogHeader>
-
- {/* 기존 로그 섹션 */}
- <div className="max-h-[200px] overflow-y-auto space-y-2">
- {isLoading ? (
- <div className="flex justify-center p-4">
- <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
- </div>
- ) : logs.length > 0 ? (
- <div className="space-y-2">
- <h3 className="text-sm font-medium">Previous Comments:</h3>
- {logs.map((log) => (
- <div key={log.id} className="p-2 border rounded text-sm">
- <p className="font-medium">{log.reviewerName}</p>
- <p>{log.reviewerComment}</p>
- <p className="text-xs text-muted-foreground">
- {formatDate(log.createdAt, "KR")}
- </p>
- </div>
- ))}
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">No previous comments yet.</p>
- )}
- </div>
-
- {/* 구분선 */}
- {/* <Separator /> */}
-
- {/* 새 코멘트 추가 섹션 */}
- <div className="space-y-2 mt-2">
- <div className="flex items-center justify-between">
- {/* <h3 className="text-sm font-medium">Add New Comment:</h3> */}
- {/* <p className="text-xs text-muted-foreground">
- Comments will be saved when you click "Request Changes"
- </p> */}
- </div>
- <Textarea
- placeholder="Add your comment..."
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- className="min-h-[100px]"
- />
- <Button
- onClick={handleAddComment}
- disabled={isLoading || !newComment.trim()}
- >
- Add Comment
- </Button>
- </div>
- </DialogContent>
- </Dialog>
- </>
- );
-} \ No newline at end of file
diff --git a/components/pq/pq-review-table.tsx b/components/pq/pq-review-table.tsx
deleted file mode 100644
index ce30bac0..00000000
--- a/components/pq/pq-review-table.tsx
+++ /dev/null
@@ -1,344 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ChevronsUpDown, MessagesSquare, Download, Loader2 } from "lucide-react"
-
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { Card } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { PQGroupData } from "@/lib/pq/service"
-import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Textarea } from "@/components/ui/textarea"
-import { addReviewCommentAction, getItemReviewLogsAction } from "@/lib/pq/service"
-import { useToast } from "@/hooks/use-toast"
-import { formatDate } from "@/lib/utils"
-import { downloadFileAction } from "@/lib/downloadFile"
-import { useSession } from "next-auth/react"
-
-interface ReviewLog {
- id: number
- reviewerComment: string
- reviewerName: string | null
- createdAt: Date
-}
-
-interface VendorPQReviewPageProps {
- data: PQGroupData[];
- onCommentAdded?: () => void; // 코멘트 추가 콜백
-}
-
-export default function VendorPQReviewPage({ data, onCommentAdded }: VendorPQReviewPageProps) {
- const { toast } = useToast()
-
- // 파일 다운로드 함수 - 서버 액션 사용
- const handleFileDownload = async (filePath: string, fileName: string) => {
- try {
- toast({
- title: "Download Started",
- description: `Preparing ${fileName} for download...`,
- });
-
- // 서버 액션 호출
- const result = await downloadFileAction(filePath);
-
- if (!result.ok || !result.data) {
- throw new Error(result.error || 'Failed to download file');
- }
-
- // Base64 디코딩하여 Blob 생성
- const binaryString = atob(result.data.content);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
-
- // Blob 생성 및 다운로드
- const blob = new Blob([bytes.buffer], { type: result.data.mimeType });
- const url = URL.createObjectURL(blob);
-
- // 다운로드 링크 생성 및 클릭
- const a = document.createElement('a');
- a.href = url;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
-
- // 정리
- URL.revokeObjectURL(url);
- document.body.removeChild(a);
-
- toast({
- title: "Download Complete",
- description: `${fileName} downloaded successfully`,
- });
- } catch (error) {
- console.error('Download error:', error);
- toast({
- title: "Download Error",
- description: error instanceof Error ? error.message : "Failed to download file",
- variant: "destructive"
- });
- }
- };
-
- return (
- <div className="space-y-4">
- {data.map((group) => (
- <Collapsible key={group.groupName} defaultOpen>
- <CollapsibleTrigger asChild>
- <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded">
- <h2 className="font-semibold text-lg">{group.groupName}</h2>
- <Button variant="ghost" size="sm" className="p-0 h-7 w-7">
- <ChevronsUpDown className="h-4 w-4" />
- </Button>
- </div>
- </CollapsibleTrigger>
-
- <CollapsibleContent>
- <Card className="mt-2 p-4">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[60px]">Code</TableHead>
- <TableHead>Check Point</TableHead>
- <TableHead>Answer</TableHead>
- <TableHead className="w-[180px]">Attachments</TableHead>
- <TableHead className="w-[60px] text-center">Comments</TableHead>
- </TableRow>
- </TableHeader>
-
- <TableBody>
- {group.items.map((item) => (
- <TableRow key={item.criteriaId}>
- <TableCell className="font-medium">{item.code}</TableCell>
- <TableCell>{item.checkPoint}</TableCell>
-
- <TableCell>
- {item.answer ? (
- <p className="whitespace-pre-wrap text-sm">
- {item.answer}
- </p>
- ) : (
- <p className="text-sm text-muted-foreground">(no answer)</p>
- )}
- </TableCell>
-
- <TableCell>
- {item.attachments.length > 0 ? (
- <ul className="list-none space-y-1">
- {item.attachments.map((file) => (
- <li key={file.attachId} className="text-sm flex items-center">
- <button
- className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]"
- onClick={() => handleFileDownload(file.filePath, file.fileName)}
- >
- <Download className="h-3 w-3 mr-1 flex-shrink-0" />
- <span className="truncate">{file.fileName}</span>
- </button>
- </li>
- ))}
- </ul>
- ) : (
- <p className="text-sm text-muted-foreground">(none)</p>
- )}
- </TableCell>
-
- <TableCell className="text-center">
- <ItemReviewButton
- answerId={item.answerId ?? undefined}
- checkPoint={item.checkPoint}
- onCommentAdded={onCommentAdded}
- />
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </Card>
- </CollapsibleContent>
- </Collapsible>
- ))}
- </div>
- )
-}
-
-interface ItemReviewButtonProps {
- answerId?: number;
- checkPoint: string; // Check Point 추가
- onCommentAdded?: () => void;
-}
-
-/**
- * A button that opens a dialog to show logs + add new comment for a single item (vendorPqCriteriaAnswers).
- */
-function ItemReviewButton({ answerId, checkPoint, onCommentAdded }: ItemReviewButtonProps) {
- const { toast } = useToast();
- const [open, setOpen] = React.useState(false);
- const [logs, setLogs] = React.useState<ReviewLog[]>([]);
- const [newComment, setNewComment] = React.useState("");
- const [isLoading, setIsLoading] = React.useState(false);
- const [hasComments, setHasComments] = React.useState(false);
- const { data: session } = useSession()
- const reviewerName = session?.user?.name || "Unknown Reviewer"
- const reviewerId = session?.user?.id
-
- // If there's no answerId, item wasn't answered
- if (!answerId) {
- return <p className="text-xs text-muted-foreground">N/A</p>;
- }
-
- // fetchLogs 함수를 useCallback으로 메모이제이션
- const fetchLogs = React.useCallback(async () => {
- try {
- setIsLoading(true);
- const res = await getItemReviewLogsAction({ answerId });
-
- if (res.ok && res.data) {
- setLogs(res.data);
- // 코멘트 존재 여부 설정
- setHasComments(res.data.length > 0);
- } else {
- console.error("Error response:", res.error);
- toast({ title: "Error", description: res.error, variant: "destructive" });
- }
- } catch (error) {
- console.error("Fetch error:", error);
- toast({ title: "Error", description: String(error), variant: "destructive" });
- } finally {
- setIsLoading(false);
- }
- }, [answerId, toast]);
-
- // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용)
- React.useEffect(() => {
- const checkComments = async () => {
- try {
- const res = await getItemReviewLogsAction({ answerId });
- if (res.ok && res.data) {
- setHasComments(res.data.length > 0);
- }
- } catch (error) {
- console.error("Error checking comments:", error);
- }
- };
-
- checkComments();
- }, [answerId]);
-
- // open 상태가 변경될 때 로그 가져오기
- React.useEffect(() => {
- if (open) {
- fetchLogs();
- }
- }, [open, fetchLogs]);
-
- // 버튼 클릭 핸들러 - 다이얼로그 열기
- const handleButtonClick = React.useCallback(() => {
- setOpen(true);
- }, []);
-
- // 다이얼로그 상태 변경 핸들러
- const handleOpenChange = React.useCallback((nextOpen: boolean) => {
- setOpen(nextOpen);
- }, []);
-
- // 코멘트 추가 핸들러
- const handleAddComment = React.useCallback(async () => {
- try {
- setIsLoading(true);
-
- const res = await addReviewCommentAction({
- answerId,
- comment: newComment,
- reviewerName,
- });
-
- if (res.ok) {
- toast({ title: "Comment added", description: "New review comment saved" });
- setNewComment("");
- setHasComments(true); // 코멘트 추가 성공 시 상태 업데이트
-
- // 코멘트가 추가되었음을 부모 컴포넌트에 알림
- if (onCommentAdded) {
- onCommentAdded();
- }
-
- // 로그 다시 가져오기
- fetchLogs();
- } else {
- toast({ title: "Error", description: res.error, variant: "destructive" });
- }
- } catch (error) {
- toast({ title: "Error", description: String(error), variant: "destructive" });
- } finally {
- setIsLoading(false);
- }
- }, [answerId, newComment, onCommentAdded, fetchLogs, toast]);
-
- return (
- <>
- <Button variant="ghost" size="sm" onClick={handleButtonClick}>
- <MessagesSquare
- className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`}
- />
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>{checkPoint} Comments</DialogTitle>
- </DialogHeader>
-
- {/* Logs section */}
- <div className="max-h-[200px] overflow-y-auto space-y-2">
- {isLoading ? (
- <div className="flex justify-center p-4">
- <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
- </div>
- ) : logs.length > 0 ? (
- logs.map((log) => (
- <div key={log.id} className="p-2 border rounded text-sm">
- <p className="font-medium">{log.reviewerName}</p>
- <p>{log.reviewerComment}</p>
- <p className="text-xs text-muted-foreground">
- {formatDate(log.createdAt, "KR")}
- </p>
- </div>
- ))
- ) : (
- <p className="text-sm text-muted-foreground">No comments yet.</p>
- )}
- </div>
-
- {/* Add new comment */}
- <div className="space-y-2">
- <Textarea
- placeholder="Add a new comment..."
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- />
- <Button
- size="sm"
- onClick={handleAddComment}
- disabled={isLoading || !newComment.trim()}
- >
- Add Comment
- </Button>
- </div>
- </DialogContent>
- </Dialog>
- </>
- );
-} \ No newline at end of file
diff --git a/components/pq/project-select-wrapper.tsx b/components/pq/project-select-wrapper.tsx
deleted file mode 100644
index 1405ab02..00000000
--- a/components/pq/project-select-wrapper.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { type Project } from "@/lib/rfqs/service"
-import { ProjectSelector } from "./project-select"
-
-interface ProjectSelectorWrapperProps {
- selectedProjectId?: number | null
-}
-
-export function ProjectSelectorWrapper({ selectedProjectId }: ProjectSelectorWrapperProps) {
- const router = useRouter()
-
- const handleProjectSelect = (project: Project | null) => {
- if (project && project.id) {
- router.push(`/evcp/pq-criteria/${project.id}`)
- } else {
- // 프로젝트가 null인 경우 (선택 해제)
- router.push(`/evcp/pq-criteria`)
- }
- }
-
- return (
- <div className="w-[400px]">
- <ProjectSelector
- selectedProjectId={selectedProjectId}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트를 선택하세요"
- showClearOption={true}
- clearOptionText="일반 PQ 보기"
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/components/pq/project-select.tsx b/components/pq/project-select.tsx
deleted file mode 100644
index 0d6e6445..00000000
--- a/components/pq/project-select.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Check, ChevronsUpDown, X } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
-import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from "@/components/ui/command"
-import { cn } from "@/lib/utils"
-import { getProjects, type Project } from "@/lib/rfqs/service"
-
-interface ProjectSelectorProps {
- selectedProjectId?: number | null;
- onProjectSelect: (project: Project | null) => void;
- placeholder?: string;
- showClearOption?: boolean;
- clearOptionText?: string;
-}
-
-export function ProjectSelector({
- selectedProjectId,
- onProjectSelect,
- placeholder = "프로젝트 선택...",
- showClearOption = true,
- clearOptionText = "일반 PQ 보기"
-}: ProjectSelectorProps) {
- const [open, setOpen] = React.useState(false)
- const [searchTerm, setSearchTerm] = React.useState("")
- const [projects, setProjects] = React.useState<Project[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
- const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
-
- // 모든 프로젝트 데이터 로드 (한 번만)
- React.useEffect(() => {
- async function loadAllProjects() {
- setIsLoading(true);
- try {
- const allProjects = await getProjects();
- setProjects(allProjects);
-
- // 초기 선택된 프로젝트가 있으면 설정
- if (selectedProjectId) {
- const selected = allProjects.find(p => p.id === selectedProjectId);
- if (selected) {
- setSelectedProject(selected);
- }
- }
- } catch (error) {
- console.error("프로젝트 목록 로드 오류:", error);
- } finally {
- setIsLoading(false);
- }
- }
-
- loadAllProjects();
- }, [selectedProjectId]);
-
- // 클라이언트 측에서 검색어로 필터링
- const filteredProjects = React.useMemo(() => {
- if (!searchTerm.trim()) return projects;
-
- const lowerSearch = searchTerm.toLowerCase();
- return projects.filter(
- project =>
- project.projectCode.toLowerCase().includes(lowerSearch) ||
- project.projectName.toLowerCase().includes(lowerSearch)
- );
- }, [projects, searchTerm]);
-
- // 프로젝트 선택 처리
- const handleSelectProject = (project: Project) => {
- setSelectedProject(project);
- onProjectSelect(project);
- setOpen(false);
- };
-
- // 선택 해제 처리
- const handleClearSelection = () => {
- setSelectedProject(null);
- onProjectSelect(null);
- setOpen(false);
- };
-
- return (
- <div className="space-y-1">
- {/* 선택된 프로젝트 정보 표시 (선택된 경우에만) */}
- {selectedProject && (
- <div className="flex items-center justify-between px-2">
- <div className="flex flex-col">
- <div className="text-sm font-medium">{selectedProject.projectCode}</div>
- <div className="text-xs text-muted-foreground truncate max-w-[300px]">
- {selectedProject.projectName}
- </div>
- </div>
- <Button
- variant="ghost"
- size="sm"
- className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
- onClick={handleClearSelection}
- >
- <X className="h-4 w-4" />
- <span className="sr-only">선택 해제</span>
- </Button>
- </div>
- )}
-
- {/* 셀렉터 컴포넌트 */}
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={open}
- className="w-full justify-between"
- >
- {selectedProject ? "프로젝트 변경..." : placeholder}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
-
- <PopoverContent className="w-[400px] p-0">
- <Command>
- <CommandInput
- placeholder="프로젝트 코드/이름 검색..."
- onValueChange={setSearchTerm}
- />
- <CommandList className="max-h-[300px]">
- <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
-
- {showClearOption && selectedProject && (
- <>
- <CommandGroup>
- <CommandItem
- onSelect={handleClearSelection}
- className="text-blue-600 font-medium"
- >
- {clearOptionText}
- </CommandItem>
- </CommandGroup>
- <CommandSeparator />
- </>
- )}
-
- {isLoading ? (
- <div className="py-6 text-center text-sm">로딩 중...</div>
- ) : (
- <CommandGroup>
- {filteredProjects.map((project) => (
- <CommandItem
- key={project.id}
- value={`${project.projectCode} ${project.projectName}`}
- onSelect={() => handleSelectProject(project)}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- selectedProject?.id === project.id
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- <span className="font-medium">{project.projectCode}</span>
- <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span>
- </CommandItem>
- ))}
- </CommandGroup>
- )}
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- </div>
- );
-} \ No newline at end of file