diff options
Diffstat (limited to 'lib/b-rfq/vendor-response')
| -rw-r--r-- | lib/b-rfq/vendor-response/comment-edit-dialog.tsx | 187 | ||||
| -rw-r--r-- | lib/b-rfq/vendor-response/response-detail-columns.tsx | 653 | ||||
| -rw-r--r-- | lib/b-rfq/vendor-response/response-detail-sheet.tsx | 358 | ||||
| -rw-r--r-- | lib/b-rfq/vendor-response/response-detail-table.tsx | 161 | ||||
| -rw-r--r-- | lib/b-rfq/vendor-response/upload-response-dialog.tsx | 325 | ||||
| -rw-r--r-- | lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx | 351 | ||||
| -rw-r--r-- | lib/b-rfq/vendor-response/vendor-responses-table.tsx | 160 | ||||
| -rw-r--r-- | lib/b-rfq/vendor-response/waive-response-dialog.tsx | 210 |
8 files changed, 2405 insertions, 0 deletions
diff --git a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx new file mode 100644 index 00000000..0c2c0c62 --- /dev/null +++ b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx @@ -0,0 +1,187 @@ +// components/rfq/comment-edit-dialog.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { MessageSquare, Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { useRouter } from "next/navigation"; + +const commentFormSchema = z.object({ + responseComment: z.string().optional(), + vendorComment: z.string().optional(), +}); + +type CommentFormData = z.infer<typeof commentFormSchema>; + +interface CommentEditDialogProps { + responseId: number; + currentResponseComment?: string; + currentVendorComment?: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +export function CommentEditDialog({ + responseId, + currentResponseComment, + currentVendorComment, + trigger, + onSuccess, +}: CommentEditDialogProps) { + const [open, setOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm<CommentFormData>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + responseComment: currentResponseComment || "", + vendorComment: currentVendorComment || "", + }, + }); + + const onSubmit = async (data: CommentFormData) => { + setIsSaving(true); + + try { + const response = await fetch("/api/vendor-responses/update-comment", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + responseId, + responseComment: data.responseComment, + vendorComment: data.vendorComment, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "코멘트 업데이트 실패"); + } + + toast({ + title: "코멘트 업데이트 완료", + description: "코멘트가 성공적으로 업데이트되었습니다.", + }); + + setOpen(false); + + router.refresh(); + onSuccess?.(); + + } catch (error) { + console.error("Comment update error:", error); + toast({ + title: "업데이트 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + {trigger || ( + <Button size="sm" variant="outline"> + <MessageSquare className="h-3 w-3 mr-1" /> + 코멘트 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <MessageSquare className="h-5 w-5" /> + 코멘트 수정 + </DialogTitle> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 응답 코멘트 */} + <FormField + control={form.control} + name="responseComment" + render={({ field }) => ( + <FormItem> + <FormLabel>응답 코멘트</FormLabel> + <FormControl> + <Textarea + placeholder="응답에 대한 설명을 입력하세요..." + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더 코멘트 */} + <FormField + control={form.control} + name="vendorComment" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더 코멘트 (내부용)</FormLabel> + <FormControl> + <Textarea + placeholder="내부 참고용 코멘트를 입력하세요..." + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 버튼 */} + <div className="flex justify-end gap-2"> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isSaving} + > + 취소 + </Button> + <Button type="submit" disabled={isSaving}> + {isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} + {isSaving ? "저장 중..." : "저장"} + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-columns.tsx b/lib/b-rfq/vendor-response/response-detail-columns.tsx new file mode 100644 index 00000000..bc27d103 --- /dev/null +++ b/lib/b-rfq/vendor-response/response-detail-columns.tsx @@ -0,0 +1,653 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import type { Row } from "@tanstack/react-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + FileText, + Upload, + CheckCircle, + Clock, + AlertTriangle, + FileX, + Download, + AlertCircle, + RefreshCw, + Calendar, + MessageSquare, + GitBranch, + Ellipsis +} from "lucide-react" +import { cn } from "@/lib/utils" +import type { EnhancedVendorResponse } from "@/lib/b-rfq/service" +import { UploadResponseDialog } from "./upload-response-dialog" +import { CommentEditDialog } from "./comment-edit-dialog" +import { WaiveResponseDialog } from "./waive-response-dialog" +import { ResponseDetailSheet } from "./response-detail-sheet" + +export interface DataTableRowAction<TData> { + row: Row<TData> + type: 'upload' | 'waive' | 'edit' | 'detail' +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedVendorResponse> | null>> +} + +// 파일 다운로드 핸들러 +async function handleFileDownload( + filePath: string, + fileName: string, + type: "client" | "vendor" = "client", + id?: number +) { + try { + const params = new URLSearchParams({ + path: filePath, + type: type, + }); + + if (id) { + if (type === "client") { + params.append("revisionId", id.toString()); + } else { + params.append("responseFileId", id.toString()); + } + } + + const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Download failed: ${response.status}`); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + } catch (error) { + console.error("❌ 파일 다운로드 실패:", error); + alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } +} + +// 상태별 정보 반환 +function getEffectiveStatusInfo(effectiveStatus: string) { + switch (effectiveStatus) { + case "NOT_RESPONDED": + return { + icon: Clock, + label: "미응답", + variant: "outline" as const, + color: "text-orange-600" + }; + case "UP_TO_DATE": + return { + icon: CheckCircle, + label: "최신", + variant: "default" as const, + color: "text-green-600" + }; + case "VERSION_MISMATCH": + return { + icon: RefreshCw, + label: "업데이트 필요", + variant: "secondary" as const, + color: "text-blue-600" + }; + case "REVISION_REQUESTED": + return { + icon: AlertTriangle, + label: "수정요청", + variant: "secondary" as const, + color: "text-yellow-600" + }; + case "WAIVED": + return { + icon: FileX, + label: "포기", + variant: "outline" as const, + color: "text-gray-600" + }; + default: + return { + icon: FileText, + label: effectiveStatus, + variant: "outline" as const, + color: "text-gray-600" + }; + } +} + +// 파일명 컴포넌트 +function AttachmentFileNameCell({ revisions }: { + revisions: Array<{ + id: number; + originalFileName: string; + revisionNo: string; + isLatest: boolean; + filePath?: string; + fileSize: number; + createdAt: string; + revisionComment?: string; + }> +}) { + if (!revisions || revisions.length === 0) { + return <span className="text-muted-foreground">파일 없음</span>; + } + + const latestRevision = revisions.find(r => r.isLatest) || revisions[0]; + const hasMultipleRevisions = revisions.length > 1; + const canDownload = latestRevision.filePath; + + return ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + {canDownload ? ( + <button + onClick={() => handleFileDownload( + latestRevision.filePath!, + latestRevision.originalFileName, + "client", + latestRevision.id + )} + className="font-medium text-sm text-blue-600 hover:text-blue-800 hover:underline text-left max-w-64 truncate" + title={`${latestRevision.originalFileName} - 클릭하여 다운로드`} + > + {latestRevision.originalFileName} + </button> + ) : ( + <span className="font-medium text-sm text-muted-foreground max-w-64 truncate" title={latestRevision.originalFileName}> + {latestRevision.originalFileName} + </span> + )} + + {canDownload && ( + <Button + size="sm" + variant="ghost" + className="h-6 w-6 p-0" + onClick={() => handleFileDownload( + latestRevision.filePath!, + latestRevision.originalFileName, + "client", + latestRevision.id + )} + title="파일 다운로드" + > + <Download className="h-3 w-3" /> + </Button> + )} + + {hasMultipleRevisions && ( + <Badge variant="outline" className="text-xs"> + v{latestRevision.revisionNo} + </Badge> + )} + </div> + + {hasMultipleRevisions && ( + <div className="text-xs text-muted-foreground"> + 총 {revisions.length}개 리비전 + </div> + )} + </div> + ); +} + +// 리비전 비교 컴포넌트 +function RevisionComparisonCell({ response }: { response: EnhancedVendorResponse }) { + const isUpToDate = response.isVersionMatched; + const hasResponse = !!response.respondedRevision; + const versionLag = response.versionLag || 0; + + return ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground">발주처:</span> + <Badge variant="secondary" className="text-xs font-mono"> + {response.currentRevision} + </Badge> + </div> + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground">응답:</span> + {hasResponse ? ( + <Badge + variant={isUpToDate ? "default" : "outline"} + className={cn( + "text-xs font-mono", + !isUpToDate && "text-blue-600 border-blue-300" + )} + > + {response.respondedRevision} + </Badge> + ) : ( + <span className="text-xs text-muted-foreground">-</span> + )} + </div> + {hasResponse && !isUpToDate && versionLag > 0 && ( + <div className="flex items-center gap-1 text-xs text-blue-600"> + <AlertCircle className="h-3 w-3" /> + <span>{versionLag}버전 차이</span> + </div> + )} + {response.hasMultipleRevisions && ( + <div className="flex items-center gap-1 text-xs text-muted-foreground"> + <GitBranch className="h-3 w-3" /> + <span>다중 리비전</span> + </div> + )} + </div> + ); +} + +// 코멘트 표시 컴포넌트 +function CommentDisplayCell({ response }: { response: EnhancedVendorResponse }) { + const hasResponseComment = !!response.responseComment; + const hasVendorComment = !!response.vendorComment; + const hasRevisionRequestComment = !!response.revisionRequestComment; + const hasClientComment = !!response.attachment?.revisions?.find(r => r.revisionComment); + + const commentCount = [hasResponseComment, hasVendorComment, hasRevisionRequestComment, hasClientComment].filter(Boolean).length; + + if (commentCount === 0) { + return <span className="text-xs text-muted-foreground">-</span>; + } + + return ( + <div className="space-y-1"> + {hasResponseComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-blue-500" title="벤더 응답 코멘트"></div> + <span className="text-xs text-blue-600 truncate max-w-32" title={response.responseComment}> + {response.responseComment} + </span> + </div> + )} + + {hasVendorComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-green-500" title="벤더 내부 메모"></div> + <span className="text-xs text-green-600 truncate max-w-32" title={response.vendorComment}> + {response.vendorComment} + </span> + </div> + )} + + {hasRevisionRequestComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-red-500" title="수정 요청 사유"></div> + <span className="text-xs text-red-600 truncate max-w-32" title={response.revisionRequestComment}> + {response.revisionRequestComment} + </span> + </div> + )} + + {hasClientComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-orange-500" title="발주처 리비전 코멘트"></div> + <span className="text-xs text-orange-600 truncate max-w-32" + title={response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment}> + {response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment} + </span> + </div> + )} + + {/* <div className="text-xs text-muted-foreground text-center"> + {commentCount}개 + </div> */} + </div> + ); +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<EnhancedVendorResponse>[] { + return [ + // 시리얼 번호 - 핀고정용 최소 너비 + { + accessorKey: "serialNo", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="시리얼" /> + ), + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.getValue("serialNo")}</div> + ), + + meta: { + excelHeader: "시리얼", + paddingFactor: 0.8 + }, + }, + + // 분류 - 핀고정용 적절한 너비 + { + accessorKey: "attachmentType", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="분류" /> + ), + cell: ({ row }) => ( + <div className="space-y-1"> + <div className="font-medium text-sm">{row.getValue("attachmentType")}</div> + {row.original.attachmentDescription && ( + <div className="text-xs text-muted-foreground truncate max-w-32" + title={row.original.attachmentDescription}> + {row.original.attachmentDescription} + </div> + )} + </div> + ), + + meta: { + excelHeader: "분류", + paddingFactor: 1.0 + }, + }, + + // 파일명 - 가장 긴 텍스트를 위한 여유 공간 + { + id: "fileName", + header: "파일명", + cell: ({ row }) => ( + <AttachmentFileNameCell revisions={row.original.attachment?.revisions || []} /> + ), + + meta: { + paddingFactor: 1.5 + }, + }, + + // 상태 - 뱃지 크기 고려 + { + accessorKey: "effectiveStatus", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const statusInfo = getEffectiveStatusInfo(row.getValue("effectiveStatus")); + const StatusIcon = statusInfo.icon; + + return ( + <div className="space-y-1"> + <Badge variant={statusInfo.variant} className="flex items-center gap-1 w-fit"> + <StatusIcon className="h-3 w-3" /> + <span>{statusInfo.label}</span> + </Badge> + {row.original.needsUpdate && ( + <div className="text-xs text-blue-600 flex items-center gap-1"> + <RefreshCw className="h-3 w-3" /> + <span>업데이트 권장</span> + </div> + )} + </div> + ); + }, + + meta: { + excelHeader: "상태", + paddingFactor: 1.2 + }, + }, + + // 리비전 현황 - 복합 정보로 넓은 공간 필요 + { + id: "revisionStatus", + header: "리비전 현황", + cell: ({ row }) => <RevisionComparisonCell response={row.original} />, + + meta: { + paddingFactor: 1.3 + }, + }, + + // 요청일 - 날짜 형식 고정 + { + accessorKey: "requestedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="요청일" /> + ), + cell: ({ row }) => ( + <div className="text-sm flex items-center gap-1"> + <Calendar className="h-3 w-3 text-muted-foreground" /> + <span className="whitespace-nowrap">{formatDateTime(new Date(row.getValue("requestedAt")))}</span> + </div> + ), + + meta: { + excelHeader: "요청일", + paddingFactor: 0.9 + }, + }, + + // 응답일 - 날짜 형식 고정 + { + accessorKey: "respondedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="응답일" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + <span className="whitespace-nowrap"> + {row.getValue("respondedAt") + ? formatDateTime(new Date(row.getValue("respondedAt"))) + : "-" + } + </span> + </div> + ), + meta: { + excelHeader: "응답일", + paddingFactor: 0.9 + }, + }, + + // 응답파일 - 작은 공간 + { + accessorKey: "totalResponseFiles", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="응답파일" /> + ), + cell: ({ row }) => ( + <div className="text-center"> + <div className="text-sm font-medium"> + {row.getValue("totalResponseFiles")}개 + </div> + {row.original.latestResponseFileName && ( + <div className="text-xs text-muted-foreground truncate max-w-20" + title={row.original.latestResponseFileName}> + {row.original.latestResponseFileName} + </div> + )} + </div> + ), + meta: { + excelHeader: "응답파일", + paddingFactor: 0.8 + }, + }, + + // 코멘트 - 가변 텍스트 길이 + { + id: "comments", + header: "코멘트", + cell: ({ row }) => <CommentDisplayCell response={row.original} />, + // size: 180, + meta: { + paddingFactor: 1.4 + }, + }, + + // 진행도 - 중간 크기 + { + id: "progress", + header: "진행도", + cell: ({ row }) => ( + <div className="space-y-1 text-center"> + {row.original.hasMultipleRevisions && ( + <Badge variant="outline" className="text-xs"> + 다중 리비전 + </Badge> + )} + {row.original.versionLag !== undefined && row.original.versionLag > 0 && ( + <div className="text-xs text-blue-600 whitespace-nowrap"> + {row.original.versionLag}버전 차이 + </div> + )} + </div> + ), + // size: 100, + meta: { + paddingFactor: 1.1 + }, + }, + +{ + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const response = row.original; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + {/* 상태별 주요 액션들 */} + {response.effectiveStatus === "NOT_RESPONDED" && ( + <> + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <Upload className="size-4 mr-2" /> + 업로드 + </div> + } + /> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <WaiveResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileX className="size-4 mr-2" /> + 포기 + </div> + } + /> + </DropdownMenuItem> + </> + )} + + {response.effectiveStatus === "REVISION_REQUESTED" && ( + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileText className="size-4 mr-2" /> + 수정 + </div> + } + /> + </DropdownMenuItem> + )} + + {response.effectiveStatus === "VERSION_MISMATCH" && ( + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <RefreshCw className="size-4 mr-2" /> + 업데이트 + </div> + } + /> + </DropdownMenuItem> + )} + + {/* 구분선 - 주요 액션과 보조 액션 분리 */} + {(response.effectiveStatus === "NOT_RESPONDED" || + response.effectiveStatus === "REVISION_REQUESTED" || + response.effectiveStatus === "VERSION_MISMATCH") && + response.effectiveStatus !== "WAIVED" && ( + <DropdownMenuSeparator /> + )} + + {/* 공통 액션들 */} + {response.effectiveStatus !== "WAIVED" && ( + <DropdownMenuItem asChild> + <CommentEditDialog + responseId={response.responseId} + currentResponseComment={response.responseComment || ""} + currentVendorComment={response.vendorComment || ""} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <MessageSquare className="size-4 mr-2" /> + 코멘트 편집 + </div> + } + /> + </DropdownMenuItem> + )} + + <DropdownMenuItem asChild> + <ResponseDetailSheet + response={response} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileText className="size-4 mr-2" /> + 상세보기 + </div> + } + /> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + ) + }, + size: 40, +} + + ] +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-sheet.tsx b/lib/b-rfq/vendor-response/response-detail-sheet.tsx new file mode 100644 index 00000000..da7f9b01 --- /dev/null +++ b/lib/b-rfq/vendor-response/response-detail-sheet.tsx @@ -0,0 +1,358 @@ +// components/rfq/response-detail-sheet.tsx +"use client"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + FileText, + Upload, + Download, + AlertCircle, + MessageSquare, + FileCheck, + Eye +} from "lucide-react"; +import { formatDateTime, formatFileSize } from "@/lib/utils"; +import { cn } from "@/lib/utils"; +import type { EnhancedVendorResponse } from "@/lib/b-rfq/service"; + +// 파일 다운로드 핸들러 (API 사용) +async function handleFileDownload( + filePath: string, + fileName: string, + type: "client" | "vendor" = "client", + id?: number +) { + try { + const params = new URLSearchParams({ + path: filePath, + type: type, + }); + + // ID가 있으면 추가 + if (id) { + if (type === "client") { + params.append("revisionId", id.toString()); + } else { + params.append("responseFileId", id.toString()); + } + } + + const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Download failed: ${response.status}`); + } + + // Blob으로 파일 데이터 받기 + const blob = await response.blob(); + + // 임시 URL 생성하여 다운로드 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + // 정리 + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + console.log("✅ 파일 다운로드 성공:", fileName); + + } catch (error) { + console.error("❌ 파일 다운로드 실패:", error); + + // 사용자에게 에러 알림 (토스트나 알럿으로 대체 가능) + alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } +} + +// 효과적인 상태별 아이콘 및 색상 +function getEffectiveStatusInfo(effectiveStatus: string) { + switch (effectiveStatus) { + case "NOT_RESPONDED": + return { + label: "미응답", + variant: "outline" as const + }; + case "UP_TO_DATE": + return { + label: "최신", + variant: "default" as const + }; + case "VERSION_MISMATCH": + return { + label: "업데이트 필요", + variant: "secondary" as const + }; + case "REVISION_REQUESTED": + return { + label: "수정요청", + variant: "secondary" as const + }; + case "WAIVED": + return { + label: "포기", + variant: "outline" as const + }; + default: + return { + label: effectiveStatus, + variant: "outline" as const + }; + } +} + +interface ResponseDetailSheetProps { + response: EnhancedVendorResponse; + trigger?: React.ReactNode; +} + +export function ResponseDetailSheet({ response, trigger }: ResponseDetailSheetProps) { + const hasMultipleRevisions = response.attachment?.revisions && response.attachment.revisions.length > 1; + const hasResponseFiles = response.responseAttachments && response.responseAttachments.length > 0; + + return ( + <Sheet> + <SheetTrigger asChild> + {trigger || ( + <Button size="sm" variant="ghost"> + <Eye className="h-3 w-3 mr-1" /> + 상세 + </Button> + )} + </SheetTrigger> + <SheetContent side="right" className="w-[600px] sm:w-[800px] overflow-y-auto"> + <SheetHeader> + <SheetTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 상세 정보 - {response.serialNo} + </SheetTitle> + <SheetDescription> + {response.attachmentType} • {response.attachment?.revisions?.[0]?.originalFileName} + </SheetDescription> + </SheetHeader> + + <div className="space-y-6 mt-6"> + {/* 기본 정보 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <AlertCircle className="h-4 w-4" /> + 기본 정보 + </h3> + <div className="grid grid-cols-2 gap-4 p-4 bg-muted/30 rounded-lg"> + <div> + <div className="text-sm text-muted-foreground">상태</div> + <div className="font-medium">{getEffectiveStatusInfo(response.effectiveStatus).label}</div> + </div> + <div> + <div className="text-sm text-muted-foreground">현재 리비전</div> + <div className="font-medium">{response.currentRevision}</div> + </div> + <div> + <div className="text-sm text-muted-foreground">응답 리비전</div> + <div className="font-medium">{response.respondedRevision || "-"}</div> + </div> + <div> + <div className="text-sm text-muted-foreground">응답일</div> + <div className="font-medium"> + {response.respondedAt ? formatDateTime(new Date(response.respondedAt)) : "-"} + </div> + </div> + <div> + <div className="text-sm text-muted-foreground">요청일</div> + <div className="font-medium"> + {formatDateTime(new Date(response.requestedAt))} + </div> + </div> + <div> + <div className="text-sm text-muted-foreground">응답 파일 수</div> + <div className="font-medium">{response.totalResponseFiles}개</div> + </div> + </div> + </div> + + {/* 코멘트 정보 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <MessageSquare className="h-4 w-4" /> + 코멘트 + </h3> + <div className="space-y-3"> + {response.responseComment && ( + <div className="p-3 border-l-4 border-blue-500 bg-blue-50"> + <div className="text-sm font-medium text-blue-700 mb-1">발주처 응답 코멘트</div> + <div className="text-sm">{response.responseComment}</div> + </div> + )} + {response.vendorComment && ( + <div className="p-3 border-l-4 border-green-500 bg-green-50"> + <div className="text-sm font-medium text-green-700 mb-1">내부 메모</div> + <div className="text-sm">{response.vendorComment}</div> + </div> + )} + {response.attachment?.revisions?.find(r => r.revisionComment) && ( + <div className="p-3 border-l-4 border-orange-500 bg-orange-50"> + <div className="text-sm font-medium text-orange-700 mb-1">발주처 요청 사항</div> + <div className="text-sm"> + {response.attachment.revisions.find(r => r.revisionComment)?.revisionComment} + </div> + </div> + )} + {!response.responseComment && !response.vendorComment && !response.attachment?.revisions?.find(r => r.revisionComment) && ( + <div className="text-center text-muted-foreground py-4 bg-muted/20 rounded-lg"> + 코멘트가 없습니다. + </div> + )} + </div> + </div> + + {/* 발주처 리비전 히스토리 */} + {hasMultipleRevisions && ( + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <FileCheck className="h-4 w-4" /> + 발주처 리비전 히스토리 ({response.attachment!.revisions.length}개) + </h3> + <div className="space-y-3"> + {response.attachment!.revisions + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map((revision) => ( + <div + key={revision.id} + className={cn( + "flex items-center justify-between p-4 rounded-lg border", + revision.isLatest ? "bg-blue-50 border-blue-200" : "bg-white" + )} + > + <div className="flex items-center gap-3 flex-1"> + <Badge variant={revision.isLatest ? "default" : "outline"}> + {revision.revisionNo} + </Badge> + <div className="flex-1"> + <div className="font-medium text-sm">{revision.originalFileName}</div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(revision.fileSize)} • {formatDateTime(new Date(revision.createdAt))} + </div> + {revision.revisionComment && ( + <div className="text-xs text-muted-foreground mt-1 italic"> + "{revision.revisionComment}" + </div> + )} + </div> + </div> + + <div className="flex items-center gap-2"> + {revision.isLatest && ( + <Badge variant="secondary" className="text-xs">최신</Badge> + )} + {revision.revisionNo === response.respondedRevision && ( + <Badge variant="outline" className="text-xs text-green-600 border-green-300"> + 응답됨 + </Badge> + )} + <Button + size="sm" + variant="ghost" + onClick={() => { + if (revision.filePath) { + handleFileDownload( + revision.filePath, + revision.originalFileName, + "client", + revision.id + ); + } + }} + disabled={!revision.filePath} + title="파일 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </div> + )} + + {/* 벤더 응답 파일들 */} + {hasResponseFiles && ( + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <Upload className="h-4 w-4" /> + 벤더 응답 파일들 ({response.totalResponseFiles}개) + </h3> + <div className="space-y-3"> + {response.responseAttachments! + .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()) + .map((file) => ( + <div key={file.id} className="flex items-center justify-between p-4 rounded-lg border bg-green-50 border-green-200"> + <div className="flex items-center gap-3 flex-1"> + <Badge variant="outline" className="bg-green-100"> + 파일 #{file.fileSequence} + </Badge> + <div className="flex-1"> + <div className="font-medium text-sm">{file.originalFileName}</div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(file.fileSize)} • {formatDateTime(new Date(file.uploadedAt))} + </div> + {file.description && ( + <div className="text-xs text-muted-foreground mt-1 italic"> + "{file.description}" + </div> + )} + </div> + </div> + + <div className="flex items-center gap-2"> + {file.isLatestResponseFile && ( + <Badge variant="secondary" className="text-xs">최신</Badge> + )} + <Button + size="sm" + variant="ghost" + onClick={() => { + if (file.filePath) { + handleFileDownload( + file.filePath, + file.originalFileName, + "vendor", + file.id + ); + } + }} + disabled={!file.filePath} + title="파일 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </div> + )} + + {!hasMultipleRevisions && !hasResponseFiles && ( + <div className="text-center text-muted-foreground py-8 bg-muted/20 rounded-lg"> + <FileText className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p>추가 파일이나 리비전 정보가 없습니다.</p> + </div> + )} + </div> + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/response-detail-table.tsx b/lib/b-rfq/vendor-response/response-detail-table.tsx new file mode 100644 index 00000000..124d5241 --- /dev/null +++ b/lib/b-rfq/vendor-response/response-detail-table.tsx @@ -0,0 +1,161 @@ +"use client" + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import type { EnhancedVendorResponse } from "@/lib/b-rfq/service" +import { DataTableAdvancedFilterField } from "@/types/table" +import { DataTableRowAction, getColumns } from "./response-detail-columns" + +interface FinalRfqResponseTableProps { + data: EnhancedVendorResponse[] + // ✅ 헤더 정보를 props로 받기 + statistics?: { + total: number + upToDate: number + versionMismatch: number + pending: number + revisionRequested: number + waived: number + } + showHeader?: boolean + title?: string +} + +/** + * FinalRfqResponseTable: RFQ 응답 데이터를 표시하는 표 + */ +export function FinalRfqResponseTable({ + data, + statistics, + showHeader = true, + title = "첨부파일별 응답 현황" +}: FinalRfqResponseTableProps) { + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<EnhancedVendorResponse> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 고급 필터 필드 정의 + const advancedFilterFields: DataTableAdvancedFilterField<EnhancedVendorResponse>[] = [ + { + id: "effectiveStatus", + label: "상태", + type: "select", + options: [ + { label: "미응답", value: "NOT_RESPONDED" }, + { label: "최신", value: "UP_TO_DATE" }, + { label: "업데이트 필요", value: "VERSION_MISMATCH" }, + { label: "수정요청", value: "REVISION_REQUESTED" }, + { label: "포기", value: "WAIVED" }, + ], + }, + { + id: "attachmentType", + label: "첨부파일 분류", + type: "text", + }, + { + id: "serialNo", + label: "시리얼 번호", + type: "text", + }, + { + id: "isVersionMatched", + label: "버전 일치", + type: "select", + options: [ + { label: "일치", value: "true" }, + { label: "불일치", value: "false" }, + ], + }, + { + id: "hasMultipleRevisions", + label: "다중 리비전", + type: "select", + options: [ + { label: "있음", value: "true" }, + { label: "없음", value: "false" }, + ], + }, + ] + + if (data.length === 0) { + return ( + <div className="border rounded-lg p-12 text-center"> + <div className="mx-auto mb-4 h-12 w-12 text-muted-foreground"> + 📄 + </div> + <p className="text-muted-foreground">응답할 첨부파일이 없습니다.</p> + </div> + ) + } + + return ( + // ✅ 상위 컨테이너 구조 단순화 및 너비 제한 해제 +<> + {/* 코멘트 범례 */} + <div className="flex items-center gap-6 text-xs text-muted-foreground bg-muted/30 p-3 rounded-lg"> + <span className="font-medium">코멘트 범례:</span> + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-blue-500"></div> + <span>벤더 응답</span> + </div> + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-green-500"></div> + <span>내부 메모</span> + </div> + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-red-500"></div> + <span>수정 요청</span> + </div> + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-orange-500"></div> + <span>발주처 리비전</span> + </div> + </div> + <div style={{ + width: '100%', + maxWidth: '100%', + overflow: 'hidden', + contain: 'layout' + }}> + {/* 데이터 테이블 - 컨테이너 제약 최소화 */} + <ClientDataTable + data={data} + columns={columns} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={true} + compact={true} + // ✅ RFQ 테이블에 맞는 컬럼 핀고정 + initialColumnPinning={{ + left: ["serialNo", "attachmentType"], + right: ["actions"] + }} + > + {showHeader && ( + <div className="flex items-center justify-between"> + + {statistics && ( + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + <span>전체 {statistics.total}개</span> + <span className="text-green-600">최신 {statistics.upToDate}개</span> + <span className="text-blue-600">업데이트필요 {statistics.versionMismatch}개</span> + <span className="text-orange-600">미응답 {statistics.pending}개</span> + {statistics.revisionRequested > 0 && ( + <span className="text-yellow-600">수정요청 {statistics.revisionRequested}개</span> + )} + {statistics.waived > 0 && ( + <span className="text-gray-600">포기 {statistics.waived}개</span> + )} + </div> + )} + </div> + )} + </ClientDataTable> + </div> + </> + ) +} diff --git a/lib/b-rfq/vendor-response/upload-response-dialog.tsx b/lib/b-rfq/vendor-response/upload-response-dialog.tsx new file mode 100644 index 00000000..b4b306d6 --- /dev/null +++ b/lib/b-rfq/vendor-response/upload-response-dialog.tsx @@ -0,0 +1,325 @@ +// components/rfq/upload-response-dialog.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Upload, FileText, X, Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast" +import { useRouter } from "next/navigation"; + +const uploadFormSchema = z.object({ + files: z.array(z.instanceof(File)).min(1, "최소 1개의 파일을 선택해주세요"), + responseComment: z.string().optional(), + vendorComment: z.string().optional(), +}); + +type UploadFormData = z.infer<typeof uploadFormSchema>; + +interface UploadResponseDialogProps { + responseId: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +export function UploadResponseDialog({ + responseId, + attachmentType, + serialNo, + currentRevision, + trigger, + onSuccess, +}: UploadResponseDialogProps) { + const [open, setOpen] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm<UploadFormData>({ + resolver: zodResolver(uploadFormSchema), + defaultValues: { + files: [], + responseComment: "", + vendorComment: "", + }, + }); + + const selectedFiles = form.watch("files"); + + const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + form.setValue("files", files); + } + }; + + const removeFile = (index: number) => { + const currentFiles = form.getValues("files"); + const newFiles = currentFiles.filter((_, i) => i !== index); + form.setValue("files", newFiles); + }; + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + // 다이얼로그가 닫힐 때 form 리셋 + if (!newOpen) { + form.reset(); + } + }; + + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + const onSubmit = async (data: UploadFormData) => { + setIsUploading(true); + + try { + // 1. 각 파일을 업로드 API로 전송 + const uploadedFiles = []; + + for (const file of data.files) { + const formData = new FormData(); + formData.append("file", file); + formData.append("responseId", responseId.toString()); + formData.append("description", ""); // 필요시 파일별 설명 추가 가능 + + const uploadResponse = await fetch("/api/vendor-responses/upload", { + method: "POST", + body: formData, + }); + + if (!uploadResponse.ok) { + const error = await uploadResponse.json(); + throw new Error(error.message || "파일 업로드 실패"); + } + + const uploadResult = await uploadResponse.json(); + uploadedFiles.push(uploadResult); + } + + // 2. vendor response 상태 업데이트 (서버에서 자동으로 리비전 증가) + const updateResponse = await fetch("/api/vendor-responses/update", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + responseId, + responseStatus: "RESPONDED", + // respondedRevision 제거 - 서버에서 자동 처리 + responseComment: data.responseComment, + vendorComment: data.vendorComment, + respondedAt: new Date().toISOString(), + }), + }); + + if (!updateResponse.ok) { + const error = await updateResponse.json(); + throw new Error(error.message || "응답 상태 업데이트 실패"); + } + + const updateResult = await updateResponse.json(); + + toast({ + title: "업로드 완료", + description: `${data.files.length}개 파일이 성공적으로 업로드되었습니다. (${updateResult.newRevision})`, + }); + + setOpen(false); + form.reset(); + + router.refresh(); + onSuccess?.(); + + } catch (error) { + console.error("Upload error:", error); + toast({ + title: "업로드 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + {trigger || ( + <Button size="sm"> + <Upload className="h-3 w-3 mr-1" /> + 업로드 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Upload className="h-5 w-5" /> + 응답 파일 업로드 + </DialogTitle> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Badge variant="outline">{serialNo}</Badge> + <span>{attachmentType}</span> + <Badge variant="secondary">{currentRevision}</Badge> + <span className="text-xs text-blue-600">→ 벤더 응답 리비전 자동 증가</span> + </div> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 파일 선택 */} + <FormField + control={form.control} + name="files" + render={({ field }) => ( + <FormItem> + <FormLabel>파일 선택</FormLabel> + <FormControl> + <div className="space-y-4"> + <Input + type="file" + multiple + onChange={handleFileSelect} + accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.zip,.rar" + className="cursor-pointer" + /> + <div className="text-xs text-muted-foreground"> + 지원 파일: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, ZIP, RAR (최대 10MB) + </div> + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="text-sm font-medium">선택된 파일 ({selectedFiles.length}개)</div> + <div className="space-y-2 max-h-40 overflow-y-auto"> + {selectedFiles.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-3 bg-muted/50 rounded-lg" + > + <div className="flex items-center gap-2 flex-1 min-w-0"> + <FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" /> + <div className="min-w-0 flex-1"> + <div className="text-sm font-medium truncate">{file.name}</div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(file.size)} + </div> + </div> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + className="flex-shrink-0 ml-2" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + + {/* 응답 코멘트 */} + <FormField + control={form.control} + name="responseComment" + render={({ field }) => ( + <FormItem> + <FormLabel>응답 코멘트</FormLabel> + <FormControl> + <Textarea + placeholder="응답에 대한 설명을 입력하세요..." + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더 코멘트 */} + <FormField + control={form.control} + name="vendorComment" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더 코멘트 (내부용)</FormLabel> + <FormControl> + <Textarea + placeholder="내부 참고용 코멘트를 입력하세요..." + className="resize-none" + rows={2} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 버튼 */} + <div className="flex justify-end gap-2"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isUploading} + > + 취소 + </Button> + <Button type="submit" disabled={isUploading || selectedFiles.length === 0}> + {isUploading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} + {isUploading ? "업로드 중..." : "업로드"} + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx new file mode 100644 index 00000000..47b7570b --- /dev/null +++ b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx @@ -0,0 +1,351 @@ +// lib/vendor-responses/table/vendor-responses-table-columns.tsx +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, FileText, Pencil, Edit, Trash2, + Eye, MessageSquare, Clock, CheckCircle, AlertTriangle, FileX +} from "lucide-react" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { VendorResponseDetail } from "../service" +import { VendorRfqResponseSummary } from "../validations" + +// 응답 상태에 따른 배지 컴포넌트 +function ResponseStatusBadge({ status }: { status: string }) { + switch (status) { + case "NOT_RESPONDED": + return ( + <Badge variant="outline" className="text-orange-600 border-orange-600"> + <Clock className="mr-1 h-3 w-3" /> + 미응답 + </Badge> + ) + case "RESPONDED": + return ( + <Badge variant="default" className="bg-green-600 text-white"> + <CheckCircle className="mr-1 h-3 w-3" /> + 응답완료 + </Badge> + ) + case "REVISION_REQUESTED": + return ( + <Badge variant="secondary" className="text-yellow-600 border-yellow-600"> + <AlertTriangle className="mr-1 h-3 w-3" /> + 수정요청 + </Badge> + ) + case "WAIVED": + return ( + <Badge variant="outline" className="text-gray-600 border-gray-600"> + <FileX className="mr-1 h-3 w-3" /> + 포기 + </Badge> + ) + default: + return <Badge>{status}</Badge> + } +} + + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetColumnsProps { + router: NextRouter +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ + router, +}: GetColumnsProps): ColumnDef<VendorResponseDetail>[] { + + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorRfqResponseSummary> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (작성하기 버튼만) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<VendorRfqResponseSummary> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const vendorId = row.original.vendorId + const rfqRecordId = row.original.rfqRecordId + const rfqType = row.original.rfqType + const rfqCode = row.original.rfq?.rfqCode || "RFQ" + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={() => router.push(`/partners/rfq-answer/${vendorId}/${rfqRecordId}`)} + className="h-8 px-3" + > + <Edit className="h-4 w-4 mr-1" /> + 작성하기 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>{rfqCode} 응답 작성하기</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) + }, + size: 100, + minSize: 100, + maxSize: 150, + } + + // ---------------------------------------------------------------- + // 3) 컬럼 정의 배열 + // ---------------------------------------------------------------- + const columnDefinitions = [ + { + id: "rfqCode", + label: "RFQ 번호", + group: "RFQ 정보", + size: 120, + minSize: 100, + maxSize: 150, + }, + + { + id: "rfqDueDate", + label: "RFQ 마감일", + group: "RFQ 정보", + size: 120, + minSize: 100, + maxSize: 150, + }, + + { + id: "overallStatus", + label: "전체 상태", + group: null, + size: 80, + minSize: 60, + maxSize: 100, + }, + { + id: "totalAttachments", + label: "총 첨부파일", + group: "응답 통계", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "respondedCount", + label: "응답완료", + group: "응답 통계", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "pendingCount", + label: "미응답", + group: "응답 통계", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "responseRate", + label: "응답률", + group: "진행률", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "completionRate", + label: "완료율", + group: "진행률", + size: 100, + minSize: 80, + maxSize: 120, + }, + { + id: "requestedAt", + label: "요청일", + group: "날짜 정보", + size: 120, + minSize: 100, + maxSize: 150, + }, + { + id: "lastRespondedAt", + label: "최종 응답일", + group: "날짜 정보", + size: 120, + minSize: 100, + maxSize: 150, + }, + ]; + + // ---------------------------------------------------------------- + // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성) + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorRfqResponseSummary>[]> = {} + + columnDefinitions.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // 개별 컬럼 정의 + const columnDef: ColumnDef<VendorRfqResponseSummary> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + cell: ({ row, cell }) => { + // 각 컬럼별 특별한 렌더링 처리 + switch (cfg.id) { + case "rfqCode": + return row.original.rfq?.rfqCode || "-" + + + case "rfqDueDate": + const dueDate = row.original.rfq?.dueDate; + return dueDate ? formatDate(new Date(dueDate)) : "-"; + + case "overallStatus": + return <ResponseStatusBadge status={row.original.overallStatus} /> + + case "totalAttachments": + return ( + <div className="text-center font-medium"> + {row.original.totalAttachments} + </div> + ) + + case "respondedCount": + return ( + <div className="text-center text-green-600 font-medium"> + {row.original.respondedCount} + </div> + ) + + case "pendingCount": + return ( + <div className="text-center text-orange-600 font-medium"> + {row.original.pendingCount} + </div> + ) + + case "responseRate": + const responseRate = row.original.responseRate; + return ( + <div className="text-center"> + <span className={`font-medium ${responseRate >= 80 ? 'text-green-600' : responseRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}> + {responseRate}% + </span> + </div> + ) + + case "completionRate": + const completionRate = row.original.completionRate; + return ( + <div className="text-center"> + <span className={`font-medium ${completionRate >= 80 ? 'text-green-600' : completionRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}> + {completionRate}% + </span> + </div> + ) + + case "requestedAt": + return formatDateTime(new Date(row.original.requestedAt)) + + case "lastRespondedAt": + const lastRespondedAt = row.original.lastRespondedAt; + return lastRespondedAt ? formatDateTime(new Date(lastRespondedAt)) : "-"; + + default: + return row.getValue(cfg.id) ?? "" + } + }, + size: cfg.size, + minSize: cfg.minSize, + maxSize: cfg.maxSize, + } + + groupMap[groupName].push(columnDef) + }) + + // ---------------------------------------------------------------- + // 5) 그룹별 중첩 컬럼 생성 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorRfqResponseSummary>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹이 없는 컬럼들은 직접 추가 + nestedColumns.push(...colDefs) + } else { + // 그룹이 있는 컬럼들은 중첩 구조로 추가 + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/vendor-responses-table.tsx b/lib/b-rfq/vendor-response/vendor-responses-table.tsx new file mode 100644 index 00000000..251b1ad0 --- /dev/null +++ b/lib/b-rfq/vendor-response/vendor-responses-table.tsx @@ -0,0 +1,160 @@ +// lib/vendor-responses/table/vendor-responses-table.tsx +"use client" + +import * as React from "react" +import { type DataTableAdvancedFilterField, type DataTableFilterField, type DataTableRowAction } from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" +import { getColumns } from "./vendor-responses-table-columns" +import { VendorRfqResponseSummary } from "../validations" + +interface VendorResponsesTableProps { + promises: Promise<[{ data: VendorRfqResponseSummary[], pageCount: number, totalCount: number }]>; +} + +export function VendorResponsesTable({ promises }: VendorResponsesTableProps) { + const [{ data, pageCount, totalCount }] = React.use(promises); + const router = useRouter(); + + console.log(data, "vendor responses data") + + // 선택된 행 액션 상태 + + // 테이블 컬럼 정의 + const columns = React.useMemo(() => getColumns({ + router, + }), [router]); + + // 상태별 응답 수 계산 (전체 상태 기준) + const statusCounts = React.useMemo(() => { + return { + NOT_RESPONDED: data.filter(r => r.overallStatus === "NOT_RESPONDED").length, + RESPONDED: data.filter(r => r.overallStatus === "RESPONDED").length, + REVISION_REQUESTED: data.filter(r => r.overallStatus === "REVISION_REQUESTED").length, + WAIVED: data.filter(r => r.overallStatus === "WAIVED").length, + }; + }, [data]); + + + // 필터 필드 + const filterFields: DataTableFilterField<VendorRfqResponseSummary>[] = [ + { + id: "overallStatus", + label: "전체 상태", + options: [ + { label: "미응답", value: "NOT_RESPONDED", count: statusCounts.NOT_RESPONDED }, + { label: "응답완료", value: "RESPONDED", count: statusCounts.RESPONDED }, + { label: "수정요청", value: "REVISION_REQUESTED", count: statusCounts.REVISION_REQUESTED }, + { label: "포기", value: "WAIVED", count: statusCounts.WAIVED }, + ] + }, + + { + id: "rfqCode", + label: "RFQ 번호", + placeholder: "RFQ 번호 검색...", + } + ]; + + // 고급 필터 필드 + const advancedFilterFields: DataTableAdvancedFilterField<VendorRfqResponseSummary>[] = [ + { + id: "rfqCode", + label: "RFQ 번호", + type: "text", + }, + { + id: "overallStatus", + label: "전체 상태", + type: "multi-select", + options: [ + { label: "미응답", value: "NOT_RESPONDED" }, + { label: "응답완료", value: "RESPONDED" }, + { label: "수정요청", value: "REVISION_REQUESTED" }, + { label: "포기", value: "WAIVED" }, + ], + }, + { + id: "rfqType", + label: "RFQ 타입", + type: "multi-select", + options: [ + { label: "초기 RFQ", value: "INITIAL" }, + { label: "최종 RFQ", value: "FINAL" }, + ], + }, + { + id: "responseRate", + label: "응답률", + type: "number", + }, + { + id: "completionRate", + label: "완료율", + type: "number", + }, + { + id: "requestedAt", + label: "요청일", + type: "date", + }, + { + id: "lastRespondedAt", + label: "최종 응답일", + type: "date", + }, + ]; + + // useDataTable 훅 사용 + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + enableColumnResizing: true, + columnResizeMode: 'onChange', + initialState: { + sorting: [{ id: "updatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + defaultColumn: { + minSize: 50, + maxSize: 500, + }, + }); + + return ( + <div className="w-full"> + <div className="flex items-center justify-between py-4"> + <div className="flex items-center space-x-2"> + <span className="text-sm text-muted-foreground"> + 총 {totalCount}개의 응답 요청 + </span> + </div> + </div> + + <div className="overflow-x-auto"> + <DataTable + table={table} + className="min-w-full" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + {/* 추가적인 액션 버튼들을 여기에 추가할 수 있습니다 */} + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/lib/b-rfq/vendor-response/waive-response-dialog.tsx b/lib/b-rfq/vendor-response/waive-response-dialog.tsx new file mode 100644 index 00000000..5ded4da3 --- /dev/null +++ b/lib/b-rfq/vendor-response/waive-response-dialog.tsx @@ -0,0 +1,210 @@ +// components/rfq/waive-response-dialog.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { FileX, Loader2, AlertTriangle } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { useRouter } from "next/navigation"; + +const waiveFormSchema = z.object({ + responseComment: z.string().min(1, "포기 사유를 입력해주세요"), + vendorComment: z.string().optional(), +}); + +type WaiveFormData = z.infer<typeof waiveFormSchema>; + +interface WaiveResponseDialogProps { + responseId: number; + attachmentType: string; + serialNo: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +export function WaiveResponseDialog({ + responseId, + attachmentType, + serialNo, + trigger, + onSuccess, +}: WaiveResponseDialogProps) { + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm<WaiveFormData>({ + resolver: zodResolver(waiveFormSchema), + defaultValues: { + responseComment: "", + vendorComment: "", + }, + }); + + const onSubmit = async (data: WaiveFormData) => { + setIsSubmitting(true); + + try { + const response = await fetch("/api/vendor-responses/waive", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + responseId, + responseComment: data.responseComment, + vendorComment: data.vendorComment, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "응답 포기 처리 실패"); + } + + toast({ + title: "응답 포기 완료", + description: "해당 항목에 대한 응답이 포기 처리되었습니다.", + }); + + setOpen(false); + form.reset(); + + router.refresh(); + onSuccess?.(); + + } catch (error) { + console.error("Waive error:", error); + toast({ + title: "처리 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + {trigger || ( + <Button size="sm" variant="outline"> + <FileX className="h-3 w-3 mr-1" /> + 포기 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2 text-orange-600"> + <FileX className="h-5 w-5" /> + 응답 포기 + </DialogTitle> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Badge variant="outline">{serialNo}</Badge> + <span>{attachmentType}</span> + </div> + </DialogHeader> + + <div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4"> + <div className="flex items-center gap-2 text-orange-800 text-sm font-medium mb-2"> + <AlertTriangle className="h-4 w-4" /> + 주의사항 + </div> + <p className="text-orange-700 text-sm"> + 응답을 포기하면 해당 항목에 대한 입찰 참여가 불가능합니다. + 포기 사유를 명확히 기입해 주세요. + </p> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 포기 사유 (필수) */} + <FormField + control={form.control} + name="responseComment" + render={({ field }) => ( + <FormItem> + <FormLabel className="text-red-600"> + 포기 사유 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Textarea + placeholder="응답을 포기하는 사유를 구체적으로 입력하세요..." + className="resize-none" + rows={4} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 내부 코멘트 (선택) */} + <FormField + control={form.control} + name="vendorComment" + render={({ field }) => ( + <FormItem> + <FormLabel>내부 코멘트 (선택)</FormLabel> + <FormControl> + <Textarea + placeholder="내부 참고용 코멘트를 입력하세요..." + className="resize-none" + rows={2} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 버튼 */} + <div className="flex justify-end gap-2"> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + variant="destructive" + disabled={isSubmitting} + > + {isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} + {isSubmitting ? "처리 중..." : "포기하기"} + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
