"use client" import { useFormContext, useFieldArray } from "react-hook-form" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Badge } from "@/components/ui/badge" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { ScrollArea } from "@/components/ui/scroll-area" import { CalendarIcon, Eye, FileText, Download, ExternalLink, Upload, FileDown } from "lucide-react" import { format } from "date-fns" import { cn, formatCurrency } from "@/lib/utils" import { useState, useEffect, useRef } from "react" import { toast } from "sonner" import { checkPosFileExists, getDownloadUrlByMaterialCode } from "@/lib/pos" import { PosFileSelectionDialog } from "@/lib/pos/components/pos-file-selection-dialog" import ExcelJS from "exceljs" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" interface QuotationItemsTableProps { prItems: any[] decimalPlaces?: number } export default function QuotationItemsTable({ prItems, decimalPlaces = 2 }: QuotationItemsTableProps) { const { control, register, setValue, watch } = useFormContext() const { fields } = useFieldArray({ control, name: "quotationItems" }) const [selectedItem, setSelectedItem] = useState(null) const [showDetail, setShowDetail] = useState(false) const [showBulkDateDialog, setShowBulkDateDialog] = useState(false) const [bulkDeliveryDate, setBulkDeliveryDate] = useState(undefined) // POS 파일 관련 상태 const [posDialogOpen, setPosDialogOpen] = useState(false) const [selectedMaterialCode, setSelectedMaterialCode] = useState("") const [posFiles, setPosFiles] = useState>([]) const [loadingPosFiles, setLoadingPosFiles] = useState(false) const [downloadingFileIndex, setDownloadingFileIndex] = useState(null) // 엑셀 import/export 관련 상태 const fileInputRef = useRef(null) const [isImporting, setIsImporting] = useState(false) const currency = watch("vendorCurrency") || "USD" const quotationItems = watch("quotationItems") console.log(prItems,"prItems") // PR 아이템 정보를 quotationItems에 초기화 useEffect(() => { if (prItems && prItems.length > 0) { prItems.forEach((prItem, index) => { // PR 아이템 정보를 quotationItem에 포함 setValue(`quotationItems.${index}.prNo`, prItem.prNo) setValue(`quotationItems.${index}.materialCode`, prItem.materialCode) setValue(`quotationItems.${index}.materialDescription`, prItem.materialDescription) setValue(`quotationItems.${index}.quantity`, prItem.quantity) setValue(`quotationItems.${index}.uom`, prItem.uom) setValue(`quotationItems.${index}.rfqPrItemId`, prItem.id) // currency는 vendorCurrency를 따름 setValue(`quotationItems.${index}.currency`, currency) // 납기일은 PR납기요청일을 Default로 설정 (기존 값이 없을 때만) const currentDeliveryDate = quotationItems?.[index]?.vendorDeliveryDate if (prItem.deliveryDate && !currentDeliveryDate) { setValue(`quotationItems.${index}.vendorDeliveryDate`, new Date(prItem.deliveryDate)) } }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [prItems, setValue, currency]) // 단가 * 수량 계산 const calculateTotal = (index: number) => { const item = quotationItems[index] const prItem = prItems[index] if (item && prItem) { const total = (item.unitPrice || 0) * (prItem.quantity || 0) setValue(`quotationItems.${index}.totalPrice`, total) // PR 아이템 정보도 함께 업데이트 (값이 변경되었을 수 있음) setValue(`quotationItems.${index}.prNo`, prItem.prNo) setValue(`quotationItems.${index}.materialCode`, prItem.materialCode) setValue(`quotationItems.${index}.materialDescription`, prItem.materialDescription) setValue(`quotationItems.${index}.quantity`, prItem.quantity) setValue(`quotationItems.${index}.uom`, prItem.uom) } } // 일괄 납기일 적용 const applyBulkDeliveryDate = () => { if (bulkDeliveryDate && fields.length > 0) { fields.forEach((_, index) => { setValue(`quotationItems.${index}.vendorDeliveryDate`, bulkDeliveryDate) }) setShowBulkDateDialog(false) setBulkDeliveryDate(undefined) } } // 납기일 초기화 (주석처리) // const clearAllDeliveryDates = () => { // fields.forEach((_, index) => { // setValue(`quotationItems.${index}.vendorDeliveryDate`, undefined) // }) // } // 사양서 링크 열기 const handleOpenSpec = (specUrl: string) => { window.open(specUrl, '_blank', 'noopener,noreferrer') } // POS 파일 목록 조회 및 다이얼로그 열기 const handleOpenPosDialog = async (materialCode: string) => { if (!materialCode) { toast.error("자재코드가 없습니다") return } setLoadingPosFiles(true) setSelectedMaterialCode(materialCode) try { toast.loading(`POS 파일 목록 조회 중... (${materialCode})`, { id: `pos-check-${materialCode}` }) const result = await checkPosFileExists(materialCode) if (result.exists && result.files && result.files.length > 0) { const detailResult = await getDownloadUrlByMaterialCode(materialCode) if (detailResult.success && detailResult.availableFiles) { setPosFiles(detailResult.availableFiles) setPosDialogOpen(true) toast.success(`${result.fileCount}개의 POS 파일을 찾았습니다`, { id: `pos-check-${materialCode}` }) } else { toast.error('POS 파일 정보를 가져올 수 없습니다', { id: `pos-check-${materialCode}` }) } } else { toast.error(result.error || 'POS 파일을 찾을 수 없습니다', { id: `pos-check-${materialCode}` }) } } catch (error) { console.error("POS 파일 조회 오류:", error) toast.error("POS 파일 조회에 실패했습니다", { id: `pos-check-${materialCode}` }) } finally { setLoadingPosFiles(false) } } // POS 파일 다운로드 실행 const handleDownloadPosFile = async (fileIndex: number, fileName: string) => { if (!selectedMaterialCode) return setDownloadingFileIndex(fileIndex) try { toast.loading(`POS 파일 다운로드 준비 중...`, { id: `download-${fileIndex}` }) const downloadUrl = `/api/pos/download-on-demand?materialCode=${encodeURIComponent(selectedMaterialCode)}&fileIndex=${fileIndex}` toast.success(`POS 파일 다운로드 시작: ${fileName}`, { id: `download-${fileIndex}` }) window.open(downloadUrl, '_blank', 'noopener,noreferrer') setTimeout(() => { setDownloadingFileIndex(null) }, 1000) } catch (error) { console.error("POS 파일 다운로드 오류:", error) toast.error("POS 파일 다운로드에 실패했습니다", { id: `download-${fileIndex}` }) setDownloadingFileIndex(null) } } // POS 다이얼로그 닫기 const handleClosePosDialog = () => { setPosDialogOpen(false) setSelectedMaterialCode("") setPosFiles([]) setDownloadingFileIndex(null) } // 엑셀 Export const handleExportExcel = async () => { try { const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet('견적품목') // 헤더 설정 worksheet.columns = [ { header: 'No', key: 'no', width: 10 }, { header: 'PR No', key: 'prNo', width: 15 }, { header: 'PR 아이템', key: 'prItem', width: 15 }, { header: '자재코드', key: 'materialCode', width: 20 }, { header: '자재명', key: 'materialDescription', width: 40 }, { header: '수량', key: 'quantity', width: 15 }, { header: '단위', key: 'uom', width: 10 }, { header: '중량', key: 'grossWeight', width: 15 }, { header: '중량단위', key: 'gwUom', width: 15 }, { header: '단가', key: 'unitPrice', width: 20 }, { header: '총액', key: 'totalPrice', width: 20 }, { header: 'Spec.', key: 'specNo', width: 20 }, { header: 'POS', key: 'pos', width: 15 }, { header: 'PR납기 요청일', key: 'deliveryDate', width: 20 }, { header: '벤더 가능납기일', key: 'vendorDeliveryDate', width: 20 }, ] // 데이터 추가 fields.forEach((field, index) => { const prItem = prItems[index] const quotationItem = quotationItems[index] const row = worksheet.addRow({ no: index + 1, prNo: prItem?.prNo || '', prItem: prItem?.prItem || prItem?.rfqItem || '', materialCode: prItem?.materialCode || '', materialDescription: prItem?.materialDescription || '', quantity: prItem?.quantity || 0, uom: prItem?.uom || '', grossWeight: prItem?.grossWeight || 0, gwUom: prItem?.gwUom || '', unitPrice: quotationItem?.unitPrice || 0, totalPrice: quotationItem?.totalPrice || 0, specNo: prItem?.specNo || '', pos: prItem?.materialCode ? 'POS' : '', deliveryDate: prItem?.deliveryDate ? format(new Date(prItem.deliveryDate), 'yyyy-MM-dd') : '', vendorDeliveryDate: quotationItem?.vendorDeliveryDate ? format(new Date(quotationItem.vendorDeliveryDate), 'yyyy-MM-dd') : '', }) }) // 스타일 적용 worksheet.getRow(1).font = { bold: true } worksheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } } // 파일 다운로드 const buffer = await workbook.xlsx.writeBuffer() const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `견적품목_${format(new Date(), 'yyyyMMdd_HHmmss')}.xlsx` document.body.appendChild(link) link.click() document.body.removeChild(link) window.URL.revokeObjectURL(url) toast.success('엑셀 파일이 다운로드되었습니다.') } catch (error) { console.error('엑셀 Export 오류:', error) toast.error('엑셀 파일 다운로드에 실패했습니다.') } } // 엑셀 Import const handleImportExcel = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { toast.error('Excel 파일(.xlsx 또는 .xls)만 업로드 가능합니다') return } setIsImporting(true) try { const workbook = new ExcelJS.Workbook() const arrayBuffer = await file.arrayBuffer() await workbook.xlsx.load(arrayBuffer) const worksheet = workbook.worksheets[0] if (!worksheet) { toast.error('워크시트를 찾을 수 없습니다.') return } // 헤더 매핑 const headerRow = worksheet.getRow(1) const headerMap: { [key: string]: number } = {} headerRow.eachCell((cell, colNumber) => { const header = String(cell.value || '').trim() if (header) { headerMap[header] = colNumber } }) // 데이터 읽기 let successCount = 0 let errorCount = 0 worksheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return // 헤더 건너뛰기 const index = rowNumber - 2 // 0-based index if (index >= fields.length) return try { const unitPriceCol = headerMap['단가'] || headerMap['unitPrice'] const totalPriceCol = headerMap['총액'] || headerMap['totalPrice'] const vendorDeliveryDateCol = headerMap['벤더 가능납기일'] || headerMap['vendorDeliveryDate'] if (unitPriceCol) { const unitPriceValue = row.getCell(unitPriceCol).value const unitPrice = typeof unitPriceValue === 'number' ? unitPriceValue : parseFloat(String(unitPriceValue || 0)) if (!isNaN(unitPrice) && unitPrice >= 0) { const formattedPrice = decimalPlaces === 0 ? Math.floor(unitPrice) : parseFloat(unitPrice.toFixed(decimalPlaces)) setValue(`quotationItems.${index}.unitPrice`, formattedPrice) calculateTotal(index) successCount++ } } if (totalPriceCol) { const totalPriceValue = row.getCell(totalPriceCol).value const totalPrice = typeof totalPriceValue === 'number' ? totalPriceValue : parseFloat(String(totalPriceValue || 0)) if (!isNaN(totalPrice) && totalPrice >= 0) { setValue(`quotationItems.${index}.totalPrice`, totalPrice) } } if (vendorDeliveryDateCol) { const dateValue = row.getCell(vendorDeliveryDateCol).value if (dateValue) { let date: Date | null = null if (dateValue instanceof Date) { date = dateValue } else if (typeof dateValue === 'string') { date = new Date(dateValue) } else if (typeof dateValue === 'number') { // Excel serial date date = new Date((dateValue - 25569) * 86400 * 1000) } if (date && !isNaN(date.getTime())) { setValue(`quotationItems.${index}.vendorDeliveryDate`, date) } } } } catch (error) { console.error(`Row ${rowNumber} import error:`, error) errorCount++ } }) if (fileInputRef.current) { fileInputRef.current.value = '' } toast.success(`${successCount}개 항목이 성공적으로 가져왔습니다.${errorCount > 0 ? ` (${errorCount}개 오류)` : ''}`) } catch (error) { console.error('엑셀 Import 오류:', error) toast.error('엑셀 파일 가져오기에 실패했습니다.') } finally { setIsImporting(false) } } const totalAmount = quotationItems?.reduce( (sum: number, item: any) => sum + (item.totalPrice || 0), 0 ) || 0 // 상세 정보 다이얼로그 const ItemDetailDialog = ({ item, prItem, index }: any) => { const [localDeviationReason, setLocalDeviationReason] = useState("") const [localItemRemark, setLocalItemRemark] = useState("") const [localTechnicalCompliance, setLocalTechnicalCompliance] = useState(false) const [localAlternativeProposal, setLocalAlternativeProposal] = useState("") // 다이얼로그가 열릴 때 기존 값으로 초기화 useEffect(() => { if (item) { setLocalDeviationReason(item.deviationReason || "") setLocalItemRemark(item.itemRemark || "") setLocalTechnicalCompliance(item.technicalCompliance || false) setLocalAlternativeProposal(item.alternativeProposal || "") } }, [item]) // 저장 버튼 클릭 핸들러 const handleSaveDetail = () => { setValue(`quotationItems.${index}.deviationReason`, localTechnicalCompliance ? "" : localDeviationReason) setValue(`quotationItems.${index}.itemRemark`, localItemRemark) setValue(`quotationItems.${index}.technicalCompliance`, localTechnicalCompliance) setValue(`quotationItems.${index}.alternativeProposal`, localAlternativeProposal) setShowDetail(false) } // 취소 버튼 클릭 핸들러 const handleCancelDetail = () => { setShowDetail(false) } return ( !open && setShowDetail(false)}> 견적 상세 정보 {prItem.materialCode} - {prItem.materialDescription}
{/* PR 아이템 정보 */} {/* PR 아이템 정보

{prItem.prNo}

{prItem.materialCode}

{prItem.quantity} {prItem.uom}

{prItem.deliveryDate ? format(new Date(prItem.deliveryDate), "yyyy-MM-dd") : '-'}

{prItem.specNo && (

{prItem.specNo}

)} {prItem.trackingNo && (

{prItem.trackingNo}

)}
*/} {/* 제조사 정보 */} {/* 제조사 정보
*/} {/* 기술 준수 및 대안 */} 기술 사양
setLocalTechnicalCompliance(checked === true)} />
{!localTechnicalCompliance && (