diff options
Diffstat (limited to 'lib/b-rfq/attachment')
| -rw-r--r-- | lib/b-rfq/attachment/add-attachment-dialog.tsx | 355 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/add-revision-dialog.tsx | 336 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/attachment-columns.tsx | 286 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/attachment-table.tsx | 190 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/attachment-toolbar-action.tsx | 60 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/confirm-documents-dialog.tsx | 141 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/delete-attachment-dialog.tsx | 182 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/request-revision-dialog.tsx | 205 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/revision-dialog.tsx | 196 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/tbe-request-dialog.tsx | 200 | ||||
| -rw-r--r-- | lib/b-rfq/attachment/vendor-responses-panel.tsx | 386 |
11 files changed, 0 insertions, 2537 deletions
diff --git a/lib/b-rfq/attachment/add-attachment-dialog.tsx b/lib/b-rfq/attachment/add-attachment-dialog.tsx deleted file mode 100644 index 665e0f88..00000000 --- a/lib/b-rfq/attachment/add-attachment-dialog.tsx +++ /dev/null @@ -1,355 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { Plus ,X} from "lucide-react" -import { toast } from "sonner" - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { addRfqAttachmentRecord } from "../service" - -// 첨부파일 추가 폼 스키마 (단일 파일) -const addAttachmentSchema = z.object({ - attachmentType: z.enum(["구매", "설계"], { - required_error: "문서 타입을 선택해주세요.", - }), - description: z.string().optional(), - file: z.instanceof(File, { - message: "파일을 선택해주세요.", - }), -}) - -type AddAttachmentFormData = z.infer<typeof addAttachmentSchema> - -interface AddAttachmentDialogProps { - rfqId: number -} - -export function AddAttachmentDialog({ rfqId }: AddAttachmentDialogProps) { - const [open, setOpen] = React.useState(false) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [uploadProgress, setUploadProgress] = React.useState<number>(0) - - const form = useForm<AddAttachmentFormData>({ - resolver: zodResolver(addAttachmentSchema), - defaultValues: { - attachmentType: undefined, - description: "", - file: undefined, - }, - }) - - const selectedFile = form.watch("file") - - // 다이얼로그 닫기 핸들러 - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen && !isSubmitting) { - form.reset() - } - setOpen(newOpen) - } - - // 파일 선택 처리 - const handleFileChange = (files: File[]) => { - if (files.length === 0) return - - const file = files[0] // 첫 번째 파일만 사용 - - // 파일 크기 검증 - const maxFileSize = 10 * 1024 * 1024 // 10MB - if (file.size > maxFileSize) { - toast.error(`파일이 너무 큽니다. (최대 10MB)`) - return - } - - form.setValue("file", file) - form.clearErrors("file") - } - - // 파일 제거 - const removeFile = () => { - form.resetField("file") - } - - // 파일 업로드 API 호출 - const uploadFile = async (file: File): Promise<{ - fileName: string - originalFileName: string - filePath: string - fileSize: number - fileType: string - }> => { - const formData = new FormData() - formData.append("rfqId", rfqId.toString()) - formData.append("file", file) - - const response = await fetch("/api/upload/rfq-attachment", { - method: "POST", - body: formData, - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || "파일 업로드 실패") - } - - return response.json() - } - - // 폼 제출 - const onSubmit = async (data: AddAttachmentFormData) => { - setIsSubmitting(true) - setUploadProgress(0) - - try { - // 1단계: 파일 업로드 - setUploadProgress(30) - const uploadedFile = await uploadFile(data.file) - - // 2단계: DB 레코드 생성 (시리얼 번호 자동 생성) - setUploadProgress(70) - const attachmentRecord = { - rfqId, - attachmentType: data.attachmentType, - description: data.description, - fileName: uploadedFile.fileName, - originalFileName: uploadedFile.originalFileName, - filePath: uploadedFile.filePath, - fileSize: uploadedFile.fileSize, - fileType: uploadedFile.fileType, - } - - const result = await addRfqAttachmentRecord(attachmentRecord) - - setUploadProgress(100) - - if (result.success) { - toast.success(result.message) - form.reset() - handleOpenChange(false) - } else { - toast.error(result.message) - } - - } catch (error) { - console.error("Upload error:", error) - toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - setUploadProgress(0) - } - } - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogTrigger asChild> - <Button variant="outline" size="sm" className="gap-2"> - <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">새 첨부</span> - </Button> - </DialogTrigger> - - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> - <DialogTitle>새 첨부파일 추가</DialogTitle> - <DialogDescription> - RFQ에 첨부할 문서를 업로드합니다. 시리얼 번호는 자동으로 부여됩니다. - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 문서 타입 선택 */} - <FormField - control={form.control} - name="attachmentType" - render={({ field }) => ( - <FormItem> - <FormLabel>문서 타입</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="문서 타입을 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="구매">구매</SelectItem> - <SelectItem value="설계">설계</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 설명 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>설명 (선택)</FormLabel> - <FormControl> - <Textarea - placeholder="첨부파일에 대한 설명을 입력하세요" - className="resize-none" - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 선택 - Dropzone (단일 파일) */} - <FormField - control={form.control} - name="file" - render={({ field }) => ( - <FormItem> - <FormLabel>파일 선택</FormLabel> - <FormControl> - <div className="space-y-3"> - <Dropzone - onDrop={(acceptedFiles) => { - handleFileChange(acceptedFiles) - }} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'application/vnd.ms-powerpoint': ['.ppt'], - 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], - 'application/zip': ['.zip'], - 'application/x-rar-compressed': ['.rar'] - }} - maxSize={10 * 1024 * 1024} // 10MB - multiple={false} // 단일 파일만 - disabled={isSubmitting} - > - <DropzoneZone> - <DropzoneUploadIcon /> - <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle> - <DropzoneDescription> - PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개) - </DropzoneDescription> - <DropzoneInput /> - </DropzoneZone> - </Dropzone> - - {/* 선택된 파일 표시 */} - {selectedFile && ( - <div className="space-y-2"> - <FileListHeader> - 선택된 파일 - </FileListHeader> - <FileList> - <FileListItem className="flex items-center justify-between gap-3"> - <FileListIcon /> - <FileListInfo> - <FileListName>{selectedFile.name}</FileListName> - <FileListDescription> - <FileListSize>{selectedFile.size}</FileListSize> - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={removeFile} - disabled={isSubmitting} - > - <X className="h-4 w-4" /> - </FileListAction> - </FileListItem> - </FileList> - </div> - )} - - {/* 업로드 진행률 */} - {isSubmitting && uploadProgress > 0 && ( - <div className="space-y-2"> - <div className="flex justify-between text-sm"> - <span>업로드 진행률</span> - <span>{uploadProgress}%</span> - </div> - <div className="w-full bg-gray-200 rounded-full h-2"> - <div - className="bg-blue-600 h-2 rounded-full transition-all duration-300" - style={{ width: `${uploadProgress}%` }} - /> - </div> - </div> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button type="submit" disabled={isSubmitting || !selectedFile}> - {isSubmitting ? "업로드 중..." : "업로드"} - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/add-revision-dialog.tsx b/lib/b-rfq/attachment/add-revision-dialog.tsx deleted file mode 100644 index 1abefb02..00000000 --- a/lib/b-rfq/attachment/add-revision-dialog.tsx +++ /dev/null @@ -1,336 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { Upload } from "lucide-react" -import { toast } from "sonner" - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { addRevisionToAttachment } from "../service" - -// 리비전 추가 폼 스키마 -const addRevisionSchema = z.object({ - revisionComment: z.string().optional(), - file: z.instanceof(File, { - message: "파일을 선택해주세요.", - }), -}) - -type AddRevisionFormData = z.infer<typeof addRevisionSchema> - -interface AddRevisionDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - attachmentId: number - currentRevision: string - originalFileName: string - onSuccess?: () => void -} - -export function AddRevisionDialog({ - open, - onOpenChange, - attachmentId, - currentRevision, - originalFileName, - onSuccess -}: AddRevisionDialogProps) { - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [uploadProgress, setUploadProgress] = React.useState<number>(0) - - const form = useForm<AddRevisionFormData>({ - resolver: zodResolver(addRevisionSchema), - defaultValues: { - revisionComment: "", - file: undefined, - }, - }) - - const selectedFile = form.watch("file") - - // 다이얼로그 닫기 핸들러 - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen && !isSubmitting) { - form.reset() - } - onOpenChange(newOpen) - } - - // 파일 선택 처리 - const handleFileChange = (files: File[]) => { - if (files.length === 0) return - - const file = files[0] - - // 파일 크기 검증 - const maxFileSize = 10 * 1024 * 1024 // 10MB - if (file.size > maxFileSize) { - toast.error(`파일이 너무 큽니다. (최대 10MB)`) - return - } - - form.setValue("file", file) - form.clearErrors("file") - } - - // 파일 제거 - const removeFile = () => { - form.resetField("file") - } - - // 파일 업로드 API 호출 - const uploadFile = async (file: File): Promise<{ - fileName: string - originalFileName: string - filePath: string - fileSize: number - fileType: string - }> => { - const formData = new FormData() - formData.append("attachmentId", attachmentId.toString()) - formData.append("file", file) - formData.append("isRevision", "true") - - const response = await fetch("/api/upload/rfq-attachment-revision", { - method: "POST", - body: formData, - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || "파일 업로드 실패") - } - - return response.json() - } - - // 폼 제출 - const onSubmit = async (data: AddRevisionFormData) => { - setIsSubmitting(true) - setUploadProgress(0) - - try { - // 1단계: 파일 업로드 - setUploadProgress(30) - const uploadedFile = await uploadFile(data.file) - - // 2단계: DB 리비전 레코드 생성 - setUploadProgress(70) - const result = await addRevisionToAttachment(attachmentId, { - fileName: uploadedFile.fileName, - originalFileName: uploadedFile.originalFileName, - filePath: uploadedFile.filePath, - fileSize: uploadedFile.fileSize, - fileType: uploadedFile.fileType, - revisionComment: data.revisionComment, - }) - - setUploadProgress(100) - - if (result.success) { - toast.success(result.message) - form.reset() - handleOpenChange(false) - onSuccess?.() - } else { - toast.error(result.message) - } - - } catch (error) { - console.error("Upload error:", error) - toast.error(error instanceof Error ? error.message : "리비전 추가 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - setUploadProgress(0) - } - } - - // 다음 리비전 번호 계산 - const getNextRevision = (current: string) => { - const match = current.match(/Rev\.(\d+)/) - if (match) { - const num = parseInt(match[1]) + 1 - return `Rev.${num}` - } - return "Rev.1" - } - - const nextRevision = getNextRevision(currentRevision) - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Upload className="h-5 w-5" /> - 새 리비전 추가 - </DialogTitle> - <DialogDescription> - "{originalFileName}"의 새 버전을 업로드합니다. - 현재 {currentRevision} → {nextRevision} - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 리비전 코멘트 */} - <FormField - control={form.control} - name="revisionComment" - render={({ field }) => ( - <FormItem> - <FormLabel>리비전 코멘트 (선택)</FormLabel> - <FormControl> - <Textarea - placeholder={`${nextRevision} 업데이트 내용을 입력하세요`} - className="resize-none" - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 선택 - Dropzone (단일 파일) */} - <FormField - control={form.control} - name="file" - render={({ field }) => ( - <FormItem> - <FormLabel>새 파일 선택</FormLabel> - <FormControl> - <div className="space-y-3"> - <Dropzone - onDrop={(acceptedFiles) => { - handleFileChange(acceptedFiles) - }} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'application/vnd.ms-powerpoint': ['.ppt'], - 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], - 'application/zip': ['.zip'], - 'application/x-rar-compressed': ['.rar'] - }} - maxSize={10 * 1024 * 1024} // 10MB - multiple={false} - disabled={isSubmitting} - > - <DropzoneZone> - <DropzoneUploadIcon /> - <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle> - <DropzoneDescription> - PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개) - </DropzoneDescription> - <DropzoneInput /> - </DropzoneZone> - </Dropzone> - - {/* 선택된 파일 표시 */} - {selectedFile && ( - <div className="space-y-2"> - <FileListHeader> - 선택된 파일 ({nextRevision}) - </FileListHeader> - <FileList> - <FileListItem> - <FileListIcon /> - <FileListInfo> - <FileListName>{selectedFile.name}</FileListName> - <FileListDescription> - <FileListSize>{selectedFile.size}</FileListSize> - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={removeFile} - disabled={isSubmitting} - /> - </FileListItem> - </FileList> - </div> - )} - - {/* 업로드 진행률 */} - {isSubmitting && uploadProgress > 0 && ( - <div className="space-y-2"> - <div className="flex justify-between text-sm"> - <span>업로드 진행률</span> - <span>{uploadProgress}%</span> - </div> - <div className="w-full bg-gray-200 rounded-full h-2"> - <div - className="bg-blue-600 h-2 rounded-full transition-all duration-300" - style={{ width: `${uploadProgress}%` }} - /> - </div> - </div> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button type="submit" disabled={isSubmitting || !selectedFile}> - {isSubmitting ? "업로드 중..." : `${nextRevision} 추가`} - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/attachment-columns.tsx b/lib/b-rfq/attachment/attachment-columns.tsx deleted file mode 100644 index b726ebc8..00000000 --- a/lib/b-rfq/attachment/attachment-columns.tsx +++ /dev/null @@ -1,286 +0,0 @@ -"use client" - -import * as React from "react" -import { type ColumnDef } from "@tanstack/react-table" -import { - Ellipsis, FileText, Download, Eye, - MessageSquare, Upload -} from "lucide-react" - -import { formatDate, formatBytes } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger -} from "@/components/ui/dropdown-menu" -import { Progress } from "@/components/ui/progress" -import { RevisionDialog } from "./revision-dialog" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { AddRevisionDialog } from "./add-revision-dialog" - -interface GetAttachmentColumnsProps { - onSelectAttachment: (attachment: any) => void -} - -export function getAttachmentColumns({ - onSelectAttachment -}: GetAttachmentColumnsProps): ColumnDef<any>[] { - - return [ - /** ───────────── 체크박스 ───────────── */ - { - 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, - }, - - /** ───────────── 문서 정보 ───────────── */ - { - accessorKey: "serialNo", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="시리얼 번호" /> - ), - cell: ({ row }) => ( - <Button - variant="link" - className="p-0 h-auto font-medium text-blue-600 hover:text-blue-800" - onClick={() => onSelectAttachment(row.original)} - > - {row.getValue("serialNo") as string} - </Button> - ), - size: 100, - }, - { - accessorKey: "attachmentType", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="문서 타입" /> - ), - cell: ({ row }) => { - const type = row.getValue("attachmentType") as string - return ( - <Badge variant={type === "구매" ? "default" : "secondary"}> - {type} - </Badge> - ) - }, - size:100 - }, - { - accessorKey: "originalFileName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="파일명" /> - ), - cell: ({ row }) => { - const fileName = row.getValue("originalFileName") as string - return ( - <div className="flex items-center gap-2"> - <FileText className="h-4 w-4 text-muted-foreground" /> - <div className="min-w-0 flex-1"> - <div className="truncate font-medium" title={fileName}> - {fileName} - </div> - </div> - </div> - ) - }, - size:250 - }, - { - id: "currentRevision", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="리비전" /> - ), - cell: ({ row }) => ( - <RevisionDialog - attachmentId={row.original.id} - currentRevision={row.original.currentRevision} - originalFileName={row.original.originalFileName} - /> - ), - size: 100, - }, - { - accessorKey: "description", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="설명" /> - ), - cell: ({ row }) => { - const description = row.getValue("description") as string - return description - ? <div className="max-w-[200px] truncate" title={description}>{description}</div> - : <span className="text-muted-foreground">-</span> - }, - }, - - /** ───────────── 파일 정보 ───────────── */ - // { - // accessorKey: "fileSize", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="파일 크기" /> - // ), - // cell: ({ row }) => { - // const size = row.getValue("fileSize") as number - // return size ? formatBytes(size) : "-" - // }, - // }, - { - accessorKey: "createdAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등록일" /> - ), - cell: ({ row }) => { - const created = row.getValue("createdAt") as Date - const updated = row.original.updatedAt as Date - return ( - <div> - <div>{formatDate(created, "KR")}</div> - <div className="text-xs text-muted-foreground"> - {row.original.createdByName} - </div> - {updated && new Date(updated) > new Date(created) && ( - <div className="text-xs text-blue-600"> - 수정: {formatDate(updated, "KR")} - </div> - )} - </div> - ) - }, - maxSize:150 - }, - - /** ───────────── 벤더 응답 현황 ───────────── */ - { - id: "vendorCount", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 수" /> - ), - cell: ({ row }) => { - const stats = row.original.responseStats - return stats - ? ( - <div className="text-center"> - <div className="font-medium">{stats.totalVendors}</div> - <div className="text-xs text-muted-foreground"> - 활성: {stats.totalVendors - stats.waivedCount} - </div> - </div> - ) - : <span className="text-muted-foreground">-</span> - }, - }, - { - id: "responseStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="응답 현황" /> - ), - cell: ({ row }) => { - const stats = row.original.responseStats - return stats - ? ( - <div className="space-y-1"> - <div className="flex items-center gap-2"> - <div className="flex-1"> - <Progress value={stats.responseRate} className="h-2" /> - </div> - <span className="text-sm font-medium"> - {stats.responseRate}% - </span> - </div> - <div className="flex gap-2 text-xs"> - <span className="text-green-600"> - 응답: {stats.respondedCount} - </span> - <span className="text-orange-600"> - 대기: {stats.pendingCount} - </span> - {stats.waivedCount > 0 && ( - <span className="text-gray-500"> - 면제: {stats.waivedCount} - </span> - )} - </div> - </div> - ) - : <span className="text-muted-foreground">-</span> - }, - }, - - /** ───────────── 액션 ───────────── */ - { - id: "actions", - enableHiding: false, - cell: ({ row }) => { - const [isAddRevisionOpen, setIsAddRevisionOpen] = React.useState(false) - - 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-48"> - <DropdownMenuItem onClick={() => onSelectAttachment(row.original)}> - <MessageSquare className="mr-2 h-4 w-4" /> - 벤더 응답 보기 - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => row.original.filePath && window.open(row.original.filePath, "_blank")} - > - <Download className="mr-2 h-4 w-4" /> - 다운로드 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => setIsAddRevisionOpen(true)}> - <Upload className="mr-2 h-4 w-4" /> - 새 리비전 추가 - </DropdownMenuItem> - <DropdownMenuItem className="text-red-600"> - 삭제 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - <AddRevisionDialog - open={isAddRevisionOpen} - onOpenChange={setIsAddRevisionOpen} - attachmentId={row.original.id} - currentRevision={row.original.currentRevision} - originalFileName={row.original.originalFileName} - onSuccess={() => window.location.reload()} - /> - </> - ) - }, - size: 40, - }, - ] -} diff --git a/lib/b-rfq/attachment/attachment-table.tsx b/lib/b-rfq/attachment/attachment-table.tsx deleted file mode 100644 index 4c547000..00000000 --- a/lib/b-rfq/attachment/attachment-table.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableAdvancedFilterField, type DataTableFilterField } 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 { VendorResponsesPanel } from "./vendor-responses-panel" -import { Separator } from "@/components/ui/separator" -import { FileText } from "lucide-react" -import { getRfqAttachments, getVendorResponsesForAttachment } from "../service" -import { getAttachmentColumns } from "./attachment-columns" -import { RfqAttachmentsTableToolbarActions } from "./attachment-toolbar-action" - -interface RfqAttachmentsTableProps { - promises: Promise<Awaited<ReturnType<typeof getRfqAttachments>>> - rfqId: number -} - -export function RfqAttachmentsTable({ promises, rfqId }: RfqAttachmentsTableProps) { - const { data, pageCount } = React.use(promises) - - // 선택된 첨부파일과 벤더 응답 데이터 - const [selectedAttachment, setSelectedAttachment] = React.useState<any>(null) - const [vendorResponses, setVendorResponses] = React.useState<any[]>([]) - const [isLoadingResponses, setIsLoadingResponses] = React.useState(false) - - const columns = React.useMemo( - () => getAttachmentColumns({ - onSelectAttachment: setSelectedAttachment - }), - [] - ) - - // 첨부파일 선택 시 벤더 응답 데이터 로드 - React.useEffect(() => { - if (!selectedAttachment) { - setVendorResponses([]) - return - } - - const loadVendorResponses = async () => { - setIsLoadingResponses(true) - try { - const responses = await getVendorResponsesForAttachment( - selectedAttachment.id, - 'INITIAL' // 또는 현재 RFQ 상태에 따라 결정 - ) - setVendorResponses(responses) - } catch (error) { - console.error('Failed to load vendor responses:', error) - setVendorResponses([]) - } finally { - setIsLoadingResponses(false) - } - } - - loadVendorResponses() - }, [selectedAttachment]) - - /** - * 필터 필드 정의 - */ - const filterFields: DataTableFilterField<any>[] = [ - { - id: "fileName", - label: "파일명", - placeholder: "파일명으로 검색...", - }, - { - id: "attachmentType", - label: "문서 타입", - options: [ - { label: "구매 문서", value: "구매", count: 0 }, - { label: "설계 문서", value: "설계", count: 0 }, - ], - }, - { - id: "fileType", - label: "파일 형식", - options: [ - { label: "PDF", value: "pdf", count: 0 }, - { label: "Excel", value: "xlsx", count: 0 }, - { label: "Word", value: "docx", count: 0 }, - { label: "기타", value: "기타", count: 0 }, - ], - }, - ] - - /** - * 고급 필터 필드 - */ - const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ - { - id: "fileName", - label: "파일명", - type: "text", - }, - { - id: "originalFileName", - label: "원본 파일명", - type: "text", - }, - { - id: "serialNo", - label: "시리얼 번호", - type: "text", - }, - { - id: "description", - label: "설명", - type: "text", - }, - { - id: "attachmentType", - label: "문서 타입", - type: "multi-select", - options: [ - { label: "구매 문서", value: "구매" }, - { label: "설계 문서", value: "설계" }, - ], - }, - { - id: "fileType", - label: "파일 형식", - type: "multi-select", - options: [ - { label: "PDF", value: "pdf" }, - { label: "Excel", value: "xlsx" }, - { label: "Word", value: "docx" }, - { label: "기타", value: "기타" }, - ], - }, - { - id: "createdAt", - label: "등록일", - type: "date", - }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => originalRow.id.toString(), - shallow: false, - clearOnDefault: true, - }) - - return ( - <div className="space-y-6"> - {/* 메인 테이블 */} - <div className="h-full w-full"> - <DataTable table={table} className="h-full"> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <RfqAttachmentsTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - </div> - - {/* 벤더 응답 현황 패널 */} - {selectedAttachment && ( - <> - <Separator /> - <VendorResponsesPanel - attachment={selectedAttachment} - responses={vendorResponses} - isLoading={isLoadingResponses} - onRefresh={() => { - // 새로고침 로직 - if (selectedAttachment) { - setSelectedAttachment({ ...selectedAttachment }) - } - }} - /> - </> - )} - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/attachment-toolbar-action.tsx b/lib/b-rfq/attachment/attachment-toolbar-action.tsx deleted file mode 100644 index e078ea66..00000000 --- a/lib/b-rfq/attachment/attachment-toolbar-action.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" - -import { AddAttachmentDialog } from "./add-attachment-dialog" -import { ConfirmDocumentsDialog } from "./confirm-documents-dialog" -import { TbeRequestDialog } from "./tbe-request-dialog" -import { DeleteAttachmentsDialog } from "./delete-attachment-dialog" - -interface RfqAttachmentsTableToolbarActionsProps { - table: Table<any> - rfqId: number -} - -export function RfqAttachmentsTableToolbarActions({ - table, - rfqId -}: RfqAttachmentsTableToolbarActionsProps) { - - // 선택된 행들 가져오기 - const selectedRows = table.getFilteredSelectedRowModel().rows - const selectedAttachments = selectedRows.map((row) => row.original) - const selectedCount = selectedRows.length - - return ( - <div className="flex items-center gap-2"> - {/** 선택된 로우가 있으면 삭제 다이얼로그 */} - {selectedCount > 0 && ( - <DeleteAttachmentsDialog - attachments={selectedAttachments} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - )} - - {/** 새 첨부 추가 다이얼로그 */} - <AddAttachmentDialog rfqId={rfqId} /> - - {/** 문서 확정 다이얼로그 */} - <ConfirmDocumentsDialog - rfqId={rfqId} - onSuccess={() => { - // 성공 후 필요한 작업 (예: 페이지 새로고침) - window.location.reload() - }} - /> - - {/** TBE 요청 다이얼로그 (선택된 행이 있을 때만 활성화) */} - <TbeRequestDialog - rfqId={rfqId} - attachments={selectedAttachments} - onSuccess={() => { - // 선택 해제 및 페이지 새로고침 - table.toggleAllRowsSelected(false) - window.location.reload() - }} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/confirm-documents-dialog.tsx b/lib/b-rfq/attachment/confirm-documents-dialog.tsx deleted file mode 100644 index fccb4123..00000000 --- a/lib/b-rfq/attachment/confirm-documents-dialog.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client" - -import * as React from "react" -import { Loader, FileCheck } from "lucide-react" -import { toast } from "sonner" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" - -import { confirmDocuments } from "../service" - -interface ConfirmDocumentsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqId: number - showTrigger?: boolean - onSuccess?: () => void -} - -export function ConfirmDocumentsDialog({ - rfqId, - showTrigger = true, - onSuccess, - ...props -}: ConfirmDocumentsDialogProps) { - const [isConfirmPending, startConfirmTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onConfirm() { - startConfirmTransition(async () => { - const result = await confirmDocuments(rfqId) - - if (!result.success) { - toast.error(result.message) - return - } - - props.onOpenChange?.(false) - toast.success(result.message) - onSuccess?.() - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm" className="gap-2"> - <FileCheck className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">문서 확정</span> - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>문서를 확정하시겠습니까?</DialogTitle> - <DialogDescription> - 이 작업은 RFQ의 모든 첨부문서를 확정하고 상태를 "Doc. Confirmed"로 변경합니다. - 확정 후에는 문서 수정이 제한될 수 있습니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="Confirm documents" - onClick={onConfirm} - disabled={isConfirmPending} - > - {isConfirmPending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 문서 확정 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm" className="gap-2"> - <FileCheck className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">문서 확정</span> - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>문서를 확정하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 RFQ의 모든 첨부문서를 확정하고 상태를 "Doc. Confirmed"로 변경합니다. - 확정 후에는 문서 수정이 제한될 수 있습니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="Confirm documents" - onClick={onConfirm} - disabled={isConfirmPending} - > - {isConfirmPending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - 문서 확정 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/delete-attachment-dialog.tsx b/lib/b-rfq/attachment/delete-attachment-dialog.tsx deleted file mode 100644 index b5471520..00000000 --- a/lib/b-rfq/attachment/delete-attachment-dialog.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client" - -import * as React from "react" -import { type Row } from "@tanstack/react-table" -import { Loader, Trash } from "lucide-react" -import { toast } from "sonner" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" -import { deleteRfqAttachments } from "../service" - - -// 첨부파일 타입 (실제 타입에 맞게 조정 필요) -type RfqAttachment = { - id: number - serialNo: string - originalFileName: string - attachmentType: string - currentRevision: string -} - -interface DeleteAttachmentsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - attachments: Row<RfqAttachment>["original"][] - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteAttachmentsDialog({ - attachments, - showTrigger = true, - onSuccess, - ...props -}: DeleteAttachmentsDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - startDeleteTransition(async () => { - const result = await deleteRfqAttachments({ - ids: attachments.map((attachment) => attachment.id), - }) - - if (!result.success) { - toast.error(result.message) - return - } - - props.onOpenChange?.(false) - toast.success(result.message) - onSuccess?.() - }) - } - - const attachmentText = attachments.length === 1 ? "첨부파일" : "첨부파일들" - const deleteWarning = `선택된 ${attachments.length}개의 ${attachmentText}과 모든 리비전이 영구적으로 삭제됩니다.` - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({attachments.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> - <DialogDescription className="space-y-2"> - <div>이 작업은 되돌릴 수 없습니다.</div> - <div>{deleteWarning}</div> - {attachments.length <= 3 && ( - <div className="mt-3 p-2 bg-gray-50 rounded-md"> - <div className="font-medium text-sm">삭제될 파일:</div> - <ul className="text-sm text-gray-600 mt-1"> - {attachments.map((attachment) => ( - <li key={attachment.id} className="truncate"> - • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision}) - </li> - ))} - </ul> - </div> - )} - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="Delete selected attachments" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 삭제 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({attachments.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription className="space-y-2"> - <div>이 작업은 되돌릴 수 없습니다.</div> - <div>{deleteWarning}</div> - {attachments.length <= 3 && ( - <div className="mt-3 p-2 bg-gray-50 rounded-md"> - <div className="font-medium text-sm">삭제될 파일:</div> - <ul className="text-sm text-gray-600 mt-1"> - {attachments.map((attachment) => ( - <li key={attachment.id} className="truncate"> - • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision}) - </li> - ))} - </ul> - </div> - )} - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="Delete selected attachments" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - 삭제 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/request-revision-dialog.tsx b/lib/b-rfq/attachment/request-revision-dialog.tsx deleted file mode 100644 index 90d5b543..00000000 --- a/lib/b-rfq/attachment/request-revision-dialog.tsx +++ /dev/null @@ -1,205 +0,0 @@ -// components/rfq/request-revision-dialog.tsx -"use client"; - -import { useState, useTransition } 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 { AlertTriangle, Loader2 } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { requestRevision } from "../service"; - -const revisionFormSchema = z.object({ - revisionReason: z - .string() - .min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요") - .max(500, "수정 요청 사유는 500자를 초과할 수 없습니다"), -}); - -type RevisionFormData = z.infer<typeof revisionFormSchema>; - -interface RequestRevisionDialogProps { - responseId: number; - attachmentType: string; - serialNo: string; - vendorName?: string; - currentRevision: string; - trigger?: React.ReactNode; - onSuccess?: () => void; -} - -export function RequestRevisionDialog({ - responseId, - attachmentType, - serialNo, - vendorName, - currentRevision, - trigger, - onSuccess, -}: RequestRevisionDialogProps) { - const [open, setOpen] = useState(false); - const [isPending, startTransition] = useTransition(); - const { toast } = useToast(); - - const form = useForm<RevisionFormData>({ - resolver: zodResolver(revisionFormSchema), - defaultValues: { - revisionReason: "", - }, - }); - - const handleOpenChange = (newOpen: boolean) => { - setOpen(newOpen); - // 다이얼로그가 닫힐 때 form 리셋 - if (!newOpen) { - form.reset(); - } - }; - - const handleCancel = () => { - form.reset(); - setOpen(false); - }; - - const onSubmit = async (data: RevisionFormData) => { - startTransition(async () => { - try { - const result = await requestRevision(responseId, data.revisionReason); - - if (!result.success) { - throw new Error(result.message); - } - - toast({ - title: "수정 요청 완료", - description: result.message, - }); - - setOpen(false); - form.reset(); - onSuccess?.(); - - } catch (error) { - console.error("Request revision error:", error); - toast({ - title: "수정 요청 실패", - description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", - variant: "destructive", - }); - } - }); - }; - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogTrigger asChild> - {trigger || ( - <Button size="sm" variant="outline"> - <AlertTriangle className="h-3 w-3 mr-1" /> - 수정요청 - </Button> - )} - </DialogTrigger> - <DialogContent className="max-w-lg"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <AlertTriangle className="h-5 w-5 text-orange-600" /> - 수정 요청 - </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> - {vendorName && ( - <> - <span>•</span> - <span>{vendorName}</span> - </> - )} - </div> - </DialogHeader> - - <div className="space-y-4"> - <div className="bg-orange-50 border border-orange-200 rounded-lg p-4"> - <div className="flex items-start gap-2"> - <AlertTriangle className="h-4 w-4 text-orange-600 mt-0.5 flex-shrink-0" /> - <div className="text-sm text-orange-800"> - <p className="font-medium mb-1">수정 요청 안내</p> - <p> - 벤더에게 현재 제출된 응답에 대한 수정을 요청합니다. - 수정 요청 후 벤더는 새로운 파일을 다시 제출할 수 있습니다. - </p> - </div> - </div> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - <FormField - control={form.control} - name="revisionReason" - render={({ field }) => ( - <FormItem> - <FormLabel className="text-base font-medium"> - 수정 요청 사유 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <Textarea - placeholder="수정이 필요한 구체적인 사유를 입력해주세요... 예: 제출된 도면에서 치수 정보가 누락되었습니다." - className="resize-none" - rows={4} - disabled={isPending} - {...field} - /> - </FormControl> - <div className="flex justify-between text-xs text-muted-foreground"> - <FormMessage /> - <span>{field.value?.length || 0}/500</span> - </div> - </FormItem> - )} - /> - - <div className="flex justify-end gap-2 pt-2"> - <Button - type="button" - variant="outline" - onClick={handleCancel} - disabled={isPending} - > - 취소 - </Button> - <Button - type="submit" - disabled={isPending} - // className="bg-orange-600 hover:bg-orange-700" - > - {isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} - {isPending ? "요청 중..." : "수정 요청"} - </Button> - </div> - </form> - </Form> - </div> - </DialogContent> - </Dialog> - ); -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/revision-dialog.tsx b/lib/b-rfq/attachment/revision-dialog.tsx deleted file mode 100644 index d26abedb..00000000 --- a/lib/b-rfq/attachment/revision-dialog.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client" - -import * as React from "react" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { History, Download, Upload } from "lucide-react" -import { formatDate, formatBytes } from "@/lib/utils" -import { getAttachmentRevisions } from "../service" -import { AddRevisionDialog } from "./add-revision-dialog" - -interface RevisionDialogProps { - attachmentId: number - currentRevision: string - originalFileName: string -} - -export function RevisionDialog({ - attachmentId, - currentRevision, - originalFileName -}: RevisionDialogProps) { - const [open, setOpen] = React.useState(false) - const [revisions, setRevisions] = React.useState<any[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - const [isAddRevisionOpen, setIsAddRevisionOpen] = React.useState(false) - - // 리비전 목록 로드 - const loadRevisions = async () => { - setIsLoading(true) - try { - const result = await getAttachmentRevisions(attachmentId) - - if (result.success) { - setRevisions(result.revisions) - } else { - console.error("Failed to load revisions:", result.message) - } - } catch (error) { - console.error("Failed to load revisions:", error) - } finally { - setIsLoading(false) - } - } - - React.useEffect(() => { - if (open) { - loadRevisions() - } - }, [open, attachmentId]) - - return ( - <> - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button variant="ghost" size="sm" className="gap-2"> - <History className="h-4 w-4" /> - {currentRevision} - </Button> - </DialogTrigger> - - <DialogContent className="sm:max-w-[800px]" style={{maxWidth:800}}> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <History className="h-5 w-5" /> - 리비전 히스토리: {originalFileName} - </DialogTitle> - <DialogDescription> - 이 문서의 모든 버전을 확인하고 관리할 수 있습니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - {/* 새 리비전 추가 버튼 */} - <div className="flex justify-end"> - <Button - onClick={() => setIsAddRevisionOpen(true)} - className="gap-2" - > - <Upload className="h-4 w-4" /> - 새 리비전 추가 - </Button> - </div> - - {/* 리비전 목록 */} - {isLoading ? ( - <div className="text-center py-8">리비전을 불러오는 중...</div> - ) : ( - <div className="border rounded-lg"> - <Table> - <TableHeader> - <TableRow> - <TableHead>리비전</TableHead> - <TableHead>파일명</TableHead> - <TableHead>크기</TableHead> - <TableHead>업로드 일시</TableHead> - <TableHead>업로드자</TableHead> - <TableHead>코멘트</TableHead> - <TableHead>액션</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {revisions.map((revision) => ( - <TableRow key={revision.id}> - <TableCell> - <div className="flex items-center gap-2"> - <Badge - variant={revision.isLatest ? "default" : "outline"} - > - {revision.revisionNo} - </Badge> - {revision.isLatest && ( - <Badge variant="secondary" className="text-xs"> - 최신 - </Badge> - )} - </div> - </TableCell> - - <TableCell> - <div> - <div className="font-medium">{revision.originalFileName}</div> - </div> - </TableCell> - - <TableCell> - {formatBytes(revision.fileSize)} - </TableCell> - - <TableCell> - {formatDate(revision.createdAt, "KR")} - </TableCell> - - <TableCell> - {revision.createdByName || "-"} - </TableCell> - - <TableCell> - <div className="max-w-[200px] truncate" title={revision.revisionComment}> - {revision.revisionComment || "-"} - </div> - </TableCell> - - <TableCell> - <Button - variant="ghost" - size="sm" - className="gap-2" - onClick={() => { - // 파일 다운로드 - window.open(revision.filePath, '_blank') - }} - > - <Download className="h-4 w-4" /> - 다운로드 - </Button> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - )} - </div> - </DialogContent> - </Dialog> - - {/* 새 리비전 추가 다이얼로그 */} - <AddRevisionDialog - open={isAddRevisionOpen} - onOpenChange={setIsAddRevisionOpen} - attachmentId={attachmentId} - currentRevision={currentRevision} - originalFileName={originalFileName} - onSuccess={() => { - loadRevisions() // 리비전 목록 새로고침 - }} - /> - </> - ) - }
\ No newline at end of file diff --git a/lib/b-rfq/attachment/tbe-request-dialog.tsx b/lib/b-rfq/attachment/tbe-request-dialog.tsx deleted file mode 100644 index 80b20e6f..00000000 --- a/lib/b-rfq/attachment/tbe-request-dialog.tsx +++ /dev/null @@ -1,200 +0,0 @@ -"use client" - -import * as React from "react" -import { Loader, Send } from "lucide-react" -import { toast } from "sonner" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" - -import { requestTbe } from "../service" - -// 첨부파일 타입 -type RfqAttachment = { - id: number - serialNo: string - originalFileName: string - attachmentType: string - currentRevision: string -} - -interface TbeRequestDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqId: number - attachments: RfqAttachment[] - showTrigger?: boolean - onSuccess?: () => void -} - -export function TbeRequestDialog({ - rfqId, - attachments, - showTrigger = true, - onSuccess, - ...props -}: TbeRequestDialogProps) { - const [isRequestPending, startRequestTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onRequest() { - startRequestTransition(async () => { - const attachmentIds = attachments.map(attachment => attachment.id) - const result = await requestTbe(rfqId, attachmentIds) - - if (!result.success) { - toast.error(result.message) - return - } - - props.onOpenChange?.(false) - toast.success(result.message) - onSuccess?.() - }) - } - - const attachmentCount = attachments.length - const attachmentText = attachmentCount === 1 ? "문서" : "문서들" - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button - variant="outline" - size="sm" - className="gap-2" - disabled={attachmentCount === 0} - > - <Send className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - TBE 요청 {attachmentCount > 0 && `(${attachmentCount})`} - </span> - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>TBE 요청을 전송하시겠습니까?</DialogTitle> - <DialogDescription className="space-y-2"> - <div> - 선택된 <span className="font-medium">{attachmentCount}개</span>의 {attachmentText}에 대해 - 벤더들에게 기술평가(TBE) 요청을 전송합니다. - </div> - <div>RFQ 상태가 "TBE started"로 변경됩니다.</div> - {attachmentCount <= 5 && ( - <div className="mt-3 p-2 bg-gray-50 rounded-md"> - <div className="font-medium text-sm">TBE 요청 대상:</div> - <ul className="text-sm text-gray-600 mt-1"> - {attachments.map((attachment) => ( - <li key={attachment.id} className="truncate"> - • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision}) - </li> - ))} - </ul> - </div> - )} - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="Request TBE" - onClick={onRequest} - disabled={isRequestPending} - > - {isRequestPending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - TBE 요청 전송 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button - variant="outline" - size="sm" - className="gap-2" - disabled={attachmentCount === 0} - > - <Send className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - TBE 요청 {attachmentCount > 0 && `(${attachmentCount})`} - </span> - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>TBE 요청을 전송하시겠습니까?</DrawerTitle> - <DrawerDescription className="space-y-2"> - <div> - 선택된 <span className="font-medium">{attachmentCount}개</span>의 {attachmentText}에 대해 - 벤더들에게 기술평가(TBE) 요청을 전송합니다. - </div> - <div>RFQ 상태가 "TBE started"로 변경됩니다.</div> - {attachmentCount <= 5 && ( - <div className="mt-3 p-2 bg-gray-50 rounded-md"> - <div className="font-medium text-sm">TBE 요청 대상:</div> - <ul className="text-sm text-gray-600 mt-1"> - {attachments.map((attachment) => ( - <li key={attachment.id} className="truncate"> - • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision}) - </li> - ))} - </ul> - </div> - )} - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="Request TBE" - onClick={onRequest} - disabled={isRequestPending} - > - {isRequestPending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - TBE 요청 전송 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/attachment/vendor-responses-panel.tsx b/lib/b-rfq/attachment/vendor-responses-panel.tsx deleted file mode 100644 index 0cbe2a08..00000000 --- a/lib/b-rfq/attachment/vendor-responses-panel.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { - RefreshCw, - Download, - MessageSquare, - Clock, - CheckCircle2, - XCircle, - AlertCircle, - FileText, - Files, - AlertTriangle -} from "lucide-react" -import { formatDate, formatFileSize } from "@/lib/utils" -import { RequestRevisionDialog } from "./request-revision-dialog" - -interface VendorResponsesPanelProps { - attachment: any - responses: any[] - isLoading: boolean - onRefresh: () => void -} - -// 파일 다운로드 핸들러 -async function handleFileDownload(filePath: string, fileName: string, fileId: number) { - try { - const params = new URLSearchParams({ - path: filePath, - type: "vendor", - responseFileId: fileId.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); - - console.log("✅ 파일 다운로드 성공:", fileName); - } catch (error) { - console.error("❌ 파일 다운로드 실패:", error); - alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); - } -} - -// 파일 목록 컴포넌트 -function FilesList({ files }: { files: any[] }) { - if (files.length === 0) { - return ( - <div className="text-center py-4 text-muted-foreground text-sm"> - 업로드된 파일이 없습니다. - </div> - ); - } - - return ( - <div className="space-y-2 max-h-64 overflow-y-auto"> - {files.map((file, index) => ( - <div key={file.id} className="flex items-center justify-between p-3 border rounded-lg bg-green-50 border-green-200"> - <div className="flex items-center gap-2 flex-1 min-w-0"> - <FileText className="h-4 w-4 text-green-600 flex-shrink-0" /> - <div className="min-w-0 flex-1"> - <div className="font-medium text-sm truncate" title={file.originalFileName}> - {file.originalFileName} - </div> - <div className="text-xs text-muted-foreground"> - {formatFileSize(file.fileSize)} • {formatDate(file.uploadedAt)} - </div> - {file.description && ( - <div className="text-xs text-muted-foreground italic mt-1" title={file.description}> - {file.description} - </div> - )} - </div> - </div> - <Button - size="sm" - variant="ghost" - onClick={() => handleFileDownload(file.filePath, file.originalFileName, file.id)} - className="flex-shrink-0 ml-2" - title="파일 다운로드" - > - <Download className="h-4 w-4" /> - </Button> - </div> - ))} - </div> - ); -} - -export function VendorResponsesPanel({ - attachment, - responses, - isLoading, - onRefresh -}: VendorResponsesPanelProps) { - - console.log(responses) - - const getStatusIcon = (status: string) => { - switch (status) { - case 'RESPONDED': - return <CheckCircle2 className="h-4 w-4 text-green-600" /> - case 'NOT_RESPONDED': - return <Clock className="h-4 w-4 text-orange-600" /> - case 'WAIVED': - return <XCircle className="h-4 w-4 text-gray-500" /> - case 'REVISION_REQUESTED': - return <AlertCircle className="h-4 w-4 text-yellow-600" /> - default: - return <Clock className="h-4 w-4 text-gray-400" /> - } - } - - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case 'RESPONDED': - return 'default' - case 'NOT_RESPONDED': - return 'secondary' - case 'WAIVED': - return 'outline' - case 'REVISION_REQUESTED': - return 'destructive' - default: - return 'secondary' - } - } - - if (isLoading) { - return ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <Skeleton className="h-6 w-48" /> - <Skeleton className="h-9 w-24" /> - </div> - <div className="space-y-3"> - {Array.from({ length: 3 }).map((_, i) => ( - <Skeleton key={i} className="h-12 w-full" /> - ))} - </div> - </div> - ) - } - - return ( - <div className="space-y-4"> - {/* 헤더 */} - <div className="flex items-center justify-between"> - <div className="space-y-1"> - <h3 className="text-lg font-medium flex items-center gap-2"> - <MessageSquare className="h-5 w-5" /> - 벤더 응답 현황: {attachment.originalFileName} - </h3> - <div className="flex flex-wrap gap-2 text-sm text-muted-foreground"> - <Badge variant="outline"> - {attachment.attachmentType} - </Badge> - <span>시리얼: {attachment.serialNo}</span> - <span>등록: {formatDate(attachment.createdAt)}</span> - {attachment.responseStats && ( - <Badge variant="secondary"> - 응답률: {attachment.responseStats.responseRate}% - </Badge> - )} - </div> - </div> - <Button - variant="outline" - size="sm" - onClick={onRefresh} - className="flex items-center gap-2" - > - <RefreshCw className="h-4 w-4" /> - 새로고침 - </Button> - </div> - - {/* 테이블 */} - {responses.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground border rounded-lg"> - 이 문서에 대한 벤더 응답 정보가 없습니다. - </div> - ) : ( - <div className="border rounded-lg"> - <Table> - <TableHeader> - <TableRow> - <TableHead>벤더</TableHead> - <TableHead>국가</TableHead> - <TableHead>응답 상태</TableHead> - <TableHead>리비전</TableHead> - <TableHead>요청일</TableHead> - <TableHead>응답일</TableHead> - <TableHead>응답 파일</TableHead> - <TableHead>코멘트</TableHead> - <TableHead className="w-[100px]">액션</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {responses.map((response) => ( - <TableRow key={response.id}> - <TableCell className="font-medium"> - <div> - <div>{response.vendorName}</div> - <div className="text-xs text-muted-foreground"> - {response.vendorCode} - </div> - </div> - </TableCell> - - <TableCell> - {response.vendorCountry} - </TableCell> - - <TableCell> - <div className="flex items-center gap-2"> - {getStatusIcon(response.responseStatus)} - <Badge variant={getStatusBadgeVariant(response.responseStatus)}> - {response.responseStatus} - </Badge> - </div> - </TableCell> - - <TableCell> - <div className="text-sm"> - <div>현재: {response.currentRevision}</div> - {response.respondedRevision && ( - <div className="text-muted-foreground"> - 응답: {response.respondedRevision} - </div> - )} - </div> - </TableCell> - - <TableCell> - {formatDate(response.requestedAt)} - </TableCell> - - <TableCell> - {response.respondedAt ? formatDate(response.respondedAt) : '-'} - </TableCell> - - {/* 응답 파일 컬럼 */} - <TableCell> - {response.totalFiles > 0 ? ( - <div className="flex items-center gap-2"> - <Badge variant="secondary" className="text-xs"> - {response.totalFiles}개 - </Badge> - {response.totalFiles === 1 ? ( - // 파일이 1개면 바로 다운로드 - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - onClick={() => { - const file = response.files[0]; - handleFileDownload(file.filePath, file.originalFileName, file.id); - }} - title={response.latestFile?.originalFileName} - > - <Download className="h-4 w-4" /> - </Button> - ) : ( - // 파일이 여러 개면 Popover로 목록 표시 - <Popover> - <PopoverTrigger asChild> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - title="파일 목록 보기" - > - <Files className="h-4 w-4" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-96" align="start"> - <div className="space-y-2"> - <div className="font-medium text-sm"> - 응답 파일 목록 ({response.totalFiles}개) - </div> - <FilesList files={response.files} /> - </div> - </PopoverContent> - </Popover> - )} - </div> - ) : ( - <span className="text-muted-foreground text-sm">-</span> - )} - </TableCell> - - <TableCell> - <div className="space-y-1 max-w-[200px]"> - {/* 벤더 응답 코멘트 */} - {response.responseComment && ( - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" title="벤더 응답 코멘트"></div> - <div className="text-xs text-blue-600 truncate" title={response.responseComment}> - {response.responseComment} - </div> - </div> - )} - - {/* 수정 요청 사유 */} - {response.revisionRequestComment && ( - <div className="flex items-center gap-1"> - <div className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="수정 요청 사유"></div> - <div className="text-xs text-red-600 truncate" title={response.revisionRequestComment}> - {response.revisionRequestComment} - </div> - </div> - )} - - {!response.responseComment && !response.revisionRequestComment && ( - <span className="text-muted-foreground text-sm">-</span> - )} - </div> - </TableCell> - - {/* 액션 컬럼 - 수정 요청 기능으로 변경 */} - <TableCell> - <div className="flex items-center gap-1"> - {response.responseStatus === 'RESPONDED' && ( - <RequestRevisionDialog - responseId={response.id} - attachmentType={attachment.attachmentType} - serialNo={attachment.serialNo} - vendorName={response.vendorName} - currentRevision={response.currentRevision} - onSuccess={onRefresh} - trigger={ - <Button - variant="outline" - size="sm" - className="h-8 px-2" - title="수정 요청" - > - <AlertTriangle className="h-3 w-3 mr-1" /> - 수정요청 - </Button> - } - /> - )} - - {response.responseStatus === 'REVISION_REQUESTED' && ( - <Badge variant="secondary" className="text-xs"> - 수정 요청됨 - </Badge> - )} - - {(response.responseStatus === 'NOT_RESPONDED' || response.responseStatus === 'WAIVED') && ( - <span className="text-muted-foreground text-xs">-</span> - )} - </div> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - )} - </div> - ) -}
\ No newline at end of file |
