diff options
Diffstat (limited to 'components/pq')
| -rw-r--r-- | components/pq/pq-input-tabs.tsx | 780 | ||||
| -rw-r--r-- | components/pq/pq-review-detail.tsx | 712 | ||||
| -rw-r--r-- | components/pq/pq-review-table.tsx | 340 |
3 files changed, 1832 insertions, 0 deletions
diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx new file mode 100644 index 00000000..743e1729 --- /dev/null +++ b/components/pq/pq-input-tabs.tsx @@ -0,0 +1,780 @@ +"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 from shadcn/ui +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" + +// Additional UI +import { Separator } from "../ui/separator" + +// Server actions (adjust to your actual code) +import { + uploadFileAction, + savePQAnswersAction, + submitPQAction, +} 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, +}: { + data: PQGroupData[] + vendorId: number +}) { + 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() + + // ---------------------------------------------------------------------- + // 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, + 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) + if (result.ok) { + toast({ + title: "PQ Submitted", + description: "Your PQ information has been submitted successfully", + }) + // Optionally redirect + } 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) + } + } + + // ---------------------------------------------------------------------- + // H) Render + // ---------------------------------------------------------------------- + return ( + <Form {...form}> + <form> + <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 } = 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> + {/* Answer Field */} + <CardHeader className="pt-0 pb-3"> + <FormField + control={form.control} + name={`answers.${answerIndex}.answer`} + render={({ field }) => ( + <FormItem className="mt-3"> + <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> + )} + /> + </CardHeader> + + {/* Attachments / Dropzone */} + <CardContent> + <div className="grid gap-2"> + <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> + Review your answers before final submission. + </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 new file mode 100644 index 00000000..e5cd080e --- /dev/null +++ b/components/pq/pq-review-detail.tsx @@ -0,0 +1,712 @@ +"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, getItemReviewLogsAction } from "@/lib/pq/service" +import { Vendor } from "@/db/schema/vendors" +import { Separator } from "@/components/ui/separator" +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" + +// 코멘트 상태를 위한 인터페이스 정의 +interface PendingComment { + answerId: number; + checkPoint: string; + code: string; + comment: string; + createdAt: Date; +} + +interface ReviewLog { + id: number + reviewerComment: string + reviewerName: string | null + createdAt: Date +} + +export default function VendorPQAdminReview({ + data, + vendor, +}: { + data: PQGroupData[] + vendor: Vendor +}) { + const { toast } = useToast() + + // 다이얼로그 상태들 + 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) + } + + // 실제 승인 처리 + const handleSubmitApprove = async () => { + try { + setIsLoading(true) + setShowApproveDialog(false) + + const res = await updateVendorStatusAction(vendor.id, "APPROVED") + if (res.ok) { + toast({ title: "Approved", description: "Vendor PQ has been approved." }) + // 코멘트 초기화 + setPendingComments([]); + } else { + toast({ title: "Error", description: res.error, 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) + } + + // 실제 거부 처리 + const handleSubmitReject = async () => { + try { + setIsLoading(true) + setShowRejectDialog(false) + + const res = await updateVendorStatusAction(vendor.id, "REJECTED") + if (res.ok) { + toast({ title: "Rejected", description: "Vendor PQ has been rejected." }) + // 코멘트 초기화 + setPendingComments([]); + } else { + toast({ title: "Error", description: res.error, variant: "destructive" }) + } + } catch (error) { + toast({ title: "Error", description: String(error), variant: "destructive" }) + } finally { + setIsLoading(false) + setRejectComment("") + } + } + + // 3) 변경 요청 다이얼로그 표시 + const handleRequestChanges = () => { + setShowRequestDialog(true) + } + + // 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장 +// 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장 +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 + })); + + // 서버 액션 호출 + const res = await requestPqChangesAction({ + vendorId: vendor.id, + comment: itemComments, + generalComment: requestComment || undefined + }); + + if (res.ok) { + toast({ + title: "Changes Requested", + description: "Vendor 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(""); + } +}; + + return ( + <div className="space-y-4"> + {/* Top header */} + <div className="flex items-center justify-between"> + <h2 className="text-2xl font-bold"> + {vendor.vendorCode} - {vendor.vendorName} PQ Review + </h2> + <div className="flex gap-2"> + <Button + variant="outline" + disabled={isLoading} + onClick={handleReject} + > + Reject + </Button> + <Button + variant={pendingComments.length > 0 ? "default" : "outline"} + disabled={isLoading} + 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} + 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 /> + + {/* VendorPQReviewPage 컴포넌트 대신 직접 구현 */} + <VendorPQReviewPageIntegrated + data={data} + onCommentAdded={handleCommentAdded} + /> + + {/* 변경 요청 다이얼로그 */} + <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)} + </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 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 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)} + </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 new file mode 100644 index 00000000..e778cf91 --- /dev/null +++ b/components/pq/pq-review-table.tsx @@ -0,0 +1,340 @@ +"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" + +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); + + // 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: "AdminUser", + }); + + 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)} + </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 |
