diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-13 07:08:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-13 07:08:01 +0000 |
| commit | c72d0897f7b37843109c86f61d97eba05ba3ca0d (patch) | |
| tree | 887dd877f3f8beafa92b4d9a7b16c84b4a5795d8 /lib/b-rfq/attachment | |
| parent | ff902243a658067fae858a615c0629aa2e0a4837 (diff) | |
(대표님) 20250613 16시 08분 b-rfq, document 등
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 | 290 | ||||
| -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/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 | 205 |
10 files changed, 2155 insertions, 0 deletions
diff --git a/lib/b-rfq/attachment/add-attachment-dialog.tsx b/lib/b-rfq/attachment/add-attachment-dialog.tsx new file mode 100644 index 00000000..665e0f88 --- /dev/null +++ b/lib/b-rfq/attachment/add-attachment-dialog.tsx @@ -0,0 +1,355 @@ +"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 new file mode 100644 index 00000000..1abefb02 --- /dev/null +++ b/lib/b-rfq/attachment/add-revision-dialog.tsx @@ -0,0 +1,336 @@ +"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 new file mode 100644 index 00000000..c611e06c --- /dev/null +++ b/lib/b-rfq/attachment/attachment-columns.tsx @@ -0,0 +1,290 @@ +"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)}</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)} + </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> + <Eye 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 new file mode 100644 index 00000000..4c547000 --- /dev/null +++ b/lib/b-rfq/attachment/attachment-table.tsx @@ -0,0 +1,190 @@ +"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 new file mode 100644 index 00000000..e078ea66 --- /dev/null +++ b/lib/b-rfq/attachment/attachment-toolbar-action.tsx @@ -0,0 +1,60 @@ +"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 new file mode 100644 index 00000000..fccb4123 --- /dev/null +++ b/lib/b-rfq/attachment/confirm-documents-dialog.tsx @@ -0,0 +1,141 @@ +"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 new file mode 100644 index 00000000..b5471520 --- /dev/null +++ b/lib/b-rfq/attachment/delete-attachment-dialog.tsx @@ -0,0 +1,182 @@ +"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/revision-dialog.tsx b/lib/b-rfq/attachment/revision-dialog.tsx new file mode 100644 index 00000000..b1fe1576 --- /dev/null +++ b/lib/b-rfq/attachment/revision-dialog.tsx @@ -0,0 +1,196 @@ +"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)} + </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 new file mode 100644 index 00000000..80b20e6f --- /dev/null +++ b/lib/b-rfq/attachment/tbe-request-dialog.tsx @@ -0,0 +1,200 @@ +"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 new file mode 100644 index 00000000..901af3bf --- /dev/null +++ b/lib/b-rfq/attachment/vendor-responses-panel.tsx @@ -0,0 +1,205 @@ +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 { RefreshCw, Download, MessageSquare, Clock, CheckCircle2, XCircle, AlertCircle } from "lucide-react" +import { formatDate } from "@/lib/utils" + +interface VendorResponsesPanelProps { + attachment: any + responses: any[] + isLoading: boolean + onRefresh: () => void +} + +export function VendorResponsesPanel({ + attachment, + responses, + isLoading, + onRefresh +}: VendorResponsesPanelProps) { + + 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 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.vendorComment ? ( + <div className="max-w-[200px] truncate" title={response.vendorComment}> + {response.vendorComment} + </div> + ) : ( + '-' + )} + </TableCell> + + <TableCell> + <div className="flex items-center gap-1"> + {response.responseStatus === 'RESPONDED' && ( + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + title="첨부파일 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + )} + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + title="상세 보기" + > + <MessageSquare className="h-4 w-4" /> + </Button> + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + )} + </div> + ) +}
\ No newline at end of file |
