diff --git a/package.json b/package.json index 424b6a4..a4c8984 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-table": "^8.21.2", + "@tanstack/react-virtual": "^3.13.6", + "@types/papaparse": "^5.3.15", + "@types/react-window": "^1.8.8", "axios": "^1.8.4", "chart.js": "^4.4.8", "class-variance-authority": "^0.7.1", @@ -40,6 +43,7 @@ "jotai": "^2.12.2", "lucide-react": "^0.483.0", "next": "15.2.4", + "papaparse": "^5.5.2", "pretendard": "^1.3.9", "react": "^19.0.0", "react-chartjs": "^1.2.0", @@ -50,6 +54,7 @@ "react-icons": "^5.5.0", "react-resizable-panels": "^2.1.7", "react-router-dom": "^7.4.0", + "react-window": "^1.8.11", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06c6bd0..b09e70d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,15 @@ importers: '@tanstack/react-table': specifier: ^8.21.2 version: 8.21.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@tanstack/react-virtual': + specifier: ^3.13.6 + version: 3.13.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@types/papaparse': + specifier: ^5.3.15 + version: 5.3.15 + '@types/react-window': + specifier: ^1.8.8 + version: 1.8.8 axios: specifier: ^1.8.4 version: 1.8.4 @@ -95,6 +104,9 @@ importers: next: specifier: 15.2.4 version: 15.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + papaparse: + specifier: ^5.5.2 + version: 5.5.2 pretendard: specifier: ^1.3.9 version: 1.3.9 @@ -125,6 +137,9 @@ importers: react-router-dom: specifier: ^7.4.0 version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react-window: + specifier: ^1.8.11 + version: 1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0) tailwind-merge: specifier: ^3.0.2 version: 3.0.2 @@ -193,6 +208,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@babel/runtime@7.27.0': + resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} + engines: {node: '>=6.9.0'} + '@emnapi/core@1.3.1': resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==} @@ -1041,10 +1060,19 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.6': + resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.2': resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.13.6': + resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -1063,6 +1091,9 @@ packages: '@types/node@20.17.25': resolution: {integrity: sha512-bT+r2haIlplJUYtlZrEanFHdPIZTeiMeh/fSOEbOOfWf9uTn+lg8g0KU6Q3iMgjd9FLuuMAgfCNSkjUbxL6E3Q==} + '@types/papaparse@5.3.15': + resolution: {integrity: sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==} + '@types/react-chartjs-2@2.5.7': resolution: {integrity: sha512-waqYqiNULIVUqaKO7MGUpFmWrVtH7gVPOzqwV4y4zgUyu/JiDwC005PpveO442HKnby9kLgp3t1SB2sld+ACLw==} deprecated: This is a stub types definition for react-chartjs-2 (https://github.com/gor181/react-chartjs-2). react-chartjs-2 provides its own type definitions, so you don't need @types/react-chartjs-2 installed! @@ -1072,6 +1103,9 @@ packages: peerDependencies: '@types/react': ^19.0.0 + '@types/react-window@1.8.8': + resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} + '@types/react@19.0.12': resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==} @@ -2126,6 +2160,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2267,6 +2304,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + papaparse@5.5.2: + resolution: {integrity: sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2433,6 +2473,13 @@ packages: '@types/react': optional: true + react-window@1.8.11: + resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} @@ -2445,6 +2492,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -2796,6 +2846,10 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@babel/runtime@7.27.0': + dependencies: + regenerator-runtime: 0.14.1 + '@emnapi/core@1.3.1': dependencies: '@emnapi/wasi-threads': 1.0.1 @@ -3617,8 +3671,16 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + '@tanstack/react-virtual@3.13.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@tanstack/virtual-core': 3.13.6 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + '@tanstack/table-core@8.21.2': {} + '@tanstack/virtual-core@3.13.6': {} + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -3636,6 +3698,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/papaparse@5.3.15': + dependencies: + '@types/node': 20.17.25 + '@types/react-chartjs-2@2.5.7(chart.js@4.4.8)(react@19.0.0)': dependencies: react-chartjs-2: 5.3.0(chart.js@4.4.8)(react@19.0.0) @@ -3647,6 +3713,10 @@ snapshots: dependencies: '@types/react': 19.0.12 + '@types/react-window@1.8.8': + dependencies: + '@types/react': 19.0.12 + '@types/react@19.0.12': dependencies: csstype: 3.1.3 @@ -4854,6 +4924,8 @@ snapshots: math-intrinsics@1.1.0: {} + memoize-one@5.2.1: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -5011,6 +5083,8 @@ snapshots: dependencies: p-limit: 3.1.0 + papaparse@5.5.2: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5152,6 +5226,13 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + react-window@1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/runtime': 7.27.0 + memoize-one: 5.2.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react@19.0.0: {} readable-stream@3.6.2: @@ -5171,6 +5252,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.14.1: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 diff --git a/src/app/modal/chartinput-form.tsx b/src/app/modal/chartinput-form.tsx index 9f00db9..974b237 100644 --- a/src/app/modal/chartinput-form.tsx +++ b/src/app/modal/chartinput-form.tsx @@ -1,15 +1,18 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useDropzone } from 'react-dropzone'; import { Button } from '@/components/ui/button'; +import { Upload, X } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose, + DialogDescription, } from '@/components/ui/dialog'; -import { ESGCombobox } from './combobox'; // ESG 항목 선택 컴포넌트 -import DataTable from './datatable'; // 데이터 입력 테이블 컴포넌트 +import { ESGCombobox } from './combobox'; +import DataTable from './datatable'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { @@ -18,56 +21,94 @@ import { SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; // Select 관련 컴포넌트 추가 +} from '@/components/ui/select'; import { v4 as uuidv4 } from 'uuid'; -import { ChartType, ChartData } from '@/types/chart'; // ChartDataset 임포트 제거 +import { ChartType, ChartData } from '@/types/chart'; +import { Value } from '@radix-ui/react-select'; interface ESGChartDialogProps { open: boolean; setOpen: (open: boolean) => void; - onChartAdd?: (chart: ChartData) => void; // 차트 추가 콜백 + onChartAdd?: (chart: ChartData) => void; } export function ESGChartDialog({ open, setOpen, onChartAdd }: ESGChartDialogProps) { - const [step, setStep] = useState<'combobox' | 'datatable'>('combobox'); // 현재 단계 상태 + const [step, setStep] = useState<'combobox' | 'datatable'>('combobox'); const [chartType, setChartType] = useState('bar'); const [chartTitle, setChartTitle] = useState(''); const [chartDescription, setChartDescription] = useState(''); const [colSpan, setColSpan] = useState<1 | 2 | 3 | 4>(1); - const [selectedESG, setSelectedESG] = useState(null); // ESG 항목 상태 추가 - const [labels, setLabels] = useState([]); // labels 상태 추가 - const [datasets, setDatasets] = useState([]); // datasets 상태 타입 수정 - const [isLoading, setIsLoading] = useState(false); // 로딩 상태 추가 + const [selectedESG, setSelectedESG] = useState(null); + const [labels, setLabels] = useState([]); + const [datasets, setDatasets] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [file, setFile] = useState(); + const [tableKey, setTableKey] = useState(0); + const prevDataLength = useRef({ labels: 0, datasets: 0 }); + const [worker, setWorker] = useState(null); + + // Web Worker 초기화 + useEffect(() => { + const csvWorker = new Worker(new URL('../../worker/csvWorker.ts', import.meta.url), { + type: 'module', + }); + + csvWorker.onmessage = event => { + const { labels, datasets } = event.data; + setLabels(labels); + setDatasets(datasets); + }; + + setWorker(csvWorker); + + return () => { + csvWorker.terminate(); + }; + }, []); + + useEffect(() => { + if (!datasets || !labels) return; + if ( + prevDataLength.current.labels !== labels.length || + prevDataLength.current.datasets !== datasets.length + ) { + setTableKey(prevKey => prevKey + 1); + prevDataLength.current = { labels: labels.length, datasets: datasets.length }; + } + }, [labels, datasets]); + + const handleFile = (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + if (file && worker) { + worker.postMessage(file); + } + }; const handleNext = () => { if (step === 'combobox') { - setStep('datatable'); // 다음 단계로 전환 + setStep('datatable'); } else { handleSave(); } }; - // 이전 단계로 돌아가는 함수 추가 const handleBack = () => { setStep('combobox'); }; - // 데이터 테이블 변경 콜백 (DataTable에서 호출) const handleDataChange = useCallback( - (newLabels: string[], newDatasets: ChartData['datasets']) => { + (newLabels: string[] | number[], newDatasets: ChartData['datasets']) => { setLabels(newLabels); setDatasets(newDatasets); }, [] ); - // ESG 항목 변경 콜백 (ESGCombobox에서 호출) const handleESGChange = useCallback((value: string | null) => { setSelectedESG(value); }, []); const handleSave = async () => { - // async 추가 if (!chartTitle) { alert('차트 제목을 입력해주세요'); return; @@ -76,7 +117,7 @@ export function ESGChartDialog({ open, setOpen, onChartAdd }: ESGChartDialogProp alert('ESG 항목을 선택해주세요.'); return; } - // 데이터 유효성 검사 활성화 + if ( step === 'datatable' && (labels.length === 0 || @@ -88,24 +129,22 @@ export function ESGChartDialog({ open, setOpen, onChartAdd }: ESGChartDialogProp return; } - setIsLoading(true); // 로딩 시작 + setIsLoading(true); - // 새 차트 객체 생성 시 상태 값 사용 const newChart: ChartData = { id: uuidv4(), title: chartTitle, description: chartDescription, type: chartType, colSpan: colSpan, - esg: selectedESG, // ESG 항목 추가 (null 아님을 위에서 확인) - labels: labels, // 상태에서 가져온 labels 사용 - datasets: datasets, // 상태에서 가져온 datasets 사용 + esg: selectedESG, + labels: labels, + datasets: datasets, createdAt: new Date(), updatedAt: new Date(), }; try { - // 백엔드 API 호출 (엔드포인트는 예시) const response = await fetch('/api/charts', { method: 'POST', headers: { @@ -115,27 +154,21 @@ export function ESGChartDialog({ open, setOpen, onChartAdd }: ESGChartDialogProp }); if (!response.ok) { - // 오류 처리 (예: 사용자에게 알림) throw new Error(`API 오류: ${response.statusText}`); } - const savedChart = await response.json(); // 저장된 차트 데이터 (선택적) - console.log('차트 저장 성공:', savedChart); - - // 부모 컴포넌트로 전달 + const savedChart = await response.json(); if (onChartAdd) { - onChartAdd(savedChart); // 저장된 데이터 전달 (백엔드 응답 사용) + onChartAdd(savedChart); } - // 폼 초기화 및 닫기 resetForm(); setOpen(false); } catch (error) { console.error('차트 저장 실패:', error); - // 사용자에게 오류 알림 (예: alert 또는 toast 메시지) alert('차트 저장 중 오류가 발생했습니다.'); } finally { - setIsLoading(false); // 로딩 종료 + setIsLoading(false); } }; @@ -144,93 +177,78 @@ export function ESGChartDialog({ open, setOpen, onChartAdd }: ESGChartDialogProp setChartDescription(''); setChartType('bar'); setColSpan(1); - setSelectedESG(null); // ESG 항목 초기화 - setLabels([]); // labels 초기화 - setDatasets([]); // datasets 초기화 + setSelectedESG(null); + setLabels([]); + setDatasets([]); }; - // 차트 타입별 샘플 데이터 생성 함수 리팩토링 (더 이상 사용되지 않을 수 있으므로 주석 처리 또는 삭제 가능) - /* - const getSampleData = (type: ChartType): { labels?: string[], datasets?: ChartData['datasets'] } => { - switch (type) { - case 'bar': - return { - labels: ['카테고리1', '카테고리2', '카테고리3'], - datasets: [ - { - label: '샘플 데이터', // label 추가 - data: [65, 78, 82], // data로 변경 - backgroundColor: ['blue', 'green', 'purple'] // backgroundColor로 변경 - } - ] - }; - case 'line': - return { - labels: ['1월', '2월', '3월', '4월', '5월', '6월'], - datasets: [ - { - label: 'Dataset 1', // label로 변경 - data: [65, 59, 80, 81, 56, 55] // data로 변경 - }, - { - label: 'Dataset 2', // label로 변경 - data: [28, 48, 40, 19, 86, 27] // data로 변경 - } - ] - }; - case 'pie': - case 'donut': // donut 타입 추가 - return { - labels: ['항목 1', '항목 2', '항목 3'], // labels 추가 - datasets: [ - { - label: '샘플 데이터', // label 추가 - data: [45, 30, 25], // data로 변경 (숫자형) - backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56'] // 배경색 추가 - } - ] - }; - case 'area': // area 타입 추가 - return { - labels: ['1월', '2월', '3월', '4월', '5월', '6월'], - datasets: [ + const onDrop = useCallback((acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + setFile(file); + if (file) { + const reader = new FileReader(); + + reader.onload = (event) => { + if (!event.target?.result) return; + const csvText = event.target.result as string; + const parsedData = parseCSV(csvText); + + const dataset: ChartData['datasets'] = []; + parsedData.data.forEach((val, index) => { + dataset.push( { - label: '샘플 데이터', // label 추가 - data: [30, 45, 40, 55, 60, 65], // data로 변경 - borderColor: 'rgb(75, 192, 192)', // borderColor 추가 - backgroundColor: 'rgba(75, 192, 192, 0.2)', // backgroundColor 추가 - fill: true // fill 속성 추가 + label: `${index + 1}`, // Provide a default string label + data: val, // Use the data array } - ] - }; - default: - // 모든 타입에 대해 처리했는지 확인 (never 타입 활용) - const exhaustiveCheck: never = type; - console.error(`Unhandled chart type: ${exhaustiveCheck}`); - return {}; + ); + }); + setLabels(parsedData.labels); + setDatasets(dataset); + }; + console.log(labels, datasets); + reader.readAsText(file, "UTF-8"); + handleDataChange(labels, datasets); } + }, [labels, datasets]); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + accept: { 'text/csv': ['.csv'], }, + }); + + const parseCSV = (csvText: string) => { + const rows = csvText.split("\n").map(row => row.trim()).filter(row => row); // 줄바꿈 기준으로 분리 + const labels = Array.from({ length: rows.length }, (_, i) => i); + const beforedata: number[][] = []; + for (let i = 0; i < rows.length; i++) { // 첫 번째 줄은 헤더이므로 건너뜀 + const value = rows[i].split(",").map(row => parseValue(row)); + beforedata.push(value); // 숫자로 변환 + } + const data = beforedata[0].map((_, colIndex) => + beforedata.map(row => row[colIndex]) + ); + return { labels, data }; }; - */ + + const parseValue = (value: any) => isNaN(value) ? value : Number(value); return ( + CSV 업로드 후 차트를 설정하세요 차트 추가 setOpen(false)} - > + />
{step === 'combobox' && ( -
- {/* 차트 제목 입력 */} +
- +
- {/* ESG 항목 선택 */}
- {/* ESGCombobox에 콜백 및 값 전달 */}
- {/* 차트 설명 입력 */}
- +
- {/* 차트 유형 선택 */}
- {/* 차트 크기 선택 */}
+
+ +

+ CSV 파일을 추가하려면 파일 선택
또는 여기로 파일을 끌고 오세요 +

+
+
+ )} + {step === 'datatable' && ( + <> +
+ +
+ +

+ CSV 파일을 추가하려면 파일 선택
또는 여기로 파일을 끌고 오세요 +

+
+
+ -
+ )}
- {/* 버튼 컨테이너 스타일 수정 및 이전 버튼 추가 */} -
+
{step === 'datatable' && ( - )} -
diff --git a/src/app/modal/datatable.tsx b/src/app/modal/datatable.tsx index 0e5aa84..b397644 100644 --- a/src/app/modal/datatable.tsx +++ b/src/app/modal/datatable.tsx @@ -8,13 +8,15 @@ import { TableRow, TableHead, TableCell, + TableFooter } from '@/components/ui/table'; import { Input } from '@/components/ui/input'; import { Plus, Trash2, Edit } from 'lucide-react'; import { ChartData } from '@/types/chart'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; interface DataTableProps { - initialLabels?: string[]; + initialLabels?: string[]|number[]; initialDatasets?: ChartData['datasets']; onDataChange: (labels: string[], datasets: ChartData['datasets']) => void; } @@ -25,7 +27,7 @@ export default function DataTable({ onDataChange, }: DataTableProps) { const [columns, setColumns] = React.useState(() => { - if (initialLabels && initialLabels.length > 0) { + if (initialLabels.length > 0) { const datasetLabels = initialDatasets?.map(ds => ds?.label || '') || []; return ['Label', ...datasetLabels]; } @@ -33,7 +35,7 @@ export default function DataTable({ }); const [rows, setRows] = React.useState(() => { - if (initialLabels && initialLabels.length > 0) { + if (initialLabels.length > 0) { return initialLabels.map((label, index) => ({ id: Date.now() + index, columns: [label, ...(initialDatasets?.map(ds => ds?.data?.[index] ?? '') || [])], @@ -44,24 +46,85 @@ export default function DataTable({ const [editingColumnIndex, setEditingColumnIndex] = React.useState(null); const [tempColumnName, setTempColumnName] = React.useState(''); + const inputRef = React.useRef(null); + const containerRef = React.useRef(null); + const rowHeight = 48; + const [scrollTop, setScrollTop] = React.useState(0); + const visibleRowCount = 13; + + const totalHeight = rows.length * rowHeight; + const startIndex = Math.floor(scrollTop / rowHeight); + const endIndex = Math.min(rows.length, startIndex + visibleRowCount); + const visibleRows = rows.slice(startIndex, endIndex); + const topPadding = startIndex * rowHeight; + const bottomPadding = totalHeight - (endIndex * rowHeight); + const MAX_WHEEL_DELTA = 100; + + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const wheelHandler = (e: WheelEvent) => { + e.preventDefault(); + + const absDeltaX = Math.abs(e.deltaX); + const absDeltaY = Math.abs(e.deltaY); + + if (absDeltaX > absDeltaY) { + // x축 스크롤 + const limitedDeltaX = Math.max(-MAX_WHEEL_DELTA, Math.min(MAX_WHEEL_DELTA, e.deltaX)); + const newScrollLeft = Math.max( + 0, + Math.min(container.scrollWidth, container.scrollLeft + limitedDeltaX) + ); + container.scrollLeft = newScrollLeft; + } else { + // y축 스크롤 + const limitedDeltaY = Math.max(-MAX_WHEEL_DELTA, Math.min(MAX_WHEEL_DELTA, e.deltaY)); + const newScrollTop = Math.max( + 0, + Math.min(container.scrollHeight, container.scrollTop + limitedDeltaY) + ); + container.scrollTop = newScrollTop; + } + }; + + container.addEventListener('wheel', wheelHandler, { passive: false }); + + return () => { + container.removeEventListener('wheel', wheelHandler); + }; + }, []); + React.useEffect(() => { const newLabels = rows.map(row => row.columns[0]); const newDatasets: ChartData['datasets'] = columns.slice(1).map((colName, colIndex) => ({ label: colName, data: rows.map(row => { const value = row.columns[colIndex + 1]; - const numericValue = Number(value); - return isNaN(numericValue) ? 0 : numericValue; + const numericValue =value; + return numericValue; }), })); onDataChange(newLabels, newDatasets); }, [rows, columns, onDataChange]); + React.useEffect(() => { + if (editingColumnIndex !== null && inputRef.current) { + inputRef.current.focus(); + } + }, [editingColumnIndex]); + const handleInputChange = (rowIndex: number, colIndex: number, value: string) => { - const updatedRows = [...rows]; - updatedRows[rowIndex].columns[colIndex] = value; - setRows(updatedRows); + setRows(prevRows => { + const updated = [...prevRows]; + const row = { ...updated[rowIndex] }; + row.columns = [...row.columns]; + row.columns[colIndex] = value; + updated[rowIndex] = row; + return updated; + }); }; const addRow = () => { @@ -73,15 +136,15 @@ export default function DataTable({ }; const addColumn = () => { - const newColumnName = `데이터셋 ${columns.length}`; + const newColumnName = `${columns.length}`; setColumns([...columns, newColumnName]); setRows(rows.map(row => ({ ...row, columns: [...row.columns, ''] }))); }; const handleColumnHeaderDoubleClick = (index: number) => { if (index === 0) return; - setEditingColumnIndex(index); setTempColumnName(columns[index]); + setEditingColumnIndex(index); }; const handleColumnNameChange = (event: React.ChangeEvent) => { @@ -103,78 +166,141 @@ export default function DataTable({ } }; + const handleScroll = (e: React.UIEvent) => { + setScrollTop(e.currentTarget.scrollTop); + }; + + + + + const EditableCell = React.memo( + ({ + value, + onChange, + placeholder, + }: { + value: string; + onChange: (value: string) => void; + placeholder: string; + }) => { + const [localValue, setLocalValue] = React.useState(value); + + React.useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleBlur = () => { + if (localValue !== value) { + onChange(localValue); + } + // Blur the input to remove focus + }; + + return ( + e.stopPropagation()} + // onMouseDown={(e) => e.stopPropagation()} + onChange={e => {setLocalValue(e.target.value);}} + onBlur={handleBlur} + placeholder={placeholder} + className="text-sm h-8 min-w-full" + /> + + ); + } + ); + return ( -
-
-
- - - - {columns.map((col, colIndex) => ( - 0 ? 'cursor-pointer' : ''}`} - onDoubleClick={() => handleColumnHeaderDoubleClick(colIndex)} - > - {editingColumnIndex === colIndex ? ( - - ) : ( - col - )} - - ))} - - - - - - - {rows.map(row => ( - - {row.columns.map((cell, colIndex) => ( - - - handleInputChange( - rows.findIndex(r => r.id === row.id), - colIndex, - e.target.value - ) - } - placeholder={`Enter ${columns[colIndex]}`} - /> - - ))} - - - - - +
+ {/* 스크롤 영역 */} +
+
+ +
+ + + {columns.map((col, colIndex) => ( + handleColumnHeaderDoubleClick(colIndex)} + > + {editingColumnIndex === colIndex ? ( + + ) : ( + col + )} + + ))} + + + + + +
+
+ +
+ {/* 테이블 바디 */} + + + + + + + {visibleRows.map((row, i) => ( + + {row.columns.map((cell, colIndex) => ( + + handleInputChange(startIndex + i, colIndex, val)} + placeholder={`Enter ${columns[colIndex]}`} + /> + ))} - -
-
-
- + + + + + + ))} + + + + + + +
+
+ + {/* Add Row 버튼 */} +
+ 열 추가 +
+
+ + ); } diff --git a/src/types/chart.ts b/src/types/chart.ts index 0f8687e..79fcc9e 100644 --- a/src/types/chart.ts +++ b/src/types/chart.ts @@ -8,16 +8,16 @@ export type ChartType = ChartTypeCore | 'area' | 'donut'; // 차트 유형별 데이터 인터페이스 export interface BarChartData { - categories: string[]; + categories: string[]|number[]; values: number[]; colors: string[]; } export interface LineChartData { - labels: string[]; + labels: string[]|number[]; datasets: Array<{ name: string; - values: number[]; + values:string[]|number[]; }>; } @@ -36,9 +36,9 @@ export interface ChartData { type: ChartType; // 애플리케이션용 타입 사용 description?: string; esg: string; // ESG 항목 식별자 추가 - labels?: string[]; // labels 속성 추가 (선택적) + labels?: string[]|number[]; // labels 속성 추가 (선택적) // datasets와 options는 chart.js 핵심 타입을 사용하도록 수정 - datasets?: ChartDataset[]; + datasets?: ChartDataset[]; options?: ChartOptions; colSpan?: number; createdAt?: Date; // 기존 createdAt 속성 (가정) diff --git a/src/worker/csvWorker.ts b/src/worker/csvWorker.ts new file mode 100644 index 0000000..6edb31b --- /dev/null +++ b/src/worker/csvWorker.ts @@ -0,0 +1,19 @@ +import Papa from 'papaparse'; +import { ChartType, ChartData } from '@/types/chart'; // ChartType과 ChartData 타입 임포트 +self.onmessage = event => { + const file = event.data; + console.log('Received file:', file); + Papa.parse(file, { + worker: true, // Web Worker에서 처리 + header: false, // CSV의 첫 줄을 헤더로 사용 + skipEmptyLines: true, // 빈 줄 제거 + complete: result => { + const labels = Array.from({length: result.data.length}, (_, i) => i+1); // 1부터 시작하는 레이블 생성 + const dataset: ChartData['datasets'] = (result.data[0] as string[][]).map((_, colindex) => { + return ({label:`${colindex+1}`,data:(result.data as string[][]).map(row => row[colindex])}); + }); + + self.postMessage({labels:labels,datasets:dataset}); // 파싱된 데이터 반환 + }, + }); +};