diff options
Diffstat (limited to 'lib/b-rfq')
| -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 | ||||
| -rw-r--r-- | lib/b-rfq/service.ts | 975 | ||||
| -rw-r--r-- | lib/b-rfq/summary-table/summary-rfq-columns.tsx | 4 | ||||
| -rw-r--r-- | lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx | 2 | ||||
| -rw-r--r-- | lib/b-rfq/validations.ts | 69 |
14 files changed, 3078 insertions, 127 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 diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts index f64eb46c..e60e446d 100644 --- a/lib/b-rfq/service.ts +++ b/lib/b-rfq/service.ts @@ -1,13 +1,27 @@ 'use server' import { revalidateTag, unstable_cache } from "next/cache" -import { count, desc, asc, and, or, gte, lte, ilike, eq } from "drizzle-orm" +import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm" import { filterColumns } from "@/lib/filter-columns" import db from "@/db/db" -import { RfqDashboardView, bRfqs, projects, users } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 +import { RfqDashboardView, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 import { rfqDashboardView } from "@/db/schema" // 뷰 import import type { SQL } from "drizzle-orm" -import { CreateRfqInput, GetRFQDashboardSchema, createRfqServerSchema } from "./validations" +import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { unlink } from "fs/promises" + +const tag = { + rfqDashboard: 'rfq-dashboard', + rfq: (id: number) => `rfq-${id}`, + rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`, + attachmentRevisions: (attId: number) => `attachment-revisions-${attId}`, + vendorResponses: ( + attId: number, + type: 'INITIAL' | 'FINAL' = 'INITIAL', + ) => `vendor-responses-${attId}-${type}`, +} as const; export async function getRFQDashboard(input: GetRFQDashboardSchema) { return unstable_cache( @@ -46,30 +60,30 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) { let globalWhere: SQL<unknown> | undefined = undefined; if (input.search) { const s = `%${input.search}%`; - + const validSearchConditions: SQL<unknown>[] = []; - + const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s); if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); - + const descriptionCondition = ilike(rfqDashboardView.description, s); if (descriptionCondition) validSearchConditions.push(descriptionCondition); - + const projectNameCondition = ilike(rfqDashboardView.projectName, s); if (projectNameCondition) validSearchConditions.push(projectNameCondition); - + const projectCodeCondition = ilike(rfqDashboardView.projectCode, s); if (projectCodeCondition) validSearchConditions.push(projectCodeCondition); - + const picNameCondition = ilike(rfqDashboardView.picName, s); if (picNameCondition) validSearchConditions.push(picNameCondition); - + const packageNoCondition = ilike(rfqDashboardView.packageNo, s); if (packageNoCondition) validSearchConditions.push(packageNoCondition); - + const packageNameCondition = ilike(rfqDashboardView.packageName, s); if (packageNameCondition) validSearchConditions.push(packageNameCondition); - + if (validSearchConditions.length > 0) { globalWhere = or(...validSearchConditions); } @@ -79,11 +93,11 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) { // 6) 최종 WHERE 조건 생성 const whereConditions: SQL<unknown>[] = []; - + if (advancedWhere) whereConditions.push(advancedWhere); if (basicWhere) whereConditions.push(basicWhere); if (globalWhere) whereConditions.push(globalWhere); - + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; // 7) 전체 데이터 수 조회 @@ -127,10 +141,8 @@ export async function getRFQDashboard(input: GetRFQDashboardSchema) { } }, [JSON.stringify(input)], - { - revalidate: 3600, - tags: ["rfq-dashboard"], - } + { revalidate: 3600, tags: [tag.rfqDashboard] }, + )(); } @@ -165,127 +177,844 @@ function getRFQJoinedTables() { // ================================================================ async function generateNextSerial(picCode: string): Promise<string> { - try { - // 해당 picCode로 시작하는 RFQ 개수 조회 - const existingCount = await db - .select({ count: count() }) - .from(bRfqs) - .where(eq(bRfqs.picCode, picCode)) - - const nextSerial = (existingCount[0]?.count || 0) + 1 - return nextSerial.toString().padStart(5, '0') // 5자리로 패딩 - } catch (error) { - console.error("시리얼 번호 생성 오류:", error) - return "00001" // 기본값 + try { + // 해당 picCode로 시작하는 RFQ 개수 조회 + const existingCount = await db + .select({ count: count() }) + .from(bRfqs) + .where(eq(bRfqs.picCode, picCode)) + + const nextSerial = (existingCount[0]?.count || 0) + 1 + return nextSerial.toString().padStart(5, '0') // 5자리로 패딩 + } catch (error) { + console.error("시리얼 번호 생성 오류:", error) + return "00001" // 기본값 + } +} +export async function createRfqAction(input: CreateRfqInput) { + try { + // 입력 데이터 검증 + const validatedData = createRfqServerSchema.parse(input) + + // RFQ 코드 자동 생성: N + picCode + 시리얼5자리 + const serialNumber = await generateNextSerial(validatedData.picCode) + const rfqCode = `N${validatedData.picCode}${serialNumber}` + + // 데이터베이스에 삽입 + const result = await db.insert(bRfqs).values({ + rfqCode, + projectId: validatedData.projectId, + dueDate: validatedData.dueDate, + status: "DRAFT", + picCode: validatedData.picCode, + picName: validatedData.picName || null, + EngPicName: validatedData.engPicName || null, + packageNo: validatedData.packageNo || null, + packageName: validatedData.packageName || null, + remark: validatedData.remark || null, + projectCompany: validatedData.projectCompany || null, + projectFlag: validatedData.projectFlag || null, + projectSite: validatedData.projectSite || null, + createdBy: validatedData.createdBy, + updatedBy: validatedData.updatedBy, + }).returning({ + id: bRfqs.id, + rfqCode: bRfqs.rfqCode, + }) + + // 관련 페이지 캐시 무효화 + revalidateTag(tag.rfqDashboard); + revalidateTag(tag.rfq(id)); + + + return { + success: true, + data: result[0], + message: "RFQ가 성공적으로 생성되었습니다", } + + } catch (error) { + console.error("RFQ 생성 오류:", error) + + + return { + success: false, + error: "RFQ 생성에 실패했습니다", + } + } +} + +// RFQ 코드 중복 확인 액션 +export async function checkRfqCodeExists(rfqCode: string) { + try { + const existing = await db.select({ id: bRfqs.id }) + .from(bRfqs) + .where(eq(bRfqs.rfqCode, rfqCode)) + .limit(1) + + return existing.length > 0 + } catch (error) { + console.error("RFQ 코드 확인 오류:", error) + return false + } +} + +// picCode별 다음 예상 RFQ 코드 미리보기 +export async function previewNextRfqCode(picCode: string) { + try { + const serialNumber = await generateNextSerial(picCode) + return `N${picCode}${serialNumber}` + } catch (error) { + console.error("RFQ 코드 미리보기 오류:", error) + return `N${picCode}00001` + } +} + +const getBRfqById = async (id: number): Promise<RfqDashboardView | null> => { + // 1) RFQ 단건 조회 + const rfqsRes = await db + .select() + .from(rfqDashboardView) + .where(eq(rfqDashboardView.rfqId, id)) + .limit(1); + + if (rfqsRes.length === 0) return null; + const rfqRow = rfqsRes[0]; + + // 3) RfqWithItems 형태로 반환 + const result: RfqDashboardView = { + ...rfqRow, + + }; + + return result; +}; + + +export const findBRfqById = async (id: number): Promise<RfqDashboardView | null> => { + try { + + const rfq = await getBRfqById(id); + + return rfq; + } catch (error) { + throw new Error('Failed to fetch user'); } - export async function createRfqAction(input: CreateRfqInput) { - try { - // 입력 데이터 검증 - const validatedData = createRfqServerSchema.parse(input) - - // RFQ 코드 자동 생성: N + picCode + 시리얼5자리 - const serialNumber = await generateNextSerial(validatedData.picCode) - const rfqCode = `N${validatedData.picCode}${serialNumber}` - - // 데이터베이스에 삽입 - const result = await db.insert(bRfqs).values({ - rfqCode, - projectId: validatedData.projectId, - dueDate: validatedData.dueDate, - status: "DRAFT", - picCode: validatedData.picCode, - picName: validatedData.picName || null, - EngPicName: validatedData.engPicName || null, - packageNo: validatedData.packageNo || null, - packageName: validatedData.packageName || null, - remark: validatedData.remark || null, - projectCompany: validatedData.projectCompany || null, - projectFlag: validatedData.projectFlag || null, - projectSite: validatedData.projectSite || null, - createdBy: validatedData.createdBy, - updatedBy: validatedData.updatedBy, - }).returning({ - id: bRfqs.id, - rfqCode: bRfqs.rfqCode, +}; + + +export async function getRfqAttachments( + input: GetRfqAttachmentsSchema, + rfqId: number +) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + + // Advanced Filter 처리 (메인 테이블 기준) + const advancedWhere = filterColumns({ + table: bRfqsAttachments, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // 전역 검색 (첨부파일 + 리비전 파일명 검색) + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(bRfqsAttachments.serialNo, s), + ilike(bRfqsAttachments.description, s), + ilike(bRfqsAttachments.currentRevision, s), + ilike(bRfqAttachmentRevisions.fileName, s), + ilike(bRfqAttachmentRevisions.originalFileName, s) + ) + } + + // 기본 필터 + let basicWhere + if (input.attachmentType.length > 0 || input.fileType.length > 0) { + basicWhere = and( + input.attachmentType.length > 0 + ? inArray(bRfqsAttachments.attachmentType, input.attachmentType) + : undefined, + input.fileType.length > 0 + ? inArray(bRfqAttachmentRevisions.fileType, input.fileType) + : undefined + ) + } + + // 최종 WHERE 절 + const finalWhere = and( + eq(bRfqsAttachments.rfqId, rfqId), // RFQ ID 필수 조건 + advancedWhere, + globalWhere, + basicWhere + ) + + // 정렬 (메인 테이블 기준) + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) : asc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) + ) + : [desc(bRfqsAttachments.createdAt)] + + // 트랜잭션으로 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인) + const data = await tx + .select({ + // 첨부파일 메인 정보 + id: bRfqsAttachments.id, + attachmentType: bRfqsAttachments.attachmentType, + serialNo: bRfqsAttachments.serialNo, + rfqId: bRfqsAttachments.rfqId, + currentRevision: bRfqsAttachments.currentRevision, + latestRevisionId: bRfqsAttachments.latestRevisionId, + description: bRfqsAttachments.description, + createdBy: bRfqsAttachments.createdBy, + createdAt: bRfqsAttachments.createdAt, + updatedAt: bRfqsAttachments.updatedAt, + + // 최신 리비전 파일 정보 + fileName: bRfqAttachmentRevisions.fileName, + originalFileName: bRfqAttachmentRevisions.originalFileName, + filePath: bRfqAttachmentRevisions.filePath, + fileSize: bRfqAttachmentRevisions.fileSize, + fileType: bRfqAttachmentRevisions.fileType, + revisionComment: bRfqAttachmentRevisions.revisionComment, + + // 생성자 정보 + createdByName: users.name, + }) + .from(bRfqsAttachments) + .leftJoin( + bRfqAttachmentRevisions, + and( + eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id), + eq(bRfqAttachmentRevisions.isLatest, true) + ) + ) + .leftJoin(users, eq(bRfqsAttachments.createdBy, users.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset) + + // 전체 개수 조회 + const totalResult = await tx + .select({ count: count() }) + .from(bRfqsAttachments) + .leftJoin( + bRfqAttachmentRevisions, + eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id) + ) + .where(finalWhere) + + const total = totalResult[0]?.count ?? 0 + + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + // 각 첨부파일별 벤더 응답 통계 조회 + const attachmentIds = data.map(item => item.id) + let responseStatsMap: Record<number, any> = {} + + if (attachmentIds.length > 0) { + responseStatsMap = await getAttachmentResponseStats(attachmentIds) + } + + // 통계 데이터 병합 + const dataWithStats = data.map(attachment => ({ + ...attachment, + responseStats: responseStatsMap[attachment.id] || { + totalVendors: 0, + respondedCount: 0, + pendingCount: 0, + waivedCount: 0, + responseRate: 0 + } + })) + + return { data: dataWithStats, pageCount } + } catch (err) { + console.error("getRfqAttachments error:", err) + return { data: [], pageCount: 0 } + } + }, + [JSON.stringify(input), `${rfqId}`], + { revalidate: 300, tags: [tag.rfqAttachments(rfqId)] }, + )() +} + +// 첨부파일별 벤더 응답 통계 조회 +async function getAttachmentResponseStats(attachmentIds: number[]) { + try { + const stats = await db + .select({ + attachmentId: vendorAttachmentResponses.attachmentId, + totalVendors: count(), + respondedCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' then 1 end)`, + pendingCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' then 1 end)`, + waivedCount: sql<number>`count(case when ${vendorAttachmentResponses.responseStatus} = 'WAIVED' then 1 end)`, }) - - // 관련 페이지 캐시 무효화 - revalidateTag("rfq-dashboard") + .from(vendorAttachmentResponses) + .where(inArray(vendorAttachmentResponses.attachmentId, attachmentIds)) + .groupBy(vendorAttachmentResponses.attachmentId) - - return { - success: true, - data: result[0], - message: "RFQ가 성공적으로 생성되었습니다", + // 응답률 계산해서 객체로 변환 + const statsMap: Record<number, any> = {} + stats.forEach(stat => { + const activeVendors = stat.totalVendors - stat.waivedCount + const responseRate = activeVendors > 0 + ? Math.round((stat.respondedCount / activeVendors) * 100) + : 0 + + statsMap[stat.attachmentId] = { + totalVendors: stat.totalVendors, + respondedCount: stat.respondedCount, + pendingCount: stat.pendingCount, + waivedCount: stat.waivedCount, + responseRate } - - } catch (error) { - console.error("RFQ 생성 오류:", error) - - - return { - success: false, - error: "RFQ 생성에 실패했습니다", + }) + + return statsMap + } catch (error) { + console.error("getAttachmentResponseStats error:", error) + return {} + } +} + +// 특정 첨부파일에 대한 벤더 응답 현황 상세 조회 +export async function getVendorResponsesForAttachment( + attachmentId: number, + rfqType: 'INITIAL' | 'FINAL' = 'INITIAL' +) { + return unstable_cache( + async () => { + try { + const responses = await db + .select({ + id: vendorAttachmentResponses.id, + attachmentId: vendorAttachmentResponses.attachmentId, + vendorId: vendorAttachmentResponses.vendorId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorCountry: vendors.country, + rfqType: vendorAttachmentResponses.rfqType, + rfqRecordId: vendorAttachmentResponses.rfqRecordId, + responseStatus: vendorAttachmentResponses.responseStatus, + currentRevision: vendorAttachmentResponses.currentRevision, + respondedRevision: vendorAttachmentResponses.respondedRevision, + responseComment: vendorAttachmentResponses.responseComment, + vendorComment: vendorAttachmentResponses.vendorComment, + requestedAt: vendorAttachmentResponses.requestedAt, + respondedAt: vendorAttachmentResponses.respondedAt, + updatedAt: vendorAttachmentResponses.updatedAt, + }) + .from(vendorAttachmentResponses) + .leftJoin(vendors, eq(vendorAttachmentResponses.vendorId, vendors.id)) + .where( + and( + eq(vendorAttachmentResponses.attachmentId, attachmentId), + eq(vendorAttachmentResponses.rfqType, rfqType) + ) + ) + .orderBy(vendors.vendorName) + + return responses + } catch (err) { + console.error("getVendorResponsesForAttachment error:", err) + return [] } + }, + [`${attachmentId}`, rfqType], + { revalidate: 180, tags: [tag.vendorResponses(attachmentId, rfqType)] }, + + )() +} + + +export async function confirmDocuments(rfqId: number) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + // TODO: RFQ 상태를 "Doc. Confirmed"로 업데이트 + await db + .update(bRfqs) + .set({ + status: "Doc. Confirmed", + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(bRfqs.id, rfqId)) + + revalidateTag(tag.rfq(rfqId)); + revalidateTag(tag.rfqDashboard); + revalidateTag(tag.rfqAttachments(rfqId)); + + return { + success: true, + message: "문서가 확정되었습니다.", + } + + } catch (error) { + console.error("confirmDocuments error:", error) + return { + success: false, + message: error instanceof Error ? error.message : "문서 확정 중 오류가 발생했습니다.", } } - - // RFQ 코드 중복 확인 액션 - export async function checkRfqCodeExists(rfqCode: string) { - try { - const existing = await db.select({ id: bRfqs.id }) - .from(bRfqs) - .where(eq(bRfqs.rfqCode, rfqCode)) - .limit(1) - - return existing.length > 0 - } catch (error) { - console.error("RFQ 코드 확인 오류:", error) - return false +} + +// TBE 요청 서버 액션 +export async function requestTbe(rfqId: number, attachmentIds?: number[]) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + // attachmentIds가 제공된 경우 해당 첨부파일들만 처리 + let targetAttachments = [] + if (attachmentIds && attachmentIds.length > 0) { + // 선택된 첨부파일들 조회 + targetAttachments = await db + .select({ + id: bRfqsAttachments.id, + serialNo: bRfqsAttachments.serialNo, + attachmentType: bRfqsAttachments.attachmentType, + currentRevision: bRfqsAttachments.currentRevision, + }) + .from(bRfqsAttachments) + .where( + and( + eq(bRfqsAttachments.rfqId, rfqId), + inArray(bRfqsAttachments.id, attachmentIds) + ) + ) + + if (targetAttachments.length === 0) { + throw new Error("선택된 첨부파일을 찾을 수 없습니다.") + } + } else { + // 전체 RFQ의 모든 첨부파일 처리 + targetAttachments = await db + .select({ + id: bRfqsAttachments.id, + serialNo: bRfqsAttachments.serialNo, + attachmentType: bRfqsAttachments.attachmentType, + currentRevision: bRfqsAttachments.currentRevision, + }) + .from(bRfqsAttachments) + .where(eq(bRfqsAttachments.rfqId, rfqId)) + } + + if (targetAttachments.length === 0) { + throw new Error("TBE 요청할 첨부파일이 없습니다.") + } + + // TODO: TBE 요청 로직 구현 + // 1. RFQ 상태를 "TBE started"로 업데이트 (선택적) + // 2. 선택된 첨부파일들에 대해 벤더들에게 TBE 요청 이메일 발송 + // 3. vendorAttachmentResponses 테이블에 TBE 요청 레코드 생성 + // 4. TBE 관련 메타데이터 업데이트 + + + + // 예시: 선택된 첨부파일들에 대한 벤더 응답 레코드 생성 + await db.transaction(async (tx) => { + + const [updatedRfq] = await tx + .update(bRfqs) + .set({ + status: "TBE started", + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(bRfqs.id, rfqId)) + .returning() + + // 각 첨부파일에 대해 벤더 응답 레코드 생성 또는 업데이트 + for (const attachment of targetAttachments) { + // TODO: 해당 첨부파일과 연관된 벤더들에게 TBE 요청 처리 + console.log(`TBE 요청 처리: ${attachment.serialNo} (${attachment.currentRevision})`) + } + }) + + // 캐시 무효화 + revalidateTag(`rfq-${rfqId}`) + revalidateTag(`rfq-attachments-${rfqId}`) + + const attachmentCount = targetAttachments.length + const attachmentList = targetAttachments + .map(a => `${a.serialNo} (${a.currentRevision})`) + .join(', ') + + return { + success: true, + message: `${attachmentCount}개 문서에 대한 TBE 요청이 전송되었습니다.\n대상: ${attachmentList}`, + targetAttachments, + } + + } catch (error) { + console.error("requestTbe error:", error) + return { + success: false, + message: error instanceof Error ? error.message : "TBE 요청 중 오류가 발생했습니다.", } } - - // picCode별 다음 예상 RFQ 코드 미리보기 - export async function previewNextRfqCode(picCode: string) { - try { - const serialNumber = await generateNextSerial(picCode) - return `N${picCode}${serialNumber}` - } catch (error) { - console.error("RFQ 코드 미리보기 오류:", error) - return `N${picCode}00001` +} + +// 다음 시리얼 번호 생성 +async function getNextSerialNo(rfqId: number): Promise<string> { + try { + // 해당 RFQ의 기존 첨부파일 개수 조회 + const [result] = await db + .select({ count: count() }) + .from(bRfqsAttachments) + .where(eq(bRfqsAttachments.rfqId, rfqId)) + + const nextNumber = (result?.count || 0) + 1 + + // 001, 002, 003... 형태로 포맷팅 + return nextNumber.toString().padStart(3, '0') + + } catch (error) { + console.error("getNextSerialNo error:", error) + // 에러 발생 시 타임스탬프 기반으로 fallback + return Date.now().toString().slice(-3) + } +} + +export async function addRfqAttachmentRecord(record: AttachmentRecord) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const validatedRecord = attachmentRecordSchema.parse(record) + const userId = Number(session.user.id) + + const result = await db.transaction(async (tx) => { + // 1. 시리얼 번호 생성 + const [countResult] = await tx + .select({ count: count() }) + .from(bRfqsAttachments) + .where(eq(bRfqsAttachments.rfqId, validatedRecord.rfqId)) + + const serialNo = (countResult.count + 1).toString().padStart(3, '0') + + // 2. 메인 첨부파일 레코드 생성 + const [attachment] = await tx + .insert(bRfqsAttachments) + .values({ + rfqId: validatedRecord.rfqId, + attachmentType: validatedRecord.attachmentType, + serialNo: serialNo, + currentRevision: "Rev.0", + description: validatedRecord.description, + createdBy: userId, + }) + .returning() + + // 3. 초기 리비전 (Rev.0) 생성 + const [revision] = await tx + .insert(bRfqAttachmentRevisions) + .values({ + attachmentId: attachment.id, + revisionNo: "Rev.0", + fileName: validatedRecord.fileName, + originalFileName: validatedRecord.originalFileName, + filePath: validatedRecord.filePath, + fileSize: validatedRecord.fileSize, + fileType: validatedRecord.fileType, + revisionComment: validatedRecord.revisionComment || "초기 업로드", + isLatest: true, + createdBy: userId, + }) + .returning() + + // 4. 메인 테이블의 latest_revision_id 업데이트 + await tx + .update(bRfqsAttachments) + .set({ + latestRevisionId: revision.id, + updatedAt: new Date(), + }) + .where(eq(bRfqsAttachments.id, attachment.id)) + + return { attachment, revision } + }) + + revalidateTag(tag.rfq(validatedRecord.rfqId)); + revalidateTag(tag.rfqDashboard); + revalidateTag(tag.rfqAttachments(validatedRecord.rfqId)); + + return { + success: true, + message: `파일이 성공적으로 등록되었습니다. (시리얼: ${result.attachment.serialNo}, 리비전: Rev.0)`, + attachment: result.attachment, + revision: result.revision, + } + + } catch (error) { + console.error("addRfqAttachmentRecord error:", error) + return { + success: false, + message: error instanceof Error ? error.message : "첨부파일 등록 중 오류가 발생했습니다.", } } +} -const getBRfqById = async (id: number): Promise<RfqDashboardView | null> => { - // 1) RFQ 단건 조회 - const rfqsRes = await db - .select() - .from(rfqDashboardView) - .where(eq(rfqDashboardView.rfqId, id)) +// 리비전 추가 (기존 첨부파일에 새 버전 추가) +export async function addRevisionToAttachment( + attachmentId: number, + revisionData: { + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number; + fileType: string; + revisionComment?: string; + }, +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) throw new Error('인증이 필요합니다.'); + + const userId = Number(session.user.id); + + // ──────────────────────────────────────────────────────────────────────────── + // 0. 첨부파일의 rfqId 사전 조회 (태그 무효화를 위해 필요) + // ──────────────────────────────────────────────────────────────────────────── + const [attInfo] = await db + .select({ rfqId: bRfqsAttachments.rfqId }) + .from(bRfqsAttachments) + .where(eq(bRfqsAttachments.id, attachmentId)) .limit(1); - - if (rfqsRes.length === 0) return null; - const rfqRow = rfqsRes[0]; - - // 3) RfqWithItems 형태로 반환 - const result: RfqDashboardView = { - ...rfqRow, + if (!attInfo) throw new Error('첨부파일을 찾을 수 없습니다.'); + const rfqId = attInfo.rfqId; + + // ──────────────────────────────────────────────────────────────────────────── + // 1‑5. 리비전 트랜잭션 + // ──────────────────────────────────────────────────────────────────────────── + const newRevision = await db.transaction(async (tx) => { + // 1. 현재 최신 리비전 조회 + const [latestRevision] = await tx + .select({ revisionNo: bRfqAttachmentRevisions.revisionNo }) + .from(bRfqAttachmentRevisions) + .where( + and( + eq(bRfqAttachmentRevisions.attachmentId, attachmentId), + eq(bRfqAttachmentRevisions.isLatest, true), + ), + ); + + if (!latestRevision) throw new Error('기존 첨부파일을 찾을 수 없습니다.'); + + // 2. 새 리비전 번호 생성 + const currentNum = parseInt(latestRevision.revisionNo.replace('Rev.', '')); + const newRevisionNo = `Rev.${currentNum + 1}`; + + // 3. 기존 리비전 isLatest → false + await tx + .update(bRfqAttachmentRevisions) + .set({ isLatest: false }) + .where( + and( + eq(bRfqAttachmentRevisions.attachmentId, attachmentId), + eq(bRfqAttachmentRevisions.isLatest, true), + ), + ); + + // 4. 새 리비전 INSERT + const [inserted] = await tx + .insert(bRfqAttachmentRevisions) + .values({ + attachmentId, + revisionNo: newRevisionNo, + fileName: revisionData.fileName, + originalFileName: revisionData.originalFileName, + filePath: revisionData.filePath, + fileSize: revisionData.fileSize, + fileType: revisionData.fileType, + revisionComment: revisionData.revisionComment ?? `${newRevisionNo} 업데이트`, + isLatest: true, + createdBy: userId, + }) + .returning(); + + // 5. 메인 첨부파일 row 업데이트 + await tx + .update(bRfqsAttachments) + .set({ + currentRevision: newRevisionNo, + latestRevisionId: inserted.id, + updatedAt: new Date(), + }) + .where(eq(bRfqsAttachments.id, attachmentId)); + + return inserted; + }); + + // ──────────────────────────────────────────────────────────────────────────── + // 6. 캐시 무효화 (rfqId 기준으로 수정) + // ──────────────────────────────────────────────────────────────────────────── + revalidateTag(tag.rfq(rfqId)); + revalidateTag(tag.rfqDashboard); + revalidateTag(tag.rfqAttachments(rfqId)); + revalidateTag(tag.attachmentRevisions(attachmentId)); + + return { + success: true, + message: `새 리비전(${newRevision.revisionNo})이 성공적으로 추가되었습니다.`, + revision: newRevision, }; - - return result; - }; - + } catch (error) { + console.error('addRevisionToAttachment error:', error); + return { + success: false, + message: error instanceof Error ? error.message : '리비전 추가 중 오류가 발생했습니다.', + }; + } +} + +// 특정 첨부파일의 모든 리비전 조회 +export async function getAttachmentRevisions(attachmentId: number) { + return unstable_cache( + async () => { + try { + const revisions = await db + .select({ + id: bRfqAttachmentRevisions.id, + revisionNo: bRfqAttachmentRevisions.revisionNo, + fileName: bRfqAttachmentRevisions.fileName, + originalFileName: bRfqAttachmentRevisions.originalFileName, + filePath: bRfqAttachmentRevisions.filePath, + fileSize: bRfqAttachmentRevisions.fileSize, + fileType: bRfqAttachmentRevisions.fileType, + revisionComment: bRfqAttachmentRevisions.revisionComment, + isLatest: bRfqAttachmentRevisions.isLatest, + createdBy: bRfqAttachmentRevisions.createdBy, + createdAt: bRfqAttachmentRevisions.createdAt, + createdByName: users.name, + }) + .from(bRfqAttachmentRevisions) + .leftJoin(users, eq(bRfqAttachmentRevisions.createdBy, users.id)) + .where(eq(bRfqAttachmentRevisions.attachmentId, attachmentId)) + .orderBy(desc(bRfqAttachmentRevisions.createdAt)) + + return { + success: true, + revisions, + } + } catch (error) { + console.error("getAttachmentRevisions error:", error) + return { + success: false, + message: "리비전 조회 중 오류가 발생했습니다.", + revisions: [], + } + } + }, + [`${attachmentId}`], + { revalidate: 180, tags: [tag.attachmentRevisions(attachmentId)] }, + + )() +} - export const findBRfqById = async (id: number): Promise<RfqDashboardView | null> => { - try { - - const rfq = await getBRfqById(id); - return rfq; - } catch (error) { - throw new Error('Failed to fetch user'); +// 첨부파일 삭제 (리비전 포함) +export async function deleteRfqAttachments(input: DeleteAttachmentsInput) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") } - }; -
\ No newline at end of file + + const validatedInput = deleteAttachmentsSchema.parse(input) + + const result = await db.transaction(async (tx) => { + // 1. 삭제할 첨부파일들의 정보 조회 (파일 경로 포함) + const attachmentsToDelete = await tx + .select({ + id: bRfqsAttachments.id, + rfqId: bRfqsAttachments.rfqId, + serialNo: bRfqsAttachments.serialNo, + }) + .from(bRfqsAttachments) + .where(inArray(bRfqsAttachments.id, validatedInput.ids)) + + if (attachmentsToDelete.length === 0) { + throw new Error("삭제할 첨부파일을 찾을 수 없습니다.") + } + + // 2. 관련된 모든 리비전 파일 경로 조회 + const revisionFilePaths = await tx + .select({ filePath: bRfqAttachmentRevisions.filePath }) + .from(bRfqAttachmentRevisions) + .where(inArray(bRfqAttachmentRevisions.attachmentId, validatedInput.ids)) + + // 3. DB에서 리비전 삭제 (CASCADE로 자동 삭제되지만 명시적으로) + await tx + .delete(bRfqAttachmentRevisions) + .where(inArray(bRfqAttachmentRevisions.attachmentId, validatedInput.ids)) + + // 4. DB에서 첨부파일 삭제 + await tx + .delete(bRfqsAttachments) + .where(inArray(bRfqsAttachments.id, validatedInput.ids)) + + // 5. 실제 파일 삭제 (비동기로 처리) + Promise.all( + revisionFilePaths.map(async ({ filePath }) => { + try { + if (filePath) { + const fullPath = `${process.cwd()}/public${filePath}` + await unlink(fullPath) + } + } catch (fileError) { + console.warn(`Failed to delete file: ${filePath}`, fileError) + } + }) + ).catch(error => { + console.error("Some files failed to delete:", error) + }) + + return { + deletedCount: attachmentsToDelete.length, + rfqIds: [...new Set(attachmentsToDelete.map(a => a.rfqId))], + attachments: attachmentsToDelete, + } + }) + + // 캐시 무효화 + result.rfqIds.forEach(rfqId => { + revalidateTag(`rfq-attachments-${rfqId}`) + }) + + return { + success: true, + message: `${result.deletedCount}개의 첨부파일이 삭제되었습니다.`, + deletedAttachments: result.attachments, + } + + } catch (error) { + console.error("deleteRfqAttachments error:", error) + + return { + success: false, + message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.", + } + } +}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-columns.tsx b/lib/b-rfq/summary-table/summary-rfq-columns.tsx index f620858a..40f143b2 100644 --- a/lib/b-rfq/summary-table/summary-rfq-columns.tsx +++ b/lib/b-rfq/summary-table/summary-rfq-columns.tsx @@ -37,7 +37,7 @@ function getStatusBadge(status: string) { case "PIC Assigned": return { variant: "secondary" as const, label: "담당자배정" }; case "Doc. Confirmed": - return { variant: "default" as const, label: "문서확인" }; + return { variant: "default" as const, label: "문서확정" }; case "Init. RFQ Sent": return { variant: "default" as const, label: "초기RFQ발송" }; case "Init. RFQ Answered": @@ -454,7 +454,7 @@ export function getRFQColumns({ setRowAction, router }: GetRFQColumnsProps): Col // enableHiding: false, cell: function Cell({ row }) { const rfq = row.original; - const detailUrl = `/b-rfq/${rfq.rfqId}/initial`; + const detailUrl = `/evcp/b-rfq/${rfq.rfqId}/initial`; return ( diff --git a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx b/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx index 8ba95ce6..02ba4aaa 100644 --- a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx +++ b/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx @@ -35,7 +35,7 @@ export function RFQTableToolbarActions({ table }: RFQTableToolbarActionsProps) { const rfqId = selectedRfq.rfqId // RFQ 첨부문서 확인 페이지로 이동 - router.push(`/b-rfq/${rfqId}`) + router.push(`/evcp/b-rfq/${rfqId}`) } } diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts index df8dc6e6..df95b1d2 100644 --- a/lib/b-rfq/validations.ts +++ b/lib/b-rfq/validations.ts @@ -97,4 +97,71 @@ export const searchParamsRFQDashboardCache = createSearchParamsCache({ updatedBy: z.number(), }) - export type CreateRfqInput = z.infer<typeof createRfqServerSchema>
\ No newline at end of file + export type CreateRfqInput = z.infer<typeof createRfqServerSchema> + + + + export type RfqAttachment = { + id: number + attachmentType: string + serialNo: string + rfqId: number + fileName: string + originalFileName: string + filePath: string + fileSize: number | null + fileType: string | null + description: string | null + createdBy: number + createdAt: Date + createdByName?: string + responseStats?: { + totalVendors: number + respondedCount: number + pendingCount: number + waivedCount: number + responseRate: number + } + } + + // RFQ Attachments용 검색 파라미터 캐시 + export const searchParamsRfqAttachmentsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RfqAttachment>().withDefault([ + { id: "createdAt", desc: true }, + ]), + // 기본 필터 + attachmentType: parseAsArrayOf(z.string()).withDefault([]), + fileType: parseAsArrayOf(z.string()).withDefault([]), + search: parseAsString.withDefault(""), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + }) + + // 스키마 타입들 + export type GetRfqAttachmentsSchema = Awaited<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>> + + + // 첨부파일 레코드 타입 +export const attachmentRecordSchema = z.object({ + rfqId: z.number().positive(), + attachmentType: z.enum(["구매", "설계"]), + // serialNo: z.string().min(1), + description: z.string().optional(), + fileName: z.string(), + originalFileName: z.string(), + filePath: z.string(), + fileSize: z.number(), + fileType: z.string(), +}) + +export type AttachmentRecord = z.infer<typeof attachmentRecordSchema> + +export const deleteAttachmentsSchema = z.object({ + ids: z.array(z.number()).min(1, "삭제할 첨부파일을 선택해주세요."), +}) + +export type DeleteAttachmentsInput = z.infer<typeof deleteAttachmentsSchema> |
