summaryrefslogtreecommitdiff
path: root/lib/esg-check-list/table/esg-excel-import.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/esg-check-list/table/esg-excel-import.tsx')
-rw-r--r--lib/esg-check-list/table/esg-excel-import.tsx399
1 files changed, 399 insertions, 0 deletions
diff --git a/lib/esg-check-list/table/esg-excel-import.tsx b/lib/esg-check-list/table/esg-excel-import.tsx
new file mode 100644
index 00000000..0990e0e8
--- /dev/null
+++ b/lib/esg-check-list/table/esg-excel-import.tsx
@@ -0,0 +1,399 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { useTransition } from "react"
+import { Upload, FileSpreadsheet, AlertCircle, CheckCircle, X } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+
+import {
+ parseEsgExcelFile,
+ validateExcelData,
+ type ParsedExcelData
+} from "./excel-utils"
+import {
+ importEsgDataFromExcel,
+ checkDuplicateSerials,
+ type ImportOptions
+} from "./excel-actions"
+
+interface ExcelImportDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+export function ExcelImportDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+}: ExcelImportDialogProps) {
+ const [isPending, startTransition] = useTransition()
+ const [file, setFile] = React.useState<File | null>(null)
+ const [parsedData, setParsedData] = React.useState<ParsedExcelData | null>(null)
+ const [validationErrors, setValidationErrors] = React.useState<string[]>([])
+ const [duplicateSerials, setDuplicateSerials] = React.useState<string[]>([])
+ const [currentStep, setCurrentStep] = React.useState<'upload' | 'preview' | 'options'>('upload')
+
+ // 임포트 옵션
+ const [importOptions, setImportOptions] = React.useState<ImportOptions>({
+ skipDuplicates: false,
+ updateExisting: false,
+ })
+
+ // 파일 선택 처리
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = event.target.files?.[0]
+ if (selectedFile) {
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.')
+ return
+ }
+ setFile(selectedFile)
+ }
+ }
+
+ // 파일 파싱
+ const handleParseFile = async () => {
+ if (!file) return
+
+ startTransition(async () => {
+ try {
+ const data = await parseEsgExcelFile(file)
+ setParsedData(data)
+
+ // 검증
+ const errors = validateExcelData(data)
+ setValidationErrors(errors)
+
+ // 중복 확인
+ const serials = data.evaluations.map(e => e.serialNumber)
+ const duplicates = await checkDuplicateSerials(serials)
+ setDuplicateSerials(duplicates)
+
+ setCurrentStep('preview')
+ } catch (error) {
+ console.error('Parsing error:', error)
+ toast.error(error instanceof Error ? error.message : 'Excel 파일 파싱에 실패했습니다.')
+ }
+ })
+ }
+
+ // 임포트 실행
+ const handleImport = async () => {
+ if (!parsedData) return
+
+ startTransition(async () => {
+ try {
+ const result = await importEsgDataFromExcel(parsedData, importOptions)
+
+ if (result.success) {
+ toast.success(result.message)
+ onSuccess()
+ onOpenChange(false)
+ } else {
+ toast.error(result.message)
+ }
+
+ // 상세 결과가 있으면 콘솔에 출력
+ if (result.details.errors.length > 0) {
+ console.warn('Import errors:', result.details.errors)
+ }
+ } catch (error) {
+ console.error('Import error:', error)
+ toast.error('임포트 중 오류가 발생했습니다.')
+ }
+ })
+ }
+
+ // 다이얼로그 닫기 시 상태 리셋
+ const handleClose = () => {
+ setFile(null)
+ setParsedData(null)
+ setValidationErrors([])
+ setDuplicateSerials([])
+ setCurrentStep('upload')
+ setImportOptions({ skipDuplicates: false, updateExisting: false })
+ onOpenChange(false)
+ }
+
+ const canProceed = parsedData && validationErrors.length === 0
+ const hasDuplicates = duplicateSerials.length > 0
+
+ return (
+ <Dialog open={open} onOpenChange={handleClose}>
+ <DialogContent className="max-w-4xl max-h-[80vh] flex flex-col" style={{maxWidth:900, width:900}}>
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <FileSpreadsheet className="w-5 h-5" />
+ Excel 데이터 임포트
+ </DialogTitle>
+ <DialogDescription>
+ Excel 파일에서 ESG 평가표 데이터를 임포트합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto px-1">
+ <Tabs value={currentStep} className="w-full">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="upload">파일 업로드</TabsTrigger>
+ <TabsTrigger value="preview" disabled={!parsedData}>데이터 미리보기</TabsTrigger>
+ <TabsTrigger value="options" disabled={!canProceed}>임포트 옵션</TabsTrigger>
+ </TabsList>
+
+ {/* 파일 업로드 탭 */}
+ <TabsContent value="upload" className="space-y-4">
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="excel-file">Excel 파일 선택</Label>
+ <Input
+ id="excel-file"
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ className="mt-1"
+ />
+ </div>
+
+ {file && (
+ <div className="p-4 border rounded-lg bg-muted/50">
+ <div className="flex items-center gap-2">
+ <FileSpreadsheet className="w-4 h-4" />
+ <span className="font-medium">{file.name}</span>
+ <Badge variant="outline">
+ {(file.size / 1024).toFixed(1)} KB
+ </Badge>
+ </div>
+ </div>
+ )}
+
+ <Button
+ onClick={handleParseFile}
+ disabled={!file || isPending}
+ className="w-full"
+ >
+ {isPending ? '파싱 중...' : '파일 분석하기'}
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 데이터 미리보기 탭 */}
+ <TabsContent value="preview" className="space-y-4">
+ {parsedData && (
+ <div className="space-y-4">
+ {/* 검증 결과 */}
+ <div className="space-y-2">
+ {validationErrors.length > 0 ? (
+ <div className="p-4 border border-destructive/20 rounded-lg bg-destructive/10">
+ <div className="flex items-center gap-2 mb-2">
+ <AlertCircle className="w-4 h-4 text-destructive" />
+ <span className="font-medium text-destructive">검증 오류</span>
+ </div>
+ <ul className="space-y-1">
+ {validationErrors.map((error, index) => (
+ <li key={index} className="text-sm text-destructive">
+ • {error}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ) : (
+ <div className="p-4 border border-green-200 rounded-lg bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="w-4 h-4 text-green-600" />
+ <span className="font-medium text-green-800">검증 완료</span>
+ </div>
+ </div>
+ )}
+
+ {/* 중복 알림 */}
+ {hasDuplicates && (
+ <div className="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
+ <div className="flex items-center gap-2 mb-2">
+ <AlertCircle className="w-4 h-4 text-yellow-600" />
+ <span className="font-medium text-yellow-800">중복 데이터 발견</span>
+ </div>
+ <p className="text-sm text-yellow-700 mb-2">
+ 다음 시리얼번호가 이미 존재합니다:
+ </p>
+ <div className="flex flex-wrap gap-1">
+ {duplicateSerials.map(serial => (
+ <Badge key={serial} variant="outline" className="text-yellow-800">
+ {serial}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 데이터 요약 */}
+ <div className="grid grid-cols-3 gap-4">
+ <div className="p-4 border rounded-lg text-center">
+ <div className="text-2xl font-bold text-blue-600">
+ {parsedData.evaluations.length}
+ </div>
+ <div className="text-sm text-muted-foreground">평가표</div>
+ </div>
+ <div className="p-4 border rounded-lg text-center">
+ <div className="text-2xl font-bold text-green-600">
+ {parsedData.evaluationItems.length}
+ </div>
+ <div className="text-sm text-muted-foreground">평가항목</div>
+ </div>
+ <div className="p-4 border rounded-lg text-center">
+ <div className="text-2xl font-bold text-purple-600">
+ {parsedData.answerOptions.length}
+ </div>
+ <div className="text-sm text-muted-foreground">답변옵션</div>
+ </div>
+ </div>
+
+ {/* 평가표 미리보기 */}
+ <div>
+ <h4 className="font-medium mb-2">평가표 미리보기</h4>
+ <ScrollArea className="h-[200px] border rounded-lg">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>시리얼번호</TableHead>
+ <TableHead>분류</TableHead>
+ <TableHead>점검항목</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {parsedData.evaluations.slice(0, 10).map((evaluation, index) => (
+ <TableRow key={index}>
+ <TableCell className="font-medium">
+ {evaluation.serialNumber}
+ {duplicateSerials.includes(evaluation.serialNumber) && (
+ <Badge variant="destructive" className="ml-2 text-xs">
+ 중복
+ </Badge>
+ )}
+ </TableCell>
+ <TableCell>{evaluation.category}</TableCell>
+ <TableCell className="max-w-[200px] truncate">
+ {evaluation.inspectionItem}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+ {parsedData.evaluations.length > 10 && (
+ <p className="text-sm text-muted-foreground mt-2">
+ ...외 {parsedData.evaluations.length - 10}개 더
+ </p>
+ )}
+ </div>
+
+ {canProceed && (
+ <Button
+ onClick={() => setCurrentStep('options')}
+ className="w-full"
+ >
+ 다음 단계
+ </Button>
+ )}
+ </div>
+ )}
+ </TabsContent>
+
+ {/* 임포트 옵션 탭 */}
+ <TabsContent value="options" className="space-y-4">
+ <div className="space-y-4">
+ <h4 className="font-medium">임포트 옵션</h4>
+
+ {hasDuplicates && (
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="skip-duplicates"
+ checked={importOptions.skipDuplicates}
+ onCheckedChange={(checked) =>
+ setImportOptions(prev => ({
+ ...prev,
+ skipDuplicates: !!checked,
+ updateExisting: false, // 상호 배타적
+ }))
+ }
+ />
+ <Label htmlFor="skip-duplicates" className="text-sm">
+ 중복 데이터 건너뛰기
+ </Label>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="update-existing"
+ checked={importOptions.updateExisting}
+ onCheckedChange={(checked) =>
+ setImportOptions(prev => ({
+ ...prev,
+ updateExisting: !!checked,
+ skipDuplicates: false, // 상호 배타적
+ }))
+ }
+ />
+ <Label htmlFor="update-existing" className="text-sm">
+ 기존 데이터 업데이트 (덮어쓰기)
+ </Label>
+ </div>
+
+ <div className="p-3 border border-yellow-200 rounded-lg bg-yellow-50 text-sm">
+ <p className="text-yellow-800">
+ <strong>주의:</strong> 기존 데이터 업데이트를 선택하면 해당 평가표의 모든 평가항목과 답변옵션이 교체됩니다.
+ </p>
+ </div>
+ </div>
+ )}
+
+ <Button
+ onClick={handleImport}
+ disabled={isPending || (hasDuplicates && !importOptions.skipDuplicates && !importOptions.updateExisting)}
+ className="w-full"
+ >
+ {isPending ? '임포트 중...' : '데이터 임포트 실행'}
+ </Button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </div>
+ <DialogFooter className="flex-shrink-0">
+ <Button variant="outline" onClick={handleClose}>
+ 취소
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file