diff --git a/.env.example b/.env.example deleted file mode 100755 index 5adbdad..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# Local server -VITE_PORT=3000 -VITE_HOST=localhost - -# API server -VITE_API_URL=http://40.82.133.44 diff --git a/README.md b/README.md index 73767de..98b9374 100755 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ CASTAD 프론트엔드 프로젝트 입니다. # 의존성 설치 npm install +# recharts 설치 (대시보드용 차트 라이브러리) +npm install recharts + # 개발 서버 실행 (http://localhost:3000) npm run dev diff --git a/index.css b/index.css index 67688b3..bd00ee5 100644 --- a/index.css +++ b/index.css @@ -5597,7 +5597,7 @@ } .dashboard-description { - font-size: 9px; + font-size: 10px; color: var(--color-text-gray-500); margin-top: 0.125rem; } @@ -5707,6 +5707,9 @@ } .stat-trend { + display: inline-flex; + align-items: center; + gap: 2px; font-size: 8px; color: var(--color-mint); font-weight: 500; @@ -5969,6 +5972,10 @@ color: #f87171; } +.stat-trend.neutral { + color: var(--color-text-gray-400); +} + /* Dashboard Section */ .dashboard-section { margin-bottom: 0.75rem; @@ -6086,6 +6093,35 @@ } } +/* Mode Toggle */ +.mode-toggle { + display: flex; + border: 1px solid var(--color-border-gray-700); + border-radius: var(--radius-full); + overflow: hidden; +} + +.mode-btn { + padding: 0.375rem 0.875rem; + background-color: transparent; + color: var(--color-text-gray-400); + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + border: none; + transition: all var(--transition-normal); +} + +.mode-btn:hover { + color: var(--color-text-gray-300); + background-color: rgba(255, 255, 255, 0.05); +} + +.mode-btn.active { + background-color: rgba(166, 255, 234, 0.15); + color: var(--color-mint); +} + /* Platform Metrics Grid */ .platform-metrics-grid { display: grid; @@ -6237,6 +6273,7 @@ border: 1px solid var(--color-border-white-5); box-shadow: var(--shadow-xl); margin-bottom: 0.75rem; + overflow: hidden; } @media (min-width: 640px) { @@ -6321,9 +6358,6 @@ /* Chart Tooltip */ .chart-tooltip { - position: absolute; - transform: translate(-50%, -100%); - margin-top: -12px; background-color: rgba(28, 42, 46, 0.95); border: 1px solid var(--color-border-white-10); border-radius: var(--radius-lg); @@ -6336,14 +6370,8 @@ } @keyframes tooltipFadeIn { - from { - opacity: 0; - transform: translate(-50%, -90%); - } - to { - opacity: 1; - transform: translate(-50%, -100%); - } + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } } .chart-tooltip-title { @@ -6397,6 +6425,14 @@ text-align: center; } +.chart-tooltip-change.down { + color: #ff6b6b; +} + +.chart-tooltip-change.neutral { + color: var(--color-text-gray-400); +} + /* Chart Animations */ .chart-line-animated { stroke-dasharray: 2000; diff --git a/package-lock.json b/package-lock.json index 60a8d5f..9ec8865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "i18next": "^25.8.4", "react": "^19.2.3", "react-dom": "^19.2.3", - "react-i18next": "^16.5.4" + "react-i18next": "^16.5.4", + "recharts": "^3.7.0" }, "devDependencies": { "@types/node": "^22.14.0", @@ -804,6 +805,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1119,6 +1156,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1164,6 +1213,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1182,6 +1294,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", @@ -1269,6 +1387,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1276,6 +1403,127 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1294,6 +1542,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -1301,6 +1555,16 @@ "dev": true, "license": "ISC" }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1353,6 +1617,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1437,6 +1707,25 @@ } } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1578,6 +1867,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -1612,6 +1902,37 @@ } } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -1622,6 +1943,58 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", @@ -1690,6 +2063,12 @@ "node": ">=0.10.0" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1769,6 +2148,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index a5987f3..90260e3 100755 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "i18next": "^25.8.4", "react": "^19.2.3", "react-dom": "^19.2.3", - "react-i18next": "^16.5.4" + "react-i18next": "^16.5.4", + "recharts": "^3.7.0" }, "devDependencies": { "@types/node": "^22.14.0", diff --git a/src/pages/Dashboard/DashboardContent.tsx b/src/pages/Dashboard/DashboardContent.tsx index 743e825..ce2c3b4 100755 --- a/src/pages/Dashboard/DashboardContent.tsx +++ b/src/pages/Dashboard/DashboardContent.tsx @@ -1,6 +1,11 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { authenticatedFetch, API_URL } from '../../utils/api'; +import { + ResponsiveContainer, ComposedChart, Area, Line, + XAxis, CartesianGrid, Tooltip, +} from 'recharts'; // ===================================================== // Types @@ -9,10 +14,10 @@ import { useTranslation } from 'react-i18next'; interface ContentMetric { id: string; label: string; - labelEn: string; - value: string; + value: number; // 원시 숫자값 (단위: unit 참조) + unit: string; // "count" | "hours" | "minutes" trend: number; - trendDirection: 'up' | 'down'; + trendDirection: 'up' | 'down' | '-'; } interface MonthlyData { @@ -21,27 +26,33 @@ interface MonthlyData { lastYear: number; } -interface PlatformMetric { - id: string; - label: string; - value: string; - unit?: string; - trend: number; - trendDirection: 'up' | 'down'; +interface DailyData { + date: string; + thisPeriod: number; + lastPeriod: number; } -interface PlatformData { - platform: 'youtube' | 'instagram'; - displayName: string; - metrics: PlatformMetric[]; -} +// interface PlatformMetric { // 미사용 — platform_data 기능 예정 +// id: string; +// label: string; +// value: string; +// unit?: string; +// trend: number; +// trendDirection: 'up' | 'down'; +// } + +// interface PlatformData { // 미사용 — platform_data 기능 예정 +// platform: 'youtube' | 'instagram'; +// displayName: string; +// metrics: PlatformMetric[]; +// } interface TopContent { id: string; title: string; thumbnail: string; platform: 'youtube' | 'instagram'; - views: string; + views: number; // 원시 정수 (포맷팅은 formatNumber로 처리) engagement: string; date: string; } @@ -52,75 +63,72 @@ interface AudienceData { topRegions: { region: string; percentage: number }[]; } +interface DashboardResponse { + contentMetrics: ContentMetric[]; + monthlyData: MonthlyData[]; + dailyData: DailyData[]; + topContent: TopContent[]; + audienceData: AudienceData; + hasUploads: boolean; // 업로드 영상 존재 여부 (false 시 mock 데이터 + 안내 메시지 표시) + // platformData: PlatformData[]; // 미사용 +} + +interface ConnectedAccount { + id: number; + platform: string; + platformUserId: string; + platformUsername?: string | null; + channelTitle?: string | null; + connectedAt: string; + isActive: boolean; +} + // ===================================================== -// Mock Data +// Mock Data (연동 전 샘플 데이터) // ===================================================== -const CONTENT_METRICS: ContentMetric[] = [ - { id: 'impressions', label: '총 노출수', labelEn: 'IMPRESSIONS', value: '2.4M', trend: 12.5, trendDirection: 'up' }, - { id: 'reach', label: '도달', labelEn: 'REACH', value: '1.8M', trend: 9.2, trendDirection: 'up' }, - { id: 'likes', label: '좋아요', labelEn: 'LIKES', value: '158.2K', trend: 8.3, trendDirection: 'up' }, - { id: 'comments', label: '댓글', labelEn: 'COMMENTS', value: '24.9K', trend: 2.1, trendDirection: 'down' }, - { id: 'shares', label: '공유', labelEn: 'SHARES', value: '8.4K', trend: 15.7, trendDirection: 'up' }, - { id: 'saves', label: '저장', labelEn: 'SAVES', value: '12.3K', trend: 22.4, trendDirection: 'up' }, - { id: 'engagement', label: '참여율', labelEn: 'ENGAGEMENT', value: '4.8%', trend: 0.5, trendDirection: 'up' }, - { id: 'content', label: '콘텐츠', labelEn: 'CONTENT', value: '127', trend: 4, trendDirection: 'up' }, +const MOCK_CONTENT_METRICS: ContentMetric[] = [ + { id: 'total-views', label: '조회수', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' }, + { id: 'total-watch-time', label: '시청시간', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' }, + { id: 'avg-view-duration',label: '평균 시청시간', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' }, + { id: 'new-subscribers', label: '신규 구독자', value: 483, unit: 'count', trend: 50, trendDirection: 'up' }, + { id: 'likes', label: '좋아요', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' }, + { id: 'comments', label: '댓글', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' }, + { id: 'shares', label: '공유', value: 840, unit: 'count', trend: 15, trendDirection: 'up' }, + { id: 'uploaded-videos', label: '업로드 영상', value: 17, unit: 'count', trend: 4, trendDirection: 'up' }, ]; -const MONTHLY_DATA: MonthlyData[] = [ - { month: '1월', thisYear: 180000, lastYear: 145000 }, - { month: '2월', thisYear: 195000, lastYear: 158000 }, - { month: '3월', thisYear: 210000, lastYear: 172000 }, - { month: '4월', thisYear: 185000, lastYear: 168000 }, - { month: '5월', thisYear: 240000, lastYear: 195000 }, - { month: '6월', thisYear: 275000, lastYear: 210000 }, - { month: '7월', thisYear: 320000, lastYear: 235000 }, - { month: '8월', thisYear: 295000, lastYear: 248000 }, - { month: '9월', thisYear: 310000, lastYear: 262000 }, - { month: '10월', thisYear: 285000, lastYear: 255000 }, - { month: '11월', thisYear: 340000, lastYear: 278000 }, - { month: '12월', thisYear: 380000, lastYear: 295000 }, +const MOCK_MONTHLY_DATA: MonthlyData[] = [ + { month: '1월', thisYear: 18000, lastYear: 14500 }, + { month: '2월', thisYear: 19500, lastYear: 15800 }, + { month: '3월', thisYear: 21000, lastYear: 17200 }, + { month: '4월', thisYear: 18500, lastYear: 16800 }, + { month: '5월', thisYear: 24000, lastYear: 19500 }, + { month: '6월', thisYear: 27500, lastYear: 21000 }, + { month: '7월', thisYear: 32000, lastYear: 23500 }, + { month: '8월', thisYear: 29500, lastYear: 24800 }, + { month: '9월', thisYear: 31000, lastYear: 26200 }, + { month: '10월', thisYear: 28500, lastYear: 25500 }, + { month: '11월', thisYear: 34000, lastYear: 27800 }, + { month: '12월', thisYear: 38000, lastYear: 29500 }, ]; -const PLATFORM_DATA: PlatformData[] = [ - { - platform: 'youtube', - displayName: 'YouTube', - metrics: [ - { id: 'views', label: '조회수', value: '1.25M', trend: 18.2, trendDirection: 'up' }, - { id: 'watchTime', label: '시청 시간', value: '4,820', unit: '시간', trend: 12.5, trendDirection: 'up' }, - { id: 'avgViewDuration', label: '평균 시청 시간', value: '3:24', trend: 5.2, trendDirection: 'up' }, - { id: 'subscribers', label: '구독자', value: '45.2K', trend: 5.8, trendDirection: 'up' }, - { id: 'newSubscribers', label: '신규 구독자', value: '1.2K', trend: 12.3, trendDirection: 'up' }, - { id: 'engagement', label: '참여율', value: '4.8', unit: '%', trend: 0.3, trendDirection: 'up' }, - { id: 'ctr', label: '클릭률 (CTR)', value: '6.2', unit: '%', trend: 1.1, trendDirection: 'up' }, - { id: 'revenue', label: '예상 수익', value: '₩2.4M', trend: 8.5, trendDirection: 'up' }, - ], - }, - { - platform: 'instagram', - displayName: 'Instagram', - metrics: [ - { id: 'reach', label: '도달', value: '892K', trend: 22.4, trendDirection: 'up' }, - { id: 'impressions', label: '노출', value: '1.58M', trend: 15.1, trendDirection: 'up' }, - { id: 'profileVisits', label: '프로필 방문', value: '28.4K', trend: 18.7, trendDirection: 'up' }, - { id: 'followers', label: '팔로워', value: '28.5K', trend: 8.2, trendDirection: 'up' }, - { id: 'newFollowers', label: '신규 팔로워', value: '892', trend: 15.6, trendDirection: 'up' }, - { id: 'storyViews', label: '스토리 조회', value: '156K', trend: 3.2, trendDirection: 'down' }, - { id: 'reelPlays', label: '릴스 재생', value: '423K', trend: 45.2, trendDirection: 'up' }, - { id: 'websiteClicks', label: '웹사이트 클릭', value: '3.2K', trend: 11.8, trendDirection: 'up' }, - ], - }, +const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => { + const month = i <= 11 ? 1 : 2; + const day = i <= 11 ? i + 20 : i - 11; + const thisPeriod = [820, 910, 780, 1020, 890, 1100, 950, 870, 1010, 980, 1120, 1050, 930, 860, 1080, 1150, 970, 1030, 800, 990, 1180, 1070, 920, 880, 1040, 1110, 960, 1000, 850, 940][i]; + const lastPeriod = [680, 720, 650, 800, 700, 800, 770, 710, 800, 790, 900, 860, 760, 700, 870, 920, 790, 840, 690, 800, 940, 870, 700, 720, 850, 890, 780, 810, 690, 760][i]; + return { date: `${month}/${day}`, thisPeriod, lastPeriod }; +}); + +const MOCK_TOP_CONTENT: TopContent[] = [ + { id: '1', title: '겨울 펜션 프로모션 영상', thumbnail: 'https://picsum.photos/seed/content1/120/68', platform: 'youtube', views: 125400, engagement: '8.2%', date: '2025.01.15' }, + { id: '2', title: '스테이 머뭄 소개 릴스', thumbnail: 'https://picsum.photos/seed/content2/120/68', platform: 'instagram', views: 89200, engagement: '12.5%', date: '2025.01.22' }, + { id: '3', title: '신년 특가 이벤트 안내', thumbnail: 'https://picsum.photos/seed/content3/120/68', platform: 'youtube', views: 67800, engagement: '6.4%', date: '2025.01.08' }, + { id: '4', title: '펜션 야경 타임랩스', thumbnail: 'https://picsum.photos/seed/content4/120/68', platform: 'instagram', views: 54300, engagement: '15.8%', date: '2025.01.28' }, ]; -const TOP_CONTENT: TopContent[] = [ - { id: '1', title: '겨울 펜션 프로모션 영상', thumbnail: 'https://picsum.photos/seed/content1/120/68', platform: 'youtube', views: '125.4K', engagement: '8.2%', date: '2025.01.15' }, - { id: '2', title: '스테이 머뭄 소개 릴스', thumbnail: 'https://picsum.photos/seed/content2/120/68', platform: 'instagram', views: '89.2K', engagement: '12.5%', date: '2025.01.22' }, - { id: '3', title: '신년 특가 이벤트 안내', thumbnail: 'https://picsum.photos/seed/content3/120/68', platform: 'youtube', views: '67.8K', engagement: '6.4%', date: '2025.01.08' }, - { id: '4', title: '펜션 야경 타임랩스', thumbnail: 'https://picsum.photos/seed/content4/120/68', platform: 'instagram', views: '54.3K', engagement: '15.8%', date: '2025.01.28' }, -]; - -const AUDIENCE_DATA: AudienceData = { +const MOCK_AUDIENCE_DATA: AudienceData = { ageGroups: [ { label: '18-24', percentage: 12 }, { label: '25-34', percentage: 35 }, @@ -161,7 +169,7 @@ const AnimatedItem: React.FC = ({ children, index, baseDelay return (
{children} @@ -186,7 +194,7 @@ const AnimatedSection: React.FC = ({ children, delay = 0, return (
{children} @@ -194,6 +202,27 @@ const AnimatedSection: React.FC = ({ children, delay = 0, ); }; +const formatNumber = (num: number): string => { + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; + if (num >= 1000) return (num / 1000).toFixed(1) + 'K'; + return num.toString(); +}; + +// unit에 따라 value를 표시용 문자열로 변환 (언어별 suffix는 호출부에서 처리) +const formatValue = (value: number, unit: string): string => { + if (unit === 'hours') return value.toFixed(1) + '시간'; + if (unit === 'minutes') return Math.round(value) + '분'; + return formatNumber(Math.round(value)); +}; + +// unit에 따라 trend 절댓값을 표시용 문자열로 변환 +const formatTrend = (trend: number, unit: string): string => { + const abs = Math.abs(trend); + if (unit === 'hours') return abs.toFixed(1) + '시간'; + if (unit === 'minutes') return Math.round(abs) + '분'; + return formatNumber(Math.round(abs)); +}; + // ===================================================== // UI Components // ===================================================== @@ -216,225 +245,122 @@ const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => ( ); -const StatCard: React.FC = ({ labelEn, value, trend, trendDirection }) => ( -
- {labelEn} -

{value}

-
- - - {trendDirection === 'up' ? '+' : '-'}{Math.abs(trend)}% - +const StatCard: React.FC = ({ label, value, unit, trend, trendDirection }) => { + const isNeutral = trend === 0 || trendDirection === '-'; + const isUp = trendDirection === 'up'; + return ( +
+ {label} +

{formatValue(value, unit)}

+
+ {isNeutral ? ( + + ) : ( + + + {isUp ? '+' : '-'}{formatTrend(trend, unit)} + + )} +
-
-); - -const MetricCard: React.FC = ({ label, value, unit, trend, trendDirection }) => ( -
-
- {label} -
-
- {value} - {unit && {unit}} -
-
- - {trendDirection === 'up' ? '+' : '-'}{Math.abs(trend)}% -
-
-); + ); +}; // ===================================================== // Chart Component with Tooltip // ===================================================== -interface TooltipData { - x: number; - y: number; - month: string; - thisYear: number; - lastYear: number; +// 차트에 전달하는 통합 데이터 포인트 타입 +// MonthlyData / DailyData 모두 이 형식으로 변환하여 사용 +interface ChartDataPoint { + label: string; // X축 레이블 (월별: "1월", 일별: "1/20") + current: number; // 현재 기간 (thisYear 또는 thisPeriod) + previous: number; // 이전 기간 (lastYear 또는 lastPeriod) } -const formatNumber = (num: number): string => { - if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; - if (num >= 1000) return (num / 1000).toFixed(0) + 'K'; - return num.toString(); -}; +const YearOverYearChart: React.FC<{ + data: ChartDataPoint[]; + currentLabel: string; + previousLabel: string; + mode: 'day' | 'month'; +}> = ({ data, currentLabel, previousLabel, mode }) => { + if (data.length === 0) { + return ( +
+ 이 기간에 데이터가 없습니다. +
+ ); + } -const YearOverYearChart: React.FC<{ data: MonthlyData[] }> = ({ data }) => { - const { t } = useTranslation(); - const [tooltip, setTooltip] = useState(null); - const [isAnimated, setIsAnimated] = useState(false); - - useEffect(() => { - const timer = setTimeout(() => setIsAnimated(true), 500); - return () => clearTimeout(timer); - }, []); - - const width = 1000; - const height = 300; - const padding = { top: 20, right: 20, bottom: 10, left: 20 }; - const chartWidth = width - padding.left - padding.right; - const chartHeight = height - padding.top - padding.bottom; - - const allValues = data.flatMap(d => [d.thisYear, d.lastYear]); - const maxValue = Math.max(...allValues); - const minValue = Math.min(...allValues); - const yPadding = (maxValue - minValue) * 0.15; - const yMax = maxValue + yPadding; - const yMin = Math.max(0, minValue - yPadding); - - const getX = (index: number) => padding.left + (index / (data.length - 1)) * chartWidth; - const getY = (value: number) => padding.top + chartHeight - ((value - yMin) / (yMax - yMin)) * chartHeight; - - const generateSmoothPath = (points: { x: number; y: number }[]) => { - if (points.length < 2) return ''; - const path: string[] = []; - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[Math.max(i - 1, 0)]; - const p1 = points[i]; - const p2 = points[i + 1]; - const p3 = points[Math.min(i + 2, points.length - 1)]; - const tension = 0.3; - const cp1x = p1.x + (p2.x - p0.x) * tension; - const cp1y = p1.y + (p2.y - p0.y) * tension; - const cp2x = p2.x - (p3.x - p1.x) * tension; - const cp2y = p2.y - (p3.y - p1.y) * tension; - if (i === 0) path.push(`M ${p1.x} ${p1.y}`); - path.push(`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`); - } - return path.join(' '); + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) => { + if (!active || !payload?.length) return null; + const current = payload.find((p: { dataKey: string; value: number }) => p.dataKey === 'current')?.value ?? 0; + const previous = payload.find((p: { dataKey: string; value: number }) => p.dataKey === 'previous')?.value ?? 0; + const isEqual = current === previous; + const isUp = current > previous; + const diff = Math.abs(current - previous); + const changeClass = isEqual ? ' neutral' : isUp ? '' : ' down'; + return ( +
+
{label}
+
+ + {currentLabel} + {formatNumber(current)} +
+
+ + {previousLabel} + {formatNumber(previous)} +
+
+ {isEqual ? '─' : `${isUp ? '↑' : '↓'} ${formatNumber(diff)}`} +
+
+ ); }; - const thisYearPoints = data.map((d, i) => ({ x: getX(i), y: getY(d.thisYear) })); - const lastYearPoints = data.map((d, i) => ({ x: getX(i), y: getY(d.lastYear) })); - const thisYearPath = generateSmoothPath(thisYearPoints); - const lastYearPath = generateSmoothPath(lastYearPoints); - const thisYearAreaPath = `${thisYearPath} L ${getX(data.length - 1)} ${padding.top + chartHeight} L ${padding.left} ${padding.top + chartHeight} Z`; - - const handleMouseEnter = (index: number, event: React.MouseEvent) => { - const rect = event.currentTarget.getBoundingClientRect(); - const svgRect = (event.currentTarget.closest('svg') as SVGSVGElement)?.getBoundingClientRect(); - if (svgRect) { - setTooltip({ - x: rect.left - svgRect.left + rect.width / 2, - y: rect.top - svgRect.top, - month: data[index].month, - thisYear: data[index].thisYear, - lastYear: data[index].lastYear, - }); - } - }; - - const handleMouseLeave = () => setTooltip(null); - return (
- - - - - - - - - {/* Grid lines */} - {[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => ( - - ))} - - {/* Last year line (purple, dashed) */} - - - {/* This year gradient fill */} - - - {/* This year line (mint, solid) */} - - - {/* Interactive data points */} - {thisYearPoints.map((point, i) => ( - - {/* Invisible larger hit area */} - handleMouseEnter(i, e)} - onMouseLeave={handleMouseLeave} - /> - {/* Visible point */} - handleMouseEnter(i, e)} - onMouseLeave={handleMouseLeave} - /> - - ))} - - {/* Animated pulsing dot on the last point */} - - - - - - - - - - {/* Tooltip */} - {tooltip && ( -
-
{tooltip.month}
-
- - {t('dashboard.thisYear')} - {formatNumber(tooltip.thisYear)} -
-
- - {t('dashboard.lastYear')} - {formatNumber(tooltip.lastYear)} -
-
- {tooltip.thisYear > tooltip.lastYear ? '↑' : '↓'} {Math.abs(((tooltip.thisYear - tooltip.lastYear) / tooltip.lastYear) * 100).toFixed(1)}% -
-
- )} + + + + + + + + + + + } /> + + + +
); }; @@ -475,7 +401,7 @@ const TopContentItem: React.FC = ({ title, thumbnail, platform, view - {views} + {formatNumber(views)} @@ -549,113 +475,297 @@ const GenderChart: React.FC<{ male: number; female: number; delay?: number }> = // Main Component // ===================================================== -const DashboardContent: React.FC = () => { +interface DashboardContentProps { + onNavigate?: (id: string) => void; +} + +const DashboardContent: React.FC = ({ onNavigate }) => { const { t } = useTranslation(); - const [selectedPlatform, setSelectedPlatform] = useState<'youtube' | 'instagram'>('youtube'); + const [mode, setMode] = useState<'day' | 'month'>('month'); - // Translated content metrics - const contentMetrics: ContentMetric[] = [ - { id: 'impressions', label: t('dashboard.metricImpressions'), labelEn: 'IMPRESSIONS', value: '2.4M', trend: 12.5, trendDirection: 'up' }, - { id: 'reach', label: t('dashboard.metricReach'), labelEn: 'REACH', value: '1.8M', trend: 9.2, trendDirection: 'up' }, - { id: 'likes', label: t('dashboard.metricLikes'), labelEn: 'LIKES', value: '158.2K', trend: 8.3, trendDirection: 'up' }, - { id: 'comments', label: t('dashboard.metricComments'), labelEn: 'COMMENTS', value: '24.9K', trend: 2.1, trendDirection: 'down' }, - { id: 'shares', label: t('dashboard.metricShares'), labelEn: 'SHARES', value: '8.4K', trend: 15.7, trendDirection: 'up' }, - { id: 'saves', label: t('dashboard.metricSaves'), labelEn: 'SAVES', value: '12.3K', trend: 22.4, trendDirection: 'up' }, - { id: 'engagement', label: t('dashboard.metricEngagement'), labelEn: 'ENGAGEMENT', value: '4.8%', trend: 0.5, trendDirection: 'up' }, - { id: 'content', label: t('dashboard.metricContent'), labelEn: 'CONTENT', value: '127', trend: 4, trendDirection: 'up' }, - ]; + const [dashboardData, setDashboardData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - // Translated monthly data - const monthlyData: MonthlyData[] = [ - { month: t('dashboard.months.jan'), thisYear: 180000, lastYear: 145000 }, - { month: t('dashboard.months.feb'), thisYear: 195000, lastYear: 158000 }, - { month: t('dashboard.months.mar'), thisYear: 210000, lastYear: 172000 }, - { month: t('dashboard.months.apr'), thisYear: 185000, lastYear: 168000 }, - { month: t('dashboard.months.may'), thisYear: 240000, lastYear: 195000 }, - { month: t('dashboard.months.jun'), thisYear: 275000, lastYear: 210000 }, - { month: t('dashboard.months.jul'), thisYear: 320000, lastYear: 235000 }, - { month: t('dashboard.months.aug'), thisYear: 295000, lastYear: 248000 }, - { month: t('dashboard.months.sep'), thisYear: 310000, lastYear: 262000 }, - { month: t('dashboard.months.oct'), thisYear: 285000, lastYear: 255000 }, - { month: t('dashboard.months.nov'), thisYear: 340000, lastYear: 278000 }, - { month: t('dashboard.months.dec'), thisYear: 380000, lastYear: 295000 }, - ]; + // 계정 관련 state + const [accounts, setAccounts] = useState([]); + const [selectedAccountId, setSelectedAccountId] = useState(() => { + const stored = localStorage.getItem('castad_selected_account_id'); + return stored ? Number(stored) : null; + }); + const [accountsLoaded, setAccountsLoaded] = useState(false); + const [accountDropdownOpen, setAccountDropdownOpen] = useState(false); + const accountDropdownRef = React.useRef(null); - // Translated platform data - const platformData: PlatformData[] = [ - { - platform: 'youtube', - displayName: 'YouTube', - metrics: [ - { id: 'views', label: t('dashboard.youtubeMetrics.views'), value: '1.25M', trend: 18.2, trendDirection: 'up' }, - { id: 'watchTime', label: t('dashboard.youtubeMetrics.watchTime'), value: '4,820', unit: t('dashboard.youtubeMetrics.watchTimeUnit'), trend: 12.5, trendDirection: 'up' }, - { id: 'avgViewDuration', label: t('dashboard.youtubeMetrics.avgViewDuration'), value: '3:24', trend: 5.2, trendDirection: 'up' }, - { id: 'subscribers', label: t('dashboard.youtubeMetrics.subscribers'), value: '45.2K', trend: 5.8, trendDirection: 'up' }, - { id: 'newSubscribers', label: t('dashboard.youtubeMetrics.newSubscribers'), value: '1.2K', trend: 12.3, trendDirection: 'up' }, - { id: 'engagement', label: t('dashboard.youtubeMetrics.engagement'), value: '4.8', unit: '%', trend: 0.3, trendDirection: 'up' }, - { id: 'ctr', label: t('dashboard.youtubeMetrics.ctr'), value: '6.2', unit: '%', trend: 1.1, trendDirection: 'up' }, - { id: 'revenue', label: t('dashboard.youtubeMetrics.revenue'), value: '₩2.4M', trend: 8.5, trendDirection: 'up' }, - ], - }, - { - platform: 'instagram', - displayName: 'Instagram', - metrics: [ - { id: 'reach', label: t('dashboard.instagramMetrics.reach'), value: '892K', trend: 22.4, trendDirection: 'up' }, - { id: 'impressions', label: t('dashboard.instagramMetrics.impressions'), value: '1.58M', trend: 15.1, trendDirection: 'up' }, - { id: 'profileVisits', label: t('dashboard.instagramMetrics.profileVisits'), value: '28.4K', trend: 18.7, trendDirection: 'up' }, - { id: 'followers', label: t('dashboard.instagramMetrics.followers'), value: '28.5K', trend: 8.2, trendDirection: 'up' }, - { id: 'newFollowers', label: t('dashboard.instagramMetrics.newFollowers'), value: '892', trend: 15.6, trendDirection: 'up' }, - { id: 'storyViews', label: t('dashboard.instagramMetrics.storyViews'), value: '156K', trend: 3.2, trendDirection: 'down' }, - { id: 'reelPlays', label: t('dashboard.instagramMetrics.reelPlays'), value: '423K', trend: 45.2, trendDirection: 'up' }, - { id: 'websiteClicks', label: t('dashboard.instagramMetrics.websiteClicks'), value: '3.2K', trend: 11.8, trendDirection: 'up' }, - ], - }, - ]; + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + if (!accountDropdownOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (accountDropdownRef.current && !accountDropdownRef.current.contains(e.target as Node)) { + setAccountDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [accountDropdownOpen]); - // Translated top content - const topContent: TopContent[] = [ - { id: '1', title: t('dashboard.topContentTitles.winterPromotion'), thumbnail: 'https://picsum.photos/seed/content1/120/68', platform: 'youtube', views: '125.4K', engagement: '8.2%', date: '2025.01.15' }, - { id: '2', title: t('dashboard.topContentTitles.stayIntroReel'), thumbnail: 'https://picsum.photos/seed/content2/120/68', platform: 'instagram', views: '89.2K', engagement: '12.5%', date: '2025.01.22' }, - { id: '3', title: t('dashboard.topContentTitles.newYearEvent'), thumbnail: 'https://picsum.photos/seed/content3/120/68', platform: 'youtube', views: '67.8K', engagement: '6.4%', date: '2025.01.08' }, - { id: '4', title: t('dashboard.topContentTitles.nightTimelapse'), thumbnail: 'https://picsum.photos/seed/content4/120/68', platform: 'instagram', views: '54.3K', engagement: '15.8%', date: '2025.01.28' }, - ]; + // 연결된 YouTube 계정 목록 fetch (마운트 시 1회) + useEffect(() => { + const fetchAccounts = async () => { + try { + const response = await authenticatedFetch(`${API_URL}/dashboard/accounts`, { method: 'GET' }); + if (!response.ok) return; + const data = await response.json(); + // snake_case(Python 백엔드) / camelCase 모두 처리 + const list: ConnectedAccount[] = (data.accounts ?? []).map((acc: Record) => ({ + id: acc.id as number, + platform: acc.platform as string, + platformUserId: (acc.platformUserId ?? acc.platform_user_id ?? null) as string | null, + platformUsername: (acc.platformUsername ?? acc.platform_username ?? null) as string | null, + channelTitle: (acc.channelTitle ?? acc.channel_title ?? null) as string | null, + connectedAt: (acc.connectedAt ?? acc.connected_at ?? '') as string, + isActive: (acc.isActive ?? acc.is_active ?? true) as boolean, + })); + setAccounts(list); - // Translated audience data - const audienceData: AudienceData = { - ageGroups: [ - { label: '18-24', percentage: 12 }, - { label: '25-34', percentage: 35 }, - { label: '35-44', percentage: 28 }, - { label: '45-54', percentage: 18 }, - { label: '55+', percentage: 7 }, - ], - gender: { male: 42, female: 58 }, - topRegions: [ - { region: t('dashboard.regions.seoul'), percentage: 32 }, - { region: t('dashboard.regions.gyeonggi'), percentage: 24 }, - { region: t('dashboard.regions.busan'), percentage: 12 }, - { region: t('dashboard.regions.incheon'), percentage: 8 }, - { region: t('dashboard.regions.daegu'), percentage: 6 }, - ], - }; + // localStorage 값이 현재 계정 목록에 없으면 초기화 + const storedId = localStorage.getItem('castad_selected_account_id'); + if (storedId && !list.some((a: ConnectedAccount) => a.id === Number(storedId))) { + localStorage.removeItem('castad_selected_account_id'); + setSelectedAccountId(null); + } + + if (list.length === 1) { + setSelectedAccountId(list[0].id); + localStorage.setItem('castad_selected_account_id', String(list[0].id)); + } + } catch (err) { + console.error('Failed to fetch accounts:', err); + } finally { + setAccountsLoaded(true); + } + }; + fetchAccounts(); + }, []); + + useEffect(() => { + if (!accountsLoaded) return; + if (accounts.length > 1 && selectedAccountId === null) { + setIsLoading(false); // 계정 선택 전 로딩 종료 → 드롭다운 표시 + return; + } + + const fetchDashboardData = async () => { + try { + setIsLoading(true); + setError(null); + + const params = new URLSearchParams({ mode }); + // selectedAccountId로 계정을 찾아 platformUserId를 API 파라미터로 전달 + if (selectedAccountId !== null) { + const selectedAccount = accounts.find((a: ConnectedAccount) => a.id === selectedAccountId); + if (selectedAccount?.platformUserId) { + params.set('platform_user_id', selectedAccount.platformUserId); + } + } + + const response = await authenticatedFetch(`${API_URL}/dashboard/stats?${params}`, { method: 'GET' }); + + if (!response.ok) { + const errorData = await response.json(); + + if (errorData.code === 'YOUTUBE_NOT_CONNECTED') { + setError('YouTube 계정을 연동하여 데이터를 확인하세요.'); + setDashboardData(null); + return; + } + if (errorData.code === 'YOUTUBE_ACCOUNT_SELECTION_REQUIRED') { + setDashboardData(null); + return; + } + throw new Error(errorData.detail || `API Error: ${response.status}`); + } + + const data: DashboardResponse = await response.json(); + setDashboardData(data); + setError(null); + } catch (err) { + console.error('Dashboard API Error:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + setDashboardData(null); + } finally { + setIsLoading(false); + } + }; + + fetchDashboardData(); + }, [mode, selectedAccountId, accountsLoaded]); + + if (isLoading) { + return ( +
+
{t('데이터 로딩 중...')}
+
+ ); + } + + // hasUploads === false: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너 + const isEmptyState = dashboardData?.hasUploads === false; + + // API 데이터 우선 사용, 없거나 영상 없음(isEmptyState) 시 Mock 데이터로 폴백 + const contentMetrics = (!isEmptyState && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS; + const topContent = (!isEmptyState && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT; + const hasRealAudienceData = !isEmptyState && !!dashboardData?.audienceData?.ageGroups?.length; + const audienceData = hasRealAudienceData ? dashboardData!.audienceData : MOCK_AUDIENCE_DATA; + + // mode별 차트 데이터를 ChartDataPoint 통합 형식으로 변환 + const chartData: ChartDataPoint[] = mode === 'month' + ? ((!isEmptyState && dashboardData?.monthlyData?.length) + ? dashboardData.monthlyData.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear })) + : MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear }))) + : ((!isEmptyState && dashboardData?.dailyData?.length) + ? dashboardData.dailyData.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod })) + : MOCK_DAILY_DATA.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod }))); + + // mode별 차트 레이블 + const chartCurrentLabel = mode === 'month' ? '올해' : '이번달'; + const chartPreviousLabel = mode === 'month' ? '작년' : '지난달'; + const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : '전월 대비 성장'; - const currentPlatformData = platformData.find(p => p.platform === selectedPlatform); const lastUpdated = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', - minute: '2-digit', }); return (
+ {/* 업로드 영상 없음 배너 */} + {isEmptyState && ( +
+
+ + + +
+

아직 업로드된 영상이 없습니다.

+

ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다. 현재 샘플 데이터입니다.

+
+
+
+ )} + + {/* 에러 배너 */} + {error && ( +
+
+
+ + + +
+

아래는 샘플 데이터입니다. 실제 데이터를 보려면 계정을 연동하세요.

+
+
+ {error.includes('YouTube') && ( + + )} +
+
+ )} + {/* Header */} - +
-

{t('dashboard.title')}

+
+

{t('dashboard.title')}

+ {/* 계정 드롭다운: 연결된 계정이 2개 이상일 때만 표시 */} + {accounts.length > 1 && ( +
+ {/* 트리거 버튼 */} + + {/* 드롭다운 목록 */} + {accountDropdownOpen && ( +
+ {accounts.map((acc: ConnectedAccount) => ( +
{ + setSelectedAccountId(acc.id); + localStorage.setItem('castad_selected_account_id', String(acc.id)); + setAccountDropdownOpen(false); + }} + style={{ + display: 'flex', alignItems: 'center', gap: '8px', + padding: '8px 12px', + cursor: 'pointer', + fontSize: '13px', + background: acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent', + color: 'rgba(255,255,255,0.85)', + }} + onMouseEnter={(e: React.MouseEvent) => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; }} + onMouseLeave={(e: React.MouseEvent) => { e.currentTarget.style.background = acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent'; }} + > + {acc.platform === 'youtube' + ? + : } + + {acc.channelTitle ?? acc.platformUsername ?? acc.platformUserId} + +
+ ))} +
+ )} +
+ )} +

{t('dashboard.description')}

{t('dashboard.lastUpdated')} {lastUpdated} @@ -664,9 +774,25 @@ const DashboardContent: React.FC = () => { {/* Content Performance Section */} -

{t('dashboard.contentPerformance')}

+
+

{t('dashboard.contentPerformance')}

+
+ + +
+
- {contentMetrics.map((metric, index) => ( + {contentMetrics.map((metric: ContentMetric, index: number) => ( @@ -676,29 +802,29 @@ const DashboardContent: React.FC = () => { {/* Two Column Layout */}
- {/* Year over Year Growth Section */} + {/* 추이 차트 섹션 (mode=month: 연간, mode=day: 일별) */}
-

{t('dashboard.yearOverYear')}

+

{chartSectionTitle}

- {t('dashboard.thisYear')} + {chartCurrentLabel}
- {t('dashboard.lastYear')} + {chartPreviousLabel}
- -
-
- {monthlyData.map(d => ( - {d.month} - ))} +
@@ -721,58 +847,44 @@ const DashboardContent: React.FC = () => { {/* Audience Insights Section */}

{t('dashboard.audienceInsights')}

-
- -
-

{t('dashboard.ageDistribution')}

- -
-
- -
-

{t('dashboard.genderDistribution')}

- -
-
- -
-

{t('dashboard.topRegions')}

- ({ label: r.region, percentage: r.percentage }))} delay={1400} /> -
-
-
-
- - {/* Platform Metrics Section */} - -
-
-

{t('dashboard.platformMetrics')}

-
- - + {!isEmptyState && !hasRealAudienceData && ( +
+
+ + + +

+ 누적 데이터가 부족하여 실제 시청자 데이터가 없습니다. 현재 샘플 데이터로 표시됩니다. +

-
- {currentPlatformData?.metrics.map((metric, index) => ( - - - - ))} + )} + {audienceData ? ( +
+ +
+

{t('dashboard.ageDistribution')}

+ +
+
+ +
+

{t('dashboard.genderDistribution')}

+ +
+
+ +
+

{t('dashboard.topRegions')}

+ ({ label: r.region, percentage: r.percentage }))} delay={1400} /> +
+
-
+ ) : ( +
+ {t('dashboard.noData') || '이 기간에 데이터가 없습니다.'} +
+ )}
); diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index dcecd3e..6f6e262 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -411,7 +411,7 @@ const GenerationFlow: React.FC = ({ const renderContent = () => { switch (activeItem) { case '대시보드': - return ; + return ; case '비즈니스 설정': return ; case 'ADO2 콘텐츠': diff --git a/src/utils/api.ts b/src/utils/api.ts index 3062b4a..4f72f53 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -30,7 +30,7 @@ import { YTAutoSeoResponse, } from '../types/api'; -const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; +export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; console.log('[API] API_URL:', API_URL); console.log('[API] VITE_API_URL env:', import.meta.env.VITE_API_URL); @@ -451,7 +451,7 @@ function redirectToLogin() { } // 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수 -async function authenticatedFetch( +export async function authenticatedFetch( url: string, options: RequestInit = {} ): Promise {