summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/vendor-response/editor/quotation-items-table.tsx')
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx378
1 files changed, 288 insertions, 90 deletions
diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
index d2e0ff0b..9f2af046 100644
--- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
+++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
@@ -13,13 +13,14 @@ 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 } from "lucide-react"
+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 } from "react"
+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,
@@ -58,6 +59,10 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
}>>([])
const [loadingPosFiles, setLoadingPosFiles] = useState(false)
const [downloadingFileIndex, setDownloadingFileIndex] = useState<number | null>(null)
+
+ // 엑셀 import/export 관련 상태
+ const fileInputRef = useRef<HTMLInputElement>(null)
+ const [isImporting, setIsImporting] = useState(false)
const currency = watch("vendorCurrency") || "USD"
const quotationItems = watch("quotationItems")
@@ -78,8 +83,15 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
// 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])
// 단가 * 수량 계산
@@ -110,12 +122,12 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
}
}
- // 납기일 초기화
- const clearAllDeliveryDates = () => {
- fields.forEach((_, index) => {
- setValue(`quotationItems.${index}.vendorDeliveryDate`, undefined)
- })
- }
+ // 납기일 초기화 (주석처리)
+ // const clearAllDeliveryDates = () => {
+ // fields.forEach((_, index) => {
+ // setValue(`quotationItems.${index}.vendorDeliveryDate`, undefined)
+ // })
+ // }
// 사양서 링크 열기
const handleOpenSpec = (specUrl: string) => {
@@ -190,6 +202,183 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
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<HTMLInputElement>) => {
+ 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) {
+ setValue(`quotationItems.${index}.unitPrice`, Math.floor(unitPrice))
+ 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
@@ -448,14 +637,41 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
<CalendarIcon className="h-4 w-4 mr-1" />
전체 납기일 설정
</Button>
- <Button
+ {/* 납기일 초기화 버튼 주석처리 */}
+ {/* <Button
type="button"
variant="outline"
size="sm"
onClick={clearAllDeliveryDates}
>
납기일 초기화
+ </Button> */}
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleExportExcel}
+ >
+ <FileDown className="h-4 w-4 mr-1" />
+ 엑셀 Export
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isImporting}
+ >
+ <Upload className="h-4 w-4 mr-1" />
+ {isImporting ? '가져오는 중...' : '엑셀 Import'}
</Button>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleImportExcel}
+ style={{ display: 'none' }}
+ />
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">총 견적금액</p>
@@ -483,9 +699,10 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
<TableHead className="w-[60px]">중량단위</TableHead>
<TableHead className="w-[150px]">단가</TableHead>
<TableHead className="text-right w-[150px]">총액</TableHead>
+ <TableHead className="w-[120px]">Spec.</TableHead>
+ <TableHead className="w-[100px]">POS</TableHead>
<TableHead className="w-[150px]">PR납기 요청일</TableHead>
- <TableHead className="w-[180px]">사양/POS</TableHead>
- <TableHead className="w-[120px]">프로젝트</TableHead>
+ <TableHead className="w-[150px]">벤더 가능납기일</TableHead>
<TableHead className="w-[80px]">상세</TableHead>
</TableRow>
</TableHeader>
@@ -584,6 +801,58 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
{formatCurrency(quotationItem?.totalPrice || 0, currency)}
</TableCell>
<TableCell>
+ {/* Spec. 칼럼 */}
+ <div className="flex flex-col gap-1">
+ {prItem?.specNo && (
+ <div className="flex items-center gap-1">
+ <span className="text-xs font-mono">{prItem.specNo}</span>
+ {prItem.specUrl && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-5 w-5 p-0"
+ onClick={() => handleOpenSpec(prItem.specUrl!)}
+ title="사양서 열기"
+ >
+ <ExternalLink className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+ )}
+ {!prItem?.specNo && <span className="text-xs text-muted-foreground">-</span>}
+ </div>
+ </TableCell>
+ <TableCell>
+ {/* POS 칼럼 */}
+ {prItem?.materialCode ? (
+ <div className="flex items-center gap-1">
+ <FileText className="h-3 w-3 text-green-500" />
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-5 p-1 text-xs text-green-600 hover:text-green-800"
+ onClick={() => handleOpenPosDialog(prItem.materialCode!)}
+ disabled={loadingPosFiles && selectedMaterialCode === prItem.materialCode}
+ title={`POS 파일 다운로드 (자재코드: ${prItem.materialCode})`}
+ >
+ <Download className="h-3 w-3 mr-1" />
+ {loadingPosFiles && selectedMaterialCode === prItem.materialCode ? '조회중...' : 'POS'}
+ </Button>
+ </div>
+ ) : (
+ <span className="text-xs text-muted-foreground">-</span>
+ )}
+ </TableCell>
+ <TableCell>
+ {/* PR납기 요청일 */}
+ <span className="text-sm">
+ {prItem?.deliveryDate ? format(new Date(prItem.deliveryDate), "yyyy-MM-dd") : "-"}
+ </span>
+ </TableCell>
+ <TableCell>
+ {/* 벤더 가능납기일 */}
<Popover>
<PopoverTrigger asChild>
<Button
@@ -597,13 +866,15 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
<CalendarIcon className="mr-2 h-3 w-3" />
{quotationItem?.vendorDeliveryDate
? format(quotationItem.vendorDeliveryDate, "yyyy-MM-dd")
+ : prItem?.deliveryDate
+ ? format(new Date(prItem.deliveryDate), "yyyy-MM-dd")
: "선택"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
- selected={quotationItem?.vendorDeliveryDate}
+ selected={quotationItem?.vendorDeliveryDate || (prItem?.deliveryDate ? new Date(prItem.deliveryDate) : undefined)}
onSelect={(date) => setValue(`quotationItems.${index}.vendorDeliveryDate`, date)}
initialFocus
/>
@@ -619,67 +890,6 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
)}
</TableCell>
<TableCell>
- <div className="flex flex-col gap-1">
- {/* 사양서 정보 */}
- {(prItem?.specNo || prItem?.specUrl) && (
- <div className="flex items-center gap-1">
- {prItem.specNo && (
- <span className="text-xs font-mono">{prItem.specNo}</span>
- )}
- {prItem.specUrl && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- className="h-5 w-5 p-0"
- onClick={() => handleOpenSpec(prItem.specUrl!)}
- title="사양서 열기"
- >
- <ExternalLink className="h-3 w-3" />
- </Button>
- )}
- </div>
- )}
-
- {/* POS 파일 다운로드 */}
- {prItem?.materialCode && (
- <div className="flex items-center gap-1">
- <FileText className="h-3 w-3 text-green-500" />
- <Button
- type="button"
- variant="ghost"
- size="sm"
- className="h-5 p-1 text-xs text-green-600 hover:text-green-800"
- onClick={() => handleOpenPosDialog(prItem.materialCode!)}
- disabled={loadingPosFiles && selectedMaterialCode === prItem.materialCode}
- title={`POS 파일 다운로드 (자재코드: ${prItem.materialCode})`}
- >
- <Download className="h-3 w-3 mr-1" />
- {loadingPosFiles && selectedMaterialCode === prItem.materialCode ? '조회중...' : 'POS'}
- </Button>
- </div>
- )}
-
- {/* 트래킹 번호 */}
- {prItem?.trackingNo && (
- <div className="text-xs text-muted-foreground">
- TRK: {prItem.trackingNo}
- </div>
- )}
- </div>
- </TableCell>
- <TableCell>
- <div className="text-xs">
- {[
- prItem?.projectDef && `${prItem.projectDef}`,
- prItem?.projectSc && `SC: ${prItem.projectSc}`,
- prItem?.projectKl && `KL: ${prItem.projectKl}`,
- prItem?.projectLc && `LC: ${prItem.projectLc}`,
- prItem?.projectDl && `DL: ${prItem.projectDl}`
- ].filter(Boolean).join(" | ") || "-"}
- </div>
- </TableCell>
- <TableCell>
<Button
type="button"
variant="ghost"
@@ -704,23 +914,11 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
<div className="mt-4 flex justify-end">
<Card className="w-[400px]">
<CardContent className="pt-4">
- <div className="space-y-2">
- <div className="flex justify-between text-sm">
- <span className="text-muted-foreground">소계</span>
- <span>{formatCurrency(totalAmount, currency)}</span>
- </div>
- <div className="flex justify-between text-sm">
- <span className="text-muted-foreground">통화</span>
- <span>{currency}</span>
- </div>
- <div className="border-t pt-2">
- <div className="flex justify-between">
- <span className="font-semibold">총 견적금액</span>
- <span className="text-xl font-bold text-primary">
- {formatCurrency(totalAmount, currency)}
- </span>
- </div>
- </div>
+ <div className="flex justify-between">
+ <span className="font-semibold">총 견적금액</span>
+ <span className="text-xl font-bold text-primary">
+ {formatCurrency(totalAmount, currency)}
+ </span>
</div>
</CardContent>
</Card>