From 5036cf2908792cef45f06256e71f10920f647f49 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 19:03:21 +0000 Subject: (김준회) 기술영업 조선 RFQ (SHI/벤더) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vendor-response/quotation-item-editor.tsx | 664 +++++++++++++++++++++ 1 file changed, 664 insertions(+) create mode 100644 lib/techsales-rfq/vendor-response/quotation-item-editor.tsx (limited to 'lib/techsales-rfq/vendor-response/quotation-item-editor.tsx') diff --git a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx new file mode 100644 index 00000000..e11864dc --- /dev/null +++ b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx @@ -0,0 +1,664 @@ +"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="벤더 자재명 입력" + /> +
+ +
+ +