"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 any>( func: T, wait: number ): (...args: Parameters) => void { let timeout: NodeJS.Timeout | null = null; return function (...args: Parameters) { 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(null) const [isSaving, setIsSaving] = useState(false) // 저장이 필요한 항목들을 추적 const [pendingChanges, setPendingChanges] = useState>(new Set()) // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음 const updateLocalItem = ( 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 ) => { const value = e.target.value === '' ? 0 : parseFloat(e.target.value) handleItemUpdate(index, field, value) } const handleTextInputChange = ( index: number, field: keyof QuotationItem, e: React.ChangeEvent ) => { 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 ( 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 ( 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 ( { handleDateChange(index, field, date); // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거 if (date) handleBlur(index, field, date); }} disabled={disabled || isSaving} /> ) } else if (field === 'isAlternative') { return (
{ handleCheckboxChange(index, field, checked as boolean); handleBlur(index, field, checked as boolean); }} disabled={disabled || isSaving} /> 대체품
) } return null } // 대체품 필드 렌더링 const renderAlternativeFields = (item: QuotationItem, index: number) => { if (!item.isAlternative) return null; return (
{/*
handleTextInputChange(index, 'vendorMaterialCode', e)} onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)} disabled={disabled || isSaving} className="h-8 text-sm" placeholder="벤더 자재코드 입력" />
*/}
handleTextInputChange(index, 'vendorMaterialDescription', e)} onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)} disabled={disabled || isSaving} className="h-8 text-sm" placeholder="벤더 자재명 입력" />