summaryrefslogtreecommitdiff
path: root/lib/b-rfq
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-13 07:08:01 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-13 07:08:01 +0000
commitc72d0897f7b37843109c86f61d97eba05ba3ca0d (patch)
tree887dd877f3f8beafa92b4d9a7b16c84b4a5795d8 /lib/b-rfq
parentff902243a658067fae858a615c0629aa2e0a4837 (diff)
(대표님) 20250613 16시 08분 b-rfq, document 등
Diffstat (limited to 'lib/b-rfq')
-rw-r--r--lib/b-rfq/attachment/add-attachment-dialog.tsx355
-rw-r--r--lib/b-rfq/attachment/add-revision-dialog.tsx336
-rw-r--r--lib/b-rfq/attachment/attachment-columns.tsx290
-rw-r--r--lib/b-rfq/attachment/attachment-table.tsx190
-rw-r--r--lib/b-rfq/attachment/attachment-toolbar-action.tsx60
-rw-r--r--lib/b-rfq/attachment/confirm-documents-dialog.tsx141
-rw-r--r--lib/b-rfq/attachment/delete-attachment-dialog.tsx182
-rw-r--r--lib/b-rfq/attachment/revision-dialog.tsx196
-rw-r--r--lib/b-rfq/attachment/tbe-request-dialog.tsx200
-rw-r--r--lib/b-rfq/attachment/vendor-responses-panel.tsx205
-rw-r--r--lib/b-rfq/service.ts975
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-columns.tsx4
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx2
-rw-r--r--lib/b-rfq/validations.ts69
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>