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 | 152 | ||||
| -rw-r--r-- | lib/b-rfq/vendor-response/waive-response-dialog.tsx | 210 |
8 files changed, 0 insertions, 2397 deletions
diff --git a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx deleted file mode 100644 index 0c2c0c62..00000000 --- a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx +++ /dev/null @@ -1,187 +0,0 @@ -// 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 deleted file mode 100644 index bc27d103..00000000 --- a/lib/b-rfq/vendor-response/response-detail-columns.tsx +++ /dev/null @@ -1,653 +0,0 @@ -"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 deleted file mode 100644 index da7f9b01..00000000 --- a/lib/b-rfq/vendor-response/response-detail-sheet.tsx +++ /dev/null @@ -1,358 +0,0 @@ -// 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 deleted file mode 100644 index 124d5241..00000000 --- a/lib/b-rfq/vendor-response/response-detail-table.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"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 deleted file mode 100644 index b4b306d6..00000000 --- a/lib/b-rfq/vendor-response/upload-response-dialog.tsx +++ /dev/null @@ -1,325 +0,0 @@ -// 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 deleted file mode 100644 index 47b7570b..00000000 --- a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx +++ /dev/null @@ -1,351 +0,0 @@ -// 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 deleted file mode 100644 index 02a5fa59..00000000 --- a/lib/b-rfq/vendor-response/vendor-responses-table.tsx +++ /dev/null @@ -1,152 +0,0 @@ -// 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 }, - ] - }, - - - ]; - - // 고급 필터 필드 - const advancedFilterFields: DataTableAdvancedFilterField<VendorRfqResponseSummary>[] = [ - - { - 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 deleted file mode 100644 index 5ded4da3..00000000 --- a/lib/b-rfq/vendor-response/waive-response-dialog.tsx +++ /dev/null @@ -1,210 +0,0 @@ -// 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 |
