summaryrefslogtreecommitdiff
path: root/lib/b-rfq
diff options
context:
space:
mode:
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.tsx286
-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/request-revision-dialog.tsx205
-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.tsx386
-rw-r--r--lib/b-rfq/final/final-rfq-detail-columns.tsx589
-rw-r--r--lib/b-rfq/final/final-rfq-detail-table.tsx297
-rw-r--r--lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx201
-rw-r--r--lib/b-rfq/final/update-final-rfq-sheet.tsx70
-rw-r--r--lib/b-rfq/initial/add-initial-rfq-dialog.tsx584
-rw-r--r--lib/b-rfq/initial/delete-initial-rfq-dialog.tsx149
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-columns.tsx446
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-table.tsx267
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx287
-rw-r--r--lib/b-rfq/initial/short-list-confirm-dialog.tsx269
-rw-r--r--lib/b-rfq/initial/update-initial-rfq-sheet.tsx496
-rw-r--r--lib/b-rfq/repository.ts0
-rw-r--r--lib/b-rfq/service.ts2976
-rw-r--r--lib/b-rfq/summary-table/add-new-rfq-dialog.tsx523
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-columns.tsx499
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx617
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx68
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-table.tsx285
-rw-r--r--lib/b-rfq/validations.ts447
-rw-r--r--lib/b-rfq/vendor-response/comment-edit-dialog.tsx187
-rw-r--r--lib/b-rfq/vendor-response/response-detail-columns.tsx653
-rw-r--r--lib/b-rfq/vendor-response/response-detail-sheet.tsx358
-rw-r--r--lib/b-rfq/vendor-response/response-detail-table.tsx161
-rw-r--r--lib/b-rfq/vendor-response/upload-response-dialog.tsx325
-rw-r--r--lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx351
-rw-r--r--lib/b-rfq/vendor-response/vendor-responses-table.tsx152
-rw-r--r--lib/b-rfq/vendor-response/waive-response-dialog.tsx210
38 files changed, 0 insertions, 14004 deletions
diff --git a/lib/b-rfq/attachment/add-attachment-dialog.tsx b/lib/b-rfq/attachment/add-attachment-dialog.tsx
deleted file mode 100644
index 665e0f88..00000000
--- a/lib/b-rfq/attachment/add-attachment-dialog.tsx
+++ /dev/null
@@ -1,355 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { Plus ,X} from "lucide-react"
-import { toast } from "sonner"
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { addRfqAttachmentRecord } from "../service"
-
-// 첨부파일 추가 폼 스키마 (단일 파일)
-const addAttachmentSchema = z.object({
- attachmentType: z.enum(["구매", "설계"], {
- required_error: "문서 타입을 선택해주세요.",
- }),
- description: z.string().optional(),
- file: z.instanceof(File, {
- message: "파일을 선택해주세요.",
- }),
-})
-
-type AddAttachmentFormData = z.infer<typeof addAttachmentSchema>
-
-interface AddAttachmentDialogProps {
- rfqId: number
-}
-
-export function AddAttachmentDialog({ rfqId }: AddAttachmentDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [uploadProgress, setUploadProgress] = React.useState<number>(0)
-
- const form = useForm<AddAttachmentFormData>({
- resolver: zodResolver(addAttachmentSchema),
- defaultValues: {
- attachmentType: undefined,
- description: "",
- file: undefined,
- },
- })
-
- const selectedFile = form.watch("file")
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen && !isSubmitting) {
- form.reset()
- }
- setOpen(newOpen)
- }
-
- // 파일 선택 처리
- const handleFileChange = (files: File[]) => {
- if (files.length === 0) return
-
- const file = files[0] // 첫 번째 파일만 사용
-
- // 파일 크기 검증
- const maxFileSize = 10 * 1024 * 1024 // 10MB
- if (file.size > maxFileSize) {
- toast.error(`파일이 너무 큽니다. (최대 10MB)`)
- return
- }
-
- form.setValue("file", file)
- form.clearErrors("file")
- }
-
- // 파일 제거
- const removeFile = () => {
- form.resetField("file")
- }
-
- // 파일 업로드 API 호출
- const uploadFile = async (file: File): Promise<{
- fileName: string
- originalFileName: string
- filePath: string
- fileSize: number
- fileType: string
- }> => {
- const formData = new FormData()
- formData.append("rfqId", rfqId.toString())
- formData.append("file", file)
-
- const response = await fetch("/api/upload/rfq-attachment", {
- method: "POST",
- body: formData,
- })
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.message || "파일 업로드 실패")
- }
-
- return response.json()
- }
-
- // 폼 제출
- const onSubmit = async (data: AddAttachmentFormData) => {
- setIsSubmitting(true)
- setUploadProgress(0)
-
- try {
- // 1단계: 파일 업로드
- setUploadProgress(30)
- const uploadedFile = await uploadFile(data.file)
-
- // 2단계: DB 레코드 생성 (시리얼 번호 자동 생성)
- setUploadProgress(70)
- const attachmentRecord = {
- rfqId,
- attachmentType: data.attachmentType,
- description: data.description,
- fileName: uploadedFile.fileName,
- originalFileName: uploadedFile.originalFileName,
- filePath: uploadedFile.filePath,
- fileSize: uploadedFile.fileSize,
- fileType: uploadedFile.fileType,
- }
-
- const result = await addRfqAttachmentRecord(attachmentRecord)
-
- setUploadProgress(100)
-
- if (result.success) {
- toast.success(result.message)
- form.reset()
- handleOpenChange(false)
- } else {
- toast.error(result.message)
- }
-
- } catch (error) {
- console.error("Upload error:", error)
- toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- setUploadProgress(0)
- }
- }
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">새 첨부</span>
- </Button>
- </DialogTrigger>
-
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle>새 첨부파일 추가</DialogTitle>
- <DialogDescription>
- RFQ에 첨부할 문서를 업로드합니다. 시리얼 번호는 자동으로 부여됩니다.
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 문서 타입 선택 */}
- <FormField
- control={form.control}
- name="attachmentType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>문서 타입</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="문서 타입을 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="구매">구매</SelectItem>
- <SelectItem value="설계">설계</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설명 (선택)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="첨부파일에 대한 설명을 입력하세요"
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 파일 선택 - Dropzone (단일 파일) */}
- <FormField
- control={form.control}
- name="file"
- render={({ field }) => (
- <FormItem>
- <FormLabel>파일 선택</FormLabel>
- <FormControl>
- <div className="space-y-3">
- <Dropzone
- onDrop={(acceptedFiles) => {
- handleFileChange(acceptedFiles)
- }}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- 'application/vnd.ms-powerpoint': ['.ppt'],
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
- 'application/zip': ['.zip'],
- 'application/x-rar-compressed': ['.rar']
- }}
- maxSize={10 * 1024 * 1024} // 10MB
- multiple={false} // 단일 파일만
- disabled={isSubmitting}
- >
- <DropzoneZone>
- <DropzoneUploadIcon />
- <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle>
- <DropzoneDescription>
- PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개)
- </DropzoneDescription>
- <DropzoneInput />
- </DropzoneZone>
- </Dropzone>
-
- {/* 선택된 파일 표시 */}
- {selectedFile && (
- <div className="space-y-2">
- <FileListHeader>
- 선택된 파일
- </FileListHeader>
- <FileList>
- <FileListItem className="flex items-center justify-between gap-3">
- <FileListIcon />
- <FileListInfo>
- <FileListName>{selectedFile.name}</FileListName>
- <FileListDescription>
- <FileListSize>{selectedFile.size}</FileListSize>
- </FileListDescription>
- </FileListInfo>
- <FileListAction
- onClick={removeFile}
- disabled={isSubmitting}
- >
- <X className="h-4 w-4" />
- </FileListAction>
- </FileListItem>
- </FileList>
- </div>
- )}
-
- {/* 업로드 진행률 */}
- {isSubmitting && uploadProgress > 0 && (
- <div className="space-y-2">
- <div className="flex justify-between text-sm">
- <span>업로드 진행률</span>
- <span>{uploadProgress}%</span>
- </div>
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- className="bg-blue-600 h-2 rounded-full transition-all duration-300"
- style={{ width: `${uploadProgress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isSubmitting || !selectedFile}>
- {isSubmitting ? "업로드 중..." : "업로드"}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/add-revision-dialog.tsx b/lib/b-rfq/attachment/add-revision-dialog.tsx
deleted file mode 100644
index 1abefb02..00000000
--- a/lib/b-rfq/attachment/add-revision-dialog.tsx
+++ /dev/null
@@ -1,336 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { addRevisionToAttachment } from "../service"
-
-// 리비전 추가 폼 스키마
-const addRevisionSchema = z.object({
- revisionComment: z.string().optional(),
- file: z.instanceof(File, {
- message: "파일을 선택해주세요.",
- }),
-})
-
-type AddRevisionFormData = z.infer<typeof addRevisionSchema>
-
-interface AddRevisionDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- attachmentId: number
- currentRevision: string
- originalFileName: string
- onSuccess?: () => void
-}
-
-export function AddRevisionDialog({
- open,
- onOpenChange,
- attachmentId,
- currentRevision,
- originalFileName,
- onSuccess
-}: AddRevisionDialogProps) {
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [uploadProgress, setUploadProgress] = React.useState<number>(0)
-
- const form = useForm<AddRevisionFormData>({
- resolver: zodResolver(addRevisionSchema),
- defaultValues: {
- revisionComment: "",
- file: undefined,
- },
- })
-
- const selectedFile = form.watch("file")
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen && !isSubmitting) {
- form.reset()
- }
- onOpenChange(newOpen)
- }
-
- // 파일 선택 처리
- const handleFileChange = (files: File[]) => {
- if (files.length === 0) return
-
- const file = files[0]
-
- // 파일 크기 검증
- const maxFileSize = 10 * 1024 * 1024 // 10MB
- if (file.size > maxFileSize) {
- toast.error(`파일이 너무 큽니다. (최대 10MB)`)
- return
- }
-
- form.setValue("file", file)
- form.clearErrors("file")
- }
-
- // 파일 제거
- const removeFile = () => {
- form.resetField("file")
- }
-
- // 파일 업로드 API 호출
- const uploadFile = async (file: File): Promise<{
- fileName: string
- originalFileName: string
- filePath: string
- fileSize: number
- fileType: string
- }> => {
- const formData = new FormData()
- formData.append("attachmentId", attachmentId.toString())
- formData.append("file", file)
- formData.append("isRevision", "true")
-
- const response = await fetch("/api/upload/rfq-attachment-revision", {
- method: "POST",
- body: formData,
- })
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.message || "파일 업로드 실패")
- }
-
- return response.json()
- }
-
- // 폼 제출
- const onSubmit = async (data: AddRevisionFormData) => {
- setIsSubmitting(true)
- setUploadProgress(0)
-
- try {
- // 1단계: 파일 업로드
- setUploadProgress(30)
- const uploadedFile = await uploadFile(data.file)
-
- // 2단계: DB 리비전 레코드 생성
- setUploadProgress(70)
- const result = await addRevisionToAttachment(attachmentId, {
- fileName: uploadedFile.fileName,
- originalFileName: uploadedFile.originalFileName,
- filePath: uploadedFile.filePath,
- fileSize: uploadedFile.fileSize,
- fileType: uploadedFile.fileType,
- revisionComment: data.revisionComment,
- })
-
- setUploadProgress(100)
-
- if (result.success) {
- toast.success(result.message)
- form.reset()
- handleOpenChange(false)
- onSuccess?.()
- } else {
- toast.error(result.message)
- }
-
- } catch (error) {
- console.error("Upload error:", error)
- toast.error(error instanceof Error ? error.message : "리비전 추가 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- setUploadProgress(0)
- }
- }
-
- // 다음 리비전 번호 계산
- const getNextRevision = (current: string) => {
- const match = current.match(/Rev\.(\d+)/)
- if (match) {
- const num = parseInt(match[1]) + 1
- return `Rev.${num}`
- }
- return "Rev.1"
- }
-
- const nextRevision = getNextRevision(currentRevision)
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Upload className="h-5 w-5" />
- 새 리비전 추가
- </DialogTitle>
- <DialogDescription>
- "{originalFileName}"의 새 버전을 업로드합니다.
- 현재 {currentRevision} → {nextRevision}
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 리비전 코멘트 */}
- <FormField
- control={form.control}
- name="revisionComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>리비전 코멘트 (선택)</FormLabel>
- <FormControl>
- <Textarea
- placeholder={`${nextRevision} 업데이트 내용을 입력하세요`}
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 파일 선택 - Dropzone (단일 파일) */}
- <FormField
- control={form.control}
- name="file"
- render={({ field }) => (
- <FormItem>
- <FormLabel>새 파일 선택</FormLabel>
- <FormControl>
- <div className="space-y-3">
- <Dropzone
- onDrop={(acceptedFiles) => {
- handleFileChange(acceptedFiles)
- }}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- 'application/vnd.ms-powerpoint': ['.ppt'],
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
- 'application/zip': ['.zip'],
- 'application/x-rar-compressed': ['.rar']
- }}
- maxSize={10 * 1024 * 1024} // 10MB
- multiple={false}
- disabled={isSubmitting}
- >
- <DropzoneZone>
- <DropzoneUploadIcon />
- <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle>
- <DropzoneDescription>
- PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개)
- </DropzoneDescription>
- <DropzoneInput />
- </DropzoneZone>
- </Dropzone>
-
- {/* 선택된 파일 표시 */}
- {selectedFile && (
- <div className="space-y-2">
- <FileListHeader>
- 선택된 파일 ({nextRevision})
- </FileListHeader>
- <FileList>
- <FileListItem>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{selectedFile.name}</FileListName>
- <FileListDescription>
- <FileListSize>{selectedFile.size}</FileListSize>
- </FileListDescription>
- </FileListInfo>
- <FileListAction
- onClick={removeFile}
- disabled={isSubmitting}
- />
- </FileListItem>
- </FileList>
- </div>
- )}
-
- {/* 업로드 진행률 */}
- {isSubmitting && uploadProgress > 0 && (
- <div className="space-y-2">
- <div className="flex justify-between text-sm">
- <span>업로드 진행률</span>
- <span>{uploadProgress}%</span>
- </div>
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- className="bg-blue-600 h-2 rounded-full transition-all duration-300"
- style={{ width: `${uploadProgress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isSubmitting || !selectedFile}>
- {isSubmitting ? "업로드 중..." : `${nextRevision} 추가`}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/attachment-columns.tsx b/lib/b-rfq/attachment/attachment-columns.tsx
deleted file mode 100644
index b726ebc8..00000000
--- a/lib/b-rfq/attachment/attachment-columns.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import {
- Ellipsis, FileText, Download, Eye,
- MessageSquare, Upload
-} from "lucide-react"
-
-import { formatDate, formatBytes } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu, DropdownMenuContent, DropdownMenuItem,
- DropdownMenuSeparator, DropdownMenuTrigger
-} from "@/components/ui/dropdown-menu"
-import { Progress } from "@/components/ui/progress"
-import { RevisionDialog } from "./revision-dialog"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { AddRevisionDialog } from "./add-revision-dialog"
-
-interface GetAttachmentColumnsProps {
- onSelectAttachment: (attachment: any) => void
-}
-
-export function getAttachmentColumns({
- onSelectAttachment
-}: GetAttachmentColumnsProps): ColumnDef<any>[] {
-
- return [
- /** ───────────── 체크박스 ───────────── */
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- },
-
- /** ───────────── 문서 정보 ───────────── */
- {
- accessorKey: "serialNo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="시리얼 번호" />
- ),
- cell: ({ row }) => (
- <Button
- variant="link"
- className="p-0 h-auto font-medium text-blue-600 hover:text-blue-800"
- onClick={() => onSelectAttachment(row.original)}
- >
- {row.getValue("serialNo") as string}
- </Button>
- ),
- size: 100,
- },
- {
- accessorKey: "attachmentType",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="문서 타입" />
- ),
- cell: ({ row }) => {
- const type = row.getValue("attachmentType") as string
- return (
- <Badge variant={type === "구매" ? "default" : "secondary"}>
- {type}
- </Badge>
- )
- },
- size:100
- },
- {
- accessorKey: "originalFileName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="파일명" />
- ),
- cell: ({ row }) => {
- const fileName = row.getValue("originalFileName") as string
- return (
- <div className="flex items-center gap-2">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <div className="min-w-0 flex-1">
- <div className="truncate font-medium" title={fileName}>
- {fileName}
- </div>
- </div>
- </div>
- )
- },
- size:250
- },
- {
- id: "currentRevision",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="리비전" />
- ),
- cell: ({ row }) => (
- <RevisionDialog
- attachmentId={row.original.id}
- currentRevision={row.original.currentRevision}
- originalFileName={row.original.originalFileName}
- />
- ),
- size: 100,
- },
- {
- accessorKey: "description",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="설명" />
- ),
- cell: ({ row }) => {
- const description = row.getValue("description") as string
- return description
- ? <div className="max-w-[200px] truncate" title={description}>{description}</div>
- : <span className="text-muted-foreground">-</span>
- },
- },
-
- /** ───────────── 파일 정보 ───────────── */
- // {
- // accessorKey: "fileSize",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="파일 크기" />
- // ),
- // cell: ({ row }) => {
- // const size = row.getValue("fileSize") as number
- // return size ? formatBytes(size) : "-"
- // },
- // },
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="등록일" />
- ),
- cell: ({ row }) => {
- const created = row.getValue("createdAt") as Date
- const updated = row.original.updatedAt as Date
- return (
- <div>
- <div>{formatDate(created, "KR")}</div>
- <div className="text-xs text-muted-foreground">
- {row.original.createdByName}
- </div>
- {updated && new Date(updated) > new Date(created) && (
- <div className="text-xs text-blue-600">
- 수정: {formatDate(updated, "KR")}
- </div>
- )}
- </div>
- )
- },
- maxSize:150
- },
-
- /** ───────────── 벤더 응답 현황 ───────────── */
- {
- id: "vendorCount",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 수" />
- ),
- cell: ({ row }) => {
- const stats = row.original.responseStats
- return stats
- ? (
- <div className="text-center">
- <div className="font-medium">{stats.totalVendors}</div>
- <div className="text-xs text-muted-foreground">
- 활성: {stats.totalVendors - stats.waivedCount}
- </div>
- </div>
- )
- : <span className="text-muted-foreground">-</span>
- },
- },
- {
- id: "responseStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="응답 현황" />
- ),
- cell: ({ row }) => {
- const stats = row.original.responseStats
- return stats
- ? (
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- <div className="flex-1">
- <Progress value={stats.responseRate} className="h-2" />
- </div>
- <span className="text-sm font-medium">
- {stats.responseRate}%
- </span>
- </div>
- <div className="flex gap-2 text-xs">
- <span className="text-green-600">
- 응답: {stats.respondedCount}
- </span>
- <span className="text-orange-600">
- 대기: {stats.pendingCount}
- </span>
- {stats.waivedCount > 0 && (
- <span className="text-gray-500">
- 면제: {stats.waivedCount}
- </span>
- )}
- </div>
- </div>
- )
- : <span className="text-muted-foreground">-</span>
- },
- },
-
- /** ───────────── 액션 ───────────── */
- {
- id: "actions",
- enableHiding: false,
- cell: ({ row }) => {
- const [isAddRevisionOpen, setIsAddRevisionOpen] = React.useState(false)
-
- return (
- <>
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-48">
- <DropdownMenuItem onClick={() => onSelectAttachment(row.original)}>
- <MessageSquare className="mr-2 h-4 w-4" />
- 벤더 응답 보기
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() => row.original.filePath && window.open(row.original.filePath, "_blank")}
- >
- <Download className="mr-2 h-4 w-4" />
- 다운로드
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setIsAddRevisionOpen(true)}>
- <Upload className="mr-2 h-4 w-4" />
- 새 리비전 추가
- </DropdownMenuItem>
- <DropdownMenuItem className="text-red-600">
- 삭제
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
-
- <AddRevisionDialog
- open={isAddRevisionOpen}
- onOpenChange={setIsAddRevisionOpen}
- attachmentId={row.original.id}
- currentRevision={row.original.currentRevision}
- originalFileName={row.original.originalFileName}
- onSuccess={() => window.location.reload()}
- />
- </>
- )
- },
- size: 40,
- },
- ]
-}
diff --git a/lib/b-rfq/attachment/attachment-table.tsx b/lib/b-rfq/attachment/attachment-table.tsx
deleted file mode 100644
index 4c547000..00000000
--- a/lib/b-rfq/attachment/attachment-table.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { VendorResponsesPanel } from "./vendor-responses-panel"
-import { Separator } from "@/components/ui/separator"
-import { FileText } from "lucide-react"
-import { getRfqAttachments, getVendorResponsesForAttachment } from "../service"
-import { getAttachmentColumns } from "./attachment-columns"
-import { RfqAttachmentsTableToolbarActions } from "./attachment-toolbar-action"
-
-interface RfqAttachmentsTableProps {
- promises: Promise<Awaited<ReturnType<typeof getRfqAttachments>>>
- rfqId: number
-}
-
-export function RfqAttachmentsTable({ promises, rfqId }: RfqAttachmentsTableProps) {
- const { data, pageCount } = React.use(promises)
-
- // 선택된 첨부파일과 벤더 응답 데이터
- const [selectedAttachment, setSelectedAttachment] = React.useState<any>(null)
- const [vendorResponses, setVendorResponses] = React.useState<any[]>([])
- const [isLoadingResponses, setIsLoadingResponses] = React.useState(false)
-
- const columns = React.useMemo(
- () => getAttachmentColumns({
- onSelectAttachment: setSelectedAttachment
- }),
- []
- )
-
- // 첨부파일 선택 시 벤더 응답 데이터 로드
- React.useEffect(() => {
- if (!selectedAttachment) {
- setVendorResponses([])
- return
- }
-
- const loadVendorResponses = async () => {
- setIsLoadingResponses(true)
- try {
- const responses = await getVendorResponsesForAttachment(
- selectedAttachment.id,
- 'INITIAL' // 또는 현재 RFQ 상태에 따라 결정
- )
- setVendorResponses(responses)
- } catch (error) {
- console.error('Failed to load vendor responses:', error)
- setVendorResponses([])
- } finally {
- setIsLoadingResponses(false)
- }
- }
-
- loadVendorResponses()
- }, [selectedAttachment])
-
- /**
- * 필터 필드 정의
- */
- const filterFields: DataTableFilterField<any>[] = [
- {
- id: "fileName",
- label: "파일명",
- placeholder: "파일명으로 검색...",
- },
- {
- id: "attachmentType",
- label: "문서 타입",
- options: [
- { label: "구매 문서", value: "구매", count: 0 },
- { label: "설계 문서", value: "설계", count: 0 },
- ],
- },
- {
- id: "fileType",
- label: "파일 형식",
- options: [
- { label: "PDF", value: "pdf", count: 0 },
- { label: "Excel", value: "xlsx", count: 0 },
- { label: "Word", value: "docx", count: 0 },
- { label: "기타", value: "기타", count: 0 },
- ],
- },
- ]
-
- /**
- * 고급 필터 필드
- */
- const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
- {
- id: "fileName",
- label: "파일명",
- type: "text",
- },
- {
- id: "originalFileName",
- label: "원본 파일명",
- type: "text",
- },
- {
- id: "serialNo",
- label: "시리얼 번호",
- type: "text",
- },
- {
- id: "description",
- label: "설명",
- type: "text",
- },
- {
- id: "attachmentType",
- label: "문서 타입",
- type: "multi-select",
- options: [
- { label: "구매 문서", value: "구매" },
- { label: "설계 문서", value: "설계" },
- ],
- },
- {
- id: "fileType",
- label: "파일 형식",
- type: "multi-select",
- options: [
- { label: "PDF", value: "pdf" },
- { label: "Excel", value: "xlsx" },
- { label: "Word", value: "docx" },
- { label: "기타", value: "기타" },
- ],
- },
- {
- id: "createdAt",
- label: "등록일",
- type: "date",
- },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => originalRow.id.toString(),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <div className="space-y-6">
- {/* 메인 테이블 */}
- <div className="h-full w-full">
- <DataTable table={table} className="h-full">
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <RfqAttachmentsTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
-
- {/* 벤더 응답 현황 패널 */}
- {selectedAttachment && (
- <>
- <Separator />
- <VendorResponsesPanel
- attachment={selectedAttachment}
- responses={vendorResponses}
- isLoading={isLoadingResponses}
- onRefresh={() => {
- // 새로고침 로직
- if (selectedAttachment) {
- setSelectedAttachment({ ...selectedAttachment })
- }
- }}
- />
- </>
- )}
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/attachment-toolbar-action.tsx b/lib/b-rfq/attachment/attachment-toolbar-action.tsx
deleted file mode 100644
index e078ea66..00000000
--- a/lib/b-rfq/attachment/attachment-toolbar-action.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-
-import { AddAttachmentDialog } from "./add-attachment-dialog"
-import { ConfirmDocumentsDialog } from "./confirm-documents-dialog"
-import { TbeRequestDialog } from "./tbe-request-dialog"
-import { DeleteAttachmentsDialog } from "./delete-attachment-dialog"
-
-interface RfqAttachmentsTableToolbarActionsProps {
- table: Table<any>
- rfqId: number
-}
-
-export function RfqAttachmentsTableToolbarActions({
- table,
- rfqId
-}: RfqAttachmentsTableToolbarActionsProps) {
-
- // 선택된 행들 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedAttachments = selectedRows.map((row) => row.original)
- const selectedCount = selectedRows.length
-
- return (
- <div className="flex items-center gap-2">
- {/** 선택된 로우가 있으면 삭제 다이얼로그 */}
- {selectedCount > 0 && (
- <DeleteAttachmentsDialog
- attachments={selectedAttachments}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- )}
-
- {/** 새 첨부 추가 다이얼로그 */}
- <AddAttachmentDialog rfqId={rfqId} />
-
- {/** 문서 확정 다이얼로그 */}
- <ConfirmDocumentsDialog
- rfqId={rfqId}
- onSuccess={() => {
- // 성공 후 필요한 작업 (예: 페이지 새로고침)
- window.location.reload()
- }}
- />
-
- {/** TBE 요청 다이얼로그 (선택된 행이 있을 때만 활성화) */}
- <TbeRequestDialog
- rfqId={rfqId}
- attachments={selectedAttachments}
- onSuccess={() => {
- // 선택 해제 및 페이지 새로고침
- table.toggleAllRowsSelected(false)
- window.location.reload()
- }}
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/confirm-documents-dialog.tsx b/lib/b-rfq/attachment/confirm-documents-dialog.tsx
deleted file mode 100644
index fccb4123..00000000
--- a/lib/b-rfq/attachment/confirm-documents-dialog.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Loader, FileCheck } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { confirmDocuments } from "../service"
-
-interface ConfirmDocumentsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- rfqId: number
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function ConfirmDocumentsDialog({
- rfqId,
- showTrigger = true,
- onSuccess,
- ...props
-}: ConfirmDocumentsDialogProps) {
- const [isConfirmPending, startConfirmTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onConfirm() {
- startConfirmTransition(async () => {
- const result = await confirmDocuments(rfqId)
-
- if (!result.success) {
- toast.error(result.message)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success(result.message)
- onSuccess?.()
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <FileCheck className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">문서 확정</span>
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>문서를 확정하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 RFQ의 모든 첨부문서를 확정하고 상태를 "Doc. Confirmed"로 변경합니다.
- 확정 후에는 문서 수정이 제한될 수 있습니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Confirm documents"
- onClick={onConfirm}
- disabled={isConfirmPending}
- >
- {isConfirmPending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 문서 확정
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <FileCheck className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">문서 확정</span>
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>문서를 확정하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 RFQ의 모든 첨부문서를 확정하고 상태를 "Doc. Confirmed"로 변경합니다.
- 확정 후에는 문서 수정이 제한될 수 있습니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Confirm documents"
- onClick={onConfirm}
- disabled={isConfirmPending}
- >
- {isConfirmPending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 문서 확정
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/delete-attachment-dialog.tsx b/lib/b-rfq/attachment/delete-attachment-dialog.tsx
deleted file mode 100644
index b5471520..00000000
--- a/lib/b-rfq/attachment/delete-attachment-dialog.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { deleteRfqAttachments } from "../service"
-
-
-// 첨부파일 타입 (실제 타입에 맞게 조정 필요)
-type RfqAttachment = {
- id: number
- serialNo: string
- originalFileName: string
- attachmentType: string
- currentRevision: string
-}
-
-interface DeleteAttachmentsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- attachments: Row<RfqAttachment>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteAttachmentsDialog({
- attachments,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteAttachmentsDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- startDeleteTransition(async () => {
- const result = await deleteRfqAttachments({
- ids: attachments.map((attachment) => attachment.id),
- })
-
- if (!result.success) {
- toast.error(result.message)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success(result.message)
- onSuccess?.()
- })
- }
-
- const attachmentText = attachments.length === 1 ? "첨부파일" : "첨부파일들"
- const deleteWarning = `선택된 ${attachments.length}개의 ${attachmentText}과 모든 리비전이 영구적으로 삭제됩니다.`
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({attachments.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription className="space-y-2">
- <div>이 작업은 되돌릴 수 없습니다.</div>
- <div>{deleteWarning}</div>
- {attachments.length <= 3 && (
- <div className="mt-3 p-2 bg-gray-50 rounded-md">
- <div className="font-medium text-sm">삭제될 파일:</div>
- <ul className="text-sm text-gray-600 mt-1">
- {attachments.map((attachment) => (
- <li key={attachment.id} className="truncate">
- • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision})
- </li>
- ))}
- </ul>
- </div>
- )}
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Delete selected attachments"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({attachments.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription className="space-y-2">
- <div>이 작업은 되돌릴 수 없습니다.</div>
- <div>{deleteWarning}</div>
- {attachments.length <= 3 && (
- <div className="mt-3 p-2 bg-gray-50 rounded-md">
- <div className="font-medium text-sm">삭제될 파일:</div>
- <ul className="text-sm text-gray-600 mt-1">
- {attachments.map((attachment) => (
- <li key={attachment.id} className="truncate">
- • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision})
- </li>
- ))}
- </ul>
- </div>
- )}
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Delete selected attachments"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/request-revision-dialog.tsx b/lib/b-rfq/attachment/request-revision-dialog.tsx
deleted file mode 100644
index 90d5b543..00000000
--- a/lib/b-rfq/attachment/request-revision-dialog.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-// components/rfq/request-revision-dialog.tsx
-"use client";
-
-import { useState, useTransition } from "react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Textarea } from "@/components/ui/textarea";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { AlertTriangle, Loader2 } from "lucide-react";
-import { useToast } from "@/hooks/use-toast";
-import { requestRevision } from "../service";
-
-const revisionFormSchema = z.object({
- revisionReason: z
- .string()
- .min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요")
- .max(500, "수정 요청 사유는 500자를 초과할 수 없습니다"),
-});
-
-type RevisionFormData = z.infer<typeof revisionFormSchema>;
-
-interface RequestRevisionDialogProps {
- responseId: number;
- attachmentType: string;
- serialNo: string;
- vendorName?: string;
- currentRevision: string;
- trigger?: React.ReactNode;
- onSuccess?: () => void;
-}
-
-export function RequestRevisionDialog({
- responseId,
- attachmentType,
- serialNo,
- vendorName,
- currentRevision,
- trigger,
- onSuccess,
-}: RequestRevisionDialogProps) {
- const [open, setOpen] = useState(false);
- const [isPending, startTransition] = useTransition();
- const { toast } = useToast();
-
- const form = useForm<RevisionFormData>({
- resolver: zodResolver(revisionFormSchema),
- defaultValues: {
- revisionReason: "",
- },
- });
-
- const handleOpenChange = (newOpen: boolean) => {
- setOpen(newOpen);
- // 다이얼로그가 닫힐 때 form 리셋
- if (!newOpen) {
- form.reset();
- }
- };
-
- const handleCancel = () => {
- form.reset();
- setOpen(false);
- };
-
- const onSubmit = async (data: RevisionFormData) => {
- startTransition(async () => {
- try {
- const result = await requestRevision(responseId, data.revisionReason);
-
- if (!result.success) {
- throw new Error(result.message);
- }
-
- toast({
- title: "수정 요청 완료",
- description: result.message,
- });
-
- setOpen(false);
- form.reset();
- onSuccess?.();
-
- } catch (error) {
- console.error("Request revision error:", error);
- toast({
- title: "수정 요청 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
- variant: "destructive",
- });
- }
- });
- };
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- {trigger || (
- <Button size="sm" variant="outline">
- <AlertTriangle className="h-3 w-3 mr-1" />
- 수정요청
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-lg">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <AlertTriangle className="h-5 w-5 text-orange-600" />
- 수정 요청
- </DialogTitle>
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Badge variant="outline">{serialNo}</Badge>
- <span>{attachmentType}</span>
- <Badge variant="secondary">{currentRevision}</Badge>
- {vendorName && (
- <>
- <span>•</span>
- <span>{vendorName}</span>
- </>
- )}
- </div>
- </DialogHeader>
-
- <div className="space-y-4">
- <div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
- <div className="flex items-start gap-2">
- <AlertTriangle className="h-4 w-4 text-orange-600 mt-0.5 flex-shrink-0" />
- <div className="text-sm text-orange-800">
- <p className="font-medium mb-1">수정 요청 안내</p>
- <p>
- 벤더에게 현재 제출된 응답에 대한 수정을 요청합니다.
- 수정 요청 후 벤더는 새로운 파일을 다시 제출할 수 있습니다.
- </p>
- </div>
- </div>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <FormField
- control={form.control}
- name="revisionReason"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-base font-medium">
- 수정 요청 사유 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Textarea
- placeholder="수정이 필요한 구체적인 사유를 입력해주세요...&#10;예: 제출된 도면에서 치수 정보가 누락되었습니다."
- className="resize-none"
- rows={4}
- disabled={isPending}
- {...field}
- />
- </FormControl>
- <div className="flex justify-between text-xs text-muted-foreground">
- <FormMessage />
- <span>{field.value?.length || 0}/500</span>
- </div>
- </FormItem>
- )}
- />
-
- <div className="flex justify-end gap-2 pt-2">
- <Button
- type="button"
- variant="outline"
- onClick={handleCancel}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isPending}
- // className="bg-orange-600 hover:bg-orange-700"
- >
- {isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
- {isPending ? "요청 중..." : "수정 요청"}
- </Button>
- </div>
- </form>
- </Form>
- </div>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/revision-dialog.tsx b/lib/b-rfq/attachment/revision-dialog.tsx
deleted file mode 100644
index d26abedb..00000000
--- a/lib/b-rfq/attachment/revision-dialog.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { History, Download, Upload } from "lucide-react"
-import { formatDate, formatBytes } from "@/lib/utils"
-import { getAttachmentRevisions } from "../service"
-import { AddRevisionDialog } from "./add-revision-dialog"
-
-interface RevisionDialogProps {
- attachmentId: number
- currentRevision: string
- originalFileName: string
-}
-
-export function RevisionDialog({
- attachmentId,
- currentRevision,
- originalFileName
-}: RevisionDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [revisions, setRevisions] = React.useState<any[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
- const [isAddRevisionOpen, setIsAddRevisionOpen] = React.useState(false)
-
- // 리비전 목록 로드
- const loadRevisions = async () => {
- setIsLoading(true)
- try {
- const result = await getAttachmentRevisions(attachmentId)
-
- if (result.success) {
- setRevisions(result.revisions)
- } else {
- console.error("Failed to load revisions:", result.message)
- }
- } catch (error) {
- console.error("Failed to load revisions:", error)
- } finally {
- setIsLoading(false)
- }
- }
-
- React.useEffect(() => {
- if (open) {
- loadRevisions()
- }
- }, [open, attachmentId])
-
- return (
- <>
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- <Button variant="ghost" size="sm" className="gap-2">
- <History className="h-4 w-4" />
- {currentRevision}
- </Button>
- </DialogTrigger>
-
- <DialogContent className="sm:max-w-[800px]" style={{maxWidth:800}}>
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <History className="h-5 w-5" />
- 리비전 히스토리: {originalFileName}
- </DialogTitle>
- <DialogDescription>
- 이 문서의 모든 버전을 확인하고 관리할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- {/* 새 리비전 추가 버튼 */}
- <div className="flex justify-end">
- <Button
- onClick={() => setIsAddRevisionOpen(true)}
- className="gap-2"
- >
- <Upload className="h-4 w-4" />
- 새 리비전 추가
- </Button>
- </div>
-
- {/* 리비전 목록 */}
- {isLoading ? (
- <div className="text-center py-8">리비전을 불러오는 중...</div>
- ) : (
- <div className="border rounded-lg">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>리비전</TableHead>
- <TableHead>파일명</TableHead>
- <TableHead>크기</TableHead>
- <TableHead>업로드 일시</TableHead>
- <TableHead>업로드자</TableHead>
- <TableHead>코멘트</TableHead>
- <TableHead>액션</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {revisions.map((revision) => (
- <TableRow key={revision.id}>
- <TableCell>
- <div className="flex items-center gap-2">
- <Badge
- variant={revision.isLatest ? "default" : "outline"}
- >
- {revision.revisionNo}
- </Badge>
- {revision.isLatest && (
- <Badge variant="secondary" className="text-xs">
- 최신
- </Badge>
- )}
- </div>
- </TableCell>
-
- <TableCell>
- <div>
- <div className="font-medium">{revision.originalFileName}</div>
- </div>
- </TableCell>
-
- <TableCell>
- {formatBytes(revision.fileSize)}
- </TableCell>
-
- <TableCell>
- {formatDate(revision.createdAt, "KR")}
- </TableCell>
-
- <TableCell>
- {revision.createdByName || "-"}
- </TableCell>
-
- <TableCell>
- <div className="max-w-[200px] truncate" title={revision.revisionComment}>
- {revision.revisionComment || "-"}
- </div>
- </TableCell>
-
- <TableCell>
- <Button
- variant="ghost"
- size="sm"
- className="gap-2"
- onClick={() => {
- // 파일 다운로드
- window.open(revision.filePath, '_blank')
- }}
- >
- <Download className="h-4 w-4" />
- 다운로드
- </Button>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
-
- {/* 새 리비전 추가 다이얼로그 */}
- <AddRevisionDialog
- open={isAddRevisionOpen}
- onOpenChange={setIsAddRevisionOpen}
- attachmentId={attachmentId}
- currentRevision={currentRevision}
- originalFileName={originalFileName}
- onSuccess={() => {
- loadRevisions() // 리비전 목록 새로고침
- }}
- />
- </>
- )
- } \ No newline at end of file
diff --git a/lib/b-rfq/attachment/tbe-request-dialog.tsx b/lib/b-rfq/attachment/tbe-request-dialog.tsx
deleted file mode 100644
index 80b20e6f..00000000
--- a/lib/b-rfq/attachment/tbe-request-dialog.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Loader, Send } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { requestTbe } from "../service"
-
-// 첨부파일 타입
-type RfqAttachment = {
- id: number
- serialNo: string
- originalFileName: string
- attachmentType: string
- currentRevision: string
-}
-
-interface TbeRequestDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- rfqId: number
- attachments: RfqAttachment[]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function TbeRequestDialog({
- rfqId,
- attachments,
- showTrigger = true,
- onSuccess,
- ...props
-}: TbeRequestDialogProps) {
- const [isRequestPending, startRequestTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onRequest() {
- startRequestTransition(async () => {
- const attachmentIds = attachments.map(attachment => attachment.id)
- const result = await requestTbe(rfqId, attachmentIds)
-
- if (!result.success) {
- toast.error(result.message)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success(result.message)
- onSuccess?.()
- })
- }
-
- const attachmentCount = attachments.length
- const attachmentText = attachmentCount === 1 ? "문서" : "문서들"
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={attachmentCount === 0}
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- TBE 요청 {attachmentCount > 0 && `(${attachmentCount})`}
- </span>
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>TBE 요청을 전송하시겠습니까?</DialogTitle>
- <DialogDescription className="space-y-2">
- <div>
- 선택된 <span className="font-medium">{attachmentCount}개</span>의 {attachmentText}에 대해
- 벤더들에게 기술평가(TBE) 요청을 전송합니다.
- </div>
- <div>RFQ 상태가 "TBE started"로 변경됩니다.</div>
- {attachmentCount <= 5 && (
- <div className="mt-3 p-2 bg-gray-50 rounded-md">
- <div className="font-medium text-sm">TBE 요청 대상:</div>
- <ul className="text-sm text-gray-600 mt-1">
- {attachments.map((attachment) => (
- <li key={attachment.id} className="truncate">
- • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision})
- </li>
- ))}
- </ul>
- </div>
- )}
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Request TBE"
- onClick={onRequest}
- disabled={isRequestPending}
- >
- {isRequestPending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- TBE 요청 전송
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={attachmentCount === 0}
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- TBE 요청 {attachmentCount > 0 && `(${attachmentCount})`}
- </span>
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>TBE 요청을 전송하시겠습니까?</DrawerTitle>
- <DrawerDescription className="space-y-2">
- <div>
- 선택된 <span className="font-medium">{attachmentCount}개</span>의 {attachmentText}에 대해
- 벤더들에게 기술평가(TBE) 요청을 전송합니다.
- </div>
- <div>RFQ 상태가 "TBE started"로 변경됩니다.</div>
- {attachmentCount <= 5 && (
- <div className="mt-3 p-2 bg-gray-50 rounded-md">
- <div className="font-medium text-sm">TBE 요청 대상:</div>
- <ul className="text-sm text-gray-600 mt-1">
- {attachments.map((attachment) => (
- <li key={attachment.id} className="truncate">
- • {attachment.serialNo}: {attachment.originalFileName} ({attachment.currentRevision})
- </li>
- ))}
- </ul>
- </div>
- )}
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Request TBE"
- onClick={onRequest}
- disabled={isRequestPending}
- >
- {isRequestPending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- TBE 요청 전송
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/attachment/vendor-responses-panel.tsx b/lib/b-rfq/attachment/vendor-responses-panel.tsx
deleted file mode 100644
index 0cbe2a08..00000000
--- a/lib/b-rfq/attachment/vendor-responses-panel.tsx
+++ /dev/null
@@ -1,386 +0,0 @@
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Skeleton } from "@/components/ui/skeleton"
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import {
- RefreshCw,
- Download,
- MessageSquare,
- Clock,
- CheckCircle2,
- XCircle,
- AlertCircle,
- FileText,
- Files,
- AlertTriangle
-} from "lucide-react"
-import { formatDate, formatFileSize } from "@/lib/utils"
-import { RequestRevisionDialog } from "./request-revision-dialog"
-
-interface VendorResponsesPanelProps {
- attachment: any
- responses: any[]
- isLoading: boolean
- onRefresh: () => void
-}
-
-// 파일 다운로드 핸들러
-async function handleFileDownload(filePath: string, fileName: string, fileId: number) {
- try {
- const params = new URLSearchParams({
- path: filePath,
- type: "vendor",
- responseFileId: fileId.toString(),
- });
-
- const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`);
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || `Download failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = fileName;
- document.body.appendChild(link);
- link.click();
-
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- console.log("✅ 파일 다운로드 성공:", fileName);
- } catch (error) {
- console.error("❌ 파일 다운로드 실패:", error);
- alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
- }
-}
-
-// 파일 목록 컴포넌트
-function FilesList({ files }: { files: any[] }) {
- if (files.length === 0) {
- return (
- <div className="text-center py-4 text-muted-foreground text-sm">
- 업로드된 파일이 없습니다.
- </div>
- );
- }
-
- return (
- <div className="space-y-2 max-h-64 overflow-y-auto">
- {files.map((file, index) => (
- <div key={file.id} className="flex items-center justify-between p-3 border rounded-lg bg-green-50 border-green-200">
- <div className="flex items-center gap-2 flex-1 min-w-0">
- <FileText className="h-4 w-4 text-green-600 flex-shrink-0" />
- <div className="min-w-0 flex-1">
- <div className="font-medium text-sm truncate" title={file.originalFileName}>
- {file.originalFileName}
- </div>
- <div className="text-xs text-muted-foreground">
- {formatFileSize(file.fileSize)} • {formatDate(file.uploadedAt)}
- </div>
- {file.description && (
- <div className="text-xs text-muted-foreground italic mt-1" title={file.description}>
- {file.description}
- </div>
- )}
- </div>
- </div>
- <Button
- size="sm"
- variant="ghost"
- onClick={() => handleFileDownload(file.filePath, file.originalFileName, file.id)}
- className="flex-shrink-0 ml-2"
- title="파일 다운로드"
- >
- <Download className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- );
-}
-
-export function VendorResponsesPanel({
- attachment,
- responses,
- isLoading,
- onRefresh
-}: VendorResponsesPanelProps) {
-
- console.log(responses)
-
- const getStatusIcon = (status: string) => {
- switch (status) {
- case 'RESPONDED':
- return <CheckCircle2 className="h-4 w-4 text-green-600" />
- case 'NOT_RESPONDED':
- return <Clock className="h-4 w-4 text-orange-600" />
- case 'WAIVED':
- return <XCircle className="h-4 w-4 text-gray-500" />
- case 'REVISION_REQUESTED':
- return <AlertCircle className="h-4 w-4 text-yellow-600" />
- default:
- return <Clock className="h-4 w-4 text-gray-400" />
- }
- }
-
- const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case 'RESPONDED':
- return 'default'
- case 'NOT_RESPONDED':
- return 'secondary'
- case 'WAIVED':
- return 'outline'
- case 'REVISION_REQUESTED':
- return 'destructive'
- default:
- return 'secondary'
- }
- }
-
- if (isLoading) {
- return (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <Skeleton className="h-6 w-48" />
- <Skeleton className="h-9 w-24" />
- </div>
- <div className="space-y-3">
- {Array.from({ length: 3 }).map((_, i) => (
- <Skeleton key={i} className="h-12 w-full" />
- ))}
- </div>
- </div>
- )
- }
-
- return (
- <div className="space-y-4">
- {/* 헤더 */}
- <div className="flex items-center justify-between">
- <div className="space-y-1">
- <h3 className="text-lg font-medium flex items-center gap-2">
- <MessageSquare className="h-5 w-5" />
- 벤더 응답 현황: {attachment.originalFileName}
- </h3>
- <div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
- <Badge variant="outline">
- {attachment.attachmentType}
- </Badge>
- <span>시리얼: {attachment.serialNo}</span>
- <span>등록: {formatDate(attachment.createdAt)}</span>
- {attachment.responseStats && (
- <Badge variant="secondary">
- 응답률: {attachment.responseStats.responseRate}%
- </Badge>
- )}
- </div>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="flex items-center gap-2"
- >
- <RefreshCw className="h-4 w-4" />
- 새로고침
- </Button>
- </div>
-
- {/* 테이블 */}
- {responses.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground border rounded-lg">
- 이 문서에 대한 벤더 응답 정보가 없습니다.
- </div>
- ) : (
- <div className="border rounded-lg">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>벤더</TableHead>
- <TableHead>국가</TableHead>
- <TableHead>응답 상태</TableHead>
- <TableHead>리비전</TableHead>
- <TableHead>요청일</TableHead>
- <TableHead>응답일</TableHead>
- <TableHead>응답 파일</TableHead>
- <TableHead>코멘트</TableHead>
- <TableHead className="w-[100px]">액션</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {responses.map((response) => (
- <TableRow key={response.id}>
- <TableCell className="font-medium">
- <div>
- <div>{response.vendorName}</div>
- <div className="text-xs text-muted-foreground">
- {response.vendorCode}
- </div>
- </div>
- </TableCell>
-
- <TableCell>
- {response.vendorCountry}
- </TableCell>
-
- <TableCell>
- <div className="flex items-center gap-2">
- {getStatusIcon(response.responseStatus)}
- <Badge variant={getStatusBadgeVariant(response.responseStatus)}>
- {response.responseStatus}
- </Badge>
- </div>
- </TableCell>
-
- <TableCell>
- <div className="text-sm">
- <div>현재: {response.currentRevision}</div>
- {response.respondedRevision && (
- <div className="text-muted-foreground">
- 응답: {response.respondedRevision}
- </div>
- )}
- </div>
- </TableCell>
-
- <TableCell>
- {formatDate(response.requestedAt)}
- </TableCell>
-
- <TableCell>
- {response.respondedAt ? formatDate(response.respondedAt) : '-'}
- </TableCell>
-
- {/* 응답 파일 컬럼 */}
- <TableCell>
- {response.totalFiles > 0 ? (
- <div className="flex items-center gap-2">
- <Badge variant="secondary" className="text-xs">
- {response.totalFiles}개
- </Badge>
- {response.totalFiles === 1 ? (
- // 파일이 1개면 바로 다운로드
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- onClick={() => {
- const file = response.files[0];
- handleFileDownload(file.filePath, file.originalFileName, file.id);
- }}
- title={response.latestFile?.originalFileName}
- >
- <Download className="h-4 w-4" />
- </Button>
- ) : (
- // 파일이 여러 개면 Popover로 목록 표시
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- title="파일 목록 보기"
- >
- <Files className="h-4 w-4" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-96" align="start">
- <div className="space-y-2">
- <div className="font-medium text-sm">
- 응답 파일 목록 ({response.totalFiles}개)
- </div>
- <FilesList files={response.files} />
- </div>
- </PopoverContent>
- </Popover>
- )}
- </div>
- ) : (
- <span className="text-muted-foreground text-sm">-</span>
- )}
- </TableCell>
-
- <TableCell>
- <div className="space-y-1 max-w-[200px]">
- {/* 벤더 응답 코멘트 */}
- {response.responseComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" title="벤더 응답 코멘트"></div>
- <div className="text-xs text-blue-600 truncate" title={response.responseComment}>
- {response.responseComment}
- </div>
- </div>
- )}
-
- {/* 수정 요청 사유 */}
- {response.revisionRequestComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="수정 요청 사유"></div>
- <div className="text-xs text-red-600 truncate" title={response.revisionRequestComment}>
- {response.revisionRequestComment}
- </div>
- </div>
- )}
-
- {!response.responseComment && !response.revisionRequestComment && (
- <span className="text-muted-foreground text-sm">-</span>
- )}
- </div>
- </TableCell>
-
- {/* 액션 컬럼 - 수정 요청 기능으로 변경 */}
- <TableCell>
- <div className="flex items-center gap-1">
- {response.responseStatus === 'RESPONDED' && (
- <RequestRevisionDialog
- responseId={response.id}
- attachmentType={attachment.attachmentType}
- serialNo={attachment.serialNo}
- vendorName={response.vendorName}
- currentRevision={response.currentRevision}
- onSuccess={onRefresh}
- trigger={
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2"
- title="수정 요청"
- >
- <AlertTriangle className="h-3 w-3 mr-1" />
- 수정요청
- </Button>
- }
- />
- )}
-
- {response.responseStatus === 'REVISION_REQUESTED' && (
- <Badge variant="secondary" className="text-xs">
- 수정 요청됨
- </Badge>
- )}
-
- {(response.responseStatus === 'NOT_RESPONDED' || response.responseStatus === 'WAIVED') && (
- <span className="text-muted-foreground text-xs">-</span>
- )}
- </div>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
- )}
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-columns.tsx b/lib/b-rfq/final/final-rfq-detail-columns.tsx
deleted file mode 100644
index 88d62765..00000000
--- a/lib/b-rfq/final/final-rfq-detail-columns.tsx
+++ /dev/null
@@ -1,589 +0,0 @@
-// final-rfq-detail-columns.tsx
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { type Row } from "@tanstack/react-table"
-import {
- Ellipsis, Building, Eye, Edit,
- MessageSquare, Settings, CheckCircle2, XCircle, DollarSign, Calendar
-} from "lucide-react"
-
-import { formatDate } 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, DropdownMenuShortcut
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { FinalRfqDetailView } from "@/db/schema"
-
-// RowAction 타입 정의
-export interface DataTableRowAction<TData> {
- row: Row<TData>
- type: "update"
-}
-
-interface GetFinalRfqDetailColumnsProps {
- onSelectDetail?: (detail: any) => void
- setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<FinalRfqDetailView> | null>>
-}
-
-export function getFinalRfqDetailColumns({
- onSelectDetail,
- setRowAction
-}: GetFinalRfqDetailColumnsProps = {}): ColumnDef<FinalRfqDetailView>[] {
-
- 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,
- },
-
- /** 1. RFQ Status */
- {
- accessorKey: "finalRfqStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최종 RFQ Status" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("finalRfqStatus") as string
- const getFinalStatusColor = (status: string) => {
- switch (status) {
- case "DRAFT": return "outline"
- case "Final RFQ Sent": return "default"
- case "Quotation Received": return "success"
- case "Vendor Selected": return "default"
- default: return "secondary"
- }
- }
- return (
- <Badge variant={getFinalStatusColor(status) as any}>
- {status}
- </Badge>
- )
- },
- size: 120
- },
-
- /** 2. RFQ No. */
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ No." />
- ),
- cell: ({ row }) => (
- <div className="text-sm font-medium">
- {row.getValue("rfqCode") as string}
- </div>
- ),
- size: 120,
- },
-
- /** 3. Rev. */
- {
- accessorKey: "returnRevision",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Rev." />
- ),
- cell: ({ row }) => {
- const revision = row.getValue("returnRevision") as number
- return revision > 0 ? (
- <Badge variant="outline">
- Rev. {revision}
- </Badge>
- ) : (
- <Badge variant="outline">
- Rev. 0
- </Badge>
- )
- },
- size: 80,
- },
-
- /** 4. Vendor Code */
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Vendor Code" />
- ),
- cell: ({ row }) => (
- <div className="text-sm font-medium">
- {row.original.vendorCode}
- </div>
- ),
- size: 100,
- },
-
- /** 5. Vendor Name */
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Vendor Name" />
- ),
- cell: ({ row }) => (
- <div className="text-sm font-medium">
- {row.original.vendorName}
- </div>
- ),
- size: 150,
- },
-
- /** 6. 업체분류 */
- {
- id: "vendorClassification",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="업체분류" />
- ),
- cell: ({ row }) => {
- const vendorCode = row.original.vendorCode as string
- return vendorCode ? (
- <Badge variant="success" className="text-xs">
- 정규업체
- </Badge>
- ) : (
- <Badge variant="secondary" className="text-xs">
- 잠재업체
- </Badge>
- )
- },
- size: 100,
- },
-
- /** 7. CP 현황 */
- {
- accessorKey: "cpRequestYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="CP 현황" />
- ),
- cell: ({ row }) => {
- const cpRequest = row.getValue("cpRequestYn") as boolean
- return cpRequest ? (
- <Badge variant="success" className="text-xs">
- 신청
- </Badge>
- ) : (
- <Badge variant="outline" className="text-xs">
- 미신청
- </Badge>
- )
- },
- size: 80,
- },
-
- /** 8. GTC현황 */
- {
- id: "gtcStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="GTC현황" />
- ),
- cell: ({ row }) => {
- const gtc = row.original.gtc as string
- const gtcValidDate = row.original.gtcValidDate as string
- const prjectGtcYn = row.original.prjectGtcYn as boolean
-
- if (prjectGtcYn || gtc) {
- return (
- <div className="space-y-1">
- <Badge variant="success" className="text-xs">
- 보유
- </Badge>
- {gtcValidDate && (
- <div className="text-xs text-muted-foreground">
- {gtcValidDate}
- </div>
- )}
- </div>
- )
- }
- return (
- <Badge variant="outline" className="text-xs">
- 미보유
- </Badge>
- )
- },
- size: 100,
- },
-
- /** 9. TBE 결과 (스키마에 없어서 placeholder) */
- {
- id: "tbeResult",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
- ),
- cell: ({ row }) => {
- // TODO: TBE 결과 로직 구현 필요
- return (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 80,
- },
-
- /** 10. 최종 선정 */
- {
- id: "finalSelection",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최종 선정" />
- ),
- cell: ({ row }) => {
- const status = row.original.finalRfqStatus as string
- return status === "Vendor Selected" ? (
- <Badge variant="success" className="text-xs">
- <CheckCircle2 className="h-3 w-3 mr-1" />
- 선정
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 80,
- },
-
- /** 11. Currency */
- {
- accessorKey: "currency",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Currency" />
- ),
- cell: ({ row }) => {
- const currency = row.getValue("currency") as string
- return currency ? (
- <Badge variant="outline" className="text-xs">
- {/* <DollarSign className="h-3 w-3 mr-1" /> */}
- {currency}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 80,
- },
-
- /** 12. Terms of Payment */
- {
- accessorKey: "paymentTermsCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Terms of Payment" />
- ),
- cell: ({ row }) => {
- const paymentTermsCode = row.getValue("paymentTermsCode") as string
- return paymentTermsCode ? (
- <Badge variant="secondary" className="text-xs">
- {paymentTermsCode}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 13. Payment Desc. */
- {
- accessorKey: "paymentTermsDescription",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Payment Desc." />
- ),
- cell: ({ row }) => {
- const description = row.getValue("paymentTermsDescription") as string
- return description ? (
- <div className="text-xs max-w-[150px] truncate" title={description}>
- {description}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 150,
- },
-
- /** 14. TAX */
- {
- accessorKey: "taxCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="TAX" />
- ),
- cell: ({ row }) => {
- const taxCode = row.getValue("taxCode") as string
- return taxCode ? (
- <Badge variant="outline" className="text-xs">
- {taxCode}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 80,
- },
-
- /** 15. Delivery Date* */
- {
- accessorKey: "deliveryDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Delivery Date*" />
- ),
- cell: ({ row }) => {
- const deliveryDate = row.getValue("deliveryDate") as Date
- return deliveryDate ? (
- <div className="text-sm">
- {formatDate(deliveryDate, "KR")}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 16. Country */
- {
- accessorKey: "vendorCountry",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Country" />
- ),
- cell: ({ row }) => {
- const country = row.getValue("vendorCountry") as string
- const countryDisplay = country === "KR" ? "D" : "F"
- return (
- <Badge variant="outline" className="text-xs">
- {countryDisplay}
- </Badge>
- )
- },
- size: 80,
- },
-
- /** 17. Place of Shipping */
- {
- accessorKey: "placeOfShipping",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Place of Shipping" />
- ),
- cell: ({ row }) => {
- const placeOfShipping = row.getValue("placeOfShipping") as string
- return placeOfShipping ? (
- <div className="text-xs max-w-[120px] truncate" title={placeOfShipping}>
- {placeOfShipping}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 18. Place of Destination */
- {
- accessorKey: "placeOfDestination",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Place of Destination" />
- ),
- cell: ({ row }) => {
- const placeOfDestination = row.getValue("placeOfDestination") as string
- return placeOfDestination ? (
- <div className="text-xs max-w-[120px] truncate" title={placeOfDestination}>
- {placeOfDestination}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 19. 초도 여부* */
- {
- accessorKey: "firsttimeYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="초도 여부*" />
- ),
- cell: ({ row }) => {
- const firsttime = row.getValue("firsttimeYn") as boolean
- return firsttime ? (
- <Badge variant="success" className="text-xs">
- 초도
- </Badge>
- ) : (
- <Badge variant="outline" className="text-xs">
- 재구매
- </Badge>
- )
- },
- size: 80,
- },
-
- /** 20. 연동제 적용* */
- {
- accessorKey: "materialPriceRelatedYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="연동제 적용*" />
- ),
- cell: ({ row }) => {
- const materialPrice = row.getValue("materialPriceRelatedYn") as boolean
- return materialPrice ? (
- <Badge variant="success" className="text-xs">
- 적용
- </Badge>
- ) : (
- <Badge variant="outline" className="text-xs">
- 미적용
- </Badge>
- )
- },
- size: 100,
- },
-
- /** 21. Business Size */
- {
- id: "businessSizeDisplay",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Business Size" />
- ),
- cell: ({ row }) => {
- const businessSize = row.original.vendorBusinessSize as string
- return businessSize ? (
- <Badge variant="outline" className="text-xs">
- {businessSize}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
-
- /** 22. 최종 Update일 */
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최종 Update일" />
- ),
- cell: ({ row }) => {
- const updated = row.getValue("updatedAt") as Date
- return updated ? (
- <div className="text-sm">
- {formatDate(updated, "KR")}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** 23. 최종 Update담당자 (스키마에 없어서 placeholder) */
- {
- id: "updatedByUser",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최종 Update담당자" />
- ),
- cell: ({ row }) => {
- // TODO: updatedBy 사용자 정보 조인 필요
- return (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 120,
- },
-
- /** 24. Vendor 설명 */
- {
- accessorKey: "vendorRemark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Vendor 설명" />
- ),
- cell: ({ row }) => {
- const vendorRemark = row.getValue("vendorRemark") as string
- return vendorRemark ? (
- <div className="text-xs max-w-[150px] truncate" title={vendorRemark}>
- {vendorRemark}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 150,
- },
-
- /** 25. 비고 */
- {
- accessorKey: "remark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="비고" />
- ),
- cell: ({ row }) => {
- const remark = row.getValue("remark") as string
- return remark ? (
- <div className="text-xs max-w-[150px] truncate" title={remark}>
- {remark}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 150,
- },
-
- /** ───────────── 액션 ───────────── */
- {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- 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>
- <MessageSquare className="mr-2 h-4 w-4" />
- 벤더 견적 보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- {setRowAction && (
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- <Edit className="mr-2 h-4 w-4" />
- 수정
- </DropdownMenuItem>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- },
- ]
-} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-table.tsx b/lib/b-rfq/final/final-rfq-detail-table.tsx
deleted file mode 100644
index 8ae42e7e..00000000
--- a/lib/b-rfq/final/final-rfq-detail-table.tsx
+++ /dev/null
@@ -1,297 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getFinalRfqDetail } from "../service" // 앞서 만든 서버 액션
-import {
- getFinalRfqDetailColumns,
- type DataTableRowAction
-} from "./final-rfq-detail-columns"
-import { FinalRfqDetailTableToolbarActions } from "./final-rfq-detail-toolbar-actions"
-import { UpdateFinalRfqSheet } from "./update-final-rfq-sheet"
-import { FinalRfqDetailView } from "@/db/schema"
-
-interface FinalRfqDetailTableProps {
- promises: Promise<Awaited<ReturnType<typeof getFinalRfqDetail>>>
- rfqId?: number
-}
-
-export function FinalRfqDetailTable({ promises, rfqId }: FinalRfqDetailTableProps) {
- const { data, pageCount } = React.use(promises)
-
- // 선택된 상세 정보
- const [selectedDetail, setSelectedDetail] = React.useState<any>(null)
-
- // Row action 상태 (update만)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<FinalRfqDetailView> | null>(null)
-
- const columns = React.useMemo(
- () => getFinalRfqDetailColumns({
- onSelectDetail: setSelectedDetail,
- setRowAction: setRowAction
- }),
- []
- )
-
- /**
- * 필터 필드 정의
- */
- const filterFields: DataTableFilterField<any>[] = [
- {
- id: "rfqCode",
- label: "RFQ 코드",
- placeholder: "RFQ 코드로 검색...",
- },
- {
- id: "vendorName",
- label: "벤더명",
- placeholder: "벤더명으로 검색...",
- },
- {
- id: "rfqStatus",
- label: "RFQ 상태",
- options: [
- { label: "Draft", value: "DRAFT", count: 0 },
- { label: "문서 접수", value: "Doc. Received", count: 0 },
- { label: "담당자 배정", value: "PIC Assigned", count: 0 },
- { label: "문서 확정", value: "Doc. Confirmed", count: 0 },
- { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 },
- { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 },
- { label: "TBE 시작", value: "TBE started", count: 0 },
- { label: "TBE 완료", value: "TBE finished", count: 0 },
- { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 },
- { label: "견적 접수", value: "Quotation Received", count: 0 },
- { label: "벤더 선정", value: "Vendor Selected", count: 0 },
- ],
- },
- {
- id: "finalRfqStatus",
- label: "최종 RFQ 상태",
- options: [
- { label: "초안", value: "DRAFT", count: 0 },
- { label: "발송", value: "Final RFQ Sent", count: 0 },
- { label: "견적 접수", value: "Quotation Received", count: 0 },
- { label: "벤더 선정", value: "Vendor Selected", count: 0 },
- ],
- },
- {
- id: "vendorCountry",
- label: "벤더 국가",
- options: [
- { label: "한국", value: "KR", count: 0 },
- { label: "중국", value: "CN", count: 0 },
- { label: "일본", value: "JP", count: 0 },
- { label: "미국", value: "US", count: 0 },
- { label: "독일", value: "DE", count: 0 },
- ],
- },
- {
- id: "currency",
- label: "통화",
- options: [
- { label: "USD", value: "USD", count: 0 },
- { label: "EUR", value: "EUR", count: 0 },
- { label: "KRW", value: "KRW", count: 0 },
- { label: "JPY", value: "JPY", count: 0 },
- { label: "CNY", value: "CNY", count: 0 },
- ],
- },
- ]
-
- /**
- * 고급 필터 필드
- */
- const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
- {
- id: "rfqCode",
- label: "RFQ 코드",
- type: "text",
- },
- {
- id: "vendorName",
- label: "벤더명",
- type: "text",
- },
- {
- id: "vendorCode",
- label: "벤더 코드",
- type: "text",
- },
- {
- id: "vendorCountry",
- label: "벤더 국가",
- type: "multi-select",
- options: [
- { label: "한국", value: "KR" },
- { label: "중국", value: "CN" },
- { label: "일본", value: "JP" },
- { label: "미국", value: "US" },
- { label: "독일", value: "DE" },
- ],
- },
- {
- id: "rfqStatus",
- label: "RFQ 상태",
- type: "multi-select",
- options: [
- { label: "Draft", value: "DRAFT" },
- { label: "문서 접수", value: "Doc. Received" },
- { label: "담당자 배정", value: "PIC Assigned" },
- { label: "문서 확정", value: "Doc. Confirmed" },
- { label: "초기 RFQ 발송", value: "Init. RFQ Sent" },
- { label: "초기 RFQ 응답", value: "Init. RFQ Answered" },
- { label: "TBE 시작", value: "TBE started" },
- { label: "TBE 완료", value: "TBE finished" },
- { label: "최종 RFQ 발송", value: "Final RFQ Sent" },
- { label: "견적 접수", value: "Quotation Received" },
- { label: "벤더 선정", value: "Vendor Selected" },
- ],
- },
- {
- id: "finalRfqStatus",
- label: "최종 RFQ 상태",
- type: "multi-select",
- options: [
- { label: "초안", value: "DRAFT" },
- { label: "발송", value: "Final RFQ Sent" },
- { label: "견적 접수", value: "Quotation Received" },
- { label: "벤더 선정", value: "Vendor Selected" },
- ],
- },
- {
- id: "vendorBusinessSize",
- label: "벤더 규모",
- type: "multi-select",
- options: [
- { label: "대기업", value: "LARGE" },
- { label: "중기업", value: "MEDIUM" },
- { label: "소기업", value: "SMALL" },
- { label: "스타트업", value: "STARTUP" },
- ],
- },
- {
- id: "incotermsCode",
- label: "Incoterms",
- type: "text",
- },
- {
- id: "paymentTermsCode",
- label: "Payment Terms",
- type: "text",
- },
- {
- id: "currency",
- label: "통화",
- type: "multi-select",
- options: [
- { label: "USD", value: "USD" },
- { label: "EUR", value: "EUR" },
- { label: "KRW", value: "KRW" },
- { label: "JPY", value: "JPY" },
- { label: "CNY", value: "CNY" },
- ],
- },
- {
- id: "dueDate",
- label: "마감일",
- type: "date",
- },
- {
- id: "validDate",
- label: "유효일",
- type: "date",
- },
- {
- id: "deliveryDate",
- label: "납기일",
- type: "date",
- },
- {
- id: "shortList",
- label: "Short List",
- type: "boolean",
- },
- {
- id: "returnYn",
- label: "Return 여부",
- type: "boolean",
- },
- {
- id: "cpRequestYn",
- label: "CP Request 여부",
- type: "boolean",
- },
- {
- id: "prjectGtcYn",
- label: "Project GTC 여부",
- type: "boolean",
- },
- {
- id: "firsttimeYn",
- label: "First Time 여부",
- type: "boolean",
- },
- {
- id: "materialPriceRelatedYn",
- label: "Material Price Related 여부",
- type: "boolean",
- },
- {
- id: "classification",
- label: "분류",
- type: "text",
- },
- {
- id: "sparepart",
- label: "예비부품",
- type: "text",
- },
- {
- 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.finalRfqId ? originalRow.finalRfqId.toString() : "1",
- 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}
- >
- <FinalRfqDetailTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
-
- {/* Update Sheet */}
- <UpdateFinalRfqSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- finalRfq={rowAction?.type === "update" ? rowAction.row.original : null}
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx
deleted file mode 100644
index d8be4f7b..00000000
--- a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { useRouter } from "next/navigation"
-import { toast } from "sonner"
-import { Button } from "@/components/ui/button"
-import {
- Mail,
- CheckCircle2,
- Loader,
- Award,
- RefreshCw
-} from "lucide-react"
-import { FinalRfqDetailView } from "@/db/schema"
-
-interface FinalRfqDetailTableToolbarActionsProps {
- table: Table<FinalRfqDetailView>
- rfqId?: number
- onRefresh?: () => void // 데이터 새로고침 콜백
-}
-
-export function FinalRfqDetailTableToolbarActions({
- table,
- rfqId,
- onRefresh
-}: FinalRfqDetailTableToolbarActionsProps) {
- const router = useRouter()
-
- // 선택된 행들 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedDetails = selectedRows.map((row) => row.original)
- const selectedCount = selectedRows.length
-
- // 상태 관리
- const [isEmailSending, setIsEmailSending] = React.useState(false)
- const [isSelecting, setIsSelecting] = React.useState(false)
-
- // RFQ 발송 핸들러 (로직 없음)
- const handleBulkRfqSend = async () => {
- if (selectedCount === 0) {
- toast.error("발송할 RFQ를 선택해주세요.")
- return
- }
-
- setIsEmailSending(true)
-
- try {
- // TODO: 실제 RFQ 발송 로직 구현
- await new Promise(resolve => setTimeout(resolve, 2000)) // 임시 딜레이
-
- toast.success(`${selectedCount}개의 최종 RFQ가 발송되었습니다.`)
-
- // 선택 해제
- table.toggleAllRowsSelected(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
-
- } catch (error) {
- console.error("RFQ sending error:", error)
- toast.error("최종 RFQ 발송 중 오류가 발생했습니다.")
- } finally {
- setIsEmailSending(false)
- }
- }
-
- // 최종 선정 핸들러 (로직 없음)
- const handleFinalSelection = async () => {
- if (selectedCount === 0) {
- toast.error("최종 선정할 벤더를 선택해주세요.")
- return
- }
-
- if (selectedCount > 1) {
- toast.error("최종 선정은 1개의 벤더만 가능합니다.")
- return
- }
-
- setIsSelecting(true)
-
- try {
- // TODO: 실제 최종 선정 로직 구현
- await new Promise(resolve => setTimeout(resolve, 1500)) // 임시 딜레이
-
- const selectedVendor = selectedDetails[0]
- toast.success(`${selectedVendor.vendorName}이(가) 최종 선정되었습니다.`)
-
- // 선택 해제
- table.toggleAllRowsSelected(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
-
- // 계약서 페이지로 이동 (필요시)
- if (rfqId) {
- setTimeout(() => {
- toast.info("계약서 작성 페이지로 이동합니다.")
- // router.push(`/evcp/contracts/${rfqId}`)
- }, 1500)
- }
-
- } catch (error) {
- console.error("Final selection error:", error)
- toast.error("최종 선정 중 오류가 발생했습니다.")
- } finally {
- setIsSelecting(false)
- }
- }
-
- // 발송 가능한 RFQ 필터링 (DRAFT 상태)
- const sendableRfqs = selectedDetails.filter(
- detail => detail.finalRfqStatus === "DRAFT"
- )
- const sendableCount = sendableRfqs.length
-
- // 선정 가능한 벤더 필터링 (견적 접수 상태)
- const selectableVendors = selectedDetails.filter(
- detail => detail.finalRfqStatus === "Quotation Received"
- )
- const selectableCount = selectableVendors.length
-
- // 전체 벤더 중 견적 접수 완료된 벤더 수
- const allVendors = table.getRowModel().rows.map(row => row.original)
- const quotationReceivedCount = allVendors.filter(
- vendor => vendor.finalRfqStatus === "Quotation Received"
- ).length
-
- return (
- <div className="flex items-center gap-2">
- {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
- {selectedCount > 0 && (
- <>
- {/* RFQ 발송 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkRfqSend}
- className="h-8"
- disabled={isEmailSending || sendableCount === 0}
- title={sendableCount === 0 ? "발송 가능한 RFQ가 없습니다 (DRAFT 상태만 가능)" : `${sendableCount}개의 최종 RFQ 발송`}
- >
- {isEmailSending ? (
- <Loader className="mr-2 h-4 w-4 animate-spin" />
- ) : (
- <Mail className="mr-2 h-4 w-4" />
- )}
- 최종 RFQ 발송 ({sendableCount}/{selectedCount})
- </Button>
-
- {/* 최종 선정 버튼 */}
- <Button
- variant="default"
- size="sm"
- onClick={handleFinalSelection}
- className="h-8"
- disabled={isSelecting || selectedCount !== 1 || selectableCount === 0}
- title={
- selectedCount !== 1
- ? "최종 선정은 1개의 벤더만 선택해주세요"
- : selectableCount === 0
- ? "견적 접수가 완료된 벤더만 선정 가능합니다"
- : "선택된 벤더를 최종 선정"
- }
- >
- {isSelecting ? (
- <Loader className="mr-2 h-4 w-4 animate-spin" />
- ) : (
- <Award className="mr-2 h-4 w-4" />
- )}
- 최종 선정
- </Button>
- </>
- )}
-
- {/* 정보 표시 (선택이 없을 때) */}
- {selectedCount === 0 && quotationReceivedCount > 0 && (
- <div className="text-sm text-muted-foreground">
- 견적 접수 완료: {quotationReceivedCount}개 벤더
- </div>
- )}
-
- {/* 새로고침 버튼 */}
- {onRefresh && (
- <Button
- variant="ghost"
- size="sm"
- onClick={onRefresh}
- className="h-8"
- title="데이터 새로고침"
- >
- <RefreshCw className="h-4 w-4" />
- </Button>
- )}
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/final/update-final-rfq-sheet.tsx b/lib/b-rfq/final/update-final-rfq-sheet.tsx
deleted file mode 100644
index 65e23a92..00000000
--- a/lib/b-rfq/final/update-final-rfq-sheet.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import { FinalRfqDetailView } from "@/db/schema"
-
-interface UpdateFinalRfqSheetProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- finalRfq: FinalRfqDetailView | null
-}
-
-export function UpdateFinalRfqSheet({
- open,
- onOpenChange,
- finalRfq
-}: UpdateFinalRfqSheetProps) {
- return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="sm:max-w-md">
- <SheetHeader>
- <SheetTitle>최종 RFQ 수정</SheetTitle>
- <SheetDescription>
- 최종 RFQ 정보를 수정합니다.
- </SheetDescription>
- </SheetHeader>
-
- <div className="py-6">
- {finalRfq && (
- <div className="space-y-4">
- <div>
- <h4 className="font-medium">RFQ 정보</h4>
- <p className="text-sm text-muted-foreground">
- RFQ Code: {finalRfq.rfqCode}
- </p>
- <p className="text-sm text-muted-foreground">
- 벤더: {finalRfq.vendorName}
- </p>
- <p className="text-sm text-muted-foreground">
- 상태: {finalRfq.finalRfqStatus}
- </p>
- </div>
-
- {/* TODO: 실제 업데이트 폼 구현 */}
- <div className="text-center text-muted-foreground">
- 업데이트 폼이 여기에 구현됩니다.
- </div>
- </div>
- )}
- </div>
-
- <div className="flex justify-end gap-2">
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button onClick={() => onOpenChange(false)}>
- 저장
- </Button>
- </div>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
deleted file mode 100644
index 58a091ac..00000000
--- a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
+++ /dev/null
@@ -1,584 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { Plus, Check, ChevronsUpDown, Search, Building, CalendarIcon } 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 {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Checkbox } from "@/components/ui/checkbox"
-import { cn, formatDate } from "@/lib/utils"
-import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service"
-import { Calendar } from "@/components/ui/calendar"
-import { InitialRfqDetailView } from "@/db/schema"
-
-// Initial RFQ 추가 폼 스키마
-const addInitialRfqSchema = z.object({
- vendorId: z.number({
- required_error: "벤더를 선택해주세요.",
- }),
- initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"], {
- required_error: "초기 RFQ 상태를 선택해주세요.",
- }).default("DRAFT"),
- dueDate: z.date({
- required_error: "마감일을 선택해주세요.",
- }),
- validDate: z.date().optional(),
- incotermsCode: z.string().optional(),
- gtc: z.string().optional(),
- gtcValidDate: z.string().optional(),
- classification: z.string().optional(),
- sparepart: z.string().optional(),
- shortList: z.boolean().default(false),
- returnYn: z.boolean().default(false),
- cpRequestYn: z.boolean().default(false),
- prjectGtcYn: z.boolean().default(false),
- returnRevision: z.number().default(0),
-})
-
-export type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema>
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- country: string
- taxId: string
- status: string
-}
-
-interface Incoterm {
- id: number
- code: string
- description: string
-}
-
-interface AddInitialRfqDialogProps {
- rfqId: number
- onSuccess?: () => void
- defaultValues?: InitialRfqDetailView // 선택된 항목의 기본값
-}
-
-export function AddInitialRfqDialog({ rfqId, onSuccess, defaultValues }: AddInitialRfqDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [vendors, setVendors] = React.useState<Vendor[]>([])
- const [vendorsLoading, setVendorsLoading] = React.useState(false)
- const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false)
- const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
- const [incotermsLoading, setIncotermsLoading] = React.useState(false)
- const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false)
-
- // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값)
- const getDefaultFormValues = React.useCallback((): Partial<AddInitialRfqFormData> => {
- if (defaultValues) {
- return {
- vendorId: defaultValues.vendorId,
- initialRfqStatus: "DRAFT", // 새로 추가할 때는 항상 DRAFT로 시작
- dueDate: defaultValues.dueDate || new Date(),
- validDate: defaultValues.validDate,
- incotermsCode: defaultValues.incotermsCode || "",
- classification: defaultValues.classification || "",
- sparepart: defaultValues.sparepart || "",
- shortList: false, // 새로 추가할 때는 기본적으로 false
- returnYn: false,
- cpRequestYn: defaultValues.cpRequestYn || false,
- prjectGtcYn: defaultValues.prjectGtcYn || false,
- returnRevision: 0,
- }
- }
-
- return {
- initialRfqStatus: "DRAFT",
- shortList: false,
- returnYn: false,
- cpRequestYn: false,
- prjectGtcYn: false,
- returnRevision: 0,
- }
- }, [defaultValues])
-
- const form = useForm<AddInitialRfqFormData>({
- resolver: zodResolver(addInitialRfqSchema),
- defaultValues: getDefaultFormValues(),
- })
-
- // 벤더 목록 로드
- const loadVendors = React.useCallback(async () => {
- setVendorsLoading(true)
- try {
- const vendorList = await getVendorsForSelection()
- setVendors(vendorList)
- } catch (error) {
- console.error("Failed to load vendors:", error)
- toast.error("벤더 목록을 불러오는데 실패했습니다.")
- } finally {
- setVendorsLoading(false)
- }
- }, [])
-
- // Incoterms 목록 로드
- const loadIncoterms = React.useCallback(async () => {
- setIncotermsLoading(true)
- try {
- const incotermsList = await getIncotermsForSelection()
- setIncoterms(incotermsList)
- } catch (error) {
- console.error("Failed to load incoterms:", error)
- toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
- } finally {
- setIncotermsLoading(false)
- }
- }, [])
-
- // 다이얼로그 열릴 때 실행
- React.useEffect(() => {
- if (open) {
- // 폼을 기본값으로 리셋
- form.reset(getDefaultFormValues())
-
- // 데이터 로드
- if (vendors.length === 0) {
- loadVendors()
- }
- if (incoterms.length === 0) {
- loadIncoterms()
- }
- }
- }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms, form, getDefaultFormValues])
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen && !isSubmitting) {
- form.reset(getDefaultFormValues())
- }
- setOpen(newOpen)
- }
-
- // 폼 제출
- const onSubmit = async (data: AddInitialRfqFormData) => {
- setIsSubmitting(true)
-
- try {
- const result = await addInitialRfqRecord({
- ...data,
- rfqId,
- })
-
- if (result.success) {
- toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.")
- form.reset(getDefaultFormValues())
- handleOpenChange(false)
- onSuccess?.()
- } else {
- toast.error(result.message || "초기 RFQ 추가에 실패했습니다.")
- }
-
- } catch (error) {
- console.error("Submit error:", error)
- toast.error("초기 RFQ 추가 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 선택된 벤더 정보
- const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId"))
- const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode"))
-
- // 기본값이 있을 때 버튼 텍스트 변경
- const buttonText = defaultValues ? "유사 항목 추가" : "초기 RFQ 추가"
- const dialogTitle = defaultValues ? "유사 초기 RFQ 추가" : "초기 RFQ 추가"
- const dialogDescription = defaultValues
- ? "선택된 항목을 기본값으로 하여 새로운 초기 RFQ를 추가합니다."
- : "새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다."
-
- 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">{buttonText}</span>
- </Button>
- </DialogTrigger>
-
- <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle>{dialogTitle}</DialogTitle>
- <DialogDescription>
- {dialogDescription}
- {defaultValues && (
- <div className="mt-2 p-2 bg-muted rounded-md text-sm">
- <strong>기본값 출처:</strong> {defaultValues.vendorName} ({defaultValues.vendorCode})
- </div>
- )}
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 벤더 선택 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>벤더 선택 *</FormLabel>
- <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorSearchOpen}
- className="justify-between"
- disabled={vendorsLoading}
- >
- {selectedVendor ? (
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4" />
- <span className="truncate">
- {selectedVendor.vendorName} ({selectedVendor.vendorCode})
- </span>
- </div>
- ) : (
- <span className="text-muted-foreground">
- {vendorsLoading ? "로딩 중..." : "벤더를 선택하세요"}
- </span>
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0" align="start">
- <Command>
- <CommandInput
- placeholder="벤더명 또는 코드로 검색..."
- className="h-9"
- />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorName} ${vendor.vendorCode}`}
- onSelect={() => {
- field.onChange(vendor.id)
- setVendorSearchOpen(false)
- }}
- >
- <div className="flex items-center gap-2 w-full">
- <Building className="h-4 w-4" />
- <div className="flex-1 min-w-0">
- <div className="font-medium truncate">
- {vendor.vendorName}
- </div>
- <div className="text-sm text-muted-foreground">
- {vendor.vendorCode} • {vendor.country} • {vendor.taxId}
- </div>
- </div>
- <Check
- className={cn(
- "ml-auto h-4 w-4",
- vendor.id === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 날짜 필드들 */}
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>견적 마감일 *</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- formatDate(field.value, "KR")
- ) : (
- <span>견적 마감일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="validDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>견적 유효일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- formatDate(field.value, "KR")
- ) : (
- <span>견적 유효일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* Incoterms 선택 */}
- <FormField
- control={form.control}
- name="incotermsCode"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>Incoterms</FormLabel>
- <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={incotermsSearchOpen}
- className="justify-between"
- disabled={incotermsLoading}
- >
- {selectedIncoterm ? (
- <div className="flex items-center gap-2">
- <span className="truncate">
- {selectedIncoterm.code} - {selectedIncoterm.description}
- </span>
- </div>
- ) : (
- <span className="text-muted-foreground">
- {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"}
- </span>
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0" align="start">
- <Command>
- <CommandInput
- placeholder="코드 또는 내용으로 검색..."
- className="h-9"
- />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {incoterms.map((incoterm) => (
- <CommandItem
- key={incoterm.id}
- value={`${incoterm.code} ${incoterm.description}`}
- onSelect={() => {
- field.onChange(incoterm.code)
- setIncotermsSearchOpen(false)
- }}
- >
- <div className="flex items-center gap-2 w-full">
- <div className="flex-1 min-w-0">
- <div className="font-medium truncate">
- {incoterm.code} - {incoterm.description}
- </div>
- </div>
- <Check
- className={cn(
- "ml-auto h-4 w-4",
- incoterm.code === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 옵션 체크박스 */}
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="cpRequestYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>CP 요청</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="prjectGtcYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>Project용 GTC 사용</FormLabel>
- </div>
- </FormItem>
- )}
- />
- </div>
-
- {/* 분류 정보 */}
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="classification"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선급</FormLabel>
- <FormControl>
- <Input placeholder="선급" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="sparepart"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Spare part</FormLabel>
- <FormControl>
- <Input placeholder="O1, O2" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting ? "추가 중..." : "추가"}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx
deleted file mode 100644
index b5a231b7..00000000
--- a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { InitialRfqDetailView } from "@/db/schema"
-import { removeInitialRfqs } from "../service"
-
-interface DeleteInitialRfqDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- initialRfqs: Row<InitialRfqDetailView>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteInitialRfqDialog({
- initialRfqs,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteInitialRfqDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- startDeleteTransition(async () => {
- const { error } = await removeInitialRfqs({
- ids: initialRfqs.map((rfq) => rfq.id),
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("초기 RFQ가 삭제되었습니다")
- onSuccess?.()
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({initialRfqs.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{initialRfqs.length}개</span>의
- 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Delete selected rows"
- 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" />
- 삭제 ({initialRfqs.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{initialRfqs.length}개</span>의
- 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Delete selected rows"
- 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/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
deleted file mode 100644
index 2d9c3a68..00000000
--- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
+++ /dev/null
@@ -1,446 +0,0 @@
-// initial-rfq-detail-columns.tsx
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { type Row } from "@tanstack/react-table"
-import {
- Ellipsis, Building, Eye, Edit, Trash,
- MessageSquare, Settings, CheckCircle2, XCircle
-} from "lucide-react"
-
-import { formatDate } 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, DropdownMenuShortcut
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { InitialRfqDetailView } from "@/db/schema"
-
-
-// RowAction 타입 정의
-export interface DataTableRowAction<TData> {
- row: Row<TData>
- type: "update" | "delete"
-}
-
-interface GetInitialRfqDetailColumnsProps {
- onSelectDetail?: (detail: any) => void
- setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<InitialRfqDetailView> | null>>
-}
-
-export function getInitialRfqDetailColumns({
- onSelectDetail,
- setRowAction
-}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<InitialRfqDetailView>[] {
-
- 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,
- },
-
- /** ───────────── RFQ 정보 ───────────── */
- {
- accessorKey: "initialRfqStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 상태" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("initialRfqStatus") as string
- const getInitialStatusColor = (status: string) => {
- switch (status) {
- case "DRAFT": return "outline"
- case "Init. RFQ Sent": return "default"
- case "Init. RFQ Answered": return "success"
- case "S/L Decline": return "destructive"
- default: return "secondary"
- }
- }
- return (
- <Badge variant={getInitialStatusColor(status) as any}>
- {status}
- </Badge>
- )
- },
- size: 120
- },
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ No." />
- ),
- cell: ({ row }) => (
- <div className="text-sm">
- {row.getValue("rfqCode") as string}
- </div>
- ),
- size: 120,
- },
- {
- accessorKey: "rfqRevision",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 리비전" />
- ),
- cell: ({ row }) => (
- <div className="text-sm">
- Rev. {row.getValue("rfqRevision") as number}
- </div>
- ),
- size: 120,
- },
-
- /** ───────────── 벤더 정보 ───────────── */
- {
- id: "vendorInfo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 정보" />
- ),
- cell: ({ row }) => {
- const vendorName = row.original.vendorName as string
- const vendorCode = row.original.vendorCode as string
- const vendorType = row.original.vendorCategory as string
- const vendorCountry = row.original.vendorCountry === "KR" ? "D":"F"
- const businessSize = row.original.vendorBusinessSize as string
-
- return (
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4 text-muted-foreground" />
- <div className="font-medium">{vendorName}</div>
- </div>
- <div className="text-sm text-muted-foreground">
- {vendorCode} • {vendorType} • {vendorCountry}
- </div>
- {businessSize && (
- <Badge variant="outline" className="text-xs">
- {businessSize}
- </Badge>
- )}
- </div>
- )
- },
- size: 200,
- },
-
- {
- accessorKey: "cpRequestYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="CP" />
- ),
- cell: ({ row }) => {
- const cpRequest = row.getValue("cpRequestYn") as boolean
- return cpRequest ? (
- <Badge variant="outline" className="text-xs">
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 60,
- },
- {
- accessorKey: "prjectGtcYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Project GTC" />
- ),
- cell: ({ row }) => {
- const projectGtc = row.getValue("prjectGtcYn") as boolean
- return projectGtc ? (
- <Badge variant="outline" className="text-xs">
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 100,
- },
- {
- accessorKey: "gtcYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="GTC" />
- ),
- cell: ({ row }) => {
- const gtc = row.getValue("gtcYn") as boolean
- return gtc ? (
- <Badge variant="outline" className="text-xs">
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 60,
- },
- {
- accessorKey: "gtcValidDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="GTC 유효일" />
- ),
- cell: ({ row }) => {
- const gtcValidDate = row.getValue("gtcValidDate") as string
- return gtcValidDate ? (
- <div className="text-sm">
- {gtcValidDate}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
-
- {
- accessorKey: "classification",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="선급" />
- ),
- cell: ({ row }) => {
- const classification = row.getValue("classification") as string
- return classification ? (
- <div className="text-sm font-medium max-w-[120px] truncate" title={classification}>
- {classification}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- {
- accessorKey: "sparepart",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Spare Part" />
- ),
- cell: ({ row }) => {
- const sparepart = row.getValue("sparepart") as string
- return sparepart ? (
- <Badge variant="outline" className="text-xs">
- {sparepart}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
-
- {
- id: "incoterms",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Incoterms" />
- ),
- cell: ({ row }) => {
- const code = row.original.incotermsCode as string
- const description = row.original.incotermsDescription as string
-
- return code ? (
- <div className="space-y-1">
- <Badge variant="outline">{code}</Badge>
- {description && (
- <div className="text-xs text-muted-foreground max-w-[150px] truncate" title={description}>
- {description}
- </div>
- )}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- /** ───────────── 날짜 정보 ───────────── */
- {
- accessorKey: "validDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유효일" />
- ),
- cell: ({ row }) => {
- const validDate = row.getValue("validDate") as Date
- return validDate ? (
- <div className="text-sm">
- {formatDate(validDate, "KR")}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
- {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="마감일" />
- ),
- cell: ({ row }) => {
- const dueDate = row.getValue("dueDate") as Date
- const isOverdue = dueDate && new Date(dueDate) < new Date()
-
- return dueDate ? (
- <div className={`${isOverdue ? 'text-red-600' : ''}`}>
- <div className="font-medium">{formatDate(dueDate, "KR")}</div>
- {isOverdue && (
- <div className="text-xs text-red-600">지연</div>
- )}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
- {
- accessorKey: "returnYn",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 회신여부" />
- ),
- cell: ({ row }) => {
- const returnFlag = row.getValue("returnYn") as boolean
- return returnFlag ? (
- <Badge variant="outline" className="text-xs">
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 70,
- },
- {
- accessorKey: "returnRevision",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="회신 리비전" />
- ),
- cell: ({ row }) => {
- const revision = row.getValue("returnRevision") as number
- return revision > 0 ? (
- <Badge variant="outline">
- Rev. {revision}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 80,
- },
-
- {
- accessorKey: "shortList",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Short List" />
- ),
- cell: ({ row }) => {
- const shortList = row.getValue("shortList") as boolean
- return shortList ? (
- <Badge variant="secondary" className="text-xs">
- <CheckCircle2 className="h-3 w-3 mr-1" />
- Yes
- </Badge>
- ) : (
- <span className="text-muted-foreground text-xs">-</span>
- )
- },
- size: 90,
- },
-
- /** ───────────── 등록/수정 정보 ───────────── */
- {
- 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 className="space-y-1">
- <div className="text-sm">{formatDate(created, "KR")}</div>
- {updated && new Date(updated) > new Date(created) && (
- <div className="text-xs text-blue-600">
- 수정: {formatDate(updated, "KR")}
- </div>
- )}
- </div>
- )
- },
- size: 120,
- },
-
- /** ───────────── 액션 ───────────── */
- {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- 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>
- <MessageSquare className="mr-2 h-4 w-4" />
- 벤더 응답 보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- {setRowAction && (
- <>
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- <Edit className="mr-2 h-4 w-4" />
- 수정
- </DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- <Trash className="mr-2 h-4 w-4" />
- 삭제
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- </>
- )}
-
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- },
- ]
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-table.tsx b/lib/b-rfq/initial/initial-rfq-detail-table.tsx
deleted file mode 100644
index 5ea6b0bf..00000000
--- a/lib/b-rfq/initial/initial-rfq-detail-table.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getInitialRfqDetail } from "../service" // 앞서 만든 서버 액션
-import {
- getInitialRfqDetailColumns,
- type DataTableRowAction
-} from "./initial-rfq-detail-columns"
-import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions"
-import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog"
-import { UpdateInitialRfqSheet } from "./update-initial-rfq-sheet"
-import { InitialRfqDetailView } from "@/db/schema"
-
-interface InitialRfqDetailTableProps {
- promises: Promise<Awaited<ReturnType<typeof getInitialRfqDetail>>>
- rfqId?: number
-}
-
-export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTableProps) {
- const { data, pageCount } = React.use(promises)
-
- // 선택된 상세 정보
- const [selectedDetail, setSelectedDetail] = React.useState<any>(null)
-
- // Row action 상태 (update/delete)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<InitialRfqDetailView> | null>(null)
-
- const columns = React.useMemo(
- () => getInitialRfqDetailColumns({
- onSelectDetail: setSelectedDetail,
- setRowAction: setRowAction
- }),
- []
- )
-
- /**
- * 필터 필드 정의
- */
- const filterFields: DataTableFilterField<any>[] = [
- {
- id: "rfqCode",
- label: "RFQ 코드",
- placeholder: "RFQ 코드로 검색...",
- },
- {
- id: "vendorName",
- label: "벤더명",
- placeholder: "벤더명으로 검색...",
- },
- {
- id: "rfqStatus",
- label: "RFQ 상태",
- options: [
- { label: "Draft", value: "DRAFT", count: 0 },
- { label: "문서 접수", value: "Doc. Received", count: 0 },
- { label: "담당자 배정", value: "PIC Assigned", count: 0 },
- { label: "문서 확정", value: "Doc. Confirmed", count: 0 },
- { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 },
- { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 },
- { label: "TBE 시작", value: "TBE started", count: 0 },
- { label: "TBE 완료", value: "TBE finished", count: 0 },
- { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 },
- { label: "견적 접수", value: "Quotation Received", count: 0 },
- { label: "벤더 선정", value: "Vendor Selected", count: 0 },
- ],
- },
- {
- id: "initialRfqStatus",
- label: "초기 RFQ 상태",
- options: [
- { label: "초안", value: "DRAFT", count: 0 },
- { label: "발송", value: "Init. RFQ Sent", count: 0 },
- { label: "응답", value: "Init. RFQ Answered", count: 0 },
- { label: "거절", value: "S/L Decline", count: 0 },
- ],
- },
- {
- id: "vendorCountry",
- label: "벤더 국가",
- options: [
- { label: "한국", value: "KR", count: 0 },
- { label: "중국", value: "CN", count: 0 },
- { label: "일본", value: "JP", count: 0 },
- { label: "미국", value: "US", count: 0 },
- { label: "독일", value: "DE", count: 0 },
- ],
- },
- ]
-
- /**
- * 고급 필터 필드
- */
- const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
- {
- id: "rfqCode",
- label: "RFQ 코드",
- type: "text",
- },
- {
- id: "vendorName",
- label: "벤더명",
- type: "text",
- },
- {
- id: "vendorCode",
- label: "벤더 코드",
- type: "text",
- },
- {
- id: "vendorCountry",
- label: "벤더 국가",
- type: "multi-select",
- options: [
- { label: "한국", value: "KR" },
- { label: "중국", value: "CN" },
- { label: "일본", value: "JP" },
- { label: "미국", value: "US" },
- { label: "독일", value: "DE" },
- ],
- },
- {
- id: "rfqStatus",
- label: "RFQ 상태",
- type: "multi-select",
- options: [
- { label: "Draft", value: "DRAFT" },
- { label: "문서 접수", value: "Doc. Received" },
- { label: "담당자 배정", value: "PIC Assigned" },
- { label: "문서 확정", value: "Doc. Confirmed" },
- { label: "초기 RFQ 발송", value: "Init. RFQ Sent" },
- { label: "초기 RFQ 응답", value: "Init. RFQ Answered" },
- { label: "TBE 시작", value: "TBE started" },
- { label: "TBE 완료", value: "TBE finished" },
- { label: "최종 RFQ 발송", value: "Final RFQ Sent" },
- { label: "견적 접수", value: "Quotation Received" },
- { label: "벤더 선정", value: "Vendor Selected" },
- ],
- },
- {
- id: "initialRfqStatus",
- label: "초기 RFQ 상태",
- type: "multi-select",
- options: [
- { label: "초안", value: "DRAFT" },
- { label: "발송", value: "Init. RFQ Sent" },
- { label: "응답", value: "Init. RFQ Answered" },
- { label: "거절", value: "S/L Decline" },
- ],
- },
- {
- id: "vendorBusinessSize",
- label: "벤더 규모",
- type: "multi-select",
- options: [
- { label: "대기업", value: "LARGE" },
- { label: "중기업", value: "MEDIUM" },
- { label: "소기업", value: "SMALL" },
- { label: "스타트업", value: "STARTUP" },
- ],
- },
- {
- id: "incotermsCode",
- label: "Incoterms",
- type: "text",
- },
- {
- id: "dueDate",
- label: "마감일",
- type: "date",
- },
- {
- id: "validDate",
- label: "유효일",
- type: "date",
- },
- {
- id: "shortList",
- label: "Short List",
- type: "boolean",
- },
- {
- id: "returnYn",
- label: "Return 여부",
- type: "boolean",
- },
- {
- id: "cpRequestYn",
- label: "CP Request 여부",
- type: "boolean",
- },
- {
- id: "prjectGtcYn",
- label: "Project GTC 여부",
- type: "boolean",
- },
- {
- id: "classification",
- label: "분류",
- type: "text",
- },
- {
- id: "sparepart",
- label: "예비부품",
- type: "text",
- },
- {
- 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.initialRfqId ? originalRow.initialRfqId.toString():"1",
- 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}
- >
- <InitialRfqDetailTableToolbarActions table={table} rfqId={rfqId} />
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
-
- {/* Update Sheet */}
- <UpdateInitialRfqSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- initialRfq={rowAction?.type === "update" ? rowAction.row.original : null}
- />
-
- {/* Delete Dialog */}
- <DeleteInitialRfqDialog
- open={rowAction?.type === "delete"}
- onOpenChange={() => setRowAction(null)}
- initialRfqs={rowAction?.type === "delete" ? [rowAction.row.original] : []}
- showTrigger={false}
- onSuccess={() => {
- setRowAction(null)
- // 테이블 리프레시는 revalidatePath로 자동 처리됨
- }}
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
deleted file mode 100644
index c26bda28..00000000
--- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
+++ /dev/null
@@ -1,287 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { useRouter } from "next/navigation"
-import { toast } from "sonner"
-import { Button } from "@/components/ui/button"
-import {
- Download,
- Mail,
- RefreshCw,
- Settings,
- Trash2,
- FileText,
- CheckCircle2,
- Loader
-} from "lucide-react"
-import { AddInitialRfqDialog } from "./add-initial-rfq-dialog"
-import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog"
-import { ShortListConfirmDialog } from "./short-list-confirm-dialog"
-import { InitialRfqDetailView } from "@/db/schema"
-import { sendBulkInitialRfqEmails } from "../service"
-
-interface InitialRfqDetailTableToolbarActionsProps {
- table: Table<InitialRfqDetailView>
- rfqId?: number
- onRefresh?: () => void // 데이터 새로고침 콜백
-}
-
-export function InitialRfqDetailTableToolbarActions({
- table,
- rfqId,
- onRefresh
-}: InitialRfqDetailTableToolbarActionsProps) {
- const router = useRouter()
-
- // 선택된 행들 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedDetails = selectedRows.map((row) => row.original)
- const selectedCount = selectedRows.length
-
- // 상태 관리
- const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
- const [showShortListDialog, setShowShortListDialog] = React.useState(false)
- const [isEmailSending, setIsEmailSending] = React.useState(false)
-
- // 전체 벤더 리스트 가져오기 (ShortList 확정용)
- const allVendors = table.getRowModel().rows.map(row => row.original)
-
-const handleBulkEmail = async () => {
- if (selectedCount === 0) return
-
- setIsEmailSending(true)
-
- try {
- const initialRfqIds = selectedDetails
- .map(detail => detail.initialRfqId)
- .filter((id): id is number => id !== null);
-
- if (initialRfqIds.length === 0) {
- toast.error("유효한 RFQ ID가 없습니다.")
- return
- }
-
- const result = await sendBulkInitialRfqEmails({
- initialRfqIds,
- language: "en" // 기본 영어, 필요시 사용자 설정으로 변경
- })
-
- if (result.success) {
- toast.success(result.message)
-
- // 에러가 있다면 별도 알림
- if (result.errors && result.errors.length > 0) {
- setTimeout(() => {
- toast.warning(`일부 오류 발생: ${result.errors?.join(', ')}`)
- }, 1000)
- }
-
- // 선택 해제
- table.toggleAllRowsSelected(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
- } else {
- toast.error(result.message || "RFQ 발송에 실패했습니다.")
- }
-
- } catch (error) {
- console.error("Email sending error:", error)
- toast.error("RFQ 발송 중 오류가 발생했습니다.")
- } finally {
- setIsEmailSending(false)
- }
- }
-
- const handleBulkDelete = () => {
- // DRAFT가 아닌 상태의 RFQ 확인
- const nonDraftRfqs = selectedDetails.filter(
- detail => detail.initialRfqStatus !== "DRAFT"
- )
-
- if (nonDraftRfqs.length > 0) {
- const statusMessages = {
- "Init. RFQ Sent": "이미 발송된",
- "S/L Decline": "Short List 거절 처리된",
- "Init. RFQ Answered": "답변 완료된"
- }
-
- const nonDraftStatuses = [...new Set(nonDraftRfqs.map(rfq => rfq.initialRfqStatus))]
- const statusText = nonDraftStatuses
- .map(status => statusMessages[status as keyof typeof statusMessages] || status)
- .join(", ")
-
- toast.error(
- `${statusText} RFQ는 삭제할 수 없습니다. DRAFT 상태의 RFQ만 삭제 가능합니다.`
- )
- return
- }
-
- setShowDeleteDialog(true)
- }
-
- // S/L 확정 버튼 클릭
- const handleSlConfirm = () => {
- if (!rfqId || allVendors.length === 0) {
- toast.error("S/L 확정할 벤더가 없습니다.")
- return
- }
-
- // 진행 가능한 상태 확인
- const validVendors = allVendors.filter(vendor =>
- vendor.initialRfqStatus === "Init. RFQ Answered" ||
- vendor.initialRfqStatus === "Init. RFQ Sent"
- )
-
- if (validVendors.length === 0) {
- toast.error("S/L 확정이 가능한 벤더가 없습니다. (RFQ 발송 또는 응답 완료된 벤더만 가능)")
- return
- }
-
- setShowShortListDialog(true)
- }
-
- // 초기 RFQ 추가 성공 시 처리
- const handleAddSuccess = () => {
- // 선택 해제
- table.toggleAllRowsSelected(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- } else {
- // fallback으로 페이지 새로고침
- setTimeout(() => {
- window.location.reload()
- }, 1000)
- }
- }
-
- // 삭제 성공 시 처리
- const handleDeleteSuccess = () => {
- // 선택 해제
- table.toggleAllRowsSelected(false)
- setShowDeleteDialog(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
- }
-
- // Short List 확정 성공 시 처리
- const handleShortListSuccess = () => {
- // 선택 해제
- table.toggleAllRowsSelected(false)
- setShowShortListDialog(false)
-
- // 데이터 새로고침
- if (onRefresh) {
- onRefresh()
- }
-
- // 최종 RFQ 페이지로 이동
- if (rfqId) {
- toast.success("Short List가 확정되었습니다. 최종 RFQ 페이지로 이동합니다.")
- setTimeout(() => {
- router.push(`/evcp/b-rfq/${rfqId}`)
- }, 1500)
- }
- }
-
- // 선택된 항목 중 첫 번째를 기본값으로 사용
- const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined
-
- const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT")
- const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length
-
- // S/L 확정 가능한 벤더 수
- const validForShortList = allVendors.filter(vendor =>
- vendor.initialRfqStatus === "Init. RFQ Answered" ||
- vendor.initialRfqStatus === "Init. RFQ Sent"
- ).length
-
- return (
- <>
- <div className="flex items-center gap-2">
- {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
- {selectedCount > 0 && (
- <>
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkEmail}
- className="h-8"
- disabled={isEmailSending}
- >
- {isEmailSending ? <Loader className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />}
- RFQ 발송 ({selectedCount})
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkDelete}
- className="h-8 text-red-600 hover:text-red-700"
- disabled={!canDelete || selectedCount === 0}
- title={!canDelete ? "DRAFT 상태의 RFQ만 삭제할 수 있습니다" : ""}
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제 ({draftCount}/{selectedCount})
- </Button>
- </>
- )}
-
- {/* S/L 확정 버튼 */}
- {rfqId && (
- <Button
- variant="default"
- size="sm"
- onClick={handleSlConfirm}
- className="h-8"
- disabled={validForShortList === 0}
- title={validForShortList === 0 ? "S/L 확정이 가능한 벤더가 없습니다" : `${validForShortList}개 벤더 중 Short List 선택`}
- >
- <CheckCircle2 className="mr-2 h-4 w-4" />
- S/L 확정 ({validForShortList})
- </Button>
- )}
-
- {/* 초기 RFQ 추가 버튼 */}
- {rfqId && (
- <AddInitialRfqDialog
- rfqId={rfqId}
- onSuccess={handleAddSuccess}
- defaultValues={defaultValues}
- />
- )}
- </div>
-
- {/* 삭제 다이얼로그 */}
- <DeleteInitialRfqDialog
- open={showDeleteDialog}
- onOpenChange={setShowDeleteDialog}
- initialRfqs={selectedDetails}
- showTrigger={false}
- onSuccess={handleDeleteSuccess}
- />
-
- {/* Short List 확정 다이얼로그 */}
- {rfqId && (
- <ShortListConfirmDialog
- open={showShortListDialog}
- onOpenChange={setShowShortListDialog}
- rfqId={rfqId}
- vendors={allVendors.filter(vendor =>
- vendor.initialRfqStatus === "Init. RFQ Answered" ||
- vendor.initialRfqStatus === "Init. RFQ Sent"
- )}
- onSuccess={handleShortListSuccess}
- />
- )}
- </>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/short-list-confirm-dialog.tsx b/lib/b-rfq/initial/short-list-confirm-dialog.tsx
deleted file mode 100644
index 92c62dc0..00000000
--- a/lib/b-rfq/initial/short-list-confirm-dialog.tsx
+++ /dev/null
@@ -1,269 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { z } from "zod"
-import { Loader2, Building, CheckCircle2, XCircle } from "lucide-react"
-
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-import { shortListConfirm } from "../service"
-import { InitialRfqDetailView } from "@/db/schema"
-
-const shortListSchema = z.object({
- selectedVendorIds: z.array(z.number()).min(1, "최소 1개 이상의 벤더를 선택해야 합니다."),
-})
-
-type ShortListFormData = z.infer<typeof shortListSchema>
-
-interface ShortListConfirmDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- rfqId: number
- vendors: InitialRfqDetailView[]
- onSuccess?: () => void
-}
-
-export function ShortListConfirmDialog({
- open,
- onOpenChange,
- rfqId,
- vendors,
- onSuccess
-}: ShortListConfirmDialogProps) {
- const [isLoading, setIsLoading] = React.useState(false)
-
- const form = useForm<ShortListFormData>({
- resolver: zodResolver(shortListSchema),
- defaultValues: {
- selectedVendorIds: vendors
- .filter(vendor => vendor.shortList === true)
- .map(vendor => vendor.vendorId)
- .filter(Boolean) as number[]
- },
- })
-
- const watchedSelectedIds = form.watch("selectedVendorIds")
-
- // 선택된/탈락된 벤더 계산
- const selectedVendors = vendors.filter(vendor =>
- vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId)
- )
- const rejectedVendors = vendors.filter(vendor =>
- vendor.vendorId && !watchedSelectedIds.includes(vendor.vendorId)
- )
-
- async function onSubmit(data: ShortListFormData) {
- if (!rfqId) return
-
- setIsLoading(true)
-
- try {
- const result = await shortListConfirm({
- rfqId,
- selectedVendorIds: data.selectedVendorIds,
- rejectedVendorIds: vendors
- .filter(v => v.vendorId && !data.selectedVendorIds.includes(v.vendorId))
- .map(v => v.vendorId!)
- })
-
- if (result.success) {
- toast.success(result.message)
- onOpenChange(false)
- form.reset()
- onSuccess?.()
- } else {
- toast.error(result.message || "Short List 확정에 실패했습니다.")
- }
- } catch (error) {
- console.error("Short List confirm error:", error)
- toast.error("Short List 확정 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleVendorToggle = (vendorId: number, checked: boolean) => {
- const currentSelected = form.getValues("selectedVendorIds")
-
- if (checked) {
- form.setValue("selectedVendorIds", [...currentSelected, vendorId])
- } else {
- form.setValue("selectedVendorIds", currentSelected.filter(id => id !== vendorId))
- }
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[80vh]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <CheckCircle2 className="h-5 w-5 text-green-600" />
- Short List 확정
- </DialogTitle>
- <DialogDescription>
- 최종 RFQ로 진행할 벤더를 선택해주세요. 선택되지 않은 벤더에게는 자동으로 Letter of Regret이 발송됩니다.
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- <FormField
- control={form.control}
- name="selectedVendorIds"
- render={() => (
- <FormItem>
- <FormLabel className="text-base font-semibold">
- 벤더 선택 ({vendors.length}개 업체)
- </FormLabel>
- <FormControl>
- <ScrollArea className="h-[400px] border rounded-md p-4">
- <div className="space-y-4">
- {vendors.map((vendor) => {
- const isSelected = vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId)
-
- return (
- <div
- key={vendor.vendorId}
- className={`flex items-start space-x-3 p-3 rounded-lg border transition-colors ${
- isSelected
- ? 'border-green-200 bg-green-50'
- : 'border-red-100 bg-red-50'
- }`}
- >
- <Checkbox
- checked={isSelected}
- onCheckedChange={(checked) =>
- vendor.vendorId && handleVendorToggle(vendor.vendorId, !!checked)
- }
- className="mt-1"
- />
- <div className="flex-1 space-y-2">
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium">{vendor.vendorName}</span>
- {isSelected ? (
- <Badge variant="secondary" className="bg-green-100 text-green-800">
- 선택됨
- </Badge>
- ) : (
- <Badge variant="secondary" className="bg-red-100 text-red-800">
- 탈락
- </Badge>
- )}
- </div>
- <div className="text-sm text-muted-foreground">
- <span className="font-mono">{vendor.vendorCode}</span>
- {vendor.vendorCountry && (
- <>
- <span className="mx-2">•</span>
- <span>{vendor.vendorCountry === "KR" ? "국내" : "해외"}</span>
- </>
- )}
- {vendor.vendorCategory && (
- <>
- <span className="mx-2">•</span>
- <span>{vendor.vendorCategory}</span>
- </>
- )}
- {vendor.vendorBusinessSize && (
- <>
- <span className="mx-2">•</span>
- <span>{vendor.vendorBusinessSize}</span>
- </>
- )}
- </div>
- <div className="text-xs text-muted-foreground">
- RFQ 상태: <Badge variant="outline" className="text-xs">
- {vendor.initialRfqStatus}
- </Badge>
- </div>
- </div>
- </div>
- )
- })}
- </div>
- </ScrollArea>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 요약 정보 */}
- <div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
- <div className="space-y-2">
- <div className="flex items-center gap-2 text-green-700">
- <CheckCircle2 className="h-4 w-4" />
- <span className="font-medium">선택된 벤더</span>
- </div>
- <div className="text-2xl font-bold text-green-700">
- {selectedVendors.length}개 업체
- </div>
- {selectedVendors.length > 0 && (
- <div className="text-sm text-muted-foreground">
- {selectedVendors.map(v => v.vendorName).join(", ")}
- </div>
- )}
- </div>
- <div className="space-y-2">
- <div className="flex items-center gap-2 text-red-700">
- <XCircle className="h-4 w-4" />
- <span className="font-medium">탈락 벤더</span>
- </div>
- <div className="text-2xl font-bold text-red-700">
- {rejectedVendors.length}개 업체
- </div>
- {rejectedVendors.length > 0 && (
- <div className="text-sm text-muted-foreground">
- Letter of Regret 발송 예정
- </div>
- )}
- </div>
- </div>
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isLoading}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isLoading || selectedVendors.length === 0}
- >
- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- Short List 확정
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx
deleted file mode 100644
index a19b5172..00000000
--- a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx
+++ /dev/null
@@ -1,496 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { CalendarIcon, Loader, ChevronsUpDown, Check } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Calendar } from "@/components/ui/calendar"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- } from "@/components/ui/command"
-import { Input } from "@/components/ui/input"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { UpdateInitialRfqSchema, updateInitialRfqSchema } from "../validations"
-import { getIncotermsForSelection, modifyInitialRfq } from "../service"
-import { InitialRfqDetailView } from "@/db/schema"
-
-interface UpdateInitialRfqSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- initialRfq: InitialRfqDetailView | null
-}
-
-interface Incoterm {
- id: number
- code: string
- description: string
-}
-
-export function UpdateInitialRfqSheet({ initialRfq, ...props }: UpdateInitialRfqSheetProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
- const [incotermsLoading, setIncotermsLoading] = React.useState(false)
- const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false)
-
- const loadIncoterms = React.useCallback(async () => {
- setIncotermsLoading(true)
- try {
- const incotermsList = await getIncotermsForSelection()
- setIncoterms(incotermsList)
- } catch (error) {
- console.error("Failed to load incoterms:", error)
- toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
- } finally {
- setIncotermsLoading(false)
- }
- }, [])
-
- React.useEffect(() => {
- if (incoterms.length === 0) {
- loadIncoterms()
- }
- }, [incoterms.length, loadIncoterms])
-
- const form = useForm<UpdateInitialRfqSchema>({
- resolver: zodResolver(updateInitialRfqSchema),
- defaultValues: {
- initialRfqStatus: initialRfq?.initialRfqStatus ?? "DRAFT",
- dueDate: initialRfq?.dueDate ?? new Date(),
- validDate: initialRfq?.validDate ?? undefined,
- incotermsCode: initialRfq?.incotermsCode ?? "",
- classification: initialRfq?.classification ?? "",
- sparepart: initialRfq?.sparepart ?? "",
- rfqRevision: initialRfq?.rfqRevision ?? 0,
- shortList: initialRfq?.shortList ?? false,
- returnYn: initialRfq?.returnYn ?? false,
- cpRequestYn: initialRfq?.cpRequestYn ?? false,
- prjectGtcYn: initialRfq?.prjectGtcYn ?? false,
- },
- })
-
- // initialRfq가 변경될 때 폼 값을 업데이트
- React.useEffect(() => {
- if (initialRfq) {
- form.reset({
- initialRfqStatus: initialRfq.initialRfqStatus ?? "DRAFT",
- dueDate: initialRfq.dueDate,
- validDate: initialRfq.validDate,
- incotermsCode: initialRfq.incotermsCode ?? "",
- classification: initialRfq.classification ?? "",
- sparepart: initialRfq.sparepart ?? "",
- shortList: initialRfq.shortList ?? false,
- returnYn: initialRfq.returnYn ?? false,
- rfqRevision: initialRfq.rfqRevision ?? 0,
- cpRequestYn: initialRfq.cpRequestYn ?? false,
- prjectGtcYn: initialRfq.prjectGtcYn ?? false,
- })
- }
- }, [initialRfq, form])
-
- function onSubmit(input: UpdateInitialRfqSchema) {
- startUpdateTransition(async () => {
- if (!initialRfq || !initialRfq.initialRfqId) {
- toast.error("유효하지 않은 RFQ입니다.")
- return
- }
-
- const { error } = await modifyInitialRfq({
- id: initialRfq.initialRfqId,
- ...input,
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- form.reset()
- props.onOpenChange?.(false)
- toast.success("초기 RFQ가 수정되었습니다")
- })
- }
-
- const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode"))
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col h-full sm:max-w-md">
- {/* 고정 헤더 */}
- <SheetHeader className="flex-shrink-0 text-left pb-6">
- <SheetTitle>초기 RFQ 수정</SheetTitle>
- <SheetDescription>
- 초기 RFQ 정보를 수정하고 변경사항을 저장하세요
- </SheetDescription>
- </SheetHeader>
-
- {/* 스크롤 가능한 폼 영역 */}
- <div className="flex-1 overflow-y-auto">
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4 pr-2"
- >
- {/* RFQ 리비전 */}
- <FormField
- control={form.control}
- name="rfqRevision"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ 리비전</FormLabel>
- <FormControl>
- <Input
- type="number"
- min="0"
- placeholder="0"
- {...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 마감일 */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>마감일 *</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant={"outline"}
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "PPP", { locale: ko })
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 유효일 */}
- <FormField
- control={form.control}
- name="validDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>유효일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant={"outline"}
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "PPP", { locale: ko })
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Incoterms 코드 */}
- <FormField
- control={form.control}
- name="incotermsCode"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>Incoterms</FormLabel>
- <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={incotermsSearchOpen}
- className="justify-between"
- disabled={incotermsLoading}
- >
- {selectedIncoterm ? (
- <div className="flex items-center gap-2">
- <span className="truncate">
- {selectedIncoterm.code} - {selectedIncoterm.description}
- </span>
- </div>
- ) : (
- <span className="text-muted-foreground">
- {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"}
- </span>
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0" align="start">
- <Command>
- <CommandInput
- placeholder="코드 또는 내용으로 검색..."
- className="h-9"
- />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {incoterms.map((incoterm) => (
- <CommandItem
- key={incoterm.id}
- value={`${incoterm.code} ${incoterm.description}`}
- onSelect={() => {
- field.onChange(incoterm.code)
- setIncotermsSearchOpen(false)
- }}
- >
- <div className="flex items-center gap-2 w-full">
- <div className="flex-1 min-w-0">
- <div className="font-medium truncate">
- {incoterm.code} - {incoterm.description}
- </div>
- </div>
- <Check
- className={cn(
- "ml-auto h-4 w-4",
- incoterm.code === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
- {/* 체크박스 옵션들 */}
- <div className="space-y-3">
- <FormField
- control={form.control}
- name="shortList"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>Short List</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="returnYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>회신 여부</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- {/* 선급 */}
- <FormField
- control={form.control}
- name="classification"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선급</FormLabel>
- <FormControl>
- <Input
- placeholder="선급"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 예비부품 */}
- <FormField
- control={form.control}
- name="sparepart"
- render={({ field }) => (
- <FormItem>
- <FormLabel>예비부품</FormLabel>
- <FormControl>
- <Input
- placeholder="O1, O2"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
-
-
-
- <FormField
- control={form.control}
- name="cpRequestYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>CP 요청</FormLabel>
- </div>
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="prjectGtcYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none ml-2">
- <FormLabel>프로젝트 GTC</FormLabel>
- </div>
- </FormItem>
- )}
- />
- </div>
-
- {/* 하단 여백 */}
- <div className="h-4" />
- </form>
- </Form>
- </div>
-
- {/* 고정 푸터 */}
- <SheetFooter className="flex-shrink-0 gap-2 pt-6 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- 취소
- </Button>
- </SheetClose>
- <Button
- onClick={form.handleSubmit(onSubmit)}
- disabled={isUpdatePending}
- >
- {isUpdatePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 저장
- </Button>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/repository.ts b/lib/b-rfq/repository.ts
deleted file mode 100644
index e69de29b..00000000
--- a/lib/b-rfq/repository.ts
+++ /dev/null
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts
deleted file mode 100644
index 896a082d..00000000
--- a/lib/b-rfq/service.ts
+++ /dev/null
@@ -1,2976 +0,0 @@
-'use server'
-
-import { revalidatePath, revalidateTag, unstable_cache, unstable_noStore } from "next/cache"
-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 {
- vendorResponseDetailView,
- attachmentRevisionHistoryView,
- rfqProgressSummaryView,
- vendorResponseAttachmentsEnhanced, Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors,
- vendorResponseAttachmentsB,
- finalRfq,
- finalRfqDetailView
-} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
-import { rfqDashboardView } from "@/db/schema" // 뷰 import
-import type { SQL } from "drizzle-orm"
-import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, ShortListConfirmInput, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema, shortListConfirmSchema, GetFinalRfqDetailSchema } from "./validations"
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { unlink } from "fs/promises"
-import { getErrorMessage } from "../handle-error"
-import { AddInitialRfqFormData } from "./initial/add-initial-rfq-dialog"
-import { sendEmail } from "../mail/sendEmail"
-import { RfqType } from "../rfqs/validations"
-
-const tag = {
- initialRfqDetail: "initial-rfq",
- 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) {
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- const rfqFilterMapping = createRFQFilterMapping();
- const joinedTables = getRFQJoinedTables();
-
- console.log(input, "견적 인풋")
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: rfqDashboardView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- joinedTables,
- customColumnMapping: rfqFilterMapping,
- });
- }
-
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: rfqDashboardView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- joinedTables,
- customColumnMapping: rfqFilterMapping,
- });
- }
-
- // 3) 글로벌 검색 조건
- 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);
- }
- }
-
-
-
- // 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) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(rfqDashboardView)
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- console.log(total)
-
- // 8) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof rfqDashboardView.$inferSelect;
- return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(rfqDashboardView.createdAt));
- }
-
- const rfqData = await db
- .select()
- .from(rfqDashboardView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: rfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getRFQDashboard:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-
-}
-
-// 헬퍼 함수들
-function createRFQFilterMapping() {
- return {
- // 뷰의 컬럼명과 실제 필터링할 컬럼 매핑
- rfqCode: rfqDashboardView.rfqCode,
- description: rfqDashboardView.description,
- status: rfqDashboardView.status,
- projectName: rfqDashboardView.projectName,
- projectCode: rfqDashboardView.projectCode,
- picName: rfqDashboardView.picName,
- packageNo: rfqDashboardView.packageNo,
- packageName: rfqDashboardView.packageName,
- dueDate: rfqDashboardView.dueDate,
- overallProgress: rfqDashboardView.overallProgress,
- createdAt: rfqDashboardView.createdAt,
- };
-}
-
-function getRFQJoinedTables() {
- return {
- // 조인된 테이블 정보 (뷰이므로 실제로는 사용되지 않을 수 있음)
- projects,
- users,
- };
-}
-
-// ================================================================
-// 3. RFQ Dashboard 타입 정의
-// ================================================================
-
-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" // 기본값
- }
-}
-
-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,
- })
-
-
-
- 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 getRfqAttachments(
- input: GetRfqAttachmentsSchema,
- rfqId: number
-) {
- 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 }
- }
-
-}
-
-// 첨부파일별 벤더 응답 통계 조회
-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)`,
- })
- .from(vendorAttachmentResponses)
- .where(inArray(vendorAttachmentResponses.attachmentId, attachmentIds))
- .groupBy(vendorAttachmentResponses.attachmentId)
-
- // 응답률 계산해서 객체로 변환
- 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
- }
- })
-
- return statsMap
- } catch (error) {
- console.error("getAttachmentResponseStats error:", error)
- return {}
- }
-}
-
-// 특정 첨부파일에 대한 벤더 응답 현황 상세 조회
-export async function getVendorResponsesForAttachment(
- attachmentId: number,
- rfqType: 'INITIAL' | 'FINAL' = 'INITIAL'
-) {
- try {
- // 1. 기본 벤더 응답 정보 가져오기 (첨부파일 정보와 조인)
- 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: bRfqsAttachments.currentRevision,
-
- // 벤더가 응답한 리비전
- respondedRevision: vendorAttachmentResponses.respondedRevision,
-
- responseComment: vendorAttachmentResponses.responseComment,
- vendorComment: vendorAttachmentResponses.vendorComment,
-
- // 새로 추가된 필드들
- revisionRequestComment: vendorAttachmentResponses.revisionRequestComment,
- revisionRequestedAt: vendorAttachmentResponses.revisionRequestedAt,
- requestedAt: vendorAttachmentResponses.requestedAt,
- respondedAt: vendorAttachmentResponses.respondedAt,
- updatedAt: vendorAttachmentResponses.updatedAt,
- })
- .from(vendorAttachmentResponses)
- .leftJoin(vendors, eq(vendorAttachmentResponses.vendorId, vendors.id))
- .leftJoin(bRfqsAttachments, eq(vendorAttachmentResponses.attachmentId, bRfqsAttachments.id))
- .where(
- and(
- eq(vendorAttachmentResponses.attachmentId, attachmentId),
- eq(vendorAttachmentResponses.rfqType, rfqType)
- )
- )
- .orderBy(vendors.vendorName);
-
- // 2. 각 응답에 대한 파일 정보 가져오기
- const responseIds = responses.map(r => r.id);
-
- let responseFiles: any[] = [];
- if (responseIds.length > 0) {
- responseFiles = await db
- .select({
- id: vendorResponseAttachmentsB.id,
- vendorResponseId: vendorResponseAttachmentsB.vendorResponseId,
- fileName: vendorResponseAttachmentsB.fileName,
- originalFileName: vendorResponseAttachmentsB.originalFileName,
- filePath: vendorResponseAttachmentsB.filePath,
- fileSize: vendorResponseAttachmentsB.fileSize,
- fileType: vendorResponseAttachmentsB.fileType,
- description: vendorResponseAttachmentsB.description,
- uploadedAt: vendorResponseAttachmentsB.uploadedAt,
- })
- .from(vendorResponseAttachmentsB)
- .where(inArray(vendorResponseAttachmentsB.vendorResponseId, responseIds))
- .orderBy(desc(vendorResponseAttachmentsB.uploadedAt));
- }
-
- // 3. 응답에 파일 정보 병합 및 리비전 상태 체크
- const enhancedResponses = responses.map(response => {
- const files = responseFiles.filter(file => file.vendorResponseId === response.id);
- const latestFile = files
- .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())[0] || null;
-
- // 벤더가 최신 리비전에 응답했는지 체크
- const isUpToDate = response.respondedRevision === response.currentRevision;
-
- return {
- ...response,
- files,
- totalFiles: files.length,
- latestFile,
- isUpToDate, // 최신 리비전 응답 여부
- };
- });
-
- return enhancedResponses;
- } catch (err) {
- console.error("getVendorResponsesForAttachment error:", err);
- return [];
- }
-}
-
-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))
-
-
- return {
- success: true,
- message: "문서가 확정되었습니다.",
- }
-
- } catch (error) {
- console.error("confirmDocuments error:", error)
- return {
- success: false,
- message: error instanceof Error ? error.message : "문서 확정 중 오류가 발생했습니다.",
- }
- }
-}
-
-// 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})`)
- }
- })
-
-
- 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 요청 중 오류가 발생했습니다.",
- }
- }
-}
-
-// 다음 시리얼 번호 생성
-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 }
- })
-
- 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 : "첨부파일 등록 중 오류가 발생했습니다.",
- }
- }
-}
-
-// 리비전 추가 (기존 첨부파일에 새 버전 추가)
-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 (!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;
- });
-
-
-
- return {
- success: true,
- message: `새 리비전(${newRevision.revisionNo})이 성공적으로 추가되었습니다.`,
- revision: newRevision,
- };
- } catch (error) {
- console.error('addRevisionToAttachment error:', error);
- return {
- success: false,
- message: error instanceof Error ? error.message : '리비전 추가 중 오류가 발생했습니다.',
- };
- }
-}
-
-// 특정 첨부파일의 모든 리비전 조회
-export async function getAttachmentRevisions(attachmentId: number) {
-
- 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: [],
- }
- }
-}
-
-
-// 첨부파일 삭제 (리비전 포함)
-export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
- try {
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
-
- 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,
- }
- })
-
-
- 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 : "첨부파일 삭제 중 오류가 발생했습니다.",
- }
- }
-}
-
-
-
-//Initial RFQ
-
-export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) {
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: initialRfqDetailView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
- }
-
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: initialRfqDetailView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- });
- }
-
- let rfqIdWhere: SQL<unknown> | undefined = undefined;
- if (rfqId) {
- rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId);
- }
-
-
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
-
- const validSearchConditions: SQL<unknown>[] = [];
-
- const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
-
- const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s);
- if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
-
- const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s);
- if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
-
- const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s);
- if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
-
- const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s);
- if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
-
- const classificationCondition = ilike(initialRfqDetailView.classification, s);
- if (classificationCondition) validSearchConditions.push(classificationCondition);
-
- const sparepartCondition = ilike(initialRfqDetailView.sparepart, s);
- if (sparepartCondition) validSearchConditions.push(sparepartCondition);
-
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
-
-
- // 5) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
-
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (rfqIdWhere) whereConditions.push(rfqIdWhere);
-
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 6) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(initialRfqDetailView)
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- console.log(totalResult);
- console.log(total);
-
- // 7) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect;
- return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(initialRfqDetailView.createdAt));
- }
-
- const initialRfqData = await db
- .select()
- .from(initialRfqDetailView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: initialRfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getInitialRfqDetail:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-}
-
-export async function getVendorsForSelection() {
- try {
- const vendorsData = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- taxId: vendors.taxId,
- country: vendors.country,
- status: vendors.status,
- })
- .from(vendors)
- // .where(
- // and(
- // ne(vendors.status, "BLACKLISTED"),
- // ne(vendors.status, "REJECTED")
- // )
- // )
- .orderBy(vendors.vendorName)
-
-
- return vendorsData.map(vendor => ({
- id: vendor.id,
- vendorName: vendor.vendorName || "",
- vendorCode: vendor.vendorCode || "",
- country: vendor.country || "",
- status: vendor.status,
- }))
- } catch (error) {
- console.log("Error fetching vendors:", error)
- throw new Error("Failed to fetch vendors")
- }
-}
-
-export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) {
- try {
- console.log('Incoming data:', data);
-
- const [newRecord] = await db
- .insert(initialRfq)
- .values({
- rfqId: data.rfqId,
- vendorId: data.vendorId,
- initialRfqStatus: data.initialRfqStatus,
- dueDate: data.dueDate,
- validDate: data.validDate,
- incotermsCode: data.incotermsCode,
- gtc: data.gtc,
- gtcValidDate: data.gtcValidDate,
- classification: data.classification,
- sparepart: data.sparepart,
- shortList: data.shortList,
- returnYn: data.returnYn,
- cpRequestYn: data.cpRequestYn,
- prjectGtcYn: data.prjectGtcYn,
- returnRevision: data.returnRevision,
- })
- .returning()
-
- return {
- success: true,
- message: "초기 RFQ가 성공적으로 추가되었습니다.",
- data: newRecord,
- }
- } catch (error) {
- console.error("Error adding initial RFQ:", error)
- return {
- success: false,
- message: "초기 RFQ 추가에 실패했습니다.",
- error,
- }
- }
-}
-
-export async function getIncotermsForSelection() {
- try {
- const incotermData = await db
- .select({
- code: incoterms.code,
- description: incoterms.description,
- })
- .from(incoterms)
- .orderBy(incoterms.code)
-
- return incotermData
-
- } catch (error) {
- console.error("Error fetching incoterms:", error)
- throw new Error("Failed to fetch incoterms")
- }
-}
-
-export async function removeInitialRfqs(input: RemoveInitialRfqsSchema) {
- unstable_noStore()
- try {
- const { ids } = removeInitialRfqsSchema.parse(input)
-
- await db.transaction(async (tx) => {
- await tx.delete(initialRfq).where(inArray(initialRfq.id, ids))
- })
-
-
- return {
- data: null,
- error: null,
- }
- } catch (err) {
- return {
- data: null,
- error: getErrorMessage(err),
- }
- }
-}
-
-interface ModifyInitialRfqInput extends UpdateInitialRfqSchema {
- id: number
-}
-
-export async function modifyInitialRfq(input: ModifyInitialRfqInput) {
- unstable_noStore()
- try {
- const { id, ...updateData } = input
-
- // validation
- updateInitialRfqSchema.parse(updateData)
-
- await db.transaction(async (tx) => {
- const existingRfq = await tx
- .select()
- .from(initialRfq)
- .where(eq(initialRfq.id, id))
- .limit(1)
-
- if (existingRfq.length === 0) {
- throw new Error("초기 RFQ를 찾을 수 없습니다.")
- }
-
- await tx
- .update(initialRfq)
- .set({
- ...updateData,
- // Convert empty strings to null for optional fields
- incotermsCode: updateData.incotermsCode || null,
- gtc: updateData.gtc || null,
- gtcValidDate: updateData.gtcValidDate || null,
- classification: updateData.classification || null,
- sparepart: updateData.sparepart || null,
- validDate: updateData.validDate || null,
- updatedAt: new Date(),
- })
- .where(eq(initialRfq.id, id))
- })
-
-
- return {
- data: null,
- error: null,
- }
- } catch (err) {
- return {
- data: null,
- error: getErrorMessage(err),
- }
- }
-}
-
-
-
-
-// 이메일 발송용 데이터 타입
-interface EmailData {
- rfqCode: string
- projectName: string
- projectCompany: string
- projectFlag: string
- projectSite: string
- classification: string
- incotermsCode: string
- incotermsDescription: string
- dueDate: string
- validDate: string
- sparepart: string
- vendorName: string
- picName: string
- picEmail: string
- warrantyPeriod: string
- packageName: string
- rfqRevision: number
- emailType: string
-}
-
-export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
- unstable_noStore()
- try {
-
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
-
- const { initialRfqIds, language } = bulkEmailSchema.parse(input)
-
- // 1. 선택된 초기 RFQ들의 상세 정보 조회
- const initialRfqDetails = await db
- .select({
- // initialRfqDetailView 필드들을 명시적으로 선택
- rfqId: initialRfqDetailView.rfqId,
- rfqCode: initialRfqDetailView.rfqCode,
- rfqStatus: initialRfqDetailView.rfqStatus,
- initialRfqId: initialRfqDetailView.initialRfqId,
- initialRfqStatus: initialRfqDetailView.initialRfqStatus,
- vendorId: initialRfqDetailView.vendorId,
- vendorCode: initialRfqDetailView.vendorCode,
- vendorName: initialRfqDetailView.vendorName,
- vendorCategory: initialRfqDetailView.vendorCategory,
- vendorCountry: initialRfqDetailView.vendorCountry,
- vendorBusinessSize: initialRfqDetailView.vendorBusinessSize,
- dueDate: initialRfqDetailView.dueDate,
- validDate: initialRfqDetailView.validDate,
- incotermsCode: initialRfqDetailView.incotermsCode,
- incotermsDescription: initialRfqDetailView.incotermsDescription,
- shortList: initialRfqDetailView.shortList,
- returnYn: initialRfqDetailView.returnYn,
- cpRequestYn: initialRfqDetailView.cpRequestYn,
- prjectGtcYn: initialRfqDetailView.prjectGtcYn,
- returnRevision: initialRfqDetailView.returnRevision,
- rfqRevision: initialRfqDetailView.rfqRevision,
- gtc: initialRfqDetailView.gtc,
- gtcValidDate: initialRfqDetailView.gtcValidDate,
- classification: initialRfqDetailView.classification,
- sparepart: initialRfqDetailView.sparepart,
- createdAt: initialRfqDetailView.createdAt,
- updatedAt: initialRfqDetailView.updatedAt,
- // bRfqs에서 추가로 필요한 필드들
- picName: bRfqs.picName,
- picCode: bRfqs.picCode,
- packageName: bRfqs.packageName,
- packageNo: bRfqs.packageNo,
- projectCompany: bRfqs.projectCompany,
- projectFlag: bRfqs.projectFlag,
- projectSite: bRfqs.projectSite,
- })
- .from(initialRfqDetailView)
- .leftJoin(bRfqs, eq(initialRfqDetailView.rfqId, bRfqs.id))
- .where(inArray(initialRfqDetailView.initialRfqId, initialRfqIds))
-
- if (initialRfqDetails.length === 0) {
- return {
- success: false,
- message: "선택된 초기 RFQ를 찾을 수 없습니다.",
- }
- }
-
- // 2. 각 RFQ에 대한 첨부파일 조회
- const rfqIds = [...new Set(initialRfqDetails.map(rfq => rfq.rfqId))].filter((id): id is number => id !== null)
- const attachments = await db
- .select()
- .from(bRfqsAttachments)
- .where(inArray(bRfqsAttachments.rfqId, rfqIds))
-
- // 3. 벤더 이메일 정보 조회 (모든 이메일 주소 포함)
- const vendorIds = [...new Set(initialRfqDetails.map(rfq => rfq.vendorId))].filter((id): id is number => id !== null)
- const vendorsWithAllEmails = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- email: vendors.email,
- representativeEmail: vendors.representativeEmail,
- // 연락처 이메일들을 JSON 배열로 집계
- contactEmails: sql<string[]>`
- COALESCE(
- (SELECT json_agg(contact_email)
- FROM vendor_contacts
- WHERE vendor_id = ${vendors.id}
- AND contact_email IS NOT NULL
- AND contact_email != ''
- ),
- '[]'::json
- )
- `.as("contact_emails")
- })
- .from(vendors)
- .where(inArray(vendors.id, vendorIds))
-
- // 각 벤더의 모든 유효한 이메일 주소를 정리하는 함수
- function getAllVendorEmails(vendor: typeof vendorsWithAllEmails[0]): string[] {
- const emails: string[] = []
-
- // 벤더 기본 이메일
- if (vendor.email) {
- emails.push(vendor.email)
- }
-
- // 대표자 이메일
- if (vendor.representativeEmail && vendor.representativeEmail !== vendor.email) {
- emails.push(vendor.representativeEmail)
- }
-
- // 연락처 이메일들
- if (vendor.contactEmails && Array.isArray(vendor.contactEmails)) {
- vendor.contactEmails.forEach(contactEmail => {
- if (contactEmail && !emails.includes(contactEmail)) {
- emails.push(contactEmail)
- }
- })
- }
-
- return emails.filter(email => email && email.trim() !== '')
- }
-
- const results = []
- const errors = []
-
- // 4. 각 초기 RFQ에 대해 처리
- for (const rfqDetail of initialRfqDetails) {
- try {
- // vendorId null 체크
- if (!rfqDetail.vendorId) {
- errors.push(`벤더 ID가 없습니다: RFQ ID ${rfqDetail.initialRfqId}`)
- continue
- }
-
- // 해당 RFQ의 첨부파일들
- const rfqAttachments = attachments.filter(att => att.rfqId === rfqDetail.rfqId)
-
- // 벤더 정보
- const vendor = vendorsWithAllEmails.find(v => v.id === rfqDetail.vendorId)
- if (!vendor) {
- errors.push(`벤더 정보를 찾을 수 없습니다: RFQ ID ${rfqDetail.initialRfqId}`)
- continue
- }
-
- // 해당 벤더의 모든 이메일 주소 수집
- const vendorEmails = getAllVendorEmails(vendor)
-
- if (vendorEmails.length === 0) {
- errors.push(`벤더 이메일 주소가 없습니다: ${vendor.vendorName}`)
- continue
- }
-
- // 5. 기존 vendorAttachmentResponses 조회하여 리비전 상태 확인
- const currentRfqRevision = rfqDetail.rfqRevision || 0
- let emailType: "NEW" | "RESEND" | "REVISION" = "NEW"
- let revisionToUse = currentRfqRevision
-
- // 첫 번째 첨부파일을 기준으로 기존 응답 조회 (리비전 상태 확인용)
- if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) {
- const existingResponses = await db
- .select()
- .from(vendorAttachmentResponses)
- .where(
- and(
- eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId),
- eq(vendorAttachmentResponses.rfqType, "INITIAL"),
- eq(vendorAttachmentResponses.rfqRecordId, rfqDetail.initialRfqId)
- )
- )
-
- if (existingResponses.length > 0) {
- // 기존 응답이 있음
- const existingRevision = parseInt(existingResponses[0].currentRevision?.replace("Rev.", "") || "0")
-
- if (currentRfqRevision > existingRevision) {
- // RFQ 리비전이 올라감 → 리비전 업데이트
- emailType = "REVISION"
- revisionToUse = currentRfqRevision
- } else {
- // 동일하거나 낮음 → 재전송
- emailType = "RESEND"
- revisionToUse = existingRevision
- }
- } else {
- // 기존 응답이 없음 → 신규 전송
- emailType = "NEW"
- revisionToUse = currentRfqRevision
- }
- }
-
- // 6. vendorAttachmentResponses 레코드 생성/업데이트
- for (const attachment of rfqAttachments) {
- const existingResponse = await db
- .select()
- .from(vendorAttachmentResponses)
- .where(
- and(
- eq(vendorAttachmentResponses.attachmentId, attachment.id),
- eq(vendorAttachmentResponses.vendorId, rfqDetail.vendorId),
- eq(vendorAttachmentResponses.rfqType, "INITIAL")
- )
- )
- .limit(1)
-
- if (existingResponse.length === 0) {
- // 새 응답 레코드 생성
- await db.insert(vendorAttachmentResponses).values({
- attachmentId: attachment.id,
- vendorId: rfqDetail.vendorId,
- rfqType: "INITIAL",
- rfqRecordId: rfqDetail.initialRfqId,
- responseStatus: "NOT_RESPONDED",
- currentRevision: `Rev.${revisionToUse}`,
- requestedAt: new Date(),
- })
- } else {
- // 기존 레코드 업데이트
- await db
- .update(vendorAttachmentResponses)
- .set({
- currentRevision: `Rev.${revisionToUse}`,
- requestedAt: new Date(),
- // 리비전 업데이트인 경우 응답 상태 초기화
- responseStatus: emailType === "REVISION" ? "NOT_RESPONDED" : existingResponse[0].responseStatus,
- })
- .where(eq(vendorAttachmentResponses.id, existingResponse[0].id))
- }
-
- }
-
- const formatDateSafely = (date: Date | string | null | undefined): string => {
- if (!date) return ""
- try {
- // Date 객체로 변환하고 포맷팅
- const dateObj = new Date(date)
- // 유효한 날짜인지 확인
- if (isNaN(dateObj.getTime())) return ""
-
- return dateObj.toLocaleDateString('en-US', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit'
- })
- } catch (error) {
- console.error("Date formatting error:", error)
- return ""
- }
- }
-
- // 7. 이메일 발송
- const emailData: EmailData = {
- name: vendor.vendorName,
- rfqCode: rfqDetail.rfqCode || "",
- projectName: rfqDetail.rfqCode || "", // 실제 프로젝트명이 있다면 사용
- projectCompany: rfqDetail.projectCompany || "",
- projectFlag: rfqDetail.projectFlag || "",
- projectSite: rfqDetail.projectSite || "",
- classification: rfqDetail.classification || "ABS",
- incotermsCode: rfqDetail.incotermsCode || "FOB",
- incotermsDescription: rfqDetail.incotermsDescription || "FOB Finland Port",
- dueDate: rfqDetail.dueDate ? formatDateSafely(rfqDetail.dueDate) : "",
- validDate: rfqDetail.validDate ? formatDateSafely(rfqDetail.validDate) : "",
- sparepart: rfqDetail.sparepart || "One(1) year operational spare parts",
- vendorName: vendor.vendorName,
- picName: session.user.name || rfqDetail.picName || "Procurement Manager",
- picEmail: session.user.email || "procurement@samsung.com",
- warrantyPeriod: "Refer to commercial package attached",
- packageName: rfqDetail.packageName || "",
- rfqRevision: revisionToUse, // 리비전 정보 추가
- emailType: emailType, // 이메일 타입 추가
- }
-
- // 이메일 제목 생성 (타입에 따라 다르게)
- let emailSubject = ""
- const revisionText = revisionToUse > 0 ? ` Rev.${revisionToUse}` : ""
-
- switch (emailType) {
- case "NEW":
- emailSubject = `[SHI RFQ] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
- break
- case "RESEND":
- emailSubject = `[SHI RFQ - RESEND] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
- break
- case "REVISION":
- emailSubject = `[SHI RFQ - REVISED] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
- break
- }
-
- // nodemailer로 모든 이메일 주소에 한번에 발송
- await sendEmail({
- to: vendorEmails.join(", "), // 콤마+공백으로 구분
- subject: emailSubject,
- template: "initial-rfq-invitation", // hbs 템플릿 파일명
- context: {
- ...emailData,
- language,
- }
- })
-
- // 8. 초기 RFQ 상태 업데이트 (리비전은 변경하지 않음 - 이미 DB에 저장된 값 사용)
- if (rfqDetail.initialRfqId && rfqDetail.rfqId) {
- // Promise.all로 두 테이블 동시 업데이트
- await Promise.all([
- // initialRfq 테이블 업데이트
- db
- .update(initialRfq)
- .set({
- initialRfqStatus: "Init. RFQ Sent",
- updatedAt: new Date(),
- })
- .where(eq(initialRfq.id, rfqDetail.initialRfqId)),
-
- // bRfqs 테이블 status도 함께 업데이트
- db
- .update(bRfqs)
- .set({
- status: "Init. RFQ Sent",
- // updatedBy: session.user.id,
- updatedAt: new Date(),
- })
- .where(eq(bRfqs.id, rfqDetail.rfqId))
- ]);
- }
-
- results.push({
- initialRfqId: rfqDetail.initialRfqId,
- vendorName: vendor.vendorName,
- vendorEmails: vendorEmails, // 발송된 모든 이메일 주소 기록
- emailCount: vendorEmails.length,
- emailType: emailType,
- rfqRevision: revisionToUse,
- success: true,
- })
-
- } catch (error) {
- console.error(`Error processing RFQ ${rfqDetail.initialRfqId}:`, error)
- errors.push(`RFQ ${rfqDetail.initialRfqId} 처리 중 오류: ${getErrorMessage(error)}`)
- }
- }
-
-
-
- return {
- success: true,
- message: `${results.length}개의 RFQ 이메일이 발송되었습니다.`,
- results,
- errors: errors.length > 0 ? errors : undefined,
- }
-
- } catch (err) {
- console.error("Bulk email error:", err)
- return {
- success: false,
- message: getErrorMessage(err),
- }
- }
-}
-
-// 개별 RFQ 이메일 재발송
-export async function resendInitialRfqEmail(initialRfqId: number) {
- unstable_noStore()
- try {
- const result = await sendBulkInitialRfqEmails({
- initialRfqIds: [initialRfqId],
- language: "en",
- })
-
- return result
- } catch (err) {
- return {
- success: false,
- message: getErrorMessage(err),
- }
- }
-}
-
-export type VendorResponseDetail = VendorAttachmentResponse & {
- attachment: {
- id: number;
- attachmentType: string;
- serialNo: string;
- description: string | null;
- currentRevision: string;
- };
- vendor: {
- id: number;
- vendorCode: string;
- vendorName: string;
- country: string | null;
- businessSize: string | null;
- };
- rfq: {
- id: number;
- rfqCode: string | null;
- description: string | null;
- status: string;
- dueDate: Date;
- };
-};
-
-export async function getVendorRfqResponses(input: GetVendorResponsesSchema, vendorId?: string, rfqId?: string) {
- try {
- // 페이지네이션 설정
- const page = input.page || 1;
- const perPage = input.perPage || 10;
- const offset = (page - 1) * perPage;
-
- // 기본 조건
- let whereConditions = [];
-
- // 벤더 ID 조건
- if (vendorId) {
- whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
- }
-
- // RFQ 타입 조건
- // if (input.rfqType !== "ALL") {
- // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType));
- // }
-
- // 날짜 범위 조건
- if (input.from && input.to) {
- whereConditions.push(
- and(
- gte(vendorAttachmentResponses.requestedAt, new Date(input.from)),
- lte(vendorAttachmentResponses.requestedAt, new Date(input.to))
- )
- );
- }
-
- const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 그룹핑된 응답 요약 데이터 조회
- const groupedResponses = await db
- .select({
- vendorId: vendorAttachmentResponses.vendorId,
- rfqRecordId: vendorAttachmentResponses.rfqRecordId,
- rfqType: vendorAttachmentResponses.rfqType,
-
- // 통계 계산 (조건부 COUNT 수정)
- totalAttachments: count(),
- respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
- revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
- waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
-
- // 날짜 정보
- requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`,
- lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`,
-
- // 코멘트 여부
- hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`,
- })
- .from(vendorAttachmentResponses)
- .where(baseWhere)
- .groupBy(
- vendorAttachmentResponses.vendorId,
- vendorAttachmentResponses.rfqRecordId,
- vendorAttachmentResponses.rfqType
- )
- .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`))
- .offset(offset)
- .limit(perPage);
-
- // 벤더 정보와 RFQ 정보를 별도로 조회
- const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))];
- const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))];
-
- // 벤더 정보 조회
- const vendorsData = await db.query.vendors.findMany({
- where: or(...vendorIds.map(id => eq(vendors.id, id))),
- columns: {
- id: true,
- vendorCode: true,
- vendorName: true,
- country: true,
- businessSize: true,
- }
- });
-
- // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두)
- const [initialRfqs] = await Promise.all([
- db.query.initialRfq.findMany({
- where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))),
- with: {
- rfq: {
- columns: {
- id: true,
- rfqCode: true,
- description: true,
- status: true,
- dueDate: true,
- }
- }
- }
- })
-
- ]);
-
- // 데이터 조합 및 변환
- const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => {
- const vendor = vendorsData.find(v => v.id === response.vendorId);
-
- let rfqInfo = null;
- if (response.rfqType === "INITIAL") {
- const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId);
- rfqInfo = initialRfq?.rfq || null;
- }
-
- // 응답률 계산
- const responseRate = Number(response.totalAttachments) > 0
- ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100)
- : 0;
-
- // 완료율 계산 (응답완료 + 포기)
- const completionRate = Number(response.totalAttachments) > 0
- ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100)
- : 0;
-
- // 전체 상태 결정
- let overallStatus: ResponseStatus = "NOT_RESPONDED";
- if (Number(response.revisionRequestedCount) > 0) {
- overallStatus = "REVISION_REQUESTED";
- } else if (completionRate === 100) {
- overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED";
- } else if (Number(response.respondedCount) > 0) {
- overallStatus = "RESPONDED"; // 부분 응답
- }
-
- return {
- id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`,
- vendorId: response.vendorId,
- rfqRecordId: response.rfqRecordId,
- rfqType: response.rfqType,
- rfq: rfqInfo,
- vendor: vendor || null,
- totalAttachments: Number(response.totalAttachments),
- respondedCount: Number(response.respondedCount),
- pendingCount: Number(response.pendingCount),
- revisionRequestedCount: Number(response.revisionRequestedCount),
- waivedCount: Number(response.waivedCount),
- responseRate,
- completionRate,
- overallStatus,
- requestedAt: response.requestedAt,
- lastRespondedAt: response.lastRespondedAt,
- hasComments: response.hasComments,
- };
- });
-
- // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식
- const totalCountResult = await db
- .select({
- totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))`
- })
- .from(vendorAttachmentResponses)
- .where(baseWhere);
-
- const totalCount = Number(totalCountResult[0].totalCount);
- const pageCount = Math.ceil(totalCount / perPage);
-
- return {
- data: transformedResponses,
- pageCount,
- totalCount
- };
-
- } catch (err) {
- console.error("getVendorRfqResponses 에러:", err);
- return { data: [], pageCount: 0, totalCount: 0 };
- }
-}
-/**
- * 특정 RFQ의 첨부파일별 응답 상세 조회 (상세 페이지용)
- */
-export async function getRfqAttachmentResponses(vendorId: string, rfqRecordId: string) {
- try {
- // 해당 RFQ의 모든 첨부파일 응답 조회
- const responses = await db.query.vendorAttachmentResponses.findMany({
- where: and(
- eq(vendorAttachmentResponses.vendorId, Number(vendorId)),
- eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)),
- ),
- with: {
- attachment: {
- with: {
- rfq: {
- columns: {
- id: true,
- rfqCode: true,
- description: true,
- status: true,
- dueDate: true,
- // 추가 정보
- picCode: true,
- picName: true,
- EngPicName: true,
- packageNo: true,
- packageName: true,
- projectId: true,
- projectCompany: true,
- projectFlag: true,
- projectSite: true,
- remark: true,
- },
- with: {
- project: {
- columns: {
- id: true,
- code: true,
- name: true,
- type: true,
- }
- }
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorCode: true,
- vendorName: true,
- country: true,
- businessSize: true,
- }
- },
- responseAttachments: true,
- },
- orderBy: [asc(vendorAttachmentResponses.attachmentId)]
- });
-
- return {
- data: responses,
- rfqInfo: responses[0]?.attachment?.rfq || null,
- vendorInfo: responses[0]?.vendor || null,
- };
-
- } catch (err) {
- console.error("getRfqAttachmentResponses 에러:", err);
- return { data: [], rfqInfo: null, vendorInfo: null };
- }
-}
-
-export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: string, rfqType?: RfqType) {
- try {
- const initial: Record<ResponseStatus, number> = {
- NOT_RESPONDED: 0,
- RESPONDED: 0,
- REVISION_REQUESTED: 0,
- WAIVED: 0,
- };
-
- // 조건 설정
- let whereConditions = [];
-
- // 벤더 ID 조건
- if (vendorId) {
- whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
- }
-
- // RFQ ID 조건
- if (rfqId) {
- const attachmentIds = await db
- .select({ id: bRfqsAttachments.id })
- .from(bRfqsAttachments)
- .where(eq(bRfqsAttachments.rfqId, Number(rfqId)));
-
- if (attachmentIds.length > 0) {
- whereConditions.push(
- or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
- );
- }
- }
-
- // RFQ 타입 조건
- if (rfqType) {
- whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
- }
-
- const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 상태별 그룹핑 쿼리
- const rows = await db
- .select({
- status: vendorAttachmentResponses.responseStatus,
- count: count(),
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition)
- .groupBy(vendorAttachmentResponses.responseStatus);
-
- // 결과 처리
- const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as ResponseStatus] = Number(count);
- }
- return acc;
- }, initial);
-
- return result;
- } catch (err) {
- console.error("getVendorResponseStatusCounts 에러:", err);
- return {} as Record<ResponseStatus, number>;
- }
-}
-
-/**
- * RFQ별 벤더 응답 요약 조회
- */
-export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) {
-
- try {
- // RFQ의 첨부파일 목록 조회 (relations 사용)
- const attachments = await db.query.bRfqsAttachments.findMany({
- where: eq(bRfqsAttachments.rfqId, Number(rfqId)),
- columns: {
- id: true,
- attachmentType: true,
- serialNo: true,
- description: true,
- }
- });
-
- if (attachments.length === 0) {
- return {
- totalAttachments: 0,
- totalVendors: 0,
- responseRate: 0,
- completionRate: 0,
- statusCounts: {} as Record<ResponseStatus, number>
- };
- }
-
- // 조건 설정
- let whereConditions = [
- or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
- ];
-
- if (rfqType) {
- whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
- }
-
- const whereCondition = and(...whereConditions);
-
- // 벤더 수 및 응답 통계 조회
- const [vendorStats, statusCounts] = await Promise.all([
- // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정)
- db
- .select({
- totalVendors: count(),
- respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition),
-
- // 상태별 개수
- db
- .select({
- status: vendorAttachmentResponses.responseStatus,
- count: count(),
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition)
- .groupBy(vendorAttachmentResponses.responseStatus)
- ]);
-
- const stats = vendorStats[0];
- const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as ResponseStatus] = Number(count);
- }
- return acc;
- }, {
- NOT_RESPONDED: 0,
- RESPONDED: 0,
- REVISION_REQUESTED: 0,
- WAIVED: 0,
- });
-
- const responseRate = stats.totalVendors > 0
- ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100)
- : 0;
-
- const completionRate = stats.totalVendors > 0
- ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100)
- : 0;
-
- return {
- totalAttachments: attachments.length,
- totalVendors: Number(stats.totalVendors),
- responseRate,
- completionRate,
- statusCounts: statusCountsMap
- };
-
- } catch (err) {
- console.error("getRfqResponseSummary 에러:", err);
- return {
- totalAttachments: 0,
- totalVendors: 0,
- responseRate: 0,
- completionRate: 0,
- statusCounts: {} as Record<ResponseStatus, number>
- };
- }
-}
-
-/**
- * 벤더별 응답 진행률 조회
- */
-export async function getVendorResponseProgress(vendorId: string) {
-
- try {
- let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))];
-
- const whereCondition = and(...whereConditions);
-
- const progress = await db
- .select({
- totalRequests: count(),
- responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
- revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
- waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition);
- console.log(progress, "progress")
-
- const stats = progress[0];
- const responseRate = Number(stats.totalRequests) > 0
- ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100)
- : 0;
-
- const completionRate = Number(stats.totalRequests) > 0
- ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100)
- : 0;
-
- return {
- totalRequests: Number(stats.totalRequests),
- responded: Number(stats.responded),
- pending: Number(stats.pending),
- revisionRequested: Number(stats.revisionRequested),
- waived: Number(stats.waived),
- responseRate,
- completionRate,
- };
-
- } catch (err) {
- console.error("getVendorResponseProgress 에러:", err);
- return {
- totalRequests: 0,
- responded: 0,
- pending: 0,
- revisionRequested: 0,
- waived: 0,
- responseRate: 0,
- completionRate: 0,
- };
- }
-}
-
-
-export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, rfqRecordId: string) {
- try {
- // 1. 벤더 응답 상세 정보 조회 (뷰 사용)
- const responses = await db
- .select()
- .from(vendorResponseDetailView)
- .where(
- and(
- eq(vendorResponseDetailView.vendorId, Number(vendorId)),
- eq(vendorResponseDetailView.rfqRecordId, Number(rfqRecordId))
- )
- )
- .orderBy(asc(vendorResponseDetailView.attachmentId));
-
- // 2. RFQ 진행 현황 요약 조회
- const progressSummaryResult = await db
- .select()
- .from(rfqProgressSummaryView)
- .where(eq(rfqProgressSummaryView.rfqId, responses[0]?.rfqId || 0))
- .limit(1);
-
- const progressSummary = progressSummaryResult[0] || null;
-
- // 3. 각 응답의 첨부파일 리비전 히스토리 조회
- const attachmentHistories = await Promise.all(
- responses.map(async (response) => {
- const history = await db
- .select()
- .from(attachmentRevisionHistoryView)
- .where(eq(attachmentRevisionHistoryView.attachmentId, response.attachmentId))
- .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
-
- return {
- attachmentId: response.attachmentId,
- revisions: history
- };
- })
- );
-
- // 4. 벤더 응답 파일들 조회 (향상된 정보 포함)
- const responseFiles = await Promise.all(
- responses.map(async (response) => {
- const files = await db
- .select()
- .from(vendorResponseAttachmentsEnhanced)
- .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, response.responseId))
- .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
-
- return {
- responseId: response.responseId,
- files: files
- };
- })
- );
-
- // 5. 데이터 변환 및 통합
- const enhancedResponses = responses.map(response => {
- const attachmentHistory = attachmentHistories.find(h => h.attachmentId === response.attachmentId);
- const responseFileData = responseFiles.find(f => f.responseId === response.responseId);
-
- return {
- ...response,
- // 첨부파일 정보에 리비전 히스토리 추가
- attachment: {
- id: response.attachmentId,
- attachmentType: response.attachmentType,
- serialNo: response.serialNo,
- description: response.attachmentDescription,
- currentRevision: response.currentRevision,
- // 모든 리비전 정보
- revisions: attachmentHistory?.revisions?.map(rev => ({
- id: rev.clientRevisionId,
- revisionNo: rev.clientRevisionNo,
- fileName: rev.clientFileName,
- originalFileName: rev.clientFileName,
- filePath: rev.clientFilePath, // 파일 경로 추가
- fileSize: rev.clientFileSize,
- revisionComment: rev.clientRevisionComment,
- createdAt: rev.clientRevisionCreatedAt?.toISOString() || new Date().toISOString(),
- isLatest: rev.isLatestClientRevision
- })) || []
- },
- // 벤더 응답 파일들
- responseAttachments: responseFileData?.files?.map(file => ({
- id: file.responseAttachmentId,
- fileName: file.fileName,
- originalFileName: file.originalFileName,
- filePath: file.filePath,
- fileSize: file.fileSize,
- description: file.description,
- uploadedAt: file.uploadedAt?.toISOString() || new Date().toISOString(),
- isLatestResponseFile: file.isLatestResponseFile,
- fileSequence: file.fileSequence
- })) || [],
- // 리비전 분석 정보
- isVersionMatched: response.isVersionMatched,
- versionLag: response.versionLag,
- needsUpdate: response.needsUpdate,
- hasMultipleRevisions: response.hasMultipleRevisions,
-
- // 새로 추가된 필드들
- revisionRequestComment: response.revisionRequestComment,
- revisionRequestedAt: response.revisionRequestedAt?.toISOString() || null,
- };
- });
-
- // RFQ 기본 정보 (첫 번째 응답에서 추출)
- const rfqInfo = responses[0] ? {
- id: responses[0].rfqId,
- rfqCode: responses[0].rfqCode,
- // 추가 정보는 기존 방식대로 별도 조회 필요
- description: "",
- dueDate: progressSummary?.dueDate || new Date(),
- status: progressSummary?.rfqStatus || "DRAFT",
- // ... 기타 필요한 정보들
- } : null;
-
- // 벤더 정보
- const vendorInfo = responses[0] ? {
- id: responses[0].vendorId,
- vendorCode: responses[0].vendorCode,
- vendorName: responses[0].vendorName,
- country: responses[0].vendorCountry,
- } : null;
-
- // 통계 정보 계산
- const calculateStats = (responses: typeof enhancedResponses) => {
- const total = responses.length;
- const responded = responses.filter(r => r.responseStatus === "RESPONDED").length;
- const pending = responses.filter(r => r.responseStatus === "NOT_RESPONDED").length;
- const revisionRequested = responses.filter(r => r.responseStatus === "REVISION_REQUESTED").length;
- const waived = responses.filter(r => r.responseStatus === "WAIVED").length;
- const versionMismatch = responses.filter(r => r.effectiveStatus === "VERSION_MISMATCH").length;
- const upToDate = responses.filter(r => r.effectiveStatus === "UP_TO_DATE").length;
-
- return {
- total,
- responded,
- pending,
- revisionRequested,
- waived,
- versionMismatch,
- upToDate,
- responseRate: total > 0 ? Math.round((responded / total) * 100) : 0,
- completionRate: total > 0 ? Math.round(((responded + waived) / total) * 100) : 0,
- versionMatchRate: responded > 0 ? Math.round((upToDate / responded) * 100) : 100
- };
- };
-
- const statistics = calculateStats(enhancedResponses);
-
- return {
- data: enhancedResponses,
- rfqInfo,
- vendorInfo,
- statistics,
- progressSummary: progressSummary ? {
- totalAttachments: progressSummary.totalAttachments,
- attachmentsWithMultipleRevisions: progressSummary.attachmentsWithMultipleRevisions,
- totalClientRevisions: progressSummary.totalClientRevisions,
- totalResponseFiles: progressSummary.totalResponseFiles,
- daysToDeadline: progressSummary.daysToDeadline
- } : null
- };
-
- } catch (err) {
- console.error("getRfqAttachmentResponsesWithRevisions 에러:", err);
- return {
- data: [],
- rfqInfo: null,
- vendorInfo: null,
- statistics: {
- total: 0,
- responded: 0,
- pending: 0,
- revisionRequested: 0,
- waived: 0,
- versionMismatch: 0,
- upToDate: 0,
- responseRate: 0,
- completionRate: 0,
- versionMatchRate: 100
- },
- progressSummary: null
- };
- }
-}
-
-// 첨부파일 리비전 히스토리 조회
-export async function getAttachmentRevisionHistory(attachmentId: number) {
-
- try {
- const history = await db
- .select()
- .from(attachmentRevisionHistoryView)
- .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId))
- .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
-
- return history;
- } catch (err) {
- console.error("getAttachmentRevisionHistory 에러:", err);
- return [];
- }
-}
-
-// RFQ 전체 진행 현황 조회
-export async function getRfqProgressSummary(rfqId: number) {
- try {
- const summaryResult = await db
- .select()
- .from(rfqProgressSummaryView)
- .where(eq(rfqProgressSummaryView.rfqId, rfqId))
- .limit(1);
-
- return summaryResult[0] || null;
- } catch (err) {
- console.error("getRfqProgressSummary 에러:", err);
- return null;
- }
-}
-
-// 벤더 응답 파일 상세 조회 (향상된 정보 포함)
-export async function getVendorResponseFiles(vendorResponseId: number) {
- try {
- const files = await db
- .select()
- .from(vendorResponseAttachmentsEnhanced)
- .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId))
- .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
-
- return files;
- } catch (err) {
- console.error("getVendorResponseFiles 에러:", err);
- return [];
- }
-}
-
-
-// 타입 정의 확장
-export type EnhancedVendorResponse = {
- // 기본 응답 정보
- responseId: number;
- rfqId: number;
- rfqCode: string;
- rfqType: "INITIAL" | "FINAL";
- rfqRecordId: number;
-
- // 첨부파일 정보
- attachmentId: number;
- attachmentType: string;
- serialNo: string;
- attachmentDescription?: string;
-
- // 벤더 정보
- vendorId: number;
- vendorCode: string;
- vendorName: string;
- vendorCountry: string;
-
- // 응답 상태
- responseStatus: "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED";
- currentRevision: string;
- respondedRevision?: string;
- effectiveStatus: string;
-
- // 코멘트 관련 필드들 (새로 추가된 필드 포함)
- responseComment?: string; // 벤더가 응답할 때 작성하는 코멘트
- vendorComment?: string; // 벤더 내부 메모
- revisionRequestComment?: string; // 발주처가 수정 요청할 때 작성하는 사유 (새로 추가)
-
- // 날짜 관련 필드들 (새로 추가된 필드 포함)
- requestedAt: string;
- respondedAt?: string;
- revisionRequestedAt?: string; // 수정 요청 날짜 (새로 추가)
-
- // 발주처 최신 리비전 정보
- latestClientRevisionNo?: string;
- latestClientFileName?: string;
- latestClientFileSize?: number;
- latestClientRevisionComment?: string;
-
- // 리비전 분석
- isVersionMatched: boolean;
- versionLag?: number;
- needsUpdate: boolean;
- hasMultipleRevisions: boolean;
-
- // 응답 파일 통계
- totalResponseFiles: number;
- latestResponseFileName?: string;
- latestResponseFileSize?: number;
- latestResponseUploadedAt?: string;
-
- // 첨부파일 정보 (리비전 히스토리 포함)
- attachment: {
- id: number;
- attachmentType: string;
- serialNo: string;
- description?: string;
- currentRevision: string;
- revisions: Array<{
- id: number;
- revisionNo: string;
- fileName: string;
- originalFileName: string;
- filePath?: string;
- fileSize?: number;
- revisionComment?: string;
- createdAt: string;
- isLatest: boolean;
- }>;
- };
-
- // 벤더 응답 파일들
- responseAttachments: Array<{
- id: number;
- fileName: string;
- originalFileName: string;
- filePath: string;
- fileSize?: number;
- description?: string;
- uploadedAt: string;
- isLatestResponseFile: boolean;
- fileSequence: number;
- }>;
-};
-
-
-export async function requestRevision(
- responseId: number,
- revisionReason: string
-): Promise<RequestRevisionResult> {
- try {
- // 입력값 검증
-
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.")
- }
- const validatedData = requestRevisionSchema.parse({
- responseId,
- revisionReason,
- });
-
- // 현재 응답 정보 조회
- const existingResponse = await db
- .select()
- .from(vendorAttachmentResponses)
- .where(eq(vendorAttachmentResponses.id, validatedData.responseId))
- .limit(1);
-
- if (existingResponse.length === 0) {
- return {
- success: false,
- message: "해당 응답을 찾을 수 없습니다",
- error: "NOT_FOUND",
- };
- }
-
- const response = existingResponse[0];
-
- // 응답 상태 확인 (이미 응답되었거나 포기된 상태에서만 수정 요청 가능)
- if (response.responseStatus !== "RESPONDED") {
- return {
- success: false,
- message: "응답된 상태의 항목에서만 수정을 요청할 수 있습니다",
- error: "INVALID_STATUS",
- };
- }
-
- // 응답 상태를 REVISION_REQUESTED로 업데이트
- const updateResult = await db
- .update(vendorAttachmentResponses)
- .set({
- responseStatus: "REVISION_REQUESTED",
- revisionRequestComment: validatedData.revisionReason, // 새로운 필드에 저장
- revisionRequestedAt: new Date(), // 수정 요청 시간 저장
- updatedAt: new Date(),
- updatedBy: Number(session.user.id),
- })
- .where(eq(vendorAttachmentResponses.id, validatedData.responseId))
- .returning();
-
- if (updateResult.length === 0) {
- return {
- success: false,
- message: "수정 요청 업데이트에 실패했습니다",
- error: "UPDATE_FAILED",
- };
- }
-
- return {
- success: true,
- message: "수정 요청이 성공적으로 전송되었습니다",
- };
-
- } catch (error) {
- console.error("Request revision server action error:", error);
- return {
- success: false,
- message: "내부 서버 오류가 발생했습니다",
- error: "INTERNAL_ERROR",
- };
- }
-}
-
-
-
-export async function shortListConfirm(input: ShortListConfirmInput) {
- try {
- const validatedInput = shortListConfirmSchema.parse(input)
- const { rfqId, selectedVendorIds, rejectedVendorIds } = validatedInput
-
- // 1. RFQ 정보 조회
- const rfqInfo = await db
- .select()
- .from(bRfqs)
- .where(eq(bRfqs.id, rfqId))
- .limit(1)
-
- if (!rfqInfo.length) {
- return { success: false, message: "RFQ를 찾을 수 없습니다." }
- }
-
- const rfq = rfqInfo[0]
-
- // 2. 기존 initial_rfq에서 필요한 정보 조회
- const initialRfqData = await db
- .select({
- id: initialRfq.id,
- vendorId: initialRfq.vendorId,
- dueDate: initialRfq.dueDate,
- validDate: initialRfq.validDate,
- incotermsCode: initialRfq.incotermsCode,
- gtc: initialRfq.gtc,
- gtcValidDate: initialRfq.gtcValidDate,
- classification: initialRfq.classification,
- sparepart: initialRfq.sparepart,
- cpRequestYn: initialRfq.cpRequestYn,
- prjectGtcYn: initialRfq.prjectGtcYn,
- returnRevision: initialRfq.returnRevision,
- })
- .from(initialRfq)
- .where(
- and(
- eq(initialRfq.rfqId, rfqId),
- inArray(initialRfq.vendorId, [...selectedVendorIds, ...rejectedVendorIds])
- )
- )
-
- if (!initialRfqData.length) {
- return { success: false, message: "해당 RFQ의 초기 RFQ 데이터를 찾을 수 없습니다." }
- }
-
- // 3. 탈락된 벤더들의 이메일 정보 조회
- let rejectedVendorEmails: Array<{
- vendorId: number
- vendorName: string
- email: string
- }> = []
-
- if (rejectedVendorIds.length > 0) {
- rejectedVendorEmails = await db
- .select({
- vendorId: vendors.id,
- vendorName: vendors.vendorName,
- email: vendors.email,
- })
- .from(vendors)
- .where(inArray(vendors.id, rejectedVendorIds))
- }
-
- await db.transaction(async (tx) => {
- // 4. 선택된 벤더들에 대해 final_rfq 테이블에 데이터 생성/업데이트
- for (const vendorId of selectedVendorIds) {
- const initialData = initialRfqData.find(data => data.vendorId === vendorId)
-
- if (initialData) {
- // 기존 final_rfq 레코드 확인
- const existingFinalRfq = await tx
- .select()
- .from(finalRfq)
- .where(
- and(
- eq(finalRfq.rfqId, rfqId),
- eq(finalRfq.vendorId, vendorId)
- )
- )
- .limit(1)
-
- if (existingFinalRfq.length > 0) {
- // 기존 레코드 업데이트
- await tx
- .update(finalRfq)
- .set({
- shortList: true,
- finalRfqStatus: "DRAFT",
- dueDate: initialData.dueDate,
- validDate: initialData.validDate,
- incotermsCode: initialData.incotermsCode,
- gtc: initialData.gtc,
- gtcValidDate: initialData.gtcValidDate,
- classification: initialData.classification,
- sparepart: initialData.sparepart,
- cpRequestYn: initialData.cpRequestYn,
- prjectGtcYn: initialData.prjectGtcYn,
- updatedAt: new Date(),
- })
- .where(eq(finalRfq.id, existingFinalRfq[0].id))
- } else {
- // 새 레코드 생성
- await tx
- .insert(finalRfq)
- .values({
- rfqId,
- vendorId,
- finalRfqStatus: "DRAFT",
- dueDate: initialData.dueDate,
- validDate: initialData.validDate,
- incotermsCode: initialData.incotermsCode,
- gtc: initialData.gtc,
- gtcValidDate: initialData.gtcValidDate,
- classification: initialData.classification,
- sparepart: initialData.sparepart,
- shortList: true,
- returnYn: false,
- cpRequestYn: initialData.cpRequestYn,
- prjectGtcYn: initialData.prjectGtcYn,
- returnRevision: 0,
- currency: "KRW",
- taxCode: "VV",
- deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
- firsttimeYn: true,
- materialPriceRelatedYn: false,
- })
- }
- }
- }
-
- // 5. 탈락된 벤더들에 대해서는 shortList: false로 설정 (있다면)
- if (rejectedVendorIds.length > 0) {
- // 기존에 final_rfq에 있는 탈락 벤더들은 shortList를 false로 업데이트
- await tx
- .update(finalRfq)
- .set({
- shortList: false,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(finalRfq.rfqId, rfqId),
- inArray(finalRfq.vendorId, rejectedVendorIds)
- )
- )
- }
-
- // 6. initial_rfq의 shortList 필드도 업데이트
- if (selectedVendorIds.length > 0) {
- await tx
- .update(initialRfq)
- .set({
- shortList: true,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(initialRfq.rfqId, rfqId),
- inArray(initialRfq.vendorId, selectedVendorIds)
- )
- )
- }
-
- if (rejectedVendorIds.length > 0) {
- await tx
- .update(initialRfq)
- .set({
- shortList: false,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(initialRfq.rfqId, rfqId),
- inArray(initialRfq.vendorId, rejectedVendorIds)
- )
- )
- }
- })
-
- // 7. 탈락된 벤더들에게 Letter of Regret 이메일 발송
- const emailErrors: string[] = []
-
- for (const rejectedVendor of rejectedVendorEmails) {
- if (rejectedVendor.email) {
- try {
- await sendEmail({
- to: rejectedVendor.email,
- subject: `Letter of Regret - RFQ ${rfq.rfqCode}`,
- template: "letter-of-regret",
- context: {
- rfqCode: rfq.rfqCode,
- vendorName: rejectedVendor.vendorName,
- projectTitle: rfq.projectTitle || "Project",
- dateTime: new Date().toLocaleDateString("ko-KR", {
- year: "numeric",
- month: "long",
- day: "numeric",
- }),
- companyName: "Your Company Name", // 실제 회사명으로 변경
- language: "ko",
- },
- })
- } catch (error) {
- console.error(`Email sending failed for vendor ${rejectedVendor.vendorName}:`, error)
- emailErrors.push(`${rejectedVendor.vendorName}에게 이메일 발송 실패`)
- }
- }
- }
-
- // 8. 페이지 revalidation
- revalidatePath(`/evcp/a-rfq/${rfqId}`)
- revalidatePath(`/evcp/b-rfq/${rfqId}`)
-
- const successMessage = `Short List가 확정되었습니다. (선택: ${selectedVendorIds.length}개, 탈락: ${rejectedVendorIds.length}개)`
-
- return {
- success: true,
- message: successMessage,
- errors: emailErrors.length > 0 ? emailErrors : undefined,
- data: {
- selectedCount: selectedVendorIds.length,
- rejectedCount: rejectedVendorIds.length,
- emailsSent: rejectedVendorEmails.length - emailErrors.length,
- },
- }
-
- } catch (error) {
- console.error("Short List confirm error:", error)
- return {
- success: false,
- message: "Short List 확정 중 오류가 발생했습니다.",
- }
- }
-}
-
-export async function getFinalRfqDetail(input: GetFinalRfqDetailSchema, rfqId?: number) {
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: finalRfqDetailView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
- }
-
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: finalRfqDetailView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- });
- }
-
- let rfqIdWhere: SQL<unknown> | undefined = undefined;
- if (rfqId) {
- rfqIdWhere = eq(finalRfqDetailView.rfqId, rfqId);
- }
-
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
-
- const validSearchConditions: SQL<unknown>[] = [];
-
- const rfqCodeCondition = ilike(finalRfqDetailView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
-
- const vendorNameCondition = ilike(finalRfqDetailView.vendorName, s);
- if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
-
- const vendorCodeCondition = ilike(finalRfqDetailView.vendorCode, s);
- if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
-
- const vendorCountryCondition = ilike(finalRfqDetailView.vendorCountry, s);
- if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
-
- const incotermsDescriptionCondition = ilike(finalRfqDetailView.incotermsDescription, s);
- if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
-
- const paymentTermsDescriptionCondition = ilike(finalRfqDetailView.paymentTermsDescription, s);
- if (paymentTermsDescriptionCondition) validSearchConditions.push(paymentTermsDescriptionCondition);
-
- const classificationCondition = ilike(finalRfqDetailView.classification, s);
- if (classificationCondition) validSearchConditions.push(classificationCondition);
-
- const sparepartCondition = ilike(finalRfqDetailView.sparepart, s);
- if (sparepartCondition) validSearchConditions.push(sparepartCondition);
-
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
-
- // 5) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
-
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (rfqIdWhere) whereConditions.push(rfqIdWhere);
-
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 6) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(finalRfqDetailView)
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- console.log(totalResult);
- console.log(total);
-
- // 7) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof finalRfqDetailView.$inferSelect;
- return sort.desc ? desc(finalRfqDetailView[column]) : asc(finalRfqDetailView[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(finalRfqDetailView.createdAt));
- }
-
- const finalRfqData = await db
- .select()
- .from(finalRfqDetailView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: finalRfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getFinalRfqDetail:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-} \ No newline at end of file
diff --git a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx
deleted file mode 100644
index 2333d9cf..00000000
--- a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx
+++ /dev/null
@@ -1,523 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { format } from "date-fns"
-import { CalendarIcon, Plus, Loader2, Eye } from "lucide-react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Calendar } from "@/components/ui/calendar"
-import { Badge } from "@/components/ui/badge"
-import { cn } from "@/lib/utils"
-import { toast } from "sonner"
-import { ProjectSelector } from "@/components/ProjectSelector"
-import { createRfqAction, previewNextRfqCode } from "../service"
-
-export type Project = {
- id: number;
- projectCode: string;
- projectName: string;
-}
-
-// 클라이언트 폼 스키마 (projectId 필수로 변경)
-const createRfqSchema = z.object({
- projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경
- dueDate: z.date({
- required_error: "마감일을 선택해주세요",
- }),
- picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"),
- picName: z.string().optional(),
- engPicName: z.string().optional(),
- packageNo: z.string().min(1, "패키지 번호를 입력해주세요"),
- packageName: z.string().min(1, "패키지명을 입력해주세요"),
- remark: z.string().optional(),
- projectCompany: z.string().optional(),
- projectFlag: z.string().optional(),
- projectSite: z.string().optional(),
-})
-
-type CreateRfqFormValues = z.infer<typeof createRfqSchema>
-
-interface CreateRfqDialogProps {
- onSuccess?: () => void;
-}
-
-export function CreateRfqDialog({ onSuccess }: CreateRfqDialogProps) {
- const [open, setOpen] = React.useState(false)
- const [isLoading, setIsLoading] = React.useState(false)
- const [previewCode, setPreviewCode] = React.useState<string>("")
- const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
- const router = useRouter()
- const { data: session } = useSession()
-
- const userId = React.useMemo(() => {
- return session?.user?.id ? Number(session.user.id) : null;
- }, [session]);
-
- const form = useForm<CreateRfqFormValues>({
- resolver: zodResolver(createRfqSchema),
- defaultValues: {
- projectId: undefined,
- dueDate: undefined,
- picCode: "",
- picName: "",
- engPicName: "",
- packageNo: "",
- packageName: "",
- remark: "",
- projectCompany: "",
- projectFlag: "",
- projectSite: "",
- },
- })
-
- // picCode 변경 시 미리보기 업데이트
- const watchedPicCode = form.watch("picCode")
-
- React.useEffect(() => {
- if (watchedPicCode && watchedPicCode.length > 0) {
- setIsLoadingPreview(true)
- const timer = setTimeout(async () => {
- try {
- const preview = await previewNextRfqCode(watchedPicCode)
- setPreviewCode(preview)
- } catch (error) {
- console.error("미리보기 오류:", error)
- setPreviewCode("")
- } finally {
- setIsLoadingPreview(false)
- }
- }, 500) // 500ms 디바운스
-
- return () => clearTimeout(timer)
- } else {
- setPreviewCode("")
- }
- }, [watchedPicCode])
-
- // 다이얼로그 열림/닫힘 처리 및 폼 리셋
- const handleOpenChange = (newOpen: boolean) => {
- setOpen(newOpen)
-
- // 다이얼로그가 닫힐 때 폼과 상태 초기화
- if (!newOpen) {
- form.reset()
- setPreviewCode("")
- setIsLoadingPreview(false)
- }
- }
-
- const handleCancel = () => {
- form.reset()
- setOpen(false)
- }
-
-
- const onSubmit = async (data: CreateRfqFormValues) => {
- if (!userId) {
- toast.error("로그인이 필요합니다")
- return
- }
-
- setIsLoading(true)
-
- try {
- // 서버 액션 호출 - Date 객체를 직접 전달
- const result = await createRfqAction({
- projectId: data.projectId, // 이제 항상 값이 있음
- dueDate: data.dueDate, // Date 객체 직접 전달
- picCode: data.picCode,
- picName: data.picName || "",
- engPicName: data.engPicName || "",
- packageNo: data.packageNo,
- packageName: data.packageName,
- remark: data.remark || "",
- projectCompany: data.projectCompany || "",
- projectFlag: data.projectFlag || "",
- projectSite: data.projectSite || "",
- createdBy: userId,
- updatedBy: userId,
- })
-
- if (result.success) {
- toast.success(result.message, {
- description: `RFQ 코드: ${result.data?.rfqCode}`,
- })
-
- // 다이얼로그 닫기 (handleOpenChange에서 리셋 처리됨)
- setOpen(false)
-
- // 성공 콜백 실행
- if (onSuccess) {
- onSuccess()
- }
-
- } else {
- toast.error(result.error || "RFQ 생성에 실패했습니다")
- }
-
- } catch (error) {
- console.error('RFQ 생성 오류:', error)
- toast.error("RFQ 생성에 실패했습니다", {
- description: "알 수 없는 오류가 발생했습니다",
- })
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleProjectSelect = (project: Project | null) => {
- if (project === null) {
- form.setValue("projectId", undefined as any); // 타입 에러 방지
- return;
- }
- form.setValue("projectId", project.id);
- };
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- <Button size="sm" variant="outline">
- <Plus className="mr-2 h-4 w-4" />
- 새 RFQ
- </Button>
- </DialogTrigger>
- <DialogContent className="max-w-3xl h-[90vh] flex flex-col">
- {/* 고정된 헤더 */}
- <DialogHeader className="flex-shrink-0">
- <DialogTitle>새 RFQ 생성</DialogTitle>
- <DialogDescription>
- 새로운 RFQ를 생성합니다. 필수 정보를 입력해주세요.
- </DialogDescription>
- </DialogHeader>
-
- {/* 스크롤 가능한 컨텐츠 영역 */}
- <div className="flex-1 overflow-y-auto px-1">
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2">
-
- {/* 프로젝트 선택 (필수) */}
- <FormField
- control={form.control}
- name="projectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 프로젝트 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <ProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트 선택..."
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 마감일 (필수) */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>
- 마감일 <span className="text-red-500">*</span>
- </FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "yyyy-MM-dd")
- ) : (
- <span>마감일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 구매 담당자 코드 (필수) + 미리보기 */}
- <FormField
- control={form.control}
- name="picCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 구매 담당자 코드 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <div className="space-y-2">
- <Input
- placeholder="예: P001, P002, MGR01 등"
- {...field}
- />
- {/* RFQ 코드 미리보기 */}
- {previewCode && (
- <div className="flex items-center gap-2 p-2 bg-muted rounded-md">
- <Eye className="h-4 w-4 text-muted-foreground" />
- <span className="text-sm text-muted-foreground">
- 생성될 RFQ 코드:
- </span>
- <Badge variant="outline" className="font-mono">
- {isLoadingPreview ? "생성 중..." : previewCode}
- </Badge>
- </div>
- )}
- </div>
- </FormControl>
- <FormDescription>
- RFQ 코드는 N + 담당자코드 + 시리얼번호(5자리) 형식으로 자동 생성됩니다
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 담당자 정보 (두 개 나란히) */}
- <div className="space-y-3">
- <h4 className="text-sm font-medium">담당자 정보</h4>
- <div className="grid grid-cols-2 gap-4">
- {/* 구매 담당자 */}
- <FormField
- control={form.control}
- name="picName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구매 담당자명</FormLabel>
- <FormControl>
- <Input
- placeholder="구매 담당자명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 설계 담당자 */}
- <FormField
- control={form.control}
- name="engPicName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설계 담당자명</FormLabel>
- <FormControl>
- <Input
- placeholder="설계 담당자명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* 패키지 정보 (두 개 나란히) - 필수 */}
- <div className="space-y-3">
- <h4 className="text-sm font-medium">패키지 정보</h4>
- <div className="grid grid-cols-2 gap-4">
- {/* 패키지 번호 (필수) */}
- <FormField
- control={form.control}
- name="packageNo"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 패키지 번호 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="패키지 번호"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 패키지명 (필수) */}
- <FormField
- control={form.control}
- name="packageName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 패키지명 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="패키지명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* 프로젝트 상세 정보 */}
- <div className="space-y-3">
- <h4 className="text-sm font-medium">프로젝트 상세 정보</h4>
- <div className="grid grid-cols-1 gap-3">
- <FormField
- control={form.control}
- name="projectCompany"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트 회사</FormLabel>
- <FormControl>
- <Input
- placeholder="프로젝트 회사명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-3">
- <FormField
- control={form.control}
- name="projectFlag"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트 플래그</FormLabel>
- <FormControl>
- <Input
- placeholder="프로젝트 플래그"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="projectSite"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트 사이트</FormLabel>
- <FormControl>
- <Input
- placeholder="프로젝트 사이트"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
- </div>
-
- {/* 비고 */}
- <FormField
- control={form.control}
- name="remark"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea
- placeholder="추가 비고사항을 입력하세요"
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </form>
- </Form>
- </div>
-
- {/* 고정된 푸터 */}
- <DialogFooter className="flex-shrink-0">
- <Button
- type="button"
- variant="outline"
- onClick={handleCancel}
- disabled={isLoading}
- >
- 취소
- </Button>
- <Button
- type="submit"
- onClick={form.handleSubmit(onSubmit)}
- disabled={isLoading}
- >
- {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {isLoading ? "생성 중..." : "RFQ 생성"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ 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
deleted file mode 100644
index af5c22b2..00000000
--- a/lib/b-rfq/summary-table/summary-rfq-columns.tsx
+++ /dev/null
@@ -1,499 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, Eye, Calendar, AlertTriangle, CheckCircle2, Clock, FileText } from "lucide-react"
-
-import { formatDate, cn } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import { Progress } from "@/components/ui/progress"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { useRouter } from "next/navigation"
-import { RfqDashboardView } from "@/db/schema"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetRFQColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqDashboardView> | null>>;
- router: NextRouter;
-}
-
-// 상태에 따른 Badge 변형 결정 함수
-function getStatusBadge(status: string) {
- switch (status) {
- case "DRAFT":
- return { variant: "outline" as const, label: "초안" };
- case "Doc. Received":
- return { variant: "secondary" as const, label: "문서접수" };
- case "PIC Assigned":
- return { variant: "secondary" as const, label: "담당자배정" };
- case "Doc. Confirmed":
- return { variant: "default" as const, label: "문서확정" };
- case "Init. RFQ Sent":
- return { variant: "default" as const, label: "초기RFQ발송" };
- case "Init. RFQ Answered":
- return { variant: "default" as const, label: "초기RFQ회신" };
- case "TBE started":
- return { variant: "secondary" as const, label: "TBE시작" };
- case "TBE finished":
- return { variant: "secondary" as const, label: "TBE완료" };
- case "Final RFQ Sent":
- return { variant: "default" as const, label: "최종RFQ발송" };
- case "Quotation Received":
- return { variant: "default" as const, label: "견적접수" };
- case "Vendor Selected":
- return { variant: "success" as const, label: "업체선정" };
- default:
- return { variant: "outline" as const, label: status };
- }
-}
-
-function getProgressBadge(progress: number) {
- if (progress >= 100) {
- return { variant: "success" as const, label: "완료" };
- } else if (progress >= 70) {
- return { variant: "default" as const, label: "진행중" };
- } else if (progress >= 30) {
- return { variant: "secondary" as const, label: "초기진행" };
- } else {
- return { variant: "outline" as const, label: "시작" };
- }
-}
-
-function getUrgencyLevel(daysToDeadline: number): "high" | "medium" | "low" {
- if (daysToDeadline <= 3) return "high";
- if (daysToDeadline <= 7) return "medium";
- return "low";
-}
-
-export function getRFQColumns({ setRowAction, router }: GetRFQColumnsProps): ColumnDef<RfqDashboardView>[] {
-
- // Select 컬럼
- const selectColumn: ColumnDef<RfqDashboardView> = {
- 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,
- };
-
- // RFQ 코드 컬럼
- const rfqCodeColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />
- ),
- cell: ({ row }) => (
- <div className="flex flex-col">
- <span className="font-medium">{row.getValue("rfqCode")}</span>
- {row.original.description && (
- <span className="text-xs text-muted-foreground truncate max-w-[200px]">
- {row.original.description}
- </span>
- )}
- </div>
- ),
- };
-
- // 프로젝트 정보 컬럼
- const projectColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "projectName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트" />
- ),
- cell: ({ row }) => {
- const projectName = row.original.projectName;
- const projectCode = row.original.projectCode;
-
- if (!projectName) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <span className="font-medium">{projectName}</span>
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
- {projectCode && <span>{projectCode}</span>}
- </div>
- </div>
- );
- },
- };
-
- // 패키지 정보 컬럼
- const packageColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "packageNo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="패키지" />
- ),
- cell: ({ row }) => {
- const packageNo = row.original.packageNo;
- const packageName = row.original.packageName;
-
- if (!packageNo) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <span className="font-medium">{packageNo}</span>
- {packageName && (
- <span className="text-xs text-muted-foreground truncate max-w-[150px]">
- {packageName}
- </span>
- )}
- </div>
- );
- },
- };
-
- const updatedColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "updatedBy",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Updated By" />
- ),
- cell: ({ row }) => {
- const updatedByName = row.original.updatedByName;
- const updatedByEmail = row.original.updatedByEmail;
-
- if (!updatedByName) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <span className="font-medium">{updatedByName}</span>
- {updatedByEmail && (
- <span className="text-xs text-muted-foreground truncate max-w-[150px]">
- {updatedByEmail}
- </span>
- )}
- </div>
- );
- },
- };
-
-
- // 상태 컬럼
- const statusColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => {
- const statusBadge = getStatusBadge(row.original.status);
- return <Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>;
- },
- filterFn: (row, id, value) => {
- return value.includes(row.getValue(id));
- },
- };
-
- // 진행률 컬럼
- const progressColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "overallProgress",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="진행률" />
- ),
- cell: ({ row }) => {
- const progress = row.original.overallProgress;
- const progressBadge = getProgressBadge(progress);
-
- return (
- <div className="flex flex-col gap-1 min-w-[120px]">
- <div className="flex items-center justify-between">
- <span className="text-sm font-medium">{progress}%</span>
- <Badge variant={progressBadge.variant} className="text-xs">
- {progressBadge.label}
- </Badge>
- </div>
- <Progress value={progress} className="h-2" />
- </div>
- );
- },
- };
-
- // 마감일 컬럼
- const dueDateColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="마감일" />
- ),
- cell: ({ row }) => {
- const dueDate = row.original.dueDate;
- const daysToDeadline = row.original.daysToDeadline;
- const urgencyLevel = getUrgencyLevel(daysToDeadline);
-
- if (!dueDate) {
- return <span className="text-muted-foreground">-</span>;
- }
-
- return (
- <div className="flex flex-col">
- <div className="flex items-center gap-2">
- <Calendar className="h-4 w-4 text-muted-foreground" />
- <span>{formatDate(dueDate, 'KR')}</span>
- </div>
- <div className="flex items-center gap-1 text-xs">
- {urgencyLevel === "high" && (
- <AlertTriangle className="h-3 w-3 text-red-500" />
- )}
- {urgencyLevel === "medium" && (
- <Clock className="h-3 w-3 text-yellow-500" />
- )}
- {urgencyLevel === "low" && (
- <CheckCircle2 className="h-3 w-3 text-green-500" />
- )}
- <span className={cn(
- urgencyLevel === "high" && "text-red-500",
- urgencyLevel === "medium" && "text-yellow-600",
- urgencyLevel === "low" && "text-green-600"
- )}>
- {daysToDeadline > 0 ? `${daysToDeadline}일 남음` :
- daysToDeadline === 0 ? "오늘 마감" :
- `${Math.abs(daysToDeadline)}일 지남`}
- </span>
- </div>
- </div>
- );
- },
- };
-
- // 담당자 컬럼
- const picColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "picName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="구매 담당자" />
- ),
- cell: ({ row }) => {
- const picName = row.original.picName;
- return picName ? (
- <span>{picName}</span>
- ) : (
- <span className="text-muted-foreground">미배정</span>
- );
- },
- };
-
- const engPicColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "engPicName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="설계 담당자" />
- ),
- cell: ({ row }) => {
- const picName = row.original.engPicName;
- return picName ? (
- <span>{picName}</span>
- ) : (
- <span className="text-muted-foreground">미배정</span>
- );
- },
- };
-
-
- const pjtCompanyColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "projectCompany",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트 Company" />
- ),
- cell: ({ row }) => {
- const projectCompany = row.original.projectCompany;
- return projectCompany ? (
- <span>{projectCompany}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- };
-
- const pjtFlagColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "projectFlag",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트 Flag" />
- ),
- cell: ({ row }) => {
- const projectFlag = row.original.projectFlag;
- return projectFlag ? (
- <span>{projectFlag}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- };
-
-
- const pjtSiteColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "projectSite",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트 Site" />
- ),
- cell: ({ row }) => {
- const projectSite = row.original.projectSite;
- return projectSite ? (
- <span>{projectSite}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- };
- const remarkColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "remark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="비고" />
- ),
- cell: ({ row }) => {
- const remark = row.original.remark;
- return remark ? (
- <span>{remark}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- };
-
- // 첨부파일 수 컬럼
- const attachmentColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "totalAttachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
- ),
- cell: ({ row }) => {
- const count = row.original.totalAttachments;
- return (
- <div className="flex items-center gap-2">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <span>{count}</span>
- </div>
- );
- },
- };
-
- // 벤더 현황 컬럼
- const vendorStatusColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "initialVendorCount",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 현황" />
- ),
- cell: ({ row }) => {
- const initial = row.original.initialVendorCount;
- const final = row.original.finalVendorCount;
- const initialRate = row.original.initialResponseRate;
- const finalRate = row.original.finalResponseRate;
-
- return (
- <div className="flex flex-col gap-1 text-xs">
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground">초기:</span>
- <span>{initial}개사 ({Number(initialRate).toFixed(0)}%)</span>
- </div>
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground">최종:</span>
- <span>{final}개사 ({Number(finalRate).toFixed(0)}%)</span>
- </div>
- </div>
- );
- },
- };
-
- // 생성일 컬럼
- const createdAtColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="생성일" />
- ),
- cell: ({ row }) => {
- const dateVal = row.original.createdAt as Date;
- return formatDate(dateVal, 'KR');
- },
- };
-
- const updatedAtColumn: ColumnDef<RfqDashboardView> = {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일" />
- ),
- cell: ({ row }) => {
- const dateVal = row.original.updatedAt as Date;
- return formatDate(dateVal, 'KR');
- },
- };
-
- // Actions 컬럼
- const actionsColumn: ColumnDef<RfqDashboardView> = {
- id: "detail",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상세내용" />
- ),
- // enableHiding: false,
- cell: function Cell({ row }) {
- const rfq = row.original;
- const detailUrl = `/evcp/b-rfq/${rfq.rfqId}/initial`;
-
- return (
-
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- onClick={() => router.push(detailUrl)}
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- );
- },
- size: 40,
- };
-
- return [
- selectColumn,
- rfqCodeColumn,
- projectColumn,
- packageColumn,
- statusColumn,
- picColumn,
- progressColumn,
- dueDateColumn,
- actionsColumn,
-
- engPicColumn,
-
- pjtCompanyColumn,
- pjtFlagColumn,
- pjtSiteColumn,
-
- attachmentColumn,
- vendorStatusColumn,
- createdAtColumn,
-
- updatedAtColumn,
- updatedColumn,
- remarkColumn
- ];
-} \ No newline at end of file
diff --git a/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx b/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx
deleted file mode 100644
index ff3bc132..00000000
--- a/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx
+++ /dev/null
@@ -1,617 +0,0 @@
-"use client"
-
-import { useEffect, useTransition, useState, useRef } from "react"
-import { useRouter, useParams } from "next/navigation"
-import { z } from "zod"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Search, X } from "lucide-react"
-import { customAlphabet } from "nanoid"
-import { parseAsStringEnum, useQueryState } from "nuqs"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { getFiltersStateParser } from "@/lib/parsers"
-
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
-
-// RFQ 필터 스키마 정의
-const rfqFilterSchema = z.object({
- rfqCode: z.string().optional(),
- projectCode: z.string().optional(),
- picName: z.string().optional(),
- packageNo: z.string().optional(),
- packageName: z.string().optional(),
- status: z.string().optional(),
-})
-
-// RFQ 상태 옵션 정의
-const rfqStatusOptions = [
- { value: "DRAFT", label: "초안" },
- { value: "Doc. Received", label: "문서접수" },
- { value: "PIC Assigned", label: "담당자배정" },
- { value: "Doc. Confirmed", label: "문서확인" },
- { value: "Init. RFQ Sent", label: "초기RFQ발송" },
- { value: "Init. RFQ Answered", label: "초기RFQ회신" },
- { value: "TBE started", label: "TBE시작" },
- { value: "TBE finished", label: "TBE완료" },
- { value: "Final RFQ Sent", label: "최종RFQ발송" },
- { value: "Quotation Received", label: "견적접수" },
- { value: "Vendor Selected", label: "업체선정" },
-]
-
-type RFQFilterFormValues = z.infer<typeof rfqFilterSchema>
-
-interface RFQFilterSheetProps {
- isOpen: boolean;
- onClose: () => void;
- onSearch?: () => void;
- isLoading?: boolean;
-}
-
-export function RFQFilterSheet({
- isOpen,
- onClose,
- onSearch,
- isLoading = false
-}: RFQFilterSheetProps) {
- const router = useRouter()
- const params = useParams();
- const lng = params ? (params.lng as string) : 'ko';
-
- const [isPending, startTransition] = useTransition()
-
- // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
- const [isInitializing, setIsInitializing] = useState(false)
- // 마지막으로 적용된 필터를 추적하기 위한 ref
- const lastAppliedFilters = useRef<string>("")
-
- // nuqs로 URL 상태 관리
- const [filters, setFilters] = useQueryState(
- "basicFilters",
- getFiltersStateParser().withDefault([])
- )
-
- // joinOperator 설정
- const [joinOperator, setJoinOperator] = useQueryState(
- "basicJoinOperator",
- parseAsStringEnum(["and", "or"]).withDefault("and")
- )
-
- // 현재 URL의 페이지 파라미터도 가져옴
- const [page, setPage] = useQueryState("page", { defaultValue: "1" })
-
- // 폼 상태 초기화
- const form = useForm<RFQFilterFormValues>({
- resolver: zodResolver(rfqFilterSchema),
- defaultValues: {
- rfqCode: "",
- projectCode: "",
- picName: "",
- packageNo: "",
- packageName: "",
- status: "",
- },
- })
-
- // URL 필터에서 초기 폼 상태 설정
- useEffect(() => {
- // 현재 필터를 문자열로 직렬화
- const currentFiltersString = JSON.stringify(filters);
-
- // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
- if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
- setIsInitializing(true);
-
- const formValues = { ...form.getValues() };
- let formUpdated = false;
-
- filters.forEach(filter => {
- if (filter.id in formValues) {
- // @ts-ignore - 동적 필드 접근
- formValues[filter.id] = filter.value;
- formUpdated = true;
- }
- });
-
- // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
- if (formUpdated) {
- form.reset(formValues);
- lastAppliedFilters.current = currentFiltersString;
- }
-
- setIsInitializing(false);
- }
- }, [filters, isOpen])
-
- // 현재 적용된 필터 카운트
- const getActiveFilterCount = () => {
- return filters?.length || 0
- }
-
- // 폼 제출 핸들러
- async function onSubmit(data: RFQFilterFormValues) {
- // 초기화 중이면 제출 방지
- if (isInitializing) return;
-
- startTransition(async () => {
- try {
- // 필터 배열 생성
- const newFilters = []
-
- if (data.rfqCode?.trim()) {
- newFilters.push({
- id: "rfqCode",
- value: data.rfqCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.projectCode?.trim()) {
- newFilters.push({
- id: "projectCode",
- value: data.projectCode.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.picName?.trim()) {
- newFilters.push({
- id: "picName",
- value: data.picName.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.packageNo?.trim()) {
- newFilters.push({
- id: "packageNo",
- value: data.packageNo.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.packageName?.trim()) {
- newFilters.push({
- id: "packageName",
- value: data.packageName.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- if (data.status?.trim()) {
- newFilters.push({
- id: "status",
- value: data.status.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 수동으로 URL 업데이트 (nuqs 대신)
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- // 기존 필터 관련 파라미터 제거
- params.delete('basicFilters');
- params.delete('rfqBasicFilters');
- params.delete('basicJoinOperator');
- params.delete('rfqBasicJoinOperator');
- params.delete('page');
-
- // 새로운 필터 추가
- if (newFilters.length > 0) {
- params.set('basicFilters', JSON.stringify(newFilters));
- params.set('basicJoinOperator', joinOperator);
- }
-
- // 페이지를 1로 설정
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- console.log("New RFQ Filter URL:", newUrl);
-
- // 페이지 완전 새로고침으로 서버 렌더링 강제
- window.location.href = newUrl;
-
- // 마지막 적용된 필터 업데이트
- lastAppliedFilters.current = JSON.stringify(newFilters);
-
- // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
- if (onSearch) {
- console.log("Calling RFQ onSearch...");
- onSearch();
- }
-
- console.log("=== RFQ Filter Submit Complete ===");
- } catch (error) {
- console.error("RFQ 필터 적용 오류:", error);
- }
- })
- }
-
- // 필터 초기화 핸들러
- async function handleReset() {
- try {
- setIsInitializing(true);
-
- form.reset({
- rfqCode: "",
- projectCode: "",
- picName: "",
- packageNo: "",
- packageName: "",
- status: "",
- });
-
- console.log("=== RFQ Filter Reset Debug ===");
- console.log("Current URL before reset:", window.location.href);
-
- // 수동으로 URL 초기화
- const currentUrl = new URL(window.location.href);
- const params = new URLSearchParams(currentUrl.search);
-
- // 필터 관련 파라미터 제거
- params.delete('basicFilters');
- params.delete('rfqBasicFilters');
- params.delete('basicJoinOperator');
- params.delete('rfqBasicJoinOperator');
- params.set('page', '1');
-
- const newUrl = `${currentUrl.pathname}?${params.toString()}`;
- console.log("Reset URL:", newUrl);
-
- // 페이지 완전 새로고침
- window.location.href = newUrl;
-
- // 마지막 적용된 필터 초기화
- lastAppliedFilters.current = "";
-
- console.log("RFQ 필터 초기화 완료");
- setIsInitializing(false);
- } catch (error) {
- console.error("RFQ 필터 초기화 오류:", error);
- setIsInitializing(false);
- }
- }
-
- // Don't render if not open (for side panel use)
- if (!isOpen) {
- return null;
- }
-
- return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
- {/* Filter Panel Header */}
- <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
- <h3 className="text-lg font-semibold whitespace-nowrap">RFQ 검색 필터</h3>
- <div className="flex items-center gap-2">
- {getActiveFilterCount() > 0 && (
- <Badge variant="secondary" className="px-2 py-1">
- {getActiveFilterCount()}개 필터 적용됨
- </Badge>
- )}
- </div>
- </div>
-
- {/* Join Operator Selection */}
- <div className="px-6 shrink-0">
- <label className="text-sm font-medium">조건 결합 방식</label>
- <Select
- value={joinOperator}
- onValueChange={(value: "and" | "or") => setJoinOperator(value)}
- disabled={isInitializing}
- >
- <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
- <SelectValue placeholder="조건 결합 방식" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
- <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
- {/* Scrollable content area */}
- <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
- <div className="space-y-4 pt-2">
-
- {/* RFQ 코드 */}
- <FormField
- control={form.control}
- name="rfqCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ 코드</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="RFQ 코드 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("rfqCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트 코드 */}
- <FormField
- control={form.control}
- name="projectCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트 코드</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="프로젝트 코드 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("projectCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 담당자명 */}
- <FormField
- control={form.control}
- name="picName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자명</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="담당자명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("picName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 패키지 번호 */}
- <FormField
- control={form.control}
- name="packageNo"
- render={({ field }) => (
- <FormItem>
- <FormLabel>패키지 번호</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="패키지 번호 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("packageNo", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 패키지명 */}
- <FormField
- control={form.control}
- name="packageName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>패키지명</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="패키지명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("packageName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ 상태 */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ 상태</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isInitializing}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder="RFQ 상태 선택" />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("status", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {rfqStatusOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* Fixed buttons at bottom */}
- <div className="p-4 shrink-0">
- <div className="flex gap-2 justify-end">
- <Button
- type="button"
- variant="outline"
- onClick={handleReset}
- disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
- className="px-4"
- >
- 초기화
- </Button>
- <Button
- type="submit"
- variant="samsung"
- disabled={isPending || isLoading || isInitializing}
- className="px-4"
- >
- <Search className="size-4 mr-2" />
- {isPending || isLoading ? "조회 중..." : "조회"}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- )
-} \ No newline at end of file
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
deleted file mode 100644
index 02ba4aaa..00000000
--- a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, FileText, Mail, Search } from "lucide-react"
-import { useRouter } from "next/navigation"
-
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { RfqDashboardView } from "@/db/schema"
-import { CreateRfqDialog } from "./add-new-rfq-dialog"
-
-interface RFQTableToolbarActionsProps {
- table: Table<RfqDashboardView>
-}
-
-export function RFQTableToolbarActions({ table }: RFQTableToolbarActionsProps) {
- const router = useRouter()
-
- // 선택된 행 정보
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedCount = selectedRows.length
- const isSingleSelected = selectedCount === 1
-
- // RFQ 문서 확인 핸들러
- const handleDocumentCheck = () => {
- if (isSingleSelected) {
- const selectedRfq = selectedRows[0].original
- const rfqId = selectedRfq.rfqId
-
- // RFQ 첨부문서 확인 페이지로 이동
- router.push(`/evcp/b-rfq/${rfqId}`)
- }
- }
-
- // 테이블 새로고침 핸들러
- const handleRefresh = () => {
- // 페이지 새로고침 또는 데이터 다시 fetch
- router.refresh()
- }
-
- return (
- <div className="flex items-center gap-2">
- {/* 새 RFQ 생성 다이얼로그 */}
- <CreateRfqDialog onSuccess={handleRefresh} />
-
- {/* RFQ 문서 확인 버튼 - 단일 선택시만 활성화 */}
- <Button
- size="sm"
- variant="outline"
- onClick={handleDocumentCheck}
- disabled={!isSingleSelected}
- className="flex items-center"
- >
- <Search className="mr-2 h-4 w-4" />
- RFQ 문서 확인
- </Button>
-
-
- </div>
- )
-}
diff --git a/lib/b-rfq/summary-table/summary-rfq-table.tsx b/lib/b-rfq/summary-table/summary-rfq-table.tsx
deleted file mode 100644
index 83d50685..00000000
--- a/lib/b-rfq/summary-table/summary-rfq-table.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter, useSearchParams } from "next/navigation"
-import { Button } from "@/components/ui/button"
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getRFQDashboard } from "../service"
-import { cn } from "@/lib/utils"
-import { useTablePresets } from "@/components/data-table/use-table-presets"
-import { TablePresetManager } from "@/components/data-table/data-table-preset"
-import { useMemo } from "react"
-import { getRFQColumns } from "./summary-rfq-columns"
-import { RfqDashboardView } from "@/db/schema"
-import { RFQTableToolbarActions } from "./summary-rfq-table-toolbar-actions"
-import { RFQFilterSheet } from "./summary-rfq-filter-sheet"
-
-interface RFQDashboardTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getRFQDashboard>>]>
- className?: string
-}
-
-export function RFQDashboardTable({ promises, className }: RFQDashboardTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDashboardView> | null>(null)
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
-
- const router = useRouter()
- const searchParams = useSearchParams()
-
- const containerRef = React.useRef<HTMLDivElement>(null)
- const [containerTop, setContainerTop] = React.useState(0)
-
- const updateContainerBounds = React.useCallback(() => {
- if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect()
- setContainerTop(rect.top)
- }
- }, [])
-
- React.useEffect(() => {
- updateContainerBounds()
-
- const handleResize = () => {
- updateContainerBounds()
- }
-
- window.addEventListener('resize', handleResize)
- window.addEventListener('scroll', updateContainerBounds)
-
- return () => {
- window.removeEventListener('resize', handleResize)
- window.removeEventListener('scroll', updateContainerBounds)
- }
- }, [updateContainerBounds])
-
- const [promiseData] = React.use(promises)
- const tableData = promiseData
-
- console.log("RFQ Dashboard Table Data:", {
- dataLength: tableData.data?.length,
- pageCount: tableData.pageCount,
- total: tableData.total,
- sampleData: tableData.data?.[0]
- })
-
- const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams.get('page') || '1'),
- perPage: parseInt(searchParams.get('perPage') || '10'),
- sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }],
- filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters') ?
- JSON.parse(searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters')!) : [],
- basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams.get('search') || '',
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["actions"] },
- groupBy: [],
- expandedRows: []
- }), [searchParams])
-
- const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
- updateClientState,
- getCurrentSettings,
- } = useTablePresets<RfqDashboardView>('rfq-dashboard-table', initialSettings)
-
- const columns = React.useMemo(
- () => getRFQColumns({ setRowAction, router }),
- [setRowAction, router]
- )
-
- const filterFields: DataTableFilterField<RfqDashboardView>[] = [
- { id: "rfqCode", label: "RFQ 코드" },
- { id: "projectName", label: "프로젝트" },
- { id: "status", label: "상태" },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<RfqDashboardView>[] = [
- { id: "rfqCode", label: "RFQ 코드", type: "text" },
- { id: "description", label: "설명", type: "text" },
- { id: "projectName", label: "프로젝트명", type: "text" },
- { id: "projectCode", label: "프로젝트 코드", type: "text" },
- { id: "packageNo", label: "패키지 번호", type: "text" },
- { id: "packageName", label: "패키지명", type: "text" },
- { id: "picName", label: "담당자", type: "text" },
- { id: "status", label: "상태", type: "select", options: [
- { label: "초안", value: "DRAFT" },
- { label: "문서접수", value: "Doc. Received" },
- { label: "담당자배정", value: "PIC Assigned" },
- { label: "문서확인", value: "Doc. Confirmed" },
- { label: "초기RFQ발송", value: "Init. RFQ Sent" },
- { label: "초기RFQ회신", value: "Init. RFQ Answered" },
- { label: "TBE시작", value: "TBE started" },
- { label: "TBE완료", value: "TBE finished" },
- { label: "최종RFQ발송", value: "Final RFQ Sent" },
- { label: "견적접수", value: "Quotation Received" },
- { label: "업체선정", value: "Vendor Selected" },
- ]},
- { id: "overallProgress", label: "진행률", type: "number" },
- { id: "dueDate", label: "마감일", type: "date" },
- { id: "createdAt", label: "생성일", type: "date" },
- ]
-
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
-
- const initialState = useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id)
- return columnExists
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- const { table } = useDataTable({
- data: tableData.data,
- columns,
- pageCount: tableData.pageCount,
- rowCount: tableData.total || tableData.data.length,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (originalRow) => String(originalRow.rfqId),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- const getActiveBasicFilterCount = () => {
- try {
- const basicFilters = searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch (e) {
- return 0
- }
- }
-
- const FILTER_PANEL_WIDTH = 400;
-
- return (
- <>
- {/* Filter Panel */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- top: `${containerTop}px`,
- height: `calc(100vh - ${containerTop}px)`
- }}
- >
- <div className="h-full">
- <RFQFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false}
- />
- </div>
- </div>
-
- {/* Main Content Container */}
- <div
- ref={containerRef}
- className={cn("relative w-full overflow-hidden", className)}
- >
- <div className="flex w-full h-full">
- <div
- className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
- }}
- >
- {/* Header Bar */}
- <div className="flex items-center justify-between p-4 bg-background shrink-0">
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- type='button'
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
- {getActiveBasicFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveBasicFilterCount()}
- </span>
- )}
- </Button>
- </div>
-
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || tableData.data.length}건</span>
- )}
- </div>
- </div>
-
- {/* Table Content Area */}
- <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}>
- <div className="h-full w-full">
- <DataTable table={table} className="h-full">
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <div className="flex items-center gap-2">
- <TablePresetManager<RfqDashboardView>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <RFQTableToolbarActions table={table} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </div>
- </div>
- </div>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts
deleted file mode 100644
index bee10a11..00000000
--- a/lib/b-rfq/validations.ts
+++ /dev/null
@@ -1,447 +0,0 @@
-import { createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,parseAsBoolean
- } from "nuqs/server"
- import * as z from "zod"
-
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { FinalRfqDetailView, VendorAttachmentResponse } from "@/db/schema";
-
-export const searchParamsRFQDashboardCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 - rfqDashboardView 기반
- sort: getSortingStateParser<{
- rfqId: number;
- rfqCode: string;
- description: string;
- status: string;
- dueDate: Date;
- projectCode: string;
- projectName: string;
- packageNo: string;
- packageName: string;
- picName: string;
- totalAttachments: number;
- initialVendorCount: number;
- finalVendorCount: number;
- initialResponseRate: number;
- finalResponseRate: number;
- overallProgress: number;
- daysToDeadline: number;
- createdAt: Date;
- }>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터
- rfqBasicFilters: getFiltersStateParser().withDefault([]),
- rfqBasicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // RFQ 특화 필터
- rfqCode: parseAsString.withDefault(""),
- projectName: parseAsString.withDefault(""),
- projectCode: parseAsString.withDefault(""),
- picName: parseAsString.withDefault(""),
- packageNo: parseAsString.withDefault(""),
- status: parseAsStringEnum([
- "DRAFT",
- "Doc. Received",
- "PIC Assigned",
- "Doc. Confirmed",
- "Init. RFQ Sent",
- "Init. RFQ Answered",
- "TBE started",
- "TBE finished",
- "Final RFQ Sent",
- "Quotation Received",
- "Vendor Selected"
- ]),
- dueDateFrom: parseAsString.withDefault(""),
- dueDateTo: parseAsString.withDefault(""),
- progressMin: parseAsInteger.withDefault(0),
- progressMax: parseAsInteger.withDefault(100),
- });
-
- export type GetRFQDashboardSchema = Awaited<ReturnType<typeof searchParamsRFQDashboardCache.parse>>
-
-
- export const createRfqServerSchema = z.object({
- projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경
- dueDate: z.date(), // Date 객체로 직접 받기
- picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"),
- picName: z.string().optional(),
- engPicName: z.string().optional(),
- packageNo: z.string().min(1, "패키지 번호를 입력해주세요"),
- packageName: z.string().min(1, "패키지명을 입력해주세요"),
- remark: z.string().optional(),
- projectCompany: z.string().optional(),
- projectFlag: z.string().optional(),
- projectSite: z.string().optional(),
- createdBy: z.number(),
- updatedBy: z.number(),
- })
-
- 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>
-
-
-//Inital RFQ
-export const searchParamsInitialRfqDetailCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 - initialRfqDetailView 기반
- sort: getSortingStateParser<{
- rfqId: number;
- rfqCode: string;
- rfqStatus: string;
- initialRfqId: number;
- initialRfqStatus: string;
- vendorId: number;
- vendorCode: string;
- vendorName: string;
- vendorCountry: string;
- vendorBusinessSize: string;
- dueDate: Date;
- validDate: Date;
- incotermsCode: string;
- incotermsDescription: string;
- shortList: boolean;
- returnYn: boolean;
- cpRequestYn: boolean;
- prjectGtcYn: boolean;
- returnRevision: number;
- gtc: string;
- gtcValidDate: string;
- classification: string;
- sparepart: string;
- createdAt: Date;
- updatedAt: Date;
- }>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // Initial RFQ Detail 특화 필터
- rfqCode: parseAsString.withDefault(""),
- rfqStatus: parseAsStringEnum([
- "DRAFT",
- "Doc. Received",
- "PIC Assigned",
- "Doc. Confirmed",
- "Init. RFQ Sent",
- "Init. RFQ Answered",
- "TBE started",
- "TBE finished",
- "Final RFQ Sent",
- "Quotation Received",
- "Vendor Selected"
- ]),
- initialRfqStatus: parseAsStringEnum([
- "PENDING",
- "SENT",
- "RESPONDED",
- "EXPIRED",
- "CANCELLED"
- ]),
- vendorName: parseAsString.withDefault(""),
- vendorCode: parseAsString.withDefault(""),
- vendorCountry: parseAsString.withDefault(""),
- vendorBusinessSize: parseAsStringEnum([
- "LARGE",
- "MEDIUM",
- "SMALL",
- "STARTUP"
- ]),
- incotermsCode: parseAsString.withDefault(""),
- dueDateFrom: parseAsString.withDefault(""),
- dueDateTo: parseAsString.withDefault(""),
- validDateFrom: parseAsString.withDefault(""),
- validDateTo: parseAsString.withDefault(""),
- shortList: parseAsStringEnum(["true", "false"]),
- returnYn: parseAsStringEnum(["true", "false"]),
- cpRequestYn: parseAsStringEnum(["true", "false"]),
- prjectGtcYn: parseAsStringEnum(["true", "false"]),
- classification: parseAsString.withDefault(""),
- sparepart: parseAsString.withDefault(""),
-});
-
-export type GetInitialRfqDetailSchema = Awaited<ReturnType<typeof searchParamsInitialRfqDetailCache.parse>>;
-
-
-
-export const updateInitialRfqSchema = z.object({
- initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"]),
- dueDate: z.date({
- required_error: "마감일을 선택해주세요.",
- }),
- validDate: z.date().optional(),
- gtc: z.string().optional(),
- gtcValidDate: z.string().optional(),
- incotermsCode: z.string().max(20, "Incoterms 코드는 20자 이하여야 합니다.").optional(),
- classification: z.string().max(255, "분류는 255자 이하여야 합니다.").optional(),
- sparepart: z.string().max(255, "예비부품은 255자 이하여야 합니다.").optional(),
- shortList: z.boolean().default(false),
- returnYn: z.boolean().default(false),
- cpRequestYn: z.boolean().default(false),
- prjectGtcYn: z.boolean().default(false),
- rfqRevision: z.number().int().min(0, "RFQ 리비전은 0 이상이어야 합니다.").default(0),
-})
-
-export const removeInitialRfqsSchema = z.object({
- ids: z.array(z.number()).min(1, "최소 하나의 항목을 선택해주세요."),
-})
-
-export type UpdateInitialRfqSchema = z.infer<typeof updateInitialRfqSchema>
-export type RemoveInitialRfqsSchema = z.infer<typeof removeInitialRfqsSchema>
-
-// 벌크 이메일 발송 스키마
-export const bulkEmailSchema = z.object({
- initialRfqIds: z.array(z.number()).min(1, "최소 하나의 초기 RFQ를 선택해주세요."),
- language: z.enum(["en", "ko"]).default("en"),
-})
-
-export type BulkEmailInput = z.infer<typeof bulkEmailSchema>
-
-// 검색 파라미터 캐시 설정
-
-export type ResponseStatus = "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED";
-export type RfqType = "INITIAL" | "FINAL";
-
-
-export type VendorRfqResponseColumns = {
- id: string;
- vendorId: number;
- rfqRecordId: number;
- rfqType: RfqType;
- overallStatus: ResponseStatus;
- totalAttachments: number;
- respondedCount: number;
- pendingCount: number;
- responseRate: number;
- completionRate: number;
- requestedAt: Date;
- lastRespondedAt: Date | null;
-};
-
-// 검색 파라미터 캐시 설정
-export const searchParamsVendorResponseCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<VendorRfqResponseColumns>().withDefault([
- { id: "requestedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 및 필터
- search: parseAsString.withDefault(""),
- rfqType: parseAsStringEnum(["INITIAL", "FINAL", "ALL"]).withDefault("ALL"),
- responseStatus: parseAsStringEnum(["NOT_RESPONDED", "RESPONDED", "REVISION_REQUESTED", "WAIVED", "ALL"]).withDefault("ALL"),
-
- // 날짜 범위
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
-export type GetVendorResponsesSchema = Awaited<ReturnType<typeof searchParamsVendorResponseCache.parse>>;
-
-// vendorId + rfqRecordId로 그룹핑된 응답 요약 타입
-export type VendorRfqResponseSummary = {
- id: string; // vendorId + rfqRecordId + rfqType 조합으로 생성된 고유 ID
- vendorId: number;
- rfqRecordId: number;
- rfqType: RfqType;
-
- // RFQ 정보
- rfq: {
- id: number;
- rfqCode: string | null;
- description: string | null;
- status: string;
- dueDate: Date;
- } | null;
-
- // 벤더 정보
- vendor: {
- id: number;
- vendorCode: string;
- vendorName: string;
- country: string | null;
- businessSize: string | null;
- } | null;
-
- // 응답 통계
- totalAttachments: number;
- respondedCount: number;
- pendingCount: number;
- revisionRequestedCount: number;
- waivedCount: number;
- responseRate: number;
- completionRate: number;
- overallStatus: ResponseStatus; // 전체적인 상태
-
- // 날짜 정보
- requestedAt: Date;
- lastRespondedAt: Date | null;
-
- // 기타
- hasComments: boolean;
-};
-
-
-// 수정 요청 스키마
-export const requestRevisionSchema = z.object({
- responseId: z.number().positive(),
- revisionReason: z.string().min(10, "수정 요청 사유를 최소 10자 이상 입력해주세요").max(500),
-});
-
-// 수정 요청 결과 타입
-export type RequestRevisionResult = {
- success: boolean;
- message: string;
- error?: string;
-};
-
-export const shortListConfirmSchema = z.object({
- rfqId: z.number(),
- selectedVendorIds: z.array(z.number()).min(1),
- rejectedVendorIds: z.array(z.number()),
-})
-
-export type ShortListConfirmInput = z.infer<typeof shortListConfirmSchema>
-
-
-export const searchParamsFinalRfqDetailCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 - initialRfqDetailView 기반
- sort: getSortingStateParser<FinalRfqDetailView>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
-
-});
-
-export type GetFinalRfqDetailSchema = Awaited<ReturnType<typeof searchParamsFinalRfqDetailCache.parse>>;
-
diff --git a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx
deleted file mode 100644
index 0c2c0c62..00000000
--- a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-// components/rfq/comment-edit-dialog.tsx
-"use client";
-
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Textarea } from "@/components/ui/textarea";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { MessageSquare, Loader2 } from "lucide-react";
-import { useToast } from "@/hooks/use-toast";
-import { useRouter } from "next/navigation";
-
-const commentFormSchema = z.object({
- responseComment: z.string().optional(),
- vendorComment: z.string().optional(),
-});
-
-type CommentFormData = z.infer<typeof commentFormSchema>;
-
-interface CommentEditDialogProps {
- responseId: number;
- currentResponseComment?: string;
- currentVendorComment?: string;
- trigger?: React.ReactNode;
- onSuccess?: () => void;
-}
-
-export function CommentEditDialog({
- responseId,
- currentResponseComment,
- currentVendorComment,
- trigger,
- onSuccess,
-}: CommentEditDialogProps) {
- const [open, setOpen] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
- const { toast } = useToast();
- const router = useRouter();
-
- const form = useForm<CommentFormData>({
- resolver: zodResolver(commentFormSchema),
- defaultValues: {
- responseComment: currentResponseComment || "",
- vendorComment: currentVendorComment || "",
- },
- });
-
- const onSubmit = async (data: CommentFormData) => {
- setIsSaving(true);
-
- try {
- const response = await fetch("/api/vendor-responses/update-comment", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- responseId,
- responseComment: data.responseComment,
- vendorComment: data.vendorComment,
- }),
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.message || "코멘트 업데이트 실패");
- }
-
- toast({
- title: "코멘트 업데이트 완료",
- description: "코멘트가 성공적으로 업데이트되었습니다.",
- });
-
- setOpen(false);
-
- router.refresh();
- onSuccess?.();
-
- } catch (error) {
- console.error("Comment update error:", error);
- toast({
- title: "업데이트 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsSaving(false);
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- {trigger || (
- <Button size="sm" variant="outline">
- <MessageSquare className="h-3 w-3 mr-1" />
- 코멘트
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-lg">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <MessageSquare className="h-5 w-5" />
- 코멘트 수정
- </DialogTitle>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 응답 코멘트 */}
- <FormField
- control={form.control}
- name="responseComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>응답 코멘트</FormLabel>
- <FormControl>
- <Textarea
- placeholder="응답에 대한 설명을 입력하세요..."
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 벤더 코멘트 */}
- <FormField
- control={form.control}
- name="vendorComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더 코멘트 (내부용)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="내부 참고용 코멘트를 입력하세요..."
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 버튼 */}
- <div className="flex justify-end gap-2">
- <Button
- type="button"
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isSaving}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isSaving}>
- {isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
- {isSaving ? "저장 중..." : "저장"}
- </Button>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/response-detail-columns.tsx b/lib/b-rfq/vendor-response/response-detail-columns.tsx
deleted file mode 100644
index bc27d103..00000000
--- a/lib/b-rfq/vendor-response/response-detail-columns.tsx
+++ /dev/null
@@ -1,653 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ColumnDef } from "@tanstack/react-table"
-import type { Row } from "@tanstack/react-table"
-import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
-import { formatDateTime } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import {
- FileText,
- Upload,
- CheckCircle,
- Clock,
- AlertTriangle,
- FileX,
- Download,
- AlertCircle,
- RefreshCw,
- Calendar,
- MessageSquare,
- GitBranch,
- Ellipsis
-} from "lucide-react"
-import { cn } from "@/lib/utils"
-import type { EnhancedVendorResponse } from "@/lib/b-rfq/service"
-import { UploadResponseDialog } from "./upload-response-dialog"
-import { CommentEditDialog } from "./comment-edit-dialog"
-import { WaiveResponseDialog } from "./waive-response-dialog"
-import { ResponseDetailSheet } from "./response-detail-sheet"
-
-export interface DataTableRowAction<TData> {
- row: Row<TData>
- type: 'upload' | 'waive' | 'edit' | 'detail'
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedVendorResponse> | null>>
-}
-
-// 파일 다운로드 핸들러
-async function handleFileDownload(
- filePath: string,
- fileName: string,
- type: "client" | "vendor" = "client",
- id?: number
-) {
- try {
- const params = new URLSearchParams({
- path: filePath,
- type: type,
- });
-
- if (id) {
- if (type === "client") {
- params.append("revisionId", id.toString());
- } else {
- params.append("responseFileId", id.toString());
- }
- }
-
- const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`);
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || `Download failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = fileName;
- document.body.appendChild(link);
- link.click();
-
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- } catch (error) {
- console.error("❌ 파일 다운로드 실패:", error);
- alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
- }
-}
-
-// 상태별 정보 반환
-function getEffectiveStatusInfo(effectiveStatus: string) {
- switch (effectiveStatus) {
- case "NOT_RESPONDED":
- return {
- icon: Clock,
- label: "미응답",
- variant: "outline" as const,
- color: "text-orange-600"
- };
- case "UP_TO_DATE":
- return {
- icon: CheckCircle,
- label: "최신",
- variant: "default" as const,
- color: "text-green-600"
- };
- case "VERSION_MISMATCH":
- return {
- icon: RefreshCw,
- label: "업데이트 필요",
- variant: "secondary" as const,
- color: "text-blue-600"
- };
- case "REVISION_REQUESTED":
- return {
- icon: AlertTriangle,
- label: "수정요청",
- variant: "secondary" as const,
- color: "text-yellow-600"
- };
- case "WAIVED":
- return {
- icon: FileX,
- label: "포기",
- variant: "outline" as const,
- color: "text-gray-600"
- };
- default:
- return {
- icon: FileText,
- label: effectiveStatus,
- variant: "outline" as const,
- color: "text-gray-600"
- };
- }
-}
-
-// 파일명 컴포넌트
-function AttachmentFileNameCell({ revisions }: {
- revisions: Array<{
- id: number;
- originalFileName: string;
- revisionNo: string;
- isLatest: boolean;
- filePath?: string;
- fileSize: number;
- createdAt: string;
- revisionComment?: string;
- }>
-}) {
- if (!revisions || revisions.length === 0) {
- return <span className="text-muted-foreground">파일 없음</span>;
- }
-
- const latestRevision = revisions.find(r => r.isLatest) || revisions[0];
- const hasMultipleRevisions = revisions.length > 1;
- const canDownload = latestRevision.filePath;
-
- return (
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- {canDownload ? (
- <button
- onClick={() => handleFileDownload(
- latestRevision.filePath!,
- latestRevision.originalFileName,
- "client",
- latestRevision.id
- )}
- className="font-medium text-sm text-blue-600 hover:text-blue-800 hover:underline text-left max-w-64 truncate"
- title={`${latestRevision.originalFileName} - 클릭하여 다운로드`}
- >
- {latestRevision.originalFileName}
- </button>
- ) : (
- <span className="font-medium text-sm text-muted-foreground max-w-64 truncate" title={latestRevision.originalFileName}>
- {latestRevision.originalFileName}
- </span>
- )}
-
- {canDownload && (
- <Button
- size="sm"
- variant="ghost"
- className="h-6 w-6 p-0"
- onClick={() => handleFileDownload(
- latestRevision.filePath!,
- latestRevision.originalFileName,
- "client",
- latestRevision.id
- )}
- title="파일 다운로드"
- >
- <Download className="h-3 w-3" />
- </Button>
- )}
-
- {hasMultipleRevisions && (
- <Badge variant="outline" className="text-xs">
- v{latestRevision.revisionNo}
- </Badge>
- )}
- </div>
-
- {hasMultipleRevisions && (
- <div className="text-xs text-muted-foreground">
- 총 {revisions.length}개 리비전
- </div>
- )}
- </div>
- );
-}
-
-// 리비전 비교 컴포넌트
-function RevisionComparisonCell({ response }: { response: EnhancedVendorResponse }) {
- const isUpToDate = response.isVersionMatched;
- const hasResponse = !!response.respondedRevision;
- const versionLag = response.versionLag || 0;
-
- return (
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <span className="text-xs text-muted-foreground">발주처:</span>
- <Badge variant="secondary" className="text-xs font-mono">
- {response.currentRevision}
- </Badge>
- </div>
- <div className="flex items-center gap-2">
- <span className="text-xs text-muted-foreground">응답:</span>
- {hasResponse ? (
- <Badge
- variant={isUpToDate ? "default" : "outline"}
- className={cn(
- "text-xs font-mono",
- !isUpToDate && "text-blue-600 border-blue-300"
- )}
- >
- {response.respondedRevision}
- </Badge>
- ) : (
- <span className="text-xs text-muted-foreground">-</span>
- )}
- </div>
- {hasResponse && !isUpToDate && versionLag > 0 && (
- <div className="flex items-center gap-1 text-xs text-blue-600">
- <AlertCircle className="h-3 w-3" />
- <span>{versionLag}버전 차이</span>
- </div>
- )}
- {response.hasMultipleRevisions && (
- <div className="flex items-center gap-1 text-xs text-muted-foreground">
- <GitBranch className="h-3 w-3" />
- <span>다중 리비전</span>
- </div>
- )}
- </div>
- );
-}
-
-// 코멘트 표시 컴포넌트
-function CommentDisplayCell({ response }: { response: EnhancedVendorResponse }) {
- const hasResponseComment = !!response.responseComment;
- const hasVendorComment = !!response.vendorComment;
- const hasRevisionRequestComment = !!response.revisionRequestComment;
- const hasClientComment = !!response.attachment?.revisions?.find(r => r.revisionComment);
-
- const commentCount = [hasResponseComment, hasVendorComment, hasRevisionRequestComment, hasClientComment].filter(Boolean).length;
-
- if (commentCount === 0) {
- return <span className="text-xs text-muted-foreground">-</span>;
- }
-
- return (
- <div className="space-y-1">
- {hasResponseComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-blue-500" title="벤더 응답 코멘트"></div>
- <span className="text-xs text-blue-600 truncate max-w-32" title={response.responseComment}>
- {response.responseComment}
- </span>
- </div>
- )}
-
- {hasVendorComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-green-500" title="벤더 내부 메모"></div>
- <span className="text-xs text-green-600 truncate max-w-32" title={response.vendorComment}>
- {response.vendorComment}
- </span>
- </div>
- )}
-
- {hasRevisionRequestComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-red-500" title="수정 요청 사유"></div>
- <span className="text-xs text-red-600 truncate max-w-32" title={response.revisionRequestComment}>
- {response.revisionRequestComment}
- </span>
- </div>
- )}
-
- {hasClientComment && (
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-orange-500" title="발주처 리비전 코멘트"></div>
- <span className="text-xs text-orange-600 truncate max-w-32"
- title={response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment}>
- {response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment}
- </span>
- </div>
- )}
-
- {/* <div className="text-xs text-muted-foreground text-center">
- {commentCount}개
- </div> */}
- </div>
- );
-}
-
-export function getColumns({
- setRowAction,
-}: GetColumnsProps): ColumnDef<EnhancedVendorResponse>[] {
- return [
- // 시리얼 번호 - 핀고정용 최소 너비
- {
- accessorKey: "serialNo",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="시리얼" />
- ),
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.getValue("serialNo")}</div>
- ),
-
- meta: {
- excelHeader: "시리얼",
- paddingFactor: 0.8
- },
- },
-
- // 분류 - 핀고정용 적절한 너비
- {
- accessorKey: "attachmentType",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="분류" />
- ),
- cell: ({ row }) => (
- <div className="space-y-1">
- <div className="font-medium text-sm">{row.getValue("attachmentType")}</div>
- {row.original.attachmentDescription && (
- <div className="text-xs text-muted-foreground truncate max-w-32"
- title={row.original.attachmentDescription}>
- {row.original.attachmentDescription}
- </div>
- )}
- </div>
- ),
-
- meta: {
- excelHeader: "분류",
- paddingFactor: 1.0
- },
- },
-
- // 파일명 - 가장 긴 텍스트를 위한 여유 공간
- {
- id: "fileName",
- header: "파일명",
- cell: ({ row }) => (
- <AttachmentFileNameCell revisions={row.original.attachment?.revisions || []} />
- ),
-
- meta: {
- paddingFactor: 1.5
- },
- },
-
- // 상태 - 뱃지 크기 고려
- {
- accessorKey: "effectiveStatus",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => {
- const statusInfo = getEffectiveStatusInfo(row.getValue("effectiveStatus"));
- const StatusIcon = statusInfo.icon;
-
- return (
- <div className="space-y-1">
- <Badge variant={statusInfo.variant} className="flex items-center gap-1 w-fit">
- <StatusIcon className="h-3 w-3" />
- <span>{statusInfo.label}</span>
- </Badge>
- {row.original.needsUpdate && (
- <div className="text-xs text-blue-600 flex items-center gap-1">
- <RefreshCw className="h-3 w-3" />
- <span>업데이트 권장</span>
- </div>
- )}
- </div>
- );
- },
-
- meta: {
- excelHeader: "상태",
- paddingFactor: 1.2
- },
- },
-
- // 리비전 현황 - 복합 정보로 넓은 공간 필요
- {
- id: "revisionStatus",
- header: "리비전 현황",
- cell: ({ row }) => <RevisionComparisonCell response={row.original} />,
-
- meta: {
- paddingFactor: 1.3
- },
- },
-
- // 요청일 - 날짜 형식 고정
- {
- accessorKey: "requestedAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="요청일" />
- ),
- cell: ({ row }) => (
- <div className="text-sm flex items-center gap-1">
- <Calendar className="h-3 w-3 text-muted-foreground" />
- <span className="whitespace-nowrap">{formatDateTime(new Date(row.getValue("requestedAt")))}</span>
- </div>
- ),
-
- meta: {
- excelHeader: "요청일",
- paddingFactor: 0.9
- },
- },
-
- // 응답일 - 날짜 형식 고정
- {
- accessorKey: "respondedAt",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="응답일" />
- ),
- cell: ({ row }) => (
- <div className="text-sm">
- <span className="whitespace-nowrap">
- {row.getValue("respondedAt")
- ? formatDateTime(new Date(row.getValue("respondedAt")))
- : "-"
- }
- </span>
- </div>
- ),
- meta: {
- excelHeader: "응답일",
- paddingFactor: 0.9
- },
- },
-
- // 응답파일 - 작은 공간
- {
- accessorKey: "totalResponseFiles",
- header: ({ column }) => (
- <ClientDataTableColumnHeaderSimple column={column} title="응답파일" />
- ),
- cell: ({ row }) => (
- <div className="text-center">
- <div className="text-sm font-medium">
- {row.getValue("totalResponseFiles")}개
- </div>
- {row.original.latestResponseFileName && (
- <div className="text-xs text-muted-foreground truncate max-w-20"
- title={row.original.latestResponseFileName}>
- {row.original.latestResponseFileName}
- </div>
- )}
- </div>
- ),
- meta: {
- excelHeader: "응답파일",
- paddingFactor: 0.8
- },
- },
-
- // 코멘트 - 가변 텍스트 길이
- {
- id: "comments",
- header: "코멘트",
- cell: ({ row }) => <CommentDisplayCell response={row.original} />,
- // size: 180,
- meta: {
- paddingFactor: 1.4
- },
- },
-
- // 진행도 - 중간 크기
- {
- id: "progress",
- header: "진행도",
- cell: ({ row }) => (
- <div className="space-y-1 text-center">
- {row.original.hasMultipleRevisions && (
- <Badge variant="outline" className="text-xs">
- 다중 리비전
- </Badge>
- )}
- {row.original.versionLag !== undefined && row.original.versionLag > 0 && (
- <div className="text-xs text-blue-600 whitespace-nowrap">
- {row.original.versionLag}버전 차이
- </div>
- )}
- </div>
- ),
- // size: 100,
- meta: {
- paddingFactor: 1.1
- },
- },
-
-{
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const response = row.original;
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-56">
- {/* 상태별 주요 액션들 */}
- {response.effectiveStatus === "NOT_RESPONDED" && (
- <>
- <DropdownMenuItem asChild>
- <UploadResponseDialog
- responseId={response.responseId}
- attachmentType={response.attachmentType}
- serialNo={response.serialNo}
- currentRevision={response.currentRevision}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <Upload className="size-4 mr-2" />
- 업로드
- </div>
- }
- />
- </DropdownMenuItem>
- <DropdownMenuItem asChild>
- <WaiveResponseDialog
- responseId={response.responseId}
- attachmentType={response.attachmentType}
- serialNo={response.serialNo}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <FileX className="size-4 mr-2" />
- 포기
- </div>
- }
- />
- </DropdownMenuItem>
- </>
- )}
-
- {response.effectiveStatus === "REVISION_REQUESTED" && (
- <DropdownMenuItem asChild>
- <UploadResponseDialog
- responseId={response.responseId}
- attachmentType={response.attachmentType}
- serialNo={response.serialNo}
- currentRevision={response.currentRevision}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <FileText className="size-4 mr-2" />
- 수정
- </div>
- }
- />
- </DropdownMenuItem>
- )}
-
- {response.effectiveStatus === "VERSION_MISMATCH" && (
- <DropdownMenuItem asChild>
- <UploadResponseDialog
- responseId={response.responseId}
- attachmentType={response.attachmentType}
- serialNo={response.serialNo}
- currentRevision={response.currentRevision}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <RefreshCw className="size-4 mr-2" />
- 업데이트
- </div>
- }
- />
- </DropdownMenuItem>
- )}
-
- {/* 구분선 - 주요 액션과 보조 액션 분리 */}
- {(response.effectiveStatus === "NOT_RESPONDED" ||
- response.effectiveStatus === "REVISION_REQUESTED" ||
- response.effectiveStatus === "VERSION_MISMATCH") &&
- response.effectiveStatus !== "WAIVED" && (
- <DropdownMenuSeparator />
- )}
-
- {/* 공통 액션들 */}
- {response.effectiveStatus !== "WAIVED" && (
- <DropdownMenuItem asChild>
- <CommentEditDialog
- responseId={response.responseId}
- currentResponseComment={response.responseComment || ""}
- currentVendorComment={response.vendorComment || ""}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <MessageSquare className="size-4 mr-2" />
- 코멘트 편집
- </div>
- }
- />
- </DropdownMenuItem>
- )}
-
- <DropdownMenuItem asChild>
- <ResponseDetailSheet
- response={response}
- trigger={
- <div className="flex items-center w-full cursor-pointer p-2">
- <FileText className="size-4 mr-2" />
- 상세보기
- </div>
- }
- />
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
-
- )
- },
- size: 40,
-}
-
- ]
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/response-detail-sheet.tsx b/lib/b-rfq/vendor-response/response-detail-sheet.tsx
deleted file mode 100644
index da7f9b01..00000000
--- a/lib/b-rfq/vendor-response/response-detail-sheet.tsx
+++ /dev/null
@@ -1,358 +0,0 @@
-// components/rfq/response-detail-sheet.tsx
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
- SheetTrigger,
-} from "@/components/ui/sheet";
-import {
- FileText,
- Upload,
- Download,
- AlertCircle,
- MessageSquare,
- FileCheck,
- Eye
-} from "lucide-react";
-import { formatDateTime, formatFileSize } from "@/lib/utils";
-import { cn } from "@/lib/utils";
-import type { EnhancedVendorResponse } from "@/lib/b-rfq/service";
-
-// 파일 다운로드 핸들러 (API 사용)
-async function handleFileDownload(
- filePath: string,
- fileName: string,
- type: "client" | "vendor" = "client",
- id?: number
-) {
- try {
- const params = new URLSearchParams({
- path: filePath,
- type: type,
- });
-
- // ID가 있으면 추가
- if (id) {
- if (type === "client") {
- params.append("revisionId", id.toString());
- } else {
- params.append("responseFileId", id.toString());
- }
- }
-
- const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`);
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || `Download failed: ${response.status}`);
- }
-
- // Blob으로 파일 데이터 받기
- const blob = await response.blob();
-
- // 임시 URL 생성하여 다운로드
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = fileName;
- document.body.appendChild(link);
- link.click();
-
- // 정리
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- console.log("✅ 파일 다운로드 성공:", fileName);
-
- } catch (error) {
- console.error("❌ 파일 다운로드 실패:", error);
-
- // 사용자에게 에러 알림 (토스트나 알럿으로 대체 가능)
- alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
- }
-}
-
-// 효과적인 상태별 아이콘 및 색상
-function getEffectiveStatusInfo(effectiveStatus: string) {
- switch (effectiveStatus) {
- case "NOT_RESPONDED":
- return {
- label: "미응답",
- variant: "outline" as const
- };
- case "UP_TO_DATE":
- return {
- label: "최신",
- variant: "default" as const
- };
- case "VERSION_MISMATCH":
- return {
- label: "업데이트 필요",
- variant: "secondary" as const
- };
- case "REVISION_REQUESTED":
- return {
- label: "수정요청",
- variant: "secondary" as const
- };
- case "WAIVED":
- return {
- label: "포기",
- variant: "outline" as const
- };
- default:
- return {
- label: effectiveStatus,
- variant: "outline" as const
- };
- }
-}
-
-interface ResponseDetailSheetProps {
- response: EnhancedVendorResponse;
- trigger?: React.ReactNode;
-}
-
-export function ResponseDetailSheet({ response, trigger }: ResponseDetailSheetProps) {
- const hasMultipleRevisions = response.attachment?.revisions && response.attachment.revisions.length > 1;
- const hasResponseFiles = response.responseAttachments && response.responseAttachments.length > 0;
-
- return (
- <Sheet>
- <SheetTrigger asChild>
- {trigger || (
- <Button size="sm" variant="ghost">
- <Eye className="h-3 w-3 mr-1" />
- 상세
- </Button>
- )}
- </SheetTrigger>
- <SheetContent side="right" className="w-[600px] sm:w-[800px] overflow-y-auto">
- <SheetHeader>
- <SheetTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 상세 정보 - {response.serialNo}
- </SheetTitle>
- <SheetDescription>
- {response.attachmentType} • {response.attachment?.revisions?.[0]?.originalFileName}
- </SheetDescription>
- </SheetHeader>
-
- <div className="space-y-6 mt-6">
- {/* 기본 정보 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold flex items-center gap-2">
- <AlertCircle className="h-4 w-4" />
- 기본 정보
- </h3>
- <div className="grid grid-cols-2 gap-4 p-4 bg-muted/30 rounded-lg">
- <div>
- <div className="text-sm text-muted-foreground">상태</div>
- <div className="font-medium">{getEffectiveStatusInfo(response.effectiveStatus).label}</div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">현재 리비전</div>
- <div className="font-medium">{response.currentRevision}</div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">응답 리비전</div>
- <div className="font-medium">{response.respondedRevision || "-"}</div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">응답일</div>
- <div className="font-medium">
- {response.respondedAt ? formatDateTime(new Date(response.respondedAt)) : "-"}
- </div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">요청일</div>
- <div className="font-medium">
- {formatDateTime(new Date(response.requestedAt))}
- </div>
- </div>
- <div>
- <div className="text-sm text-muted-foreground">응답 파일 수</div>
- <div className="font-medium">{response.totalResponseFiles}개</div>
- </div>
- </div>
- </div>
-
- {/* 코멘트 정보 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold flex items-center gap-2">
- <MessageSquare className="h-4 w-4" />
- 코멘트
- </h3>
- <div className="space-y-3">
- {response.responseComment && (
- <div className="p-3 border-l-4 border-blue-500 bg-blue-50">
- <div className="text-sm font-medium text-blue-700 mb-1">발주처 응답 코멘트</div>
- <div className="text-sm">{response.responseComment}</div>
- </div>
- )}
- {response.vendorComment && (
- <div className="p-3 border-l-4 border-green-500 bg-green-50">
- <div className="text-sm font-medium text-green-700 mb-1">내부 메모</div>
- <div className="text-sm">{response.vendorComment}</div>
- </div>
- )}
- {response.attachment?.revisions?.find(r => r.revisionComment) && (
- <div className="p-3 border-l-4 border-orange-500 bg-orange-50">
- <div className="text-sm font-medium text-orange-700 mb-1">발주처 요청 사항</div>
- <div className="text-sm">
- {response.attachment.revisions.find(r => r.revisionComment)?.revisionComment}
- </div>
- </div>
- )}
- {!response.responseComment && !response.vendorComment && !response.attachment?.revisions?.find(r => r.revisionComment) && (
- <div className="text-center text-muted-foreground py-4 bg-muted/20 rounded-lg">
- 코멘트가 없습니다.
- </div>
- )}
- </div>
- </div>
-
- {/* 발주처 리비전 히스토리 */}
- {hasMultipleRevisions && (
- <div className="space-y-4">
- <h3 className="text-lg font-semibold flex items-center gap-2">
- <FileCheck className="h-4 w-4" />
- 발주처 리비전 히스토리 ({response.attachment!.revisions.length}개)
- </h3>
- <div className="space-y-3">
- {response.attachment!.revisions
- .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
- .map((revision) => (
- <div
- key={revision.id}
- className={cn(
- "flex items-center justify-between p-4 rounded-lg border",
- revision.isLatest ? "bg-blue-50 border-blue-200" : "bg-white"
- )}
- >
- <div className="flex items-center gap-3 flex-1">
- <Badge variant={revision.isLatest ? "default" : "outline"}>
- {revision.revisionNo}
- </Badge>
- <div className="flex-1">
- <div className="font-medium text-sm">{revision.originalFileName}</div>
- <div className="text-xs text-muted-foreground">
- {formatFileSize(revision.fileSize)} • {formatDateTime(new Date(revision.createdAt))}
- </div>
- {revision.revisionComment && (
- <div className="text-xs text-muted-foreground mt-1 italic">
- "{revision.revisionComment}"
- </div>
- )}
- </div>
- </div>
-
- <div className="flex items-center gap-2">
- {revision.isLatest && (
- <Badge variant="secondary" className="text-xs">최신</Badge>
- )}
- {revision.revisionNo === response.respondedRevision && (
- <Badge variant="outline" className="text-xs text-green-600 border-green-300">
- 응답됨
- </Badge>
- )}
- <Button
- size="sm"
- variant="ghost"
- onClick={() => {
- if (revision.filePath) {
- handleFileDownload(
- revision.filePath,
- revision.originalFileName,
- "client",
- revision.id
- );
- }
- }}
- disabled={!revision.filePath}
- title="파일 다운로드"
- >
- <Download className="h-4 w-4" />
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 벤더 응답 파일들 */}
- {hasResponseFiles && (
- <div className="space-y-4">
- <h3 className="text-lg font-semibold flex items-center gap-2">
- <Upload className="h-4 w-4" />
- 벤더 응답 파일들 ({response.totalResponseFiles}개)
- </h3>
- <div className="space-y-3">
- {response.responseAttachments!
- .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
- .map((file) => (
- <div key={file.id} className="flex items-center justify-between p-4 rounded-lg border bg-green-50 border-green-200">
- <div className="flex items-center gap-3 flex-1">
- <Badge variant="outline" className="bg-green-100">
- 파일 #{file.fileSequence}
- </Badge>
- <div className="flex-1">
- <div className="font-medium text-sm">{file.originalFileName}</div>
- <div className="text-xs text-muted-foreground">
- {formatFileSize(file.fileSize)} • {formatDateTime(new Date(file.uploadedAt))}
- </div>
- {file.description && (
- <div className="text-xs text-muted-foreground mt-1 italic">
- "{file.description}"
- </div>
- )}
- </div>
- </div>
-
- <div className="flex items-center gap-2">
- {file.isLatestResponseFile && (
- <Badge variant="secondary" className="text-xs">최신</Badge>
- )}
- <Button
- size="sm"
- variant="ghost"
- onClick={() => {
- if (file.filePath) {
- handleFileDownload(
- file.filePath,
- file.originalFileName,
- "vendor",
- file.id
- );
- }
- }}
- disabled={!file.filePath}
- title="파일 다운로드"
- >
- <Download className="h-4 w-4" />
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {!hasMultipleRevisions && !hasResponseFiles && (
- <div className="text-center text-muted-foreground py-8 bg-muted/20 rounded-lg">
- <FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
- <p>추가 파일이나 리비전 정보가 없습니다.</p>
- </div>
- )}
- </div>
- </SheetContent>
- </Sheet>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/response-detail-table.tsx b/lib/b-rfq/vendor-response/response-detail-table.tsx
deleted file mode 100644
index 124d5241..00000000
--- a/lib/b-rfq/vendor-response/response-detail-table.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import type { EnhancedVendorResponse } from "@/lib/b-rfq/service"
-import { DataTableAdvancedFilterField } from "@/types/table"
-import { DataTableRowAction, getColumns } from "./response-detail-columns"
-
-interface FinalRfqResponseTableProps {
- data: EnhancedVendorResponse[]
- // ✅ 헤더 정보를 props로 받기
- statistics?: {
- total: number
- upToDate: number
- versionMismatch: number
- pending: number
- revisionRequested: number
- waived: number
- }
- showHeader?: boolean
- title?: string
-}
-
-/**
- * FinalRfqResponseTable: RFQ 응답 데이터를 표시하는 표
- */
-export function FinalRfqResponseTable({
- data,
- statistics,
- showHeader = true,
- title = "첨부파일별 응답 현황"
-}: FinalRfqResponseTableProps) {
- const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<EnhancedVendorResponse> | null>(null)
-
- const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction]
- )
-
- // 고급 필터 필드 정의
- const advancedFilterFields: DataTableAdvancedFilterField<EnhancedVendorResponse>[] = [
- {
- id: "effectiveStatus",
- label: "상태",
- type: "select",
- options: [
- { label: "미응답", value: "NOT_RESPONDED" },
- { label: "최신", value: "UP_TO_DATE" },
- { label: "업데이트 필요", value: "VERSION_MISMATCH" },
- { label: "수정요청", value: "REVISION_REQUESTED" },
- { label: "포기", value: "WAIVED" },
- ],
- },
- {
- id: "attachmentType",
- label: "첨부파일 분류",
- type: "text",
- },
- {
- id: "serialNo",
- label: "시리얼 번호",
- type: "text",
- },
- {
- id: "isVersionMatched",
- label: "버전 일치",
- type: "select",
- options: [
- { label: "일치", value: "true" },
- { label: "불일치", value: "false" },
- ],
- },
- {
- id: "hasMultipleRevisions",
- label: "다중 리비전",
- type: "select",
- options: [
- { label: "있음", value: "true" },
- { label: "없음", value: "false" },
- ],
- },
- ]
-
- if (data.length === 0) {
- return (
- <div className="border rounded-lg p-12 text-center">
- <div className="mx-auto mb-4 h-12 w-12 text-muted-foreground">
- 📄
- </div>
- <p className="text-muted-foreground">응답할 첨부파일이 없습니다.</p>
- </div>
- )
- }
-
- return (
- // ✅ 상위 컨테이너 구조 단순화 및 너비 제한 해제
-<>
- {/* 코멘트 범례 */}
- <div className="flex items-center gap-6 text-xs text-muted-foreground bg-muted/30 p-3 rounded-lg">
- <span className="font-medium">코멘트 범례:</span>
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-blue-500"></div>
- <span>벤더 응답</span>
- </div>
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-green-500"></div>
- <span>내부 메모</span>
- </div>
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-red-500"></div>
- <span>수정 요청</span>
- </div>
- <div className="flex items-center gap-1">
- <div className="w-2 h-2 rounded-full bg-orange-500"></div>
- <span>발주처 리비전</span>
- </div>
- </div>
- <div style={{
- width: '100%',
- maxWidth: '100%',
- overflow: 'hidden',
- contain: 'layout'
- }}>
- {/* 데이터 테이블 - 컨테이너 제약 최소화 */}
- <ClientDataTable
- data={data}
- columns={columns}
- advancedFilterFields={advancedFilterFields}
- autoSizeColumns={true}
- compact={true}
- // ✅ RFQ 테이블에 맞는 컬럼 핀고정
- initialColumnPinning={{
- left: ["serialNo", "attachmentType"],
- right: ["actions"]
- }}
- >
- {showHeader && (
- <div className="flex items-center justify-between">
-
- {statistics && (
- <div className="flex items-center gap-4 text-sm text-muted-foreground">
- <span>전체 {statistics.total}개</span>
- <span className="text-green-600">최신 {statistics.upToDate}개</span>
- <span className="text-blue-600">업데이트필요 {statistics.versionMismatch}개</span>
- <span className="text-orange-600">미응답 {statistics.pending}개</span>
- {statistics.revisionRequested > 0 && (
- <span className="text-yellow-600">수정요청 {statistics.revisionRequested}개</span>
- )}
- {statistics.waived > 0 && (
- <span className="text-gray-600">포기 {statistics.waived}개</span>
- )}
- </div>
- )}
- </div>
- )}
- </ClientDataTable>
- </div>
- </>
- )
-}
diff --git a/lib/b-rfq/vendor-response/upload-response-dialog.tsx b/lib/b-rfq/vendor-response/upload-response-dialog.tsx
deleted file mode 100644
index b4b306d6..00000000
--- a/lib/b-rfq/vendor-response/upload-response-dialog.tsx
+++ /dev/null
@@ -1,325 +0,0 @@
-// components/rfq/upload-response-dialog.tsx
-"use client";
-
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { Upload, FileText, X, Loader2 } from "lucide-react";
-import { useToast } from "@/hooks/use-toast"
-import { useRouter } from "next/navigation";
-
-const uploadFormSchema = z.object({
- files: z.array(z.instanceof(File)).min(1, "최소 1개의 파일을 선택해주세요"),
- responseComment: z.string().optional(),
- vendorComment: z.string().optional(),
-});
-
-type UploadFormData = z.infer<typeof uploadFormSchema>;
-
-interface UploadResponseDialogProps {
- responseId: number;
- attachmentType: string;
- serialNo: string;
- currentRevision: string;
- trigger?: React.ReactNode;
- onSuccess?: () => void;
-}
-
-export function UploadResponseDialog({
- responseId,
- attachmentType,
- serialNo,
- currentRevision,
- trigger,
- onSuccess,
-}: UploadResponseDialogProps) {
- const [open, setOpen] = useState(false);
- const [isUploading, setIsUploading] = useState(false);
- const { toast } = useToast();
- const router = useRouter();
-
- const form = useForm<UploadFormData>({
- resolver: zodResolver(uploadFormSchema),
- defaultValues: {
- files: [],
- responseComment: "",
- vendorComment: "",
- },
- });
-
- const selectedFiles = form.watch("files");
-
- const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
- const files = Array.from(e.target.files || []);
- if (files.length > 0) {
- form.setValue("files", files);
- }
- };
-
- const removeFile = (index: number) => {
- const currentFiles = form.getValues("files");
- const newFiles = currentFiles.filter((_, i) => i !== index);
- form.setValue("files", newFiles);
- };
-
- const formatFileSize = (bytes: number): string => {
- if (bytes === 0) return "0 Bytes";
- const k = 1024;
- const sizes = ["Bytes", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
- };
-
- const handleOpenChange = (newOpen: boolean) => {
- setOpen(newOpen);
- // 다이얼로그가 닫힐 때 form 리셋
- if (!newOpen) {
- form.reset();
- }
- };
-
- const handleCancel = () => {
- form.reset();
- setOpen(false);
- };
-
- const onSubmit = async (data: UploadFormData) => {
- setIsUploading(true);
-
- try {
- // 1. 각 파일을 업로드 API로 전송
- const uploadedFiles = [];
-
- for (const file of data.files) {
- const formData = new FormData();
- formData.append("file", file);
- formData.append("responseId", responseId.toString());
- formData.append("description", ""); // 필요시 파일별 설명 추가 가능
-
- const uploadResponse = await fetch("/api/vendor-responses/upload", {
- method: "POST",
- body: formData,
- });
-
- if (!uploadResponse.ok) {
- const error = await uploadResponse.json();
- throw new Error(error.message || "파일 업로드 실패");
- }
-
- const uploadResult = await uploadResponse.json();
- uploadedFiles.push(uploadResult);
- }
-
- // 2. vendor response 상태 업데이트 (서버에서 자동으로 리비전 증가)
- const updateResponse = await fetch("/api/vendor-responses/update", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- responseId,
- responseStatus: "RESPONDED",
- // respondedRevision 제거 - 서버에서 자동 처리
- responseComment: data.responseComment,
- vendorComment: data.vendorComment,
- respondedAt: new Date().toISOString(),
- }),
- });
-
- if (!updateResponse.ok) {
- const error = await updateResponse.json();
- throw new Error(error.message || "응답 상태 업데이트 실패");
- }
-
- const updateResult = await updateResponse.json();
-
- toast({
- title: "업로드 완료",
- description: `${data.files.length}개 파일이 성공적으로 업로드되었습니다. (${updateResult.newRevision})`,
- });
-
- setOpen(false);
- form.reset();
-
- router.refresh();
- onSuccess?.();
-
- } catch (error) {
- console.error("Upload error:", error);
- toast({
- title: "업로드 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsUploading(false);
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- {trigger || (
- <Button size="sm">
- <Upload className="h-3 w-3 mr-1" />
- 업로드
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Upload className="h-5 w-5" />
- 응답 파일 업로드
- </DialogTitle>
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Badge variant="outline">{serialNo}</Badge>
- <span>{attachmentType}</span>
- <Badge variant="secondary">{currentRevision}</Badge>
- <span className="text-xs text-blue-600">→ 벤더 응답 리비전 자동 증가</span>
- </div>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 파일 선택 */}
- <FormField
- control={form.control}
- name="files"
- render={({ field }) => (
- <FormItem>
- <FormLabel>파일 선택</FormLabel>
- <FormControl>
- <div className="space-y-4">
- <Input
- type="file"
- multiple
- onChange={handleFileSelect}
- accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.zip,.rar"
- className="cursor-pointer"
- />
- <div className="text-xs text-muted-foreground">
- 지원 파일: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, ZIP, RAR (최대 10MB)
- </div>
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 선택된 파일 목록 */}
- {selectedFiles.length > 0 && (
- <div className="space-y-2">
- <div className="text-sm font-medium">선택된 파일 ({selectedFiles.length}개)</div>
- <div className="space-y-2 max-h-40 overflow-y-auto">
- {selectedFiles.map((file, index) => (
- <div
- key={index}
- className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
- >
- <div className="flex items-center gap-2 flex-1 min-w-0">
- <FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
- <div className="min-w-0 flex-1">
- <div className="text-sm font-medium truncate">{file.name}</div>
- <div className="text-xs text-muted-foreground">
- {formatFileSize(file.size)}
- </div>
- </div>
- </div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeFile(index)}
- className="flex-shrink-0 ml-2"
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 응답 코멘트 */}
- <FormField
- control={form.control}
- name="responseComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>응답 코멘트</FormLabel>
- <FormControl>
- <Textarea
- placeholder="응답에 대한 설명을 입력하세요..."
- className="resize-none"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 벤더 코멘트 */}
- <FormField
- control={form.control}
- name="vendorComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더 코멘트 (내부용)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="내부 참고용 코멘트를 입력하세요..."
- className="resize-none"
- rows={2}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 버튼 */}
- <div className="flex justify-end gap-2">
- <Button
- type="button"
- variant="outline"
- onClick={handleCancel}
- disabled={isUploading}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isUploading || selectedFiles.length === 0}>
- {isUploading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
- {isUploading ? "업로드 중..." : "업로드"}
- </Button>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx
deleted file mode 100644
index 47b7570b..00000000
--- a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx
+++ /dev/null
@@ -1,351 +0,0 @@
-// lib/vendor-responses/table/vendor-responses-table-columns.tsx
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import {
- Ellipsis, FileText, Pencil, Edit, Trash2,
- Eye, MessageSquare, Clock, CheckCircle, AlertTriangle, FileX
-} from "lucide-react"
-import { formatDate, formatDateTime } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import Link from "next/link"
-import { useRouter } from "next/navigation"
-import { VendorResponseDetail } from "../service"
-import { VendorRfqResponseSummary } from "../validations"
-
-// 응답 상태에 따른 배지 컴포넌트
-function ResponseStatusBadge({ status }: { status: string }) {
- switch (status) {
- case "NOT_RESPONDED":
- return (
- <Badge variant="outline" className="text-orange-600 border-orange-600">
- <Clock className="mr-1 h-3 w-3" />
- 미응답
- </Badge>
- )
- case "RESPONDED":
- return (
- <Badge variant="default" className="bg-green-600 text-white">
- <CheckCircle className="mr-1 h-3 w-3" />
- 응답완료
- </Badge>
- )
- case "REVISION_REQUESTED":
- return (
- <Badge variant="secondary" className="text-yellow-600 border-yellow-600">
- <AlertTriangle className="mr-1 h-3 w-3" />
- 수정요청
- </Badge>
- )
- case "WAIVED":
- return (
- <Badge variant="outline" className="text-gray-600 border-gray-600">
- <FileX className="mr-1 h-3 w-3" />
- 포기
- </Badge>
- )
- default:
- return <Badge>{status}</Badge>
- }
-}
-
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetColumnsProps {
- router: NextRouter
-}
-
-/**
- * tanstack table 컬럼 정의
- */
-export function getColumns({
- router,
-}: GetColumnsProps): ColumnDef<VendorResponseDetail>[] {
-
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<VendorRfqResponseSummary> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼 (작성하기 버튼만)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<VendorRfqResponseSummary> = {
- id: "actions",
- enableHiding: false,
- cell: ({ row }) => {
- const vendorId = row.original.vendorId
- const rfqRecordId = row.original.rfqRecordId
- const rfqType = row.original.rfqType
- const rfqCode = row.original.rfq?.rfqCode || "RFQ"
-
- return (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => router.push(`/partners/rfq-answer/${vendorId}/${rfqRecordId}`)}
- className="h-8 px-3"
- >
- <Edit className="h-4 w-4 mr-1" />
- 작성하기
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{rfqCode} 응답 작성하기</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- )
- },
- size: 100,
- minSize: 100,
- maxSize: 150,
- }
-
- // ----------------------------------------------------------------
- // 3) 컬럼 정의 배열
- // ----------------------------------------------------------------
- const columnDefinitions = [
- {
- id: "rfqCode",
- label: "RFQ 번호",
- group: "RFQ 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
-
- {
- id: "rfqDueDate",
- label: "RFQ 마감일",
- group: "RFQ 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
-
- {
- id: "overallStatus",
- label: "전체 상태",
- group: null,
- size: 80,
- minSize: 60,
- maxSize: 100,
- },
- {
- id: "totalAttachments",
- label: "총 첨부파일",
- group: "응답 통계",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "respondedCount",
- label: "응답완료",
- group: "응답 통계",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "pendingCount",
- label: "미응답",
- group: "응답 통계",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "responseRate",
- label: "응답률",
- group: "진행률",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "completionRate",
- label: "완료율",
- group: "진행률",
- size: 100,
- minSize: 80,
- maxSize: 120,
- },
- {
- id: "requestedAt",
- label: "요청일",
- group: "날짜 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
- {
- id: "lastRespondedAt",
- label: "최종 응답일",
- group: "날짜 정보",
- size: 120,
- minSize: 100,
- maxSize: 150,
- },
- ];
-
- // ----------------------------------------------------------------
- // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성)
- // ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<VendorRfqResponseSummary>[]> = {}
-
- columnDefinitions.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // 개별 컬럼 정의
- const columnDef: ColumnDef<VendorRfqResponseSummary> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- cell: ({ row, cell }) => {
- // 각 컬럼별 특별한 렌더링 처리
- switch (cfg.id) {
- case "rfqCode":
- return row.original.rfq?.rfqCode || "-"
-
-
- case "rfqDueDate":
- const dueDate = row.original.rfq?.dueDate;
- return dueDate ? formatDate(new Date(dueDate)) : "-";
-
- case "overallStatus":
- return <ResponseStatusBadge status={row.original.overallStatus} />
-
- case "totalAttachments":
- return (
- <div className="text-center font-medium">
- {row.original.totalAttachments}
- </div>
- )
-
- case "respondedCount":
- return (
- <div className="text-center text-green-600 font-medium">
- {row.original.respondedCount}
- </div>
- )
-
- case "pendingCount":
- return (
- <div className="text-center text-orange-600 font-medium">
- {row.original.pendingCount}
- </div>
- )
-
- case "responseRate":
- const responseRate = row.original.responseRate;
- return (
- <div className="text-center">
- <span className={`font-medium ${responseRate >= 80 ? 'text-green-600' : responseRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
- {responseRate}%
- </span>
- </div>
- )
-
- case "completionRate":
- const completionRate = row.original.completionRate;
- return (
- <div className="text-center">
- <span className={`font-medium ${completionRate >= 80 ? 'text-green-600' : completionRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
- {completionRate}%
- </span>
- </div>
- )
-
- case "requestedAt":
- return formatDateTime(new Date(row.original.requestedAt))
-
- case "lastRespondedAt":
- const lastRespondedAt = row.original.lastRespondedAt;
- return lastRespondedAt ? formatDateTime(new Date(lastRespondedAt)) : "-";
-
- default:
- return row.getValue(cfg.id) ?? ""
- }
- },
- size: cfg.size,
- minSize: cfg.minSize,
- maxSize: cfg.maxSize,
- }
-
- groupMap[groupName].push(columnDef)
- })
-
- // ----------------------------------------------------------------
- // 5) 그룹별 중첩 컬럼 생성
- // ----------------------------------------------------------------
- const nestedColumns: ColumnDef<VendorRfqResponseSummary>[] = []
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- // 그룹이 없는 컬럼들은 직접 추가
- nestedColumns.push(...colDefs)
- } else {
- // 그룹이 있는 컬럼들은 중첩 구조로 추가
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
-
- // ----------------------------------------------------------------
- // 6) 최종 컬럼 배열
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- actionsColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/vendor-responses-table.tsx b/lib/b-rfq/vendor-response/vendor-responses-table.tsx
deleted file mode 100644
index 02a5fa59..00000000
--- a/lib/b-rfq/vendor-response/vendor-responses-table.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-// lib/vendor-responses/table/vendor-responses-table.tsx
-"use client"
-
-import * as React from "react"
-import { type DataTableAdvancedFilterField, type DataTableFilterField, type DataTableRowAction } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { Button } from "@/components/ui/button"
-import { useRouter } from "next/navigation"
-import { getColumns } from "./vendor-responses-table-columns"
-import { VendorRfqResponseSummary } from "../validations"
-
-interface VendorResponsesTableProps {
- promises: Promise<[{ data: VendorRfqResponseSummary[], pageCount: number, totalCount: number }]>;
-}
-
-export function VendorResponsesTable({ promises }: VendorResponsesTableProps) {
- const [{ data, pageCount, totalCount }] = React.use(promises);
- const router = useRouter();
-
- console.log(data, "vendor responses data")
-
- // 선택된 행 액션 상태
-
- // 테이블 컬럼 정의
- const columns = React.useMemo(() => getColumns({
- router,
- }), [router]);
-
- // 상태별 응답 수 계산 (전체 상태 기준)
- const statusCounts = React.useMemo(() => {
- return {
- NOT_RESPONDED: data.filter(r => r.overallStatus === "NOT_RESPONDED").length,
- RESPONDED: data.filter(r => r.overallStatus === "RESPONDED").length,
- REVISION_REQUESTED: data.filter(r => r.overallStatus === "REVISION_REQUESTED").length,
- WAIVED: data.filter(r => r.overallStatus === "WAIVED").length,
- };
- }, [data]);
-
-
- // 필터 필드
- const filterFields: DataTableFilterField<VendorRfqResponseSummary>[] = [
- {
- id: "overallStatus",
- label: "전체 상태",
- options: [
- { label: "미응답", value: "NOT_RESPONDED", count: statusCounts.NOT_RESPONDED },
- { label: "응답완료", value: "RESPONDED", count: statusCounts.RESPONDED },
- { label: "수정요청", value: "REVISION_REQUESTED", count: statusCounts.REVISION_REQUESTED },
- { label: "포기", value: "WAIVED", count: statusCounts.WAIVED },
- ]
- },
-
-
- ];
-
- // 고급 필터 필드
- const advancedFilterFields: DataTableAdvancedFilterField<VendorRfqResponseSummary>[] = [
-
- {
- id: "overallStatus",
- label: "전체 상태",
- type: "multi-select",
- options: [
- { label: "미응답", value: "NOT_RESPONDED" },
- { label: "응답완료", value: "RESPONDED" },
- { label: "수정요청", value: "REVISION_REQUESTED" },
- { label: "포기", value: "WAIVED" },
- ],
- },
- {
- id: "rfqType",
- label: "RFQ 타입",
- type: "multi-select",
- options: [
- { label: "초기 RFQ", value: "INITIAL" },
- { label: "최종 RFQ", value: "FINAL" },
- ],
- },
- {
- id: "responseRate",
- label: "응답률",
- type: "number",
- },
- {
- id: "completionRate",
- label: "완료율",
- type: "number",
- },
- {
- id: "requestedAt",
- label: "요청일",
- type: "date",
- },
- {
- id: "lastRespondedAt",
- label: "최종 응답일",
- type: "date",
- },
- ];
-
- // useDataTable 훅 사용
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableColumnResizing: true,
- columnResizeMode: 'onChange',
- initialState: {
- sorting: [{ id: "updatedAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- defaultColumn: {
- minSize: 50,
- maxSize: 500,
- },
- });
-
- return (
- <div className="w-full">
- <div className="flex items-center justify-between py-4">
- <div className="flex items-center space-x-2">
- <span className="text-sm text-muted-foreground">
- 총 {totalCount}개의 응답 요청
- </span>
- </div>
- </div>
-
- <div className="overflow-x-auto">
- <DataTable
- table={table}
- className="min-w-full"
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- {/* 추가적인 액션 버튼들을 여기에 추가할 수 있습니다 */}
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </div>
- );
-} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/waive-response-dialog.tsx b/lib/b-rfq/vendor-response/waive-response-dialog.tsx
deleted file mode 100644
index 5ded4da3..00000000
--- a/lib/b-rfq/vendor-response/waive-response-dialog.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-// components/rfq/waive-response-dialog.tsx
-"use client";
-
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Textarea } from "@/components/ui/textarea";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import * as z from "zod";
-import { FileX, Loader2, AlertTriangle } from "lucide-react";
-import { useToast } from "@/hooks/use-toast";
-import { useRouter } from "next/navigation";
-
-const waiveFormSchema = z.object({
- responseComment: z.string().min(1, "포기 사유를 입력해주세요"),
- vendorComment: z.string().optional(),
-});
-
-type WaiveFormData = z.infer<typeof waiveFormSchema>;
-
-interface WaiveResponseDialogProps {
- responseId: number;
- attachmentType: string;
- serialNo: string;
- trigger?: React.ReactNode;
- onSuccess?: () => void;
-}
-
-export function WaiveResponseDialog({
- responseId,
- attachmentType,
- serialNo,
- trigger,
- onSuccess,
-}: WaiveResponseDialogProps) {
- const [open, setOpen] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const { toast } = useToast();
- const router = useRouter();
-
- const form = useForm<WaiveFormData>({
- resolver: zodResolver(waiveFormSchema),
- defaultValues: {
- responseComment: "",
- vendorComment: "",
- },
- });
-
- const onSubmit = async (data: WaiveFormData) => {
- setIsSubmitting(true);
-
- try {
- const response = await fetch("/api/vendor-responses/waive", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- responseId,
- responseComment: data.responseComment,
- vendorComment: data.vendorComment,
- }),
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.message || "응답 포기 처리 실패");
- }
-
- toast({
- title: "응답 포기 완료",
- description: "해당 항목에 대한 응답이 포기 처리되었습니다.",
- });
-
- setOpen(false);
- form.reset();
-
- router.refresh();
- onSuccess?.();
-
- } catch (error) {
- console.error("Waive error:", error);
- toast({
- title: "처리 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsSubmitting(false);
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- {trigger || (
- <Button size="sm" variant="outline">
- <FileX className="h-3 w-3 mr-1" />
- 포기
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-lg">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2 text-orange-600">
- <FileX className="h-5 w-5" />
- 응답 포기
- </DialogTitle>
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Badge variant="outline">{serialNo}</Badge>
- <span>{attachmentType}</span>
- </div>
- </DialogHeader>
-
- <div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4">
- <div className="flex items-center gap-2 text-orange-800 text-sm font-medium mb-2">
- <AlertTriangle className="h-4 w-4" />
- 주의사항
- </div>
- <p className="text-orange-700 text-sm">
- 응답을 포기하면 해당 항목에 대한 입찰 참여가 불가능합니다.
- 포기 사유를 명확히 기입해 주세요.
- </p>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 포기 사유 (필수) */}
- <FormField
- control={form.control}
- name="responseComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-red-600">
- 포기 사유 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Textarea
- placeholder="응답을 포기하는 사유를 구체적으로 입력하세요..."
- className="resize-none"
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 내부 코멘트 (선택) */}
- <FormField
- control={form.control}
- name="vendorComment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>내부 코멘트 (선택)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="내부 참고용 코멘트를 입력하세요..."
- className="resize-none"
- rows={2}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 버튼 */}
- <div className="flex justify-end gap-2">
- <Button
- type="button"
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- variant="destructive"
- disabled={isSubmitting}
- >
- {isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
- {isSubmitting ? "처리 중..." : "포기하기"}
- </Button>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file