diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-19 09:44:28 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-19 09:44:28 +0000 |
| commit | 95bbe9c583ff841220da1267630e7b2025fc36dc (patch) | |
| tree | 5e3d5bb3302530bbaa7f7abbe8c9cf8193ccbd4c /lib/esg-check-list/table/esg-excel-import.tsx | |
| parent | 0eb030580b5cbe5f03d570c3c9d8c519bac3b783 (diff) | |
(대표님) 20250619 1844 KST 작업사항
Diffstat (limited to 'lib/esg-check-list/table/esg-excel-import.tsx')
| -rw-r--r-- | lib/esg-check-list/table/esg-excel-import.tsx | 399 |
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 |
