diff options
Diffstat (limited to 'lib/b-rfq')
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="수정이 필요한 구체적인 사유를 입력해주세요... 예: 제출된 도면에서 치수 정보가 누락되었습니다." - 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 |
