diff options
Diffstat (limited to 'lib/procurement-rfqs/vendor-response')
5 files changed, 0 insertions, 2626 deletions
diff --git a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx b/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx deleted file mode 100644 index 69ba0363..00000000 --- a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx +++ /dev/null @@ -1,522 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { toast } from "sonner" -import { - Send, - Paperclip, - DownloadCloud, - File, - FileText, - Image as ImageIcon, - AlertCircle, - X, - User, - Building -} from "lucide-react" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { formatDateTime, formatFileSize } from "@/lib/utils" -import { useSession } from "next-auth/react" -import { fetchBuyerVendorComments } from "../services" - -// 타입 정의 -interface Comment { - id: number; - rfqId: number; - vendorId: number | null // null 허용으로 변경 - userId?: number | null // null 허용으로 변경 - content: string; - isVendorComment: boolean | null; // null 허용으로 변경 - createdAt: Date; - updatedAt: Date; - userName?: string | null // null 허용으로 변경 - vendorName?: string | null // null 허용으로 변경 - attachments: Attachment[]; - isRead: boolean | null // null 허용으로 변경 -} - -interface Attachment { - id: number; - fileName: string; - fileSize: number; - fileType: string | null; // null 허용으로 변경 - filePath: string; - uploadedAt: Date; -} - -// 프롭스 정의 -interface BuyerCommunicationDrawerProps { - open: boolean; - onOpenChange: (open: boolean) => void; - quotation: { - id: number; - rfqId: number; - vendorId: number; - quotationCode: string; - rfq?: { - rfqCode: string; - }; - } | null; - onSuccess?: () => void; -} - - - -// 벤더 코멘트 전송 함수 -export function sendVendorCommentClient(params: { - rfqId: number; - vendorId: number; - content: string; - attachments?: File[]; -}): Promise<Comment> { - // 폼 데이터 생성 (파일 첨부를 위해) - const formData = new FormData(); - formData.append('rfqId', params.rfqId.toString()); - formData.append('vendorId', params.vendorId.toString()); - formData.append('content', params.content); - formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true - - // 첨부파일 추가 - if (params.attachments && params.attachments.length > 0) { - params.attachments.forEach((file) => { - formData.append(`attachments`, file); - }); - } - - // API 엔드포인트 구성 (벤더 API 경로) - const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; - - // API 호출 - return fetch(url, { - method: 'POST', - body: formData, // multipart/form-data 형식 사용 - }) - .then(response => { - if (!response.ok) { - return response.text().then(text => { - throw new Error(`API 요청 실패: ${response.status} ${text}`); - }); - } - return response.json(); - }) - .then(result => { - if (!result.success || !result.data) { - throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); - } - return result.data.comment; - }); -} - - -export function BuyerCommunicationDrawer({ - open, - onOpenChange, - quotation, - onSuccess -}: BuyerCommunicationDrawerProps) { - // 세션 정보 - const { data: session } = useSession(); - - // 상태 관리 - const [comments, setComments] = useState<Comment[]>([]); - const [newComment, setNewComment] = useState(""); - const [attachments, setAttachments] = useState<File[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - const messagesEndRef = useRef<HTMLDivElement>(null); - - // 첨부파일 관련 상태 - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); - - // 드로어가 열릴 때 데이터 로드 - useEffect(() => { - if (open && quotation) { - loadComments(); - } - }, [open, quotation]); - - // 스크롤 최하단으로 이동 - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); - } - }, [comments]); - - // 코멘트 로드 함수 - const loadComments = async () => { - if (!quotation) return; - - try { - setIsLoading(true); - - // API를 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId); - setComments(commentsData); - - // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정 - } catch (error) { - console.error("코멘트 로드 오류:", error); - toast.error("메시지를 불러오는 중 오류가 발생했습니다"); - } finally { - setIsLoading(false); - } - }; - - // 파일 선택 핸들러 - const handleFileSelect = () => { - fileInputRef.current?.click(); - }; - - // 파일 변경 핸들러 - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files && e.target.files.length > 0) { - const newFiles = Array.from(e.target.files); - setAttachments(prev => [...prev, ...newFiles]); - } - }; - - // 파일 제거 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)); - }; - - // 코멘트 전송 핸들러 - const handleSubmitComment = async () => { - if (!newComment.trim() && attachments.length === 0) return; - if (!quotation) return; - - try { - setIsSubmitting(true); - - // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) - const newCommentObj = await sendVendorCommentClient({ - rfqId: quotation.rfqId, - vendorId: quotation.vendorId, - content: newComment, - attachments: attachments - }); - - // 상태 업데이트 - setComments(prev => [...prev, newCommentObj]); - setNewComment(""); - setAttachments([]); - - toast.success("메시지가 전송되었습니다"); - - // 데이터 새로고침 - if (onSuccess) { - onSuccess(); - } - } catch (error) { - console.error("코멘트 전송 오류:", error); - toast.error("메시지 전송 중 오류가 발생했습니다"); - } finally { - setIsSubmitting(false); - } - }; - - // 첨부파일 미리보기 - const handleAttachmentPreview = (attachment: Attachment) => { - setSelectedAttachment(attachment); - setPreviewDialogOpen(true); - }; - - // 첨부파일 다운로드 - const handleAttachmentDownload = (attachment: Attachment) => { - // 실제 다운로드 구현 - window.open(attachment.filePath, '_blank'); - }; - - // 파일 아이콘 선택 - const getFileIcon = (fileType: string) => { - if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />; - if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />; - if (fileType.includes("spreadsheet") || fileType.includes("excel")) - return <FileText className="h-5 w-5 text-green-500" />; - if (fileType.includes("document") || fileType.includes("word")) - return <FileText className="h-5 w-5 text-blue-500" />; - return <File className="h-5 w-5 text-gray-500" />; - }; - - // 첨부파일 미리보기 다이얼로그 - const renderAttachmentPreviewDialog = () => { - if (!selectedAttachment) return null; - - const isImage = selectedAttachment.fileType.startsWith("image/"); - const isPdf = selectedAttachment.fileType.includes("pdf"); - - return ( - <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> - <DialogContent className="max-w-3xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - {getFileIcon(selectedAttachment.fileType)} - {selectedAttachment.fileName} - </DialogTitle> - <DialogDescription> - {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} - </DialogDescription> - </DialogHeader> - - <div className="min-h-[300px] flex items-center justify-center p-4"> - {isImage ? ( - <img - src={selectedAttachment.filePath} - alt={selectedAttachment.fileName} - className="max-h-[500px] max-w-full object-contain" - /> - ) : isPdf ? ( - <iframe - src={`${selectedAttachment.filePath}#toolbar=0`} - className="w-full h-[500px]" - title={selectedAttachment.fileName} - /> - ) : ( - <div className="flex flex-col items-center gap-4 p-8"> - {getFileIcon(selectedAttachment.fileType)} - <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p> - <Button - variant="outline" - onClick={() => handleAttachmentDownload(selectedAttachment)} - > - <DownloadCloud className="h-4 w-4 mr-2" /> - 다운로드 - </Button> - </div> - )} - </div> - </DialogContent> - </Dialog> - ); - }; - - if (!quotation) { - return null; - } - - // 구매자 정보 (실제로는 API에서 가져와야 함) - const buyerName = "구매 담당자"; - - return ( - <Drawer open={open} onOpenChange={onOpenChange}> - <DrawerContent className="max-h-[85vh]"> - <DrawerHeader className="border-b"> - <DrawerTitle className="flex items-center gap-2"> - <Avatar className="h-8 w-8"> - <AvatarFallback className="bg-primary/10"> - <User className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - <div> - <span>{buyerName}</span> - <Badge variant="outline" className="ml-2">구매자</Badge> - </div> - </DrawerTitle> - <DrawerDescription> - RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode} - </DrawerDescription> - </DrawerHeader> - - <div className="p-0 flex flex-col h-[60vh]"> - {/* 메시지 목록 */} - <ScrollArea className="flex-1 p-4"> - {isLoading ? ( - <div className="flex h-full items-center justify-center"> - <p className="text-muted-foreground">메시지 로딩 중...</p> - </div> - ) : comments.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <div className="flex flex-col items-center gap-2"> - <AlertCircle className="h-6 w-6 text-muted-foreground" /> - <p className="text-muted-foreground">아직 메시지가 없습니다</p> - </div> - </div> - ) : ( - <div className="space-y-4"> - {comments.map(comment => ( - <div - key={comment.id} - className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`} - > - {!comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/10"> - <User className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - )} - - <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment - ? 'bg-primary text-primary-foreground' - : 'bg-muted' - }`}> - <div className="text-sm font-medium mb-1"> - {comment.isVendorComment ? ( - session?.user?.name || "벤더" - ) : ( - comment.userName || buyerName - )} - </div> - - {comment.content && ( - <div className="text-sm whitespace-pre-wrap break-words"> - {comment.content} - </div> - )} - - {/* 첨부파일 표시 */} - {comment.attachments.length > 0 && ( - <div className={`mt-2 pt-2 ${comment.isVendorComment - ? 'border-t border-t-primary-foreground/20' - : 'border-t border-t-border/30' - }`}> - {comment.attachments.map(attachment => ( - <div - key={attachment.id} - className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer" - onClick={() => handleAttachmentPreview(attachment)} - > - {getFileIcon(attachment.fileType)} - <span className="flex-1 truncate">{attachment.fileName}</span> - <span className="text-xs opacity-70"> - {formatFileSize(attachment.fileSize)} - </span> - <Button - variant="ghost" - size="icon" - className="h-6 w-6 rounded-full" - onClick={(e) => { - e.stopPropagation(); - handleAttachmentDownload(attachment); - }} - > - <DownloadCloud className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - )} - - <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end"> - {formatDateTime(comment.createdAt)} - </div> - </div> - - {comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/20"> - <Building className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - )} - </div> - ))} - <div ref={messagesEndRef} /> - </div> - )} - </ScrollArea> - - {/* 선택된 첨부파일 표시 */} - {attachments.length > 0 && ( - <div className="p-2 bg-muted mx-4 rounded-md mb-2"> - <div className="text-xs font-medium mb-1">첨부파일</div> - <div className="flex flex-wrap gap-2"> - {attachments.map((file, index) => ( - <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs"> - {file.type.startsWith("image/") ? ( - <ImageIcon className="h-4 w-4 mr-1 text-blue-500" /> - ) : ( - <File className="h-4 w-4 mr-1 text-gray-500" /> - )} - <span className="truncate max-w-[100px]">{file.name}</span> - <Button - variant="ghost" - size="icon" - className="h-4 w-4 ml-1 p-0" - onClick={() => handleRemoveFile(index)} - > - <X className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {/* 메시지 입력 영역 */} - <div className="p-4 border-t"> - <div className="flex gap-2 items-end"> - <div className="flex-1"> - <Textarea - placeholder="메시지를 입력하세요..." - className="min-h-[80px] resize-none" - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - /> - </div> - <div className="flex flex-col gap-2"> - <input - type="file" - ref={fileInputRef} - className="hidden" - multiple - onChange={handleFileChange} - /> - <Button - variant="outline" - size="icon" - onClick={handleFileSelect} - title="파일 첨부" - > - <Paperclip className="h-4 w-4" /> - </Button> - <Button - onClick={handleSubmitComment} - disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting} - > - <Send className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - </div> - - <DrawerFooter className="border-t"> - <div className="flex justify-between"> - <Button variant="outline" onClick={() => loadComments()}> - 새로고침 - </Button> - <DrawerClose asChild> - <Button variant="outline">닫기</Button> - </DrawerClose> - </div> - </DrawerFooter> - </DrawerContent> - - {renderAttachmentPreviewDialog()} - </Drawer> - ); -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx deleted file mode 100644 index 66bb2613..00000000 --- a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx +++ /dev/null @@ -1,955 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useMemo } from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import * as z from "zod" -import { format } from "date-fns" -import { toast } from "sonner" -import { MessageSquare, Paperclip } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { ScrollArea } from "@/components/ui/scroll-area" -import { DatePicker } from "@/components/ui/date-picker" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { Skeleton } from "@/components/ui/skeleton" - -import { formatCurrency, formatDate } from "@/lib/utils" -import { QuotationItemEditor } from "./quotation-item-editor" -import { - submitVendorQuotation, - updateVendorQuotation, - fetchCurrencies, - fetchPaymentTerms, - fetchIncoterms, - fetchBuyerVendorComments, - Comment -} from "../services" -import { BuyerCommunicationDrawer } from "./buyer-communication-drawer" - -// 견적서 폼 스키마 -const quotationFormSchema = z.object({ - quotationVersion: z.number().min(1), - // 필수값 표시됨 - currency: z.string().min(1, "통화를 선택해주세요"), - // 필수값 표시됨 - validUntil: z.date({ - required_error: "견적 유효기간을 선택해주세요", - invalid_type_error: "유효한 날짜를 선택해주세요", - }), - // 필수값 표시됨 - estimatedDeliveryDate: z.date({ - required_error: "예상 납품일을 선택해주세요", - invalid_type_error: "유효한 날짜를 선택해주세요", - }), - // 필수값 표시됨 - paymentTermsCode: z.string({ - required_error: "지불 조건을 선택해주세요", - }).min(1, "지불 조건을 선택해주세요"), - // 필수값 표시됨 - incotermsCode: z.string({ - required_error: "인코텀즈를 선택해주세요", - }).min(1, "인코텀즈를 선택해주세요"), - // 필수값 아님 - incotermsDetail: z.string().optional(), - // 필수값 아님 - remark: z.string().optional(), -}) - -type QuotationFormValues = z.infer<typeof quotationFormSchema> - -// 데이터 타입 정의 -interface Currency { - code: string - name: string -} - -interface PaymentTerm { - code: string - description: string -} - -interface Incoterm { - code: string - description: string -} - -// 이 컴포넌트에 전달되는 견적서 데이터 타입 -interface VendorQuotation { - id: number - rfqId: number - vendorId: number - quotationCode: string | null - quotationVersion: number | null - totalItemsCount: number | null - subTotal: string| null - taxTotal: string| null - discountTotal: string| null - totalPrice: string| null - currency: string| null - validUntil: Date | null - estimatedDeliveryDate: Date | null - paymentTermsCode: string | null - incotermsCode: string | null - incotermsDetail: string | null - status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted" - remark: string | null - rejectionReason: string | null - submittedAt: Date | null - acceptedAt: Date | null - createdAt: Date - updatedAt: Date - rfq: { - id: number - rfqCode: string| null - dueDate: Date | null - status: string| null - // 기타 필요한 정보 - } - vendor: { - id: number - vendorName: string - vendorCode: string| null - // 기타 필요한 정보 - } - items: QuotationItem[] -} - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null - materialDescription: string | null - quantity: number - uom: string | null - unitPrice: number - totalPrice: number - currency: string - vendorMaterialCode: string | null - vendorMaterialDescription: string | null - deliveryDate: Date | null - leadTimeInDays: number | null - taxRate: number | null - taxAmount: number | null - discountRate: number | null - discountAmount: number | null - remark: string | null - isAlternative: boolean - isRecommended: boolean - createdAt: Date - updatedAt: Date - prItem?: { - id: number - materialCode: string | null - materialDescription: string | null - // 기타 필요한 정보 - } -} - -// 견적서 편집 컴포넌트 프롭스 -interface VendorQuotationEditorProps { - quotation: VendorQuotation -} - -export default function VendorQuotationEditor({ quotation }: VendorQuotationEditorProps) { - - - console.log(quotation) - - const [activeTab, setActiveTab] = useState("items") - const [isSubmitting, setIsSubmitting] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [items, setItems] = useState<QuotationItem[]>(quotation.items || []) - - // 서버에서 가져온 데이터 상태 - const [currencies, setCurrencies] = useState<Currency[]>([]) - const [paymentTerms, setPaymentTerms] = useState<PaymentTerm[]>([]) - const [incoterms, setIncoterms] = useState<Incoterm[]>([]) - - // 데이터 로딩 상태 - const [loadingCurrencies, setLoadingCurrencies] = useState(true) - const [loadingPaymentTerms, setLoadingPaymentTerms] = useState(true) - const [loadingIncoterms, setLoadingIncoterms] = useState(true) - - // 커뮤니케이션 드로어 상태 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - - const [comments, setComments] = useState<Comment[]>([]); - const [unreadCount, setUnreadCount] = useState(0); - const [loadingComments, setLoadingComments] = useState(false); - - // 컴포넌트 마운트 시 메시지 미리 로드 - useEffect(() => { - if (quotation) { - loadCommunicationData(); - } - }, [quotation]); - - // 메시지 데이터 로드 함수 - const loadCommunicationData = async () => { - try { - setLoadingComments(true); - const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId); - setComments(commentsData); - - // 읽지 않은 메시지 수 계산 - const unread = commentsData.filter( - comment => !comment.isVendorComment && !comment.isRead - ).length; - setUnreadCount(unread); - } catch (error) { - console.error("메시지 데이터 로드 오류:", error); - } finally { - setLoadingComments(false); - } - }; - - // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침 - const handleCommunicationDrawerChange = (open: boolean) => { - setCommunicationDrawerOpen(open); - if (!open) { - loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침 - } - }; - - // 버튼 비활성화 - const isBeforeDueDate = () => { - if (!quotation.rfq.dueDate) { - // dueDate가 null인 경우 기본적으로 수정 불가능하도록 설정 (false 반환) - return false; - } - - const now = new Date(); - const dueDate = new Date(quotation.rfq.dueDate); - return now < dueDate; - }; - // 수정된 isDisabled 조건 - const isDisabled = (quotation.status === "Accepted") || - ((quotation.status === "Submitted" || quotation.status === "Revised") && - !isBeforeDueDate()); - - - // 견적서 총합 계산 - const totals = useMemo(() => { - const subTotal = items.reduce((sum, item) => sum + Number(item.totalPrice), 0) - const taxTotal = items.reduce((sum, item) => sum + (Number(item.taxAmount) || 0), 0) - const discountTotal = items.reduce((sum, item) => sum + (Number(item.discountAmount) || 0), 0) - const totalPrice = subTotal + taxTotal - discountTotal - - return { - subTotal, - taxTotal, - discountTotal, - totalPrice - } - }, [items]) - - // 폼 설정 - const form = useForm<QuotationFormValues>({ - resolver: zodResolver(quotationFormSchema), - defaultValues: { - quotationVersion: quotation.quotationVersion || 0, - currency: quotation.currency || "KRW", - validUntil: quotation.validUntil || undefined, - estimatedDeliveryDate: quotation.estimatedDeliveryDate || undefined, - paymentTermsCode: quotation.paymentTermsCode || "", - incotermsCode: quotation.incotermsCode || "", - incotermsDetail: quotation.incotermsDetail || "", - remark: quotation.remark || "", - }, - mode: "onChange", // 실시간 검증 활성화 - }) - - // 마운트 시 데이터 로드 - useEffect(() => { - // 통화 데이터 로드 - const loadCurrencies = async () => { - try { - setLoadingCurrencies(true) - const result = await fetchCurrencies() - if (result.success) { - setCurrencies(result.data) - } else { - toast.error(result.message || "통화 데이터 로드 실패") - } - } catch (error) { - console.error("통화 데이터 로드 오류:", error) - toast.error("통화 데이터를 불러오는 중 오류가 발생했습니다") - } finally { - setLoadingCurrencies(false) - } - } - - // 지불 조건 데이터 로드 - const loadPaymentTerms = async () => { - try { - setLoadingPaymentTerms(true) - const result = await fetchPaymentTerms() - if (result.success) { - setPaymentTerms(result.data) - } else { - toast.error(result.message || "지불 조건 데이터 로드 실패") - } - } catch (error) { - console.error("지불 조건 데이터 로드 오류:", error) - toast.error("지불 조건 데이터를 불러오는 중 오류가 발생했습니다") - } finally { - setLoadingPaymentTerms(false) - } - } - - // 인코텀즈 데이터 로드 - const loadIncoterms = async () => { - try { - setLoadingIncoterms(true) - const result = await fetchIncoterms() - if (result.success) { - setIncoterms(result.data) - } else { - toast.error(result.message || "인코텀즈 데이터 로드 실패") - } - } catch (error) { - console.error("인코텀즈 데이터 로드 오류:", error) - toast.error("인코텀즈 데이터를 불러오는 중 오류가 발생했습니다") - } finally { - setLoadingIncoterms(false) - } - } - - // 함수 호출 - loadCurrencies() - loadPaymentTerms() - loadIncoterms() - }, []) - - // 견적서 저장 - const handleSave = async () => { - try { - setIsSaving(true) - - // 기본 검증 (통화는 필수) - const validationResult = await form.trigger(['currency']); - if (!validationResult) { - toast.warning("통화는 필수 항목입니다"); - return; - } - - const values = form.getValues() - - const result = await updateVendorQuotation({ - id: quotation.id, - ...values, - subTotal: totals.subTotal.toString(), - taxTotal: totals.taxTotal.toString(), - discountTotal: totals.discountTotal.toString(), - totalPrice: totals.totalPrice.toString(), - totalItemsCount: items.length, - }) - - if (result.success) { - toast.success("견적서가 저장되었습니다") - - // 견적서 제출 준비 상태 점검 - const formValid = await form.trigger(); - const itemsValid = !items.some(item => item.unitPrice <= 0 || !item.deliveryDate); - const alternativeItemsValid = !items.some(item => - item.isAlternative && (!item.vendorMaterialDescription || !item.remark) - ); - - if (formValid && itemsValid && alternativeItemsValid) { - toast.info("모든 필수 정보가 입력되었습니다. '견적서 제출' 버튼을 클릭하여 제출하세요."); - } else { - const missingFields = []; - if (!formValid) missingFields.push("견적서 기본 정보"); - if (!itemsValid) missingFields.push("견적 항목의 단가/납품일"); - if (!alternativeItemsValid) missingFields.push("대체품 정보"); - - toast.info(`제출하기 전에 다음 정보를 입력해주세요: ${missingFields.join(', ')}`); - } - } else { - toast.error(result.message || "견적서 저장 중 오류가 발생했습니다") - } - } catch (error) { - console.error("견적서 저장 오류:", error) - toast.error("견적서 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // 견적서 제출 - const handleSubmit = async () => { - try { - setIsSubmitting(true) - - // 1. 폼 스키마 검증 (기본 정보) - const formValid = await form.trigger(); - if (!formValid) { - const formState = form.getFieldState("validUntil"); - const estimatedDeliveryState = form.getFieldState("estimatedDeliveryDate"); - const paymentTermsState = form.getFieldState("paymentTermsCode"); - const incotermsState = form.getFieldState("incotermsCode"); - - // 주요 필드별 오류 메시지 표시 - if (!form.getValues("validUntil")) { - toast.error("견적 유효기간을 선택해주세요"); - } else if (!form.getValues("estimatedDeliveryDate")) { - toast.error("예상 납품일을 선택해주세요"); - } else if (!form.getValues("paymentTermsCode")) { - toast.error("지불 조건을 선택해주세요"); - } else if (!form.getValues("incotermsCode")) { - toast.error("인코텀즈를 선택해주세요"); - } else { - toast.error("견적서 기본 정보를 모두 입력해주세요"); - } - - // 견적 정보 탭으로 이동 - setActiveTab("details"); - return; - } - - // 2. 견적 항목 검증 - const emptyItems = items.filter(item => - item.unitPrice <= 0 || !item.deliveryDate - ); - - if (emptyItems.length > 0) { - toast.error(`${emptyItems.length}개 항목의 단가와 납품일을 입력해주세요`); - setActiveTab("items"); - return; - } - - // 3. 대체품 정보 검증 - const invalidAlternativeItems = items.filter(item => - item.isAlternative && (!item.vendorMaterialDescription || !item.remark) - ); - - if (invalidAlternativeItems.length > 0) { - toast.error(`${invalidAlternativeItems.length}개의 대체품 항목에 정보를 모두 입력해주세요`); - setActiveTab("items"); - return; - } - - // 모든 검증 통과 - 제출 진행 - const values = form.getValues(); - - const result = await submitVendorQuotation({ - id: quotation.id, - ...values, - subTotal: totals.subTotal.toString(), - taxTotal: totals.taxTotal.toString(), - discountTotal: totals.discountTotal.toString(), - totalPrice: totals.totalPrice.toString(), - totalItemsCount: items.length, - }); - - if (result.success && isBeforeDueDate()) { - toast.success("견적서가 제출되었습니다. 마감일 전까지 수정 가능합니다."); - - // 페이지 새로고침 - window.location.reload(); - } else { - toast.error(result.message || "견적서 제출 중 오류가 발생했습니다"); - } - } catch (error) { - console.error("견적서 제출 오류:", error); - toast.error("견적서 제출 중 오류가 발생했습니다"); - } finally { - setIsSubmitting(false); - } - } - - const isSubmitReady = () => { - // 폼 유효성 - const formValid = !Object.keys(form.formState.errors).length; - - // 항목 유효성 - const itemsValid = !items.some(item => - item.unitPrice <= 0 || !item.deliveryDate - ); - - // 대체품 유효성 - const alternativeItemsValid = !items.some(item => - item.isAlternative && (!item.vendorMaterialDescription || !item.remark) - ); - - // 유효하지 않은 항목 또는 대체품이 있으면 제출 불가 - return formValid && itemsValid && alternativeItemsValid; - } - - // 아이템 업데이트 핸들러 - const handleItemsUpdate = (updatedItems: QuotationItem[]) => { - setItems(updatedItems) - } - - // 상태에 따른 배지 색상 - const getStatusBadge = (status: string) => { - switch (status) { - case "Draft": - return <Badge variant="outline">초안</Badge> - case "Submitted": - return <Badge variant="default">제출됨</Badge> - case "Revised": - return <Badge variant="secondary">수정됨</Badge> - case "Rejected": - return <Badge variant="destructive">반려됨</Badge> - case "Accepted": - return <Badge variant="default">승인됨</Badge> - default: - return <Badge>{status}</Badge> - } - } - - // 셀렉트 로딩 상태 표시 컴포넌트 - const SelectSkeleton = () => ( - <div className="flex flex-col gap-2"> - <Skeleton className="h-4 w-[40%]" /> - <Skeleton className="h-10 w-full" /> - </div> - ) - - return ( - <div className="space-y-6"> - <div className="flex justify-between items-start"> - <div> - <h1 className="text-2xl font-bold tracking-tight">견적서 작성</h1> - <p className="text-muted-foreground"> - RFQ 번호: {quotation.rfq.rfqCode} | 견적서 번호: {quotation.quotationCode} - </p> - {quotation.rfq.dueDate ? ( - <p className={`text-sm ${isBeforeDueDate() ? 'text-green-600' : 'text-red-600'}`}> - 마감일: {formatDate(new Date(quotation.rfq.dueDate))} - {isBeforeDueDate() - ? ' (마감 전: 수정 가능)' - : ' (마감 됨: 수정 불가)'} - </p> - ) : ( - <p className="text-sm text-amber-600"> - 마감일이 설정되지 않았습니다 - </p> - )} - </div> - <div className="flex items-center gap-2"> - {getStatusBadge(quotation.status)} - {quotation.status === "Rejected" && ( - <div className="text-sm text-destructive"> - <span className="font-medium">반려 사유:</span> {quotation.rejectionReason || "사유 없음"} - </div> - )} - </div> - </div> - - <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> - <TabsList> - <TabsTrigger value="items">견적 항목</TabsTrigger> - <TabsTrigger value="details">견적 정보</TabsTrigger> - <TabsTrigger value="communication">커뮤니케이션</TabsTrigger> - </TabsList> - - {/* 견적 항목 탭 */} - <TabsContent value="items" className="p-0 pt-4"> - <Card> - <CardHeader> - <CardTitle>견적 항목 정보</CardTitle> - <CardDescription> - 각 항목에 대한 가격, 납품일 등을 입력해주세요 - </CardDescription> - </CardHeader> - <CardContent> - <QuotationItemEditor - items={items} - onItemsChange={handleItemsUpdate} - disabled={isDisabled} - currency={form.watch("currency")} - /> - </CardContent> - <CardFooter className="flex justify-between border-t p-4"> - <div className="space-y-1"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium">소계:</span> {formatCurrency(totals.subTotal, quotation.currency)} - </div> - <div className="text-sm text-muted-foreground"> - <span className="font-medium">세액:</span> {formatCurrency(totals.taxTotal, quotation.currency)} - </div> - <div className="text-sm text-muted-foreground"> - <span className="font-medium">할인액:</span> {formatCurrency(totals.discountTotal, quotation.currency)} - </div> - <div className="text-base font-bold"> - <span>총액:</span> {formatCurrency(totals.totalPrice, quotation.currency)} - </div> - </div> - <div className="flex space-x-2"> - <Button - variant="outline" - onClick={handleSave} - disabled={isDisabled || isSaving} - > - {isSaving ? "저장 중..." : "저장"} - </Button> - <Button - onClick={handleSubmit} - disabled={isDisabled || isSubmitting || !isSubmitReady()} - > - {isSubmitting ? "제출 중..." : "견적서 제출"} - </Button> - </div> - </CardFooter> - </Card> - </TabsContent> - - {/* 견적 정보 탭 */} - <TabsContent value="details" className="p-0 pt-4"> - <Form {...form}> - <form className="space-y-6"> - <Card> - <CardHeader> - <CardTitle>견적서 기본 정보</CardTitle> - <CardDescription> - 견적서의 일반 정보를 입력해주세요 - </CardDescription> - </CardHeader> - <CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6"> - {/* 통화 필드 */} - {loadingCurrencies ? ( - <SelectSkeleton /> - ) : ( - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 통화 - <span className="text-destructive ml-1">*</span> - </FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - disabled={isDisabled} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.code} ({currency.name}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - )} - - <FormField - control={form.control} - name="validUntil" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 견적 유효기간 - <span className="text-destructive ml-1">*</span> {/* 필수값 표시 */} - </FormLabel> - <FormControl> - <DatePicker - date={field.value} - onSelect={field.onChange} - disabled={isDisabled} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="estimatedDeliveryDate" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 예상 납품일 - <span className="text-destructive ml-1">*</span> - </FormLabel> - <FormControl> - <DatePicker - date={field.value} - onSelect={field.onChange} - disabled={isDisabled} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 지불 조건 필드 */} - {loadingPaymentTerms ? ( - <SelectSkeleton /> - ) : ( - <FormField - control={form.control} - name="paymentTermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 지불 조건 - <span className="text-destructive ml-1">*</span> - </FormLabel> - <Select - value={field.value || ""} - onValueChange={field.onChange} - disabled={isDisabled} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="지불 조건 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {paymentTerms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* 인코텀즈 필드 */} - {loadingIncoterms ? ( - <SelectSkeleton /> - ) : ( - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 인코텀즈 - <span className="text-destructive ml-1">*</span> - </FormLabel> - <Select - value={field.value || ""} - onValueChange={field.onChange} - disabled={isDisabled} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {incoterms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.code} ({term.description}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - )} - - <FormField - control={form.control} - name="incotermsDetail" - render={({ field }) => ( - <FormItem> - <FormLabel className="flex items-center"> - 인코텀즈 상세 - <span className="text-destructive ml-1"></span> - </FormLabel> - <FormControl> - <Input - placeholder="인코텀즈 상세 정보 입력" - {...field} - value={field.value || ""} - disabled={isDisabled} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="remark" - render={({ field }) => ( - <FormItem className="col-span-2"> - <FormLabel className="flex items-center"> - 비고 - <span className="text-destructive ml-1"></span> - </FormLabel> - <FormControl> - <Textarea - placeholder="추가 정보나 특이사항을 입력해주세요" - className="resize-none min-h-[100px]" - {...field} - value={field.value || ""} - disabled={isDisabled} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - <CardFooter className="flex justify-end"> - <Button - variant="outline" - onClick={handleSave} - disabled={isDisabled || isSaving} - > - {isSaving ? "저장 중..." : "저장"} - </Button> - </CardFooter> - </Card> - </form> - </Form> - </TabsContent> - - {/* 커뮤니케이션 탭 */} - <TabsContent value="communication" className="p-0 pt-4"> - <Card> - <CardHeader className="flex flex-row items-center justify-between"> - <div> - <CardTitle className="flex items-center gap-2"> - 커뮤니케이션 - {unreadCount > 0 && ( - <Badge variant="destructive" className="ml-2"> - 새 메시지 {unreadCount} - </Badge> - )} - </CardTitle> - <CardDescription> - 구매자와의 메시지 및 첨부파일 - </CardDescription> - </div> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - variant="outline" - size="sm" - > - <MessageSquare className="h-4 w-4 mr-2" /> - {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"} - </Button> - </CardHeader> - <CardContent> - {loadingComments ? ( - <div className="flex items-center justify-center p-8"> - <div className="text-center"> - <Skeleton className="h-4 w-32 mx-auto mb-2" /> - <Skeleton className="h-4 w-48 mx-auto" /> - </div> - </div> - ) : comments.length === 0 ? ( - <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8"> - <div className="max-w-md"> - <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4"> - <MessageSquare className="h-6 w-6 text-primary" /> - </div> - <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3> - <p className="text-muted-foreground mb-4"> - 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요. - </p> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - className="mx-auto" - > - 메시지 보내기 - </Button> - </div> - </div> - ) : ( - <div className="space-y-4"> - {/* 최근 메시지 3개 미리보기 */} - <div className="space-y-2"> - <h3 className="text-sm font-medium">최근 메시지</h3> - <ScrollArea className="h-[250px] rounded-md border p-4"> - {comments.slice(-3).map(comment => ( - <div - key={comment.id} - className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead - ? 'bg-primary/10 border-l-4 border-primary' - : 'bg-muted/50' - }`} - > - <div className="flex justify-between items-center mb-1"> - <span className="text-sm font-medium"> - {comment.isVendorComment - ? '나' - : comment.userName || '구매 담당자'} - </span> - <span className="text-xs text-muted-foreground"> - {new Date(comment.createdAt).toLocaleDateString()} - </span> - </div> - <p className="text-sm line-clamp-2">{comment.content}</p> - {comment.attachments.length > 0 && ( - <div className="mt-1 text-xs text-muted-foreground"> - <Paperclip className="h-3 w-3 inline mr-1" /> - 첨부파일 {comment.attachments.length}개 - </div> - )} - </div> - ))} - </ScrollArea> - </div> - - <div className="flex justify-center"> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - className="w-full" - > - 전체 메시지 보기 ({comments.length}개) - </Button> - </div> - </div> - )} - </CardContent> - </Card> - - {/* 커뮤니케이션 드로어 */} - <BuyerCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={handleCommunicationDrawerChange} - quotation={quotation} - onSuccess={loadCommunicationData} - /> - </TabsContent> - </Tabs> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx deleted file mode 100644 index e11864dc..00000000 --- a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx +++ /dev/null @@ -1,664 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { toast } from "sonner" -import { format } from "date-fns" - -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Checkbox } from "@/components/ui/checkbox" -import { DatePicker } from "@/components/ui/date-picker" -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from "@/components/ui/tooltip" -import { - Info, - Clock, - CalendarIcon, - ClipboardCheck, - AlertTriangle, - CheckCircle2, - RefreshCw, - Save, - FileText, - Sparkles -} from "lucide-react" - -import { formatCurrency } from "@/lib/utils" -import { updateQuotationItem } from "../services" -import { Textarea } from "@/components/ui/textarea" - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null - materialDescription: string | null - quantity: number - uom: string | null - unitPrice: number - totalPrice: number - currency: string - vendorMaterialCode: string | null - vendorMaterialDescription: string | null - deliveryDate: Date | null - leadTimeInDays: number | null - taxRate: number | null - taxAmount: number | null - discountRate: number | null - discountAmount: number | null - remark: string | null - isAlternative: boolean - isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음 - createdAt: Date - updatedAt: Date - prItem?: { - id: number - materialCode: string | null - materialDescription: string | null - // 기타 필요한 정보 - } -} - -// debounce 함수 구현 -function debounce<T extends (...args: any[]) => any>( - func: T, - wait: number -): (...args: Parameters<T>) => void { - let timeout: NodeJS.Timeout | null = null; - - return function (...args: Parameters<T>) { - if (timeout) clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }; -} - -interface QuotationItemEditorProps { - items: QuotationItem[] - onItemsChange: (items: QuotationItem[]) => void - disabled?: boolean - currency: string -} - -export function QuotationItemEditor({ - items, - onItemsChange, - disabled = false, - currency -}: QuotationItemEditorProps) { - const [editingItem, setEditingItem] = useState<number | null>(null) - const [isSaving, setIsSaving] = useState(false) - - // 저장이 필요한 항목들을 추적 - const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set()) - - // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음 - const updateLocalItem = <K extends keyof QuotationItem>( - index: number, - field: K, - value: QuotationItem[K] - ) => { - // 로컬 상태 업데이트 - const updatedItems = [...items] - const item = { ...updatedItems[index] } - - // 필드 업데이트 - item[field] = value - - // 대체품 체크 해제 시 관련 필드 초기화 - if (field === 'isAlternative' && value === false) { - item.vendorMaterialCode = null; - item.vendorMaterialDescription = null; - item.remark = null; - } - - // 단가나 수량이 변경되면 총액 계산 - if (field === 'unitPrice' || field === 'quantity') { - item.totalPrice = Number(item.unitPrice) * Number(item.quantity) - - // 세금이 있으면 세액 계산 - if (item.taxRate) { - item.taxAmount = item.totalPrice * (item.taxRate / 100) - } - - // 할인이 있으면 할인액 계산 - if (item.discountRate) { - item.discountAmount = item.totalPrice * (item.discountRate / 100) - } - } - - // 세율이 변경되면 세액 계산 - if (field === 'taxRate') { - item.taxAmount = item.totalPrice * (value as number / 100) - } - - // 할인율이 변경되면 할인액 계산 - if (field === 'discountRate') { - item.discountAmount = item.totalPrice * (value as number / 100) - } - - // 변경된 아이템으로 교체 - updatedItems[index] = item - - // 미저장 항목으로 표시 - setPendingChanges(prev => new Set(prev).add(item.id)) - - // 부모 컴포넌트에 변경 사항 알림 - onItemsChange(updatedItems) - - // 저장 필요함을 표시 - return item - } - - // 서버에 저장하는 함수 - const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => { - if (disabled) return - - try { - setIsSaving(true) - - const result = await updateQuotationItem({ - id: item.id, - [field]: value, - totalPrice: item.totalPrice, - taxAmount: item.taxAmount ?? 0, - discountAmount: item.discountAmount ?? 0 - }) - - // 저장 완료 후 pendingChanges에서 제거 - setPendingChanges(prev => { - const newSet = new Set(prev) - newSet.delete(item.id) - return newSet - }) - - if (!result.success) { - toast.error(result.message || "항목 저장 중 오류가 발생했습니다") - } - } catch (error) { - console.error("항목 저장 오류:", error) - toast.error("항목 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // debounce된 저장 함수 - const debouncedSave = useRef(debounce( - (item: QuotationItem, field: keyof QuotationItem, value: any) => { - saveItemToServer(item, field, value) - }, - 800 // 800ms 지연 - )).current - - // 견적 항목 업데이트 함수 - const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => { - const updatedItem = updateLocalItem(index, field, value) - - // debounce를 통해 서버 저장 지연 - if (!disabled) { - debouncedSave(updatedItem, field, value) - } - } - - // 모든 변경 사항 저장 - const saveAllChanges = async () => { - if (disabled || pendingChanges.size === 0) return - - setIsSaving(true) - toast.info(`${pendingChanges.size}개 항목 저장 중...`) - - try { - // 변경된 모든 항목 저장 - for (const itemId of pendingChanges) { - const index = items.findIndex(item => item.id === itemId) - if (index !== -1) { - const item = items[index] - await updateQuotationItem({ - id: item.id, - unitPrice: item.unitPrice, - totalPrice: item.totalPrice, - taxRate: item.taxRate ?? 0, - taxAmount: item.taxAmount ?? 0, - discountRate: item.discountRate ?? 0, - discountAmount: item.discountAmount ?? 0, - deliveryDate: item.deliveryDate, - leadTimeInDays: item.leadTimeInDays ?? 0, - vendorMaterialCode: item.vendorMaterialCode ?? "", - vendorMaterialDescription: item.vendorMaterialDescription ?? "", - isAlternative: item.isAlternative, - isRecommended: false, // 항상 false로 설정 (사용하지 않음) - remark: item.remark ?? "" - }) - } - } - - // 모든 변경 사항 저장 완료 - setPendingChanges(new Set()) - toast.success("모든 변경 사항이 저장되었습니다") - } catch (error) { - console.error("변경 사항 저장 오류:", error) - toast.error("변경 사항 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후) - const handleBlur = (index: number, field: keyof QuotationItem, value: any) => { - const itemId = items[index].id - - // 해당 항목이 pendingChanges에 있다면 즉시 저장 - if (pendingChanges.has(itemId)) { - const item = items[index] - saveItemToServer(item, field, value) - } - } - - // 전체 단가 업데이트 (일괄 반영) - const handleBulkUnitPriceUpdate = () => { - if (items.length === 0) return - - // 첫 번째 아이템의 단가 가져오기 - const firstUnitPrice = items[0].unitPrice - - if (!firstUnitPrice) { - toast.error("첫 번째 항목의 단가를 먼저 입력해주세요") - return - } - - // 모든 아이템에 동일한 단가 적용 - const updatedItems = items.map(item => ({ - ...item, - unitPrice: firstUnitPrice, - totalPrice: firstUnitPrice * item.quantity, - taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount, - discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount - })) - - // 모든 아이템을 변경 필요 항목으로 표시 - setPendingChanges(new Set(updatedItems.map(item => item.id))) - - // 부모 컴포넌트에 변경 사항 알림 - onItemsChange(updatedItems) - - toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.") - } - - // 입력 핸들러 - const handleNumberInputChange = ( - index: number, - field: keyof QuotationItem, - e: React.ChangeEvent<HTMLInputElement> - ) => { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - handleItemUpdate(index, field, value) - } - - const handleTextInputChange = ( - index: number, - field: keyof QuotationItem, - e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> - ) => { - handleItemUpdate(index, field, e.target.value) - } - - const handleDateChange = ( - index: number, - field: keyof QuotationItem, - date: Date | undefined - ) => { - handleItemUpdate(index, field, date || null) - } - - const handleCheckboxChange = ( - index: number, - field: keyof QuotationItem, - checked: boolean - ) => { - handleItemUpdate(index, field, checked) - } - - // 날짜 형식 지정 - const formatDeliveryDate = (date: Date | null) => { - if (!date) return "-" - return format(date, "yyyy-MM-dd") - } - - // 입력 폼 필드 렌더링 - const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => { - if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') { - return ( - <Input - type="number" - min={0} - step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1} - value={item[field] as number || 0} - onChange={(e) => handleNumberInputChange(index, field, e)} - onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)} - disabled={disabled || isSaving} - className="w-full" - /> - ) - } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') { - return ( - <Input - type="text" - value={item[field] as string || ''} - onChange={(e) => handleTextInputChange(index, field, e)} - onBlur={(e) => handleBlur(index, field, e.target.value)} - disabled={disabled || isSaving || !item.isAlternative} - className="w-full" - placeholder={field === 'vendorMaterialCode' ? "벤더 자재코드" : "벤더 자재명"} - /> - ) - } else if (field === 'deliveryDate') { - return ( - <DatePicker - date={item.deliveryDate ? new Date(item.deliveryDate) : undefined} - onSelect={(date) => { - handleDateChange(index, field, date); - // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거 - if (date) handleBlur(index, field, date); - }} - disabled={disabled || isSaving} - /> - ) - } else if (field === 'isAlternative') { - return ( - <div className="flex items-center gap-1"> - <Checkbox - checked={item.isAlternative} - onCheckedChange={(checked) => { - handleCheckboxChange(index, field, checked as boolean); - handleBlur(index, field, checked as boolean); - }} - disabled={disabled || isSaving} - /> - <span className="text-xs">대체품</span> - </div> - ) - } - - return null - } - - // 대체품 필드 렌더링 - const renderAlternativeFields = (item: QuotationItem, index: number) => { - if (!item.isAlternative) return null; - - return ( - <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm"> - {/* <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재코드</label> - <Input - value={item.vendorMaterialCode || ""} - onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)} - onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)} - disabled={disabled || isSaving} - className="h-8 text-sm" - placeholder="벤더 자재코드 입력" - /> - </div> */} - - <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재명</label> - <Input - value={item.vendorMaterialDescription || ""} - onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)} - onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)} - disabled={disabled || isSaving} - className="h-8 text-sm" - placeholder="벤더 자재명 입력" - /> - </div> - - <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">대체품 설명</label> - <Textarea - value={item.remark || ""} - onChange={(e) => handleTextInputChange(index, 'remark', e)} - onBlur={(e) => handleBlur(index, 'remark', e.target.value)} - disabled={disabled || isSaving} - className="min-h-[60px] text-sm" - placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요" - /> - </div> - </div> - ); - }; - - // 항목의 저장 상태 아이콘 표시 - const renderSaveStatus = (itemId: number) => { - if (pendingChanges.has(itemId)) { - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" /> - </TooltipTrigger> - <TooltipContent> - <p>저장되지 않은 변경 사항이 있습니다</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - ) - } - - return null - } - - return ( - <div className="space-y-4"> - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2"> - <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3> - {pendingChanges.size > 0 && ( - <Badge variant="outline" className="bg-yellow-50"> - 변경 {pendingChanges.size}개 - </Badge> - )} - </div> - - <div className="flex items-center gap-2"> - {pendingChanges.size > 0 && !disabled && ( - <Button - variant="default" - size="sm" - onClick={saveAllChanges} - disabled={isSaving} - > - {isSaving ? ( - <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> - ) : ( - <Save className="h-4 w-4 mr-2" /> - )} - 변경사항 저장 ({pendingChanges.size}개) - </Button> - )} - - {!disabled && ( - <Button - variant="outline" - size="sm" - onClick={handleBulkUnitPriceUpdate} - disabled={items.length === 0 || isSaving} - > - 첫 항목 단가로 일괄 적용 - </Button> - )} - </div> - </div> - - <ScrollArea className="h-[500px] rounded-md border"> - <Table> - <TableHeader className="sticky top-0 bg-background"> - <TableRow> - <TableHead className="w-[50px]">번호</TableHead> - <TableHead>자재코드</TableHead> - <TableHead>자재명</TableHead> - <TableHead>수량</TableHead> - <TableHead>단위</TableHead> - <TableHead>단가</TableHead> - <TableHead>금액</TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 세율(%) - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 납품일 - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>납품 가능한 날짜를 선택해주세요.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead>리드타임(일)</TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 대체품 - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p> - <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead className="w-[50px]">상태</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {items.length === 0 ? ( - <TableRow> - <TableCell colSpan={12} className="text-center py-10"> - 견적 항목이 없습니다 - </TableCell> - </TableRow> - ) : ( - items.map((item, index) => ( - <React.Fragment key={item.id}> - <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}> - <TableCell> - {index + 1} - </TableCell> - <TableCell> - {item.materialCode || "-"} - </TableCell> - <TableCell> - <div className="font-medium max-w-xs truncate"> - {item.materialDescription || "-"} - </div> - </TableCell> - <TableCell> - {item.quantity} - </TableCell> - <TableCell> - {item.uom || "-"} - </TableCell> - <TableCell> - {renderInputField(item, index, 'unitPrice')} - </TableCell> - <TableCell> - {formatCurrency(item.totalPrice, currency)} - </TableCell> - <TableCell> - {renderInputField(item, index, 'taxRate')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'deliveryDate')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'leadTimeInDays')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'isAlternative')} - </TableCell> - <TableCell> - {renderSaveStatus(item.id)} - </TableCell> - </TableRow> - - {/* 대체품으로 선택된 경우 추가 정보 행 표시 */} - {item.isAlternative && ( - <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}> - <TableCell colSpan={1}></TableCell> - <TableCell colSpan={10}> - {renderAlternativeFields(item, index)} - </TableCell> - <TableCell colSpan={1}></TableCell> - </TableRow> - )} - </React.Fragment> - )) - )} - </TableBody> - </Table> - </ScrollArea> - - {isSaving && ( - <div className="flex items-center justify-center text-sm text-muted-foreground"> - <Clock className="h-4 w-4 animate-spin mr-2" /> - 변경 사항을 저장 중입니다... - </div> - )} - - <div className="bg-muted p-4 rounded-md"> - <h4 className="text-sm font-medium mb-2">안내 사항</h4> - <ul className="text-sm space-y-1 text-muted-foreground"> - <li className="flex items-start gap-2"> - <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span>단가와 납품일은 필수로 입력해야 합니다.</span> - </li> - <li className="flex items-start gap-2"> - <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span> - </li> - <li className="flex items-start gap-2"> - <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span> - </li> - </ul> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx deleted file mode 100644 index 1fb225d8..00000000 --- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx +++ /dev/null @@ -1,333 +0,0 @@ -"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 } from "lucide-react" -import { formatCurrency, formatDate, formatDateTime } 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, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -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 { ProcurementVendorQuotations } from "@/db/schema" -import { useRouter } from "next/navigation" - -// 상태에 따른 배지 컴포넌트 -function StatusBadge({ status }: { status: string }) { - switch (status) { - case "Draft": - return <Badge variant="outline">초안</Badge> - case "Submitted": - return <Badge variant="default">제출됨</Badge> - case "Revised": - return <Badge variant="secondary">수정됨</Badge> - case "Rejected": - return <Badge variant="destructive">반려됨</Badge> - case "Accepted": - return <Badge variant="default">승인됨</Badge> - default: - return <Badge>{status}</Badge> - } -} - -interface QuotationWithRfqCode extends ProcurementVendorQuotations { - rfqCode?: string; - rfq?: { - id?: number; - rfqCode?: string; - status?: string; - dueDate?: Date | string | null; - rfqSendDate?: Date | string | null; - item?: { - id?: number; - itemCode?: string; - itemName?: string; - } | null; - } | null; - vendor?: { - id?: number; - vendorName?: string; - vendorCode?: string; - } | null; -} - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<QuotationWithRfqCode> | null> - > - router: NextRouter -} - -/** - * tanstack table 컬럼 정의 (RfqsTable 스타일) - */ -export function getColumns({ - setRowAction, - router, -}: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<QuotationWithRfqCode> = { - 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<QuotationWithRfqCode> = { - id: "actions", - enableHiding: false, - cell: ({ row }) => { - const id = row.original.id - const code = row.getValue("quotationCode") as string - const tooltipText = `${code} 작성하기` - - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - onClick={() => router.push(`/partners/rfq-ship/${id}`)} - className="h-8 w-8" - > - <Edit className="h-4 w-4" /> - <span className="sr-only">견적서 작성</span> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - ) - }, - size: 50, - } - - // ---------------------------------------------------------------- - // 3) 컬럼 정의 배열 - // ---------------------------------------------------------------- - const columnDefinitions = [ - { - id: "quotationCode", - label: "RFQ 번호", - group: null, - size: 150, - minSize: 100, - maxSize: 200, - }, - { - id: "quotationVersion", - label: "RFQ 버전", - group: null, - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "itemCode", - label: "자재 그룹 코드", - group: "RFQ 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "itemName", - label: "자재 이름", - group: "RFQ 정보", - // size를 제거하여 유연한 크기 조정 허용 - minSize: 150, - maxSize: 300, - }, - { - id: "rfqSendDate", - label: "RFQ 송부일", - group: "날짜 정보", - size: 150, - minSize: 120, - maxSize: 180, - }, - { - id: "dueDate", - label: "RFQ 마감일", - group: "날짜 정보", - size: 150, - minSize: 120, - maxSize: 180, - }, - { - id: "status", - label: "상태", - group: null, - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "totalPrice", - label: "총액", - group: null, - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "submittedAt", - label: "제출일", - group: "날짜 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "validUntil", - label: "유효기간", - group: "날짜 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - ]; - - // ---------------------------------------------------------------- - // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성) - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<QuotationWithRfqCode>[]> = {} - - columnDefinitions.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // 개별 컬럼 정의 - const columnDef: ColumnDef<QuotationWithRfqCode> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - cell: ({ row, cell }) => { - // 각 컬럼별 특별한 렌더링 처리 - switch (cfg.id) { - case "quotationCode": - return row.original.quotationCode || "-" - - case "quotationVersion": - return row.original.quotationVersion || "-" - - case "itemCode": - const itemCode = row.original.rfq?.item?.itemCode; - return itemCode ? itemCode : "-"; - - case "itemName": - const itemName = row.original.rfq?.item?.itemName; - return itemName ? itemName : "-"; - - case "rfqSendDate": - const sendDate = row.original.rfq?.rfqSendDate; - return sendDate ? formatDateTime(new Date(sendDate)) : "-"; - - case "dueDate": - const dueDate = row.original.rfq?.dueDate; - return dueDate ? formatDateTime(new Date(dueDate)) : "-"; - - case "status": - return <StatusBadge status={row.getValue("status") as string} /> - - case "totalPrice": - const price = parseFloat(row.getValue("totalPrice") as string || "0") - const currency = row.original.currency - return formatCurrency(price, currency) - - case "submittedAt": - const submitDate = row.getValue("submittedAt") as string | null - return submitDate ? formatDate(new Date(submitDate)) : "-" - - case "validUntil": - const validDate = row.getValue("validUntil") as string | null - return validDate ? formatDate(new Date(validDate)) : "-" - - default: - return row.getValue(cfg.id) ?? "" - } - }, - size: cfg.size, - minSize: cfg.minSize, - maxSize: cfg.maxSize, - } - - groupMap[groupName].push(columnDef) - }) - - // ---------------------------------------------------------------- - // 5) 그룹별 중첩 컬럼 생성 - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<QuotationWithRfqCode>[] = [] - 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/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx deleted file mode 100644 index 7ea0c69e..00000000 --- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx +++ /dev/null @@ -1,152 +0,0 @@ -// lib/vendor-quotations/vendor-quotations-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 { ProcurementVendorQuotations } from "@/db/schema" -import { useRouter } from "next/navigation" -import { getColumns } from "./vendor-quotations-table-columns" - -interface QuotationWithRfqCode extends ProcurementVendorQuotations { - rfqCode?: string; - rfq?: { - rfqCode?: string; - } | null; -} - -interface VendorQuotationsTableProps { - promises: Promise<[{ data: ProcurementVendorQuotations[], pageCount: number }]>; -} - -export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) { - const [{ data, pageCount }] = React.use(promises); - const router = useRouter(); - - console.log(data ,"data") - - // 선택된 행 액션 상태 - const [rowAction, setRowAction] = React.useState<DataTableRowAction<QuotationWithRfqCode> | null>(null); - - // 테이블 컬럼 정의 - const columns = React.useMemo(() => getColumns({ - setRowAction, - router, - }), [setRowAction, router]); - - // 상태별 견적서 수 계산 - const statusCounts = React.useMemo(() => { - return { - Draft: data.filter(q => q.status === "Draft").length, - Submitted: data.filter(q => q.status === "Submitted").length, - Revised: data.filter(q => q.status === "Revised").length, - Rejected: data.filter(q => q.status === "Rejected").length, - Accepted: data.filter(q => q.status === "Accepted").length, - }; - }, [data]); - - // 필터 필드 - const filterFields: DataTableFilterField<QuotationWithRfqCode>[] = [ - { - id: "status", - label: "상태", - options: [ - { label: "초안", value: "Draft", count: statusCounts.Draft }, - { label: "제출됨", value: "Submitted", count: statusCounts.Submitted }, - { label: "수정됨", value: "Revised", count: statusCounts.Revised }, - { label: "반려됨", value: "Rejected", count: statusCounts.Rejected }, - { label: "승인됨", value: "Accepted", count: statusCounts.Accepted }, - ] - }, - { - id: "quotationCode", - label: "견적서 번호", - placeholder: "견적서 번호 검색...", - }, - { - id: "rfqCode", - label: "RFQ 번호", - placeholder: "RFQ 번호 검색...", - } - ]; - - // 고급 필터 필드 - const advancedFilterFields: DataTableAdvancedFilterField<QuotationWithRfqCode>[] = [ - { - id: "quotationCode", - label: "견적서 번호", - type: "text", - }, - { - id: "rfqCode", - label: "RFQ 번호", - type: "text", - }, - { - id: "status", - label: "상태", - type: "multi-select", - options: [ - { label: "초안", value: "Draft" }, - { label: "제출됨", value: "Submitted" }, - { label: "수정됨", value: "Revised" }, - { label: "반려됨", value: "Rejected" }, - { label: "승인됨", value: "Accepted" }, - ], - }, - { - id: "validUntil", - label: "유효기간", - type: "date", - }, - { - id: "submittedAt", - label: "제출일", - type: "date", - }, - ]; - - // useDataTable 훅 사용 (RfqsTable 스타일로 개선) - 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="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 |
