summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/vendor-response
diff options
context:
space:
mode:
Diffstat (limited to 'lib/procurement-rfqs/vendor-response')
-rw-r--r--lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx522
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-editor.tsx955
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx664
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx333
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx152
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