diff options
Diffstat (limited to 'components/bidding/manage/bidding-items-editor.tsx')
| -rw-r--r-- | components/bidding/manage/bidding-items-editor.tsx | 181 |
1 files changed, 178 insertions, 3 deletions
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 90e512d2..452cdc3c 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react' +import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react' import { getPRItemsForBidding } from '@/lib/bidding/detail/service' import { updatePrItem } from '@/lib/bidding/detail/service' import { toast } from 'sonner' @@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' // PR 아이템 정보 타입 (create-bidding-dialog와 동일) -interface PRItemInfo { +export interface PRItemInfo { id: number // 실제 DB ID prNumber?: string | null projectId?: number | null @@ -84,6 +84,16 @@ import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog' import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' +import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel' +import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) { const { data: session } = useSession() @@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems isPriceAdjustmentApplicable?: boolean | null sparePartOptions?: string | null } | null>(null) + const [importDialogOpen, setImportDialogOpen] = React.useState(false) + const [importFile, setImportFile] = React.useState<File | null>(null) + const [importErrors, setImportErrors] = React.useState<string[]>([]) + const [isImporting, setIsImporting] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드 React.useEffect(() => { @@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: 1, + priceUnit: '1', purchaseUnit: 'EA', materialWeight: null, wbsCode: null, @@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const totals = calculateTotals() + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + if (items.length === 0) { + toast.error('내보낼 품목이 없습니다.') + return + } + + try { + setIsExporting(true) + await exportBiddingItemsToExcel(items, { + filename: `입찰품목목록_${biddingId}`, + }) + toast.success('Excel 파일이 다운로드되었습니다.') + } catch (error) { + console.error('Excel export error:', error) + toast.error('Excel 내보내기 중 오류가 발생했습니다.') + } finally { + setIsExporting(false) + } + }, [items, biddingId]) + + // Excel 가져오기 핸들러 + const handleImportFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + setImportFile(file) + setImportErrors([]) + } + } + + const handleImport = async () => { + if (!importFile) return + + setIsImporting(true) + setImportErrors([]) + + try { + const result = await importBiddingItemsFromExcel(importFile) + + if (result.errors.length > 0) { + setImportErrors(result.errors) + toast.warning( + `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.` + ) + return + } + + if (result.items.length === 0) { + toast.error('가져올 품목이 없습니다.') + return + } + + // 기존 아이템에 추가 + setItems((prev) => [...prev, ...result.items]) + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + toast.success(`${result.items.length}개의 품목이 추가되었습니다.`) + } catch (error) { + console.error('Excel import error:', error) + toast.error('Excel 가져오기 중 오류가 발생했습니다.') + } finally { + setIsImporting(false) + } + } + if (isLoading) { return ( <div className="flex items-center justify-center p-8"> @@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <FileText className="h-4 w-4" /> 사전견적 </Button> + <Button onClick={handleExport} variant="outline" className="flex items-center gap-2" disabled={readonly || isExporting || items.length === 0}> + <FileSpreadsheet className="h-4 w-4" /> + {isExporting ? "내보내는 중..." : "Excel 내보내기"} + </Button> + <Button onClick={() => setImportDialogOpen(true)} variant="outline" className="flex items-center gap-2" disabled={readonly}> + <Upload className="h-4 w-4" /> + Excel 가져오기 + </Button> <Button onClick={handleAddItem} className="flex items-center gap-2" disabled={readonly}> <Plus className="h-4 w-4" /> 품목 추가 @@ -1492,6 +1585,88 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems toast.success('사전견적용 일반견적이 생성되었습니다') }} /> + + {/* Excel 가져오기 다이얼로그 */} + <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Excel 가져오기</DialogTitle> + <DialogDescription> + Excel 파일을 업로드하여 품목을 일괄 추가합니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-4"> + <div> + <Label htmlFor="import-file">Excel 파일 선택</Label> + <Input + id="import-file" + type="file" + accept=".xlsx,.xls" + onChange={handleImportFileSelect} + className="mt-2" + disabled={isImporting} + /> + {importFile && ( + <p className="text-sm text-muted-foreground mt-2"> + 선택된 파일: {importFile.name} + </p> + )} + </div> + + {importErrors.length > 0 && ( + <div className="space-y-2"> + <Label className="text-destructive">오류 목록</Label> + <div className="max-h-60 overflow-y-auto border rounded-md p-3 bg-destructive/5"> + <ul className="list-disc list-inside space-y-1"> + {importErrors.map((error, index) => ( + <li key={index} className="text-sm text-destructive"> + {error} + </li> + ))} + </ul> + </div> + </div> + )} + + <div className="text-sm text-muted-foreground space-y-1"> + <p className="font-semibold">필수 컬럼:</p> + <ul className="list-disc list-inside ml-2"> + <li>자재그룹코드, 자재그룹명</li> + <li>수량 또는 중량 (둘 중 하나 필수)</li> + <li>수량단위 또는 중량단위</li> + <li>납품요청일 (YYYY-MM-DD 형식)</li> + <li>내정단가</li> + </ul> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + }} + disabled={isImporting} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!importFile || isImporting} + > + {isImporting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 가져오는 중... + </> + ) : ( + "가져오기" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ) |
