feat(dashboard, i18n): 增强仪表盘视觉效果与多语言支持
在英文、尼泊尔语和中文语言包中新增 “Other statuses” 翻译,提升仪表盘指标展示的清晰度。 在仪表盘中集成新的 StatCard 组件,用于更直观地展示关键指标数据。 更新仪表盘趋势图表,采用 recharts 实现更丰富的数据可视化效果。 重构现有组件与布局,优化整体交互与用户体验,使界面更加直观易用。
This commit is contained in:
364
package-lock.json
generated
364
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"react-day-picker": "^10.0.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -2026,6 +2027,41 @@
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"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.8",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/immer/-/immer-11.1.8.tgz",
|
||||
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -2051,6 +2087,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@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://mirrors.cloud.tencent.com/npm/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -2429,6 +2477,68 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@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://mirrors.cloud.tencent.com/npm/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@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://mirrors.cloud.tencent.com/npm/@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://mirrors.cloud.tencent.com/npm/@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://mirrors.cloud.tencent.com/npm/@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://mirrors.cloud.tencent.com/npm/@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://mirrors.cloud.tencent.com/npm/@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://mirrors.cloud.tencent.com/npm/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -2494,6 +2604,12 @@
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -4060,6 +4176,123 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -4157,6 +4390,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -4589,6 +4828,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/es-toolkit/-/es-toolkit-1.47.0.tgz",
|
||||
"integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -5045,6 +5294,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -5996,6 +6251,15 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -6043,6 +6307,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz",
|
||||
@@ -8342,9 +8615,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"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/recast": {
|
||||
"version": "0.23.11",
|
||||
"resolved": "https://registry.npmmirror.com/recast/-/recast-0.23.11.tgz",
|
||||
@@ -8361,6 +8656,51 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 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://mirrors.cloud.tencent.com/npm/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -9943,6 +10283,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/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/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/void-elements/-/void-elements-3.1.0.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"react-day-picker": "^10.0.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
373
src/components/ui/chart.tsx
Normal file
373
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -76,6 +76,7 @@
|
||||
"batchPending": "Pending review",
|
||||
"batchPublished": "Published",
|
||||
"batchTotal": "Total batches",
|
||||
"batchOther": "Other statuses",
|
||||
"settlementOverview": "Settlement batches",
|
||||
"noSettlementBatches": "No settlement batches",
|
||||
"quickLinksTitle": "Quick links",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"batchPending": "समीक्षा बाँकी",
|
||||
"batchPublished": "प्रकाशित",
|
||||
"batchTotal": "कुल ब्याच",
|
||||
"batchOther": "अन्य स्थिति",
|
||||
"settlementOverview": "सेटलमेन्ट ब्याच",
|
||||
"noSettlementBatches": "सेटलमेन्ट ब्याच छैन",
|
||||
"quickLinksTitle": "छिटो लिङ्क",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"batchPending": "待审核",
|
||||
"batchPublished": "已发布",
|
||||
"batchTotal": "批次合计",
|
||||
"batchOther": "其他状态",
|
||||
"settlementOverview": "结算批次分布",
|
||||
"noSettlementBatches": "暂无结算批次",
|
||||
"quickLinksTitle": "快捷入口",
|
||||
|
||||
78
src/modules/dashboard/dashboard-chart-config.ts
Normal file
78
src/modules/dashboard/dashboard-chart-config.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
|
||||
/** 仪表盘图表色板,对齐 globals.css --chart-1 … --chart-5 */
|
||||
export const DASHBOARD_CHART_COLORS = {
|
||||
primary: "var(--chart-1)",
|
||||
success: "var(--chart-2)",
|
||||
warning: "var(--chart-3)",
|
||||
violet: "var(--chart-4)",
|
||||
rose: "var(--chart-5)",
|
||||
muted: "var(--muted-foreground)",
|
||||
} as const;
|
||||
|
||||
export function buildTrendChartConfig(labels: {
|
||||
bet: string;
|
||||
payout: string;
|
||||
profit: string;
|
||||
}): ChartConfig {
|
||||
return {
|
||||
bet: { label: labels.bet, color: DASHBOARD_CHART_COLORS.primary },
|
||||
payout: { label: labels.payout, color: DASHBOARD_CHART_COLORS.rose },
|
||||
profit: { label: labels.profit, color: DASHBOARD_CHART_COLORS.success },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFinanceStructureConfig(labels: {
|
||||
win: string;
|
||||
jackpot: string;
|
||||
gross: string;
|
||||
}): ChartConfig {
|
||||
return {
|
||||
win: { label: labels.win, color: DASHBOARD_CHART_COLORS.success },
|
||||
jackpot: { label: labels.jackpot, color: DASHBOARD_CHART_COLORS.violet },
|
||||
gross: { label: labels.gross, color: DASHBOARD_CHART_COLORS.primary },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPayoutPieConfig(labels: { win: string; jackpot: string }): ChartConfig {
|
||||
return {
|
||||
win: { label: labels.win, color: DASHBOARD_CHART_COLORS.success },
|
||||
jackpot: { label: labels.jackpot, color: DASHBOARD_CHART_COLORS.violet },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSoldOutPieConfig(labels: Record<string, string>): ChartConfig {
|
||||
return {
|
||||
d4: { label: labels.d4, color: DASHBOARD_CHART_COLORS.primary },
|
||||
d3: { label: labels.d3, color: DASHBOARD_CHART_COLORS.success },
|
||||
d2: { label: labels.d2, color: DASHBOARD_CHART_COLORS.warning },
|
||||
special: { label: labels.special, color: DASHBOARD_CHART_COLORS.violet },
|
||||
other: { label: labels.other, color: DASHBOARD_CHART_COLORS.rose },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBatchProgressConfig(labels: {
|
||||
pending: string;
|
||||
published: string;
|
||||
other: string;
|
||||
}): ChartConfig {
|
||||
return {
|
||||
pending: { label: labels.pending, color: DASHBOARD_CHART_COLORS.warning },
|
||||
published: { label: labels.published, color: DASHBOARD_CHART_COLORS.success },
|
||||
other: { label: labels.other, color: DASHBOARD_CHART_COLORS.muted },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUsageBarConfig(label: string): ChartConfig {
|
||||
return {
|
||||
usage: { label, color: DASHBOARD_CHART_COLORS.primary },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSettlementBarConfig(
|
||||
entries: { status: string; label: string; color: string }[],
|
||||
): ChartConfig {
|
||||
return Object.fromEntries(
|
||||
entries.map((e) => [e.status, { label: e.label, color: e.color }]),
|
||||
);
|
||||
}
|
||||
7
src/modules/dashboard/dashboard-chart-empty.tsx
Normal file
7
src/modules/dashboard/dashboard-chart-empty.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export function DashboardChartEmpty({ message }: { message: string }): ReactElement {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{message}</p>;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
HotUsageBars,
|
||||
PayoutCompositionChart,
|
||||
ResultBatchProgress,
|
||||
StatCard,
|
||||
SettlementStatusChart,
|
||||
SoldOutRing,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
@@ -302,51 +303,45 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DashboardAnalyticsPanel enabled={canFinance} playOptions={playOptions} />
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label={t("pendingReviewResults")}
|
||||
value={pendingReview ?? "—"}
|
||||
hint={t("resultBatches")}
|
||||
icon={<ClipboardList className="size-5" aria-hidden />}
|
||||
accent={(pendingReview ?? 0) > 0 ? "destructive" : "muted"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("abnormalTransferOrders")}
|
||||
value={abnormalTransferTotal ?? "—"}
|
||||
hint={t("viewTransferOrders")}
|
||||
icon={<AlertTriangle className="size-5" aria-hidden />}
|
||||
accent={(abnormalTransferTotal ?? 0) > 0 ? "destructive" : "muted"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("riskCapUsage")}
|
||||
value={`${usagePct.toFixed(1)}%`}
|
||||
hint={t("lockedAndCap", { locked: formatMoneyMinor(riskLocked, currency), cap: formatMoneyMinor(riskCap, currency) })}
|
||||
icon={<Shield className="size-5" aria-hidden />}
|
||||
accent={usagePct >= 90 ? "destructive" : usagePct >= 70 ? "primary" : "muted"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("sections.currentDraw")}
|
||||
value={hall?.draw_no ?? "—"}
|
||||
hint={t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}
|
||||
icon={<Ticket className="size-5" aria-hidden />}
|
||||
accent="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground">{t("sections.operations")}</h2>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("financeStructure")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-36 w-full" />
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("payoutComposition")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-36 w-full" />
|
||||
) : finance ? (
|
||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm lg:col-span-2 xl:col-span-1">
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("riskCapUsage")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/occupancy`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 px-2 text-xs",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("occupancyDetails")}
|
||||
</Link>
|
||||
@@ -354,7 +349,7 @@ export function DashboardConsole(): ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
) : (
|
||||
<CapUsageBar
|
||||
locked={riskLocked}
|
||||
@@ -366,51 +361,6 @@ export function DashboardConsole(): ReactElement {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("hotNumbersTop10")}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1">
|
||||
{([
|
||||
{ value: "4D", label: t("tabs.4d") },
|
||||
{ value: "3D", label: t("tabs.3d") },
|
||||
{ value: "2D", label: t("tabs.2d") },
|
||||
{ value: "special", label: t("tabs.special") },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={hotTab === tab.value}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
hotTab === tab.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setHotTab(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/hot`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("actions.viewAll", { ns: "common" })}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <Skeleton className="h-64 w-full" /> : <HotUsageBars rows={hotRows} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -426,7 +376,7 @@ export function DashboardConsole(): ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
) : soldOutBuckets ? (
|
||||
<SoldOutRing buckets={soldOutBuckets} />
|
||||
) : (
|
||||
@@ -434,9 +384,7 @@ export function DashboardConsole(): ReactElement {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("resultBatches")}</CardTitle>
|
||||
@@ -451,7 +399,7 @@ export function DashboardConsole(): ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
) : drawPanel ? (
|
||||
<ResultBatchProgress draw={drawPanel} />
|
||||
) : (
|
||||
@@ -461,6 +409,40 @@ export function DashboardConsole(): ReactElement {
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("payoutComposition")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-44 w-full" />
|
||||
) : finance ? (
|
||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DashboardAnalyticsPanel enabled={canFinance} playOptions={playOptions} />
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("financeStructure")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("settlementOverview")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
@@ -474,7 +456,7 @@ export function DashboardConsole(): ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : finance ? (
|
||||
<SettlementStatusChart finance={finance} />
|
||||
) : (
|
||||
@@ -482,8 +464,49 @@ export function DashboardConsole(): ReactElement {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("hotNumbersTop10")}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1">
|
||||
{([
|
||||
{ value: "4D", label: t("tabs.4d") },
|
||||
{ value: "3D", label: t("tabs.3d") },
|
||||
{ value: "2D", label: t("tabs.2d") },
|
||||
{ value: "special", label: t("tabs.special") },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={hotTab === tab.value}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
hotTab === tab.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setHotTab(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/hot`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("actions.viewAll", { ns: "common" })}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{loading ? <Skeleton className="h-64 w-full" /> : <HotUsageBars rows={hotRows} />}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground">{t("sections.operations")}</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<div className="flex gap-4">
|
||||
@@ -523,25 +546,27 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("quickLinksTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap justify-center gap-3 py-2 sm:gap-5">
|
||||
{quickLinks.map((q) => (
|
||||
<Link
|
||||
key={q.label}
|
||||
href={q.href}
|
||||
className="flex w-24 flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm">
|
||||
{q.icon}
|
||||
</span>
|
||||
{q.label}
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("quickLinksTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap justify-center gap-3 py-2 sm:gap-5">
|
||||
{quickLinks.map((q) => (
|
||||
<Link
|
||||
key={q.label}
|
||||
href={q.href}
|
||||
className="flex w-24 flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm">
|
||||
{q.icon}
|
||||
</span>
|
||||
{q.label}
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { buildTrendChartConfig, DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
|
||||
import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics";
|
||||
import type { AdminReportDailyProfitRow } from "@/types/api/admin-reports";
|
||||
import type { DashboardAnalyticsMetric } from "@/types/api/admin-dashboard-analytics";
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
function metricValue(row: AdminReportDailyProfitRow, metric: DashboardAnalyticsMetric): number {
|
||||
switch (metric) {
|
||||
case "bet":
|
||||
return row.total_bet_minor;
|
||||
case "payout":
|
||||
return row.total_payout_minor;
|
||||
case "profit":
|
||||
return row.approx_house_gross_minor;
|
||||
default:
|
||||
return row.total_bet_minor;
|
||||
}
|
||||
}
|
||||
|
||||
function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardAnalyticsMetric): number {
|
||||
switch (metric) {
|
||||
case "bet":
|
||||
@@ -36,6 +33,16 @@ function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardA
|
||||
}
|
||||
}
|
||||
|
||||
function metricBarFill(metric: DashboardAnalyticsMetric, value: number): string {
|
||||
if (metric === "payout") {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
}
|
||||
if (metric === "profit") {
|
||||
return value >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
|
||||
}
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
|
||||
export function DailyTrendChart({
|
||||
series,
|
||||
metric,
|
||||
@@ -49,103 +56,116 @@ export function DailyTrendChart({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildTrendChartConfig({
|
||||
bet: t("chartLegend.bet"),
|
||||
payout: t("chartLegend.payout"),
|
||||
profit: t("chartLegend.profit"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
series.map((day) => ({
|
||||
date: day.business_date.slice(5),
|
||||
fullDate: day.business_date,
|
||||
bet: day.total_bet_minor,
|
||||
payout: day.total_payout_minor,
|
||||
profit: day.approx_house_gross_minor,
|
||||
profitAbs: Math.abs(day.approx_house_gross_minor),
|
||||
})),
|
||||
[series],
|
||||
);
|
||||
|
||||
if (series.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>;
|
||||
return <DashboardChartEmpty message={t("states.noData", { ns: "common" })} />;
|
||||
}
|
||||
|
||||
const maxBet = Math.max(...series.map((d) => d.total_bet_minor), 1);
|
||||
const maxPayout = Math.max(...series.map((d) => d.total_payout_minor), 1);
|
||||
const maxProfit = Math.max(...series.map((d) => Math.abs(d.approx_house_gross_minor)), 1);
|
||||
const labelEvery = series.length > 14 ? Math.ceil(series.length / 7) : 1;
|
||||
const plotHeight = series.length <= 7 ? 240 : series.length <= 14 ? 260 : 280;
|
||||
|
||||
const plotHeight = series.length <= 7 ? 200 : series.length <= 14 ? 220 : 240;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{metric === "overview" ? (
|
||||
<div className="flex shrink-0 flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-primary" />
|
||||
{t("chartLegend.bet")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-rose-500" />
|
||||
{t("chartLegend.payout")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2.5 rounded-sm bg-emerald-500" />
|
||||
{t("chartLegend.profit")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="flex items-end gap-1 overflow-x-auto rounded-md border border-border/60 bg-muted/20 px-2 pb-2 pt-3 sm:gap-1.5"
|
||||
if (metric === "overview") {
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: plotHeight }}
|
||||
>
|
||||
{series.map((day, idx) => {
|
||||
const betH = (day.total_bet_minor / maxBet) * 100;
|
||||
const payoutH = (day.total_payout_minor / maxPayout) * 100;
|
||||
const profitRaw = day.approx_house_gross_minor;
|
||||
const profitH = (Math.abs(profitRaw) / maxProfit) * 100;
|
||||
const showLabel = idx % labelEvery === 0 || idx === series.length - 1;
|
||||
const shortDate = day.business_date.slice(5);
|
||||
<BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval={series.length > 14 ? Math.ceil(series.length / 7) - 1 : 0}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as { fullDate?: string } | undefined;
|
||||
return row?.fullDate ?? "";
|
||||
}}
|
||||
formatter={(value, name, item) => {
|
||||
if (name === "profit") {
|
||||
const row = item?.payload as { profit?: number } | undefined;
|
||||
return formatMoney(row?.profit ?? Number(value), currency);
|
||||
}
|
||||
return formatMoney(Number(value), currency);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="bet" fill="var(--color-bet)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="payout" fill="var(--color-payout)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="profitAbs" name="profit" fill="var(--color-profit)" radius={[4, 4, 0, 0]} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.business_date}
|
||||
className="flex min-w-[28px] flex-1 flex-col items-stretch justify-end gap-1 self-stretch"
|
||||
title={`${day.business_date}\n${t("todayBetTotal")}: ${formatMoney(day.total_bet_minor, currency)}\n${t("todayPayout")}: ${formatMoney(day.total_payout_minor, currency)}\n${t("todayProfit")}: ${formatMoney(day.approx_house_gross_minor, currency)}`}
|
||||
>
|
||||
<div className="flex w-full flex-1 items-end justify-center gap-0.5">
|
||||
{metric === "overview" ? (
|
||||
<>
|
||||
<div
|
||||
className="w-[30%] min-w-[4px] rounded-t-sm bg-primary/90 transition-all"
|
||||
style={{ height: `${Math.max(betH, day.total_bet_minor > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
<div
|
||||
className="w-[30%] min-w-[4px] rounded-t-sm bg-rose-500/90 transition-all"
|
||||
style={{ height: `${Math.max(payoutH, day.total_payout_minor > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-[30%] min-w-[4px] rounded-t-sm transition-all",
|
||||
profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90",
|
||||
)}
|
||||
style={{ height: `${Math.max(profitH, profitRaw !== 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[70%] min-w-[6px] max-w-[20px] rounded-t-md transition-all",
|
||||
metric === "payout" && "bg-rose-500/90",
|
||||
metric === "profit" && (profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90"),
|
||||
metric === "bet" && "bg-primary/90",
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(
|
||||
(metricValue(day, metric) / (metric === "bet" ? maxBet : metric === "payout" ? maxPayout : maxProfit)) * 100,
|
||||
metricValue(day, metric) !== 0 ? 6 : 0,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-center text-[10px] tabular-nums text-muted-foreground",
|
||||
!showLabel && "invisible",
|
||||
)}
|
||||
>
|
||||
{shortDate}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
const activeKey = metric === "bet" ? "bet" : metric === "payout" ? "payout" : "profitAbs";
|
||||
const dataKey = metric === "profit" ? "profitAbs" : activeKey;
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: plotHeight }}
|
||||
>
|
||||
<BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval={series.length > 14 ? Math.ceil(series.length / 7) - 1 : 0}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as { fullDate?: string } | undefined;
|
||||
return row?.fullDate ?? "";
|
||||
}}
|
||||
formatter={(value) => formatMoney(Number(value), currency)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey={dataKey} radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((row) => (
|
||||
<Cell
|
||||
key={row.fullDate}
|
||||
fill={metricBarFill(metric, row.profit)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,50 +183,93 @@ export function PlayBreakdownChart({
|
||||
playLabel: (code: string, dimension: number) => string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noPlayData")}</p>;
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((r) => Math.abs(playMetricValue(r, metric === "overview" ? "bet" : metric))), 1);
|
||||
const activeMetric = metric === "overview" ? "bet" : metric;
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => {
|
||||
const value = playMetricValue(row, activeMetric);
|
||||
const pct = (Math.abs(value) / max) * 100;
|
||||
const label = playLabel(row.play_code, row.dimension);
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildTrendChartConfig({
|
||||
bet: t("chartLegend.bet"),
|
||||
payout: t("chartLegend.payout"),
|
||||
profit: t("chartLegend.profit"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={`${row.play_code}-${row.dimension}`}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-medium text-foreground">{label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{formatMoney(value, currency)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
activeMetric === "payout" && "bg-rose-500",
|
||||
activeMetric === "profit" && (value >= 0 ? "bg-emerald-500" : "bg-amber-500"),
|
||||
activeMetric === "bet" && "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{metric === "overview" ? (
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">
|
||||
{t("playBreakdownHint", {
|
||||
payout: formatMoney(row.total_payout_minor, currency),
|
||||
profit: formatMoney(row.approx_house_gross_minor, currency),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
rows.map((row) => {
|
||||
const value = playMetricValue(row, activeMetric);
|
||||
return {
|
||||
id: `${row.play_code}-${row.dimension}`,
|
||||
label: playLabel(row.play_code, row.dimension),
|
||||
value: Math.abs(value),
|
||||
signed: value,
|
||||
payout: row.total_payout_minor,
|
||||
profit: row.approx_house_gross_minor,
|
||||
fill: metricBarFill(activeMetric, value),
|
||||
};
|
||||
}),
|
||||
[rows, activeMetric, playLabel],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <DashboardChartEmpty message={t("analytics.noPlayData")} />;
|
||||
}
|
||||
|
||||
const chartHeight = Math.min(480, Math.max(180, rows.length * 36 + 48));
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 16, bottom: 4, left: 4 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as { label?: string } | undefined;
|
||||
return row?.label ?? "";
|
||||
}}
|
||||
formatter={(value, _name, item) => {
|
||||
const row = item.payload as { signed: number; payout: number; profit: number };
|
||||
if (metric === "overview") {
|
||||
return (
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatMoney(row.signed, currency)} · {t("playBreakdownHint", {
|
||||
payout: formatMoney(row.payout, currency),
|
||||
profit: formatMoney(row.profit, currency),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return formatMoney(row.signed, currency);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="value" radius={4} barSize={14}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={entry.id} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={100}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,34 +286,50 @@ export function PeriodCompareStrip({
|
||||
const totalBet = series.reduce((s, d) => s + d.total_bet_minor, 0);
|
||||
const totalPayout = series.reduce((s, d) => s + d.total_payout_minor, 0);
|
||||
const totalProfit = series.reduce((s, d) => s + d.approx_house_gross_minor, 0);
|
||||
const max = Math.max(totalBet, totalPayout, Math.abs(totalProfit), 1);
|
||||
const maxAbs = Math.max(Math.abs(totalBet), Math.abs(totalPayout), Math.abs(totalProfit), 1);
|
||||
const payoutRate = totalBet > 0 ? (totalPayout / totalBet) * 100 : 0;
|
||||
const profitRate = totalBet > 0 ? (totalProfit / totalBet) * 100 : 0;
|
||||
|
||||
const items = [
|
||||
{ key: "bet", label: t("chartLegend.bet"), value: totalBet, className: "bg-primary" },
|
||||
{ key: "payout", label: t("chartLegend.payout"), value: totalPayout, className: "bg-rose-500" },
|
||||
const rows = [
|
||||
{
|
||||
key: "bet",
|
||||
label: t("chartLegend.bet"),
|
||||
value: totalBet,
|
||||
pctText: "100%",
|
||||
width: (Math.abs(totalBet) / maxAbs) * 100,
|
||||
fill: "var(--chart-1)",
|
||||
},
|
||||
{
|
||||
key: "payout",
|
||||
label: t("chartLegend.payout"),
|
||||
value: totalPayout,
|
||||
pctText: `${payoutRate.toFixed(1)}%`,
|
||||
width: (Math.abs(totalPayout) / maxAbs) * 100,
|
||||
fill: "var(--chart-5)",
|
||||
},
|
||||
{
|
||||
key: "profit",
|
||||
label: t("chartLegend.profit"),
|
||||
value: totalProfit,
|
||||
className: totalProfit >= 0 ? "bg-emerald-500" : "bg-amber-500",
|
||||
pctText: `${profitRate >= 0 ? "+" : ""}${profitRate.toFixed(1)}%`,
|
||||
width: (Math.abs(totalProfit) / maxAbs) * 100,
|
||||
fill: totalProfit >= 0 ? "var(--chart-2)" : DASHBOARD_CHART_COLORS.warning,
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="grid gap-4">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-sm", item.className)} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</span>
|
||||
<span className="text-sm font-medium text-foreground">{row.label}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{row.pctText}</span>
|
||||
</div>
|
||||
<div className="mb-1 text-sm font-semibold tabular-nums">{formatMoney(row.value, currency)}</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full", item.className)}
|
||||
style={{ width: `${(Math.abs(item.value) / max) * 100}%` }}
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(2, row.width)}%`, background: row.fill }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
PolarAngleAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildBatchProgressConfig,
|
||||
buildFinanceStructureConfig,
|
||||
buildPayoutPieConfig,
|
||||
buildSoldOutPieConfig,
|
||||
buildSettlementBarConfig,
|
||||
buildUsageBarConfig,
|
||||
DASHBOARD_CHART_COLORS,
|
||||
} from "@/modules/dashboard/dashboard-chart-config";
|
||||
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||
import type {
|
||||
@@ -16,6 +48,45 @@ export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
function usageBarFill(pct: number): string {
|
||||
if (pct >= 95) {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
}
|
||||
if (pct >= 80) {
|
||||
return DASHBOARD_CHART_COLORS.warning;
|
||||
}
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
|
||||
function capUsageFill(pct: number): string {
|
||||
if (pct >= 90) {
|
||||
return "var(--destructive)";
|
||||
}
|
||||
if (pct >= 70) {
|
||||
return DASHBOARD_CHART_COLORS.warning;
|
||||
}
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
|
||||
function settlementBarColor(status: string): string {
|
||||
switch (status) {
|
||||
case "pending_review":
|
||||
return DASHBOARD_CHART_COLORS.warning;
|
||||
case "approved":
|
||||
return "oklch(0.62 0.14 230)";
|
||||
case "paid":
|
||||
case "completed":
|
||||
return DASHBOARD_CHART_COLORS.success;
|
||||
case "running":
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
case "rejected":
|
||||
case "failed":
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
default:
|
||||
return DASHBOARD_CHART_COLORS.violet;
|
||||
}
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
@@ -37,8 +108,8 @@ export function StatCard({
|
||||
: "bg-primary text-primary-foreground";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/80 bg-card p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<Card className="border-border/80 py-0 shadow-sm">
|
||||
<CardContent className="flex gap-4 p-5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
||||
@@ -52,8 +123,8 @@ export function StatCard({
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">{value}</p>
|
||||
{hint ? <div className="mt-2 text-xs text-muted-foreground">{hint}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,23 +143,49 @@ export function CapUsageBar({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const pct = Math.min(100, Math.max(0, usagePct));
|
||||
const fill = capUsageFill(pct);
|
||||
const chartConfig = useMemo(
|
||||
() => buildUsageBarConfig(t("riskCapUsage")),
|
||||
[t],
|
||||
);
|
||||
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{t("riskCapUsage")}</span>
|
||||
<span className="text-2xl font-bold tabular-nums text-foreground">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
pct >= 90 ? "bg-destructive" : pct >= 70 ? "bg-amber-500" : "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
<div className="space-y-4">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square h-[180px] w-full max-w-[200px]"
|
||||
>
|
||||
<RadialBarChart
|
||||
data={radialData}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
innerRadius="72%"
|
||||
outerRadius="100%"
|
||||
>
|
||||
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||
<RadialBar
|
||||
dataKey="usage"
|
||||
background={{ fill: "var(--muted)" }}
|
||||
cornerRadius={8}
|
||||
fill={fill}
|
||||
/>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
|
||||
return null;
|
||||
}
|
||||
const { cx, cy } = viewBox as { cx: number; cy: number };
|
||||
return (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan className="fill-foreground text-2xl font-bold">{pct.toFixed(1)}%</tspan>
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
<p className="text-center text-xs tabular-nums text-muted-foreground">
|
||||
{t("lockedAndCap", {
|
||||
locked: formatMoney(locked, currency),
|
||||
cap: formatMoney(cap, currency),
|
||||
@@ -110,50 +207,51 @@ export function FinanceStructureChart({
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_payout_minor;
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const payout = finance.total_payout_minor;
|
||||
const gross = finance.approx_house_gross_minor;
|
||||
const payout = finance.total_payout_minor;
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildFinanceStructureConfig({
|
||||
win: t("winPayout"),
|
||||
jackpot: t("jackpotPayout"),
|
||||
gross: t("houseGross"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (bet <= 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t("noFinanceActivity")}</p>;
|
||||
return <DashboardChartEmpty message={t("noFinanceActivity")} />;
|
||||
}
|
||||
|
||||
const winW = (win / bet) * 100;
|
||||
const jpW = (jackpot / bet) * 100;
|
||||
const grossW = Math.max(0, (gross / bet) * 100);
|
||||
const payoutRate = ((payout / bet) * 100).toFixed(1);
|
||||
|
||||
const segments = [
|
||||
{ key: "win", width: winW, className: "bg-emerald-500", label: t("winPayout"), value: win },
|
||||
{ key: "jackpot", width: jpW, className: "bg-violet-500", label: t("jackpotPayout"), value: jackpot },
|
||||
{ key: "gross", width: grossW, className: "bg-primary", label: t("houseGross"), value: gross },
|
||||
].filter((s) => s.width > 0.05);
|
||||
const chartData = [{ segment: "structure", win, jackpot, gross }];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-10 overflow-hidden rounded-lg ring-1 ring-border/60">
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.key}
|
||||
className={cn("min-w-[2px] transition-all", s.className)}
|
||||
style={{ width: `${s.width}%` }}
|
||||
title={`${s.label}: ${formatMoney(s.value, currency)}`}
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-12 w-full">
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, bet]} />
|
||||
<YAxis type="category" dataKey="segment" hide width={0} />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent formatter={(value, _name) => formatMoney(Number(value), currency)} />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Bar dataKey="win" stackId="structure" fill="var(--color-win)" radius={4} />
|
||||
<Bar dataKey="jackpot" stackId="structure" fill="var(--color-jackpot)" radius={4} />
|
||||
<Bar dataKey="gross" stackId="structure" fill="var(--color-gross)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("payoutRateOfBet", { rate: payoutRate })}
|
||||
</p>
|
||||
<ul className="grid gap-2 sm:grid-cols-3">
|
||||
{segments.map((s) => (
|
||||
<li key={s.key} className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2">
|
||||
<span className={cn("size-2.5 shrink-0 rounded-sm", s.className)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
<p className="truncate text-sm font-semibold tabular-nums">{formatMoney(s.value, currency)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,190 +269,215 @@ export function PayoutCompositionChart({
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const total = win + jackpot;
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildPayoutPieConfig({
|
||||
win: t("winPayout"),
|
||||
jackpot: t("jackpotPayout"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (total <= 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t("noPayoutYet")}</p>;
|
||||
return <DashboardChartEmpty message={t("noPayoutYet")} />;
|
||||
}
|
||||
|
||||
const winPct = (win / total) * 100;
|
||||
const winColor = "oklch(0.62 0.17 162)";
|
||||
const jackpotColor = "oklch(0.56 0.22 303)";
|
||||
const items = [
|
||||
{ label: t("winPayout"), value: win, pct: winPct, className: "bg-emerald-500", color: winColor },
|
||||
{
|
||||
label: t("jackpotPayout"),
|
||||
value: jackpot,
|
||||
pct: 100 - winPct,
|
||||
className: "bg-violet-500",
|
||||
color: jackpotColor,
|
||||
},
|
||||
const pieData = [
|
||||
{ key: "win", value: win, fill: "var(--color-win)" },
|
||||
{ key: "jackpot", value: jackpot, fill: "var(--color-jackpot)" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div
|
||||
className="relative mx-auto size-36 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(from -90deg, ${winColor} 0deg ${winPct * 3.6}deg, ${jackpotColor} ${winPct * 3.6}deg 360deg)`,
|
||||
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
}}
|
||||
/>
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.label}>
|
||||
<div className="mb-1 flex justify-between gap-2 text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-sm", item.className)} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{item.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</p>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${item.pct}%`, background: item.color }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[220px] w-full max-w-[280px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
nameKey="key"
|
||||
formatter={(value) => formatMoney(Number(value), currency)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="key"
|
||||
innerRadius="58%"
|
||||
outerRadius="82%"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{pieData.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
rows.map((row) => {
|
||||
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
|
||||
return {
|
||||
number: row.normalized_number.trim(),
|
||||
usage: pct,
|
||||
fill: usageBarFill(pct),
|
||||
};
|
||||
}),
|
||||
[rows],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("noPoolData")}</p>;
|
||||
return <DashboardChartEmpty message={t("noPoolData")} />;
|
||||
}
|
||||
|
||||
const chartHeight = Math.min(420, Math.max(160, rows.length * 32 + 48));
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => {
|
||||
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
|
||||
return (
|
||||
<li key={row.normalized_number}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-mono font-medium text-foreground">
|
||||
{row.normalized_number.trim()}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
pct >= 95 ? "bg-destructive" : pct >= 80 ? "bg-amber-500" : "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 12, bottom: 4, left: 4 }}
|
||||
>
|
||||
<XAxis type="number" domain={[0, 100]} hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="number"
|
||||
width={72}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => `${Number(value).toFixed(1)}%`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="usage" radius={4} barSize={14}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={entry.number} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const entries: { key: keyof SoldOutBuckets; label: string; color: string; swatch: string }[] = [
|
||||
{ key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.52 0.19 264)", swatch: "bg-blue-600" },
|
||||
{ key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.62 0.17 162)", swatch: "bg-emerald-500" },
|
||||
{ key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.72 0.16 75)", swatch: "bg-amber-500" },
|
||||
{ key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.56 0.22 303)", swatch: "bg-violet-500" },
|
||||
{ key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.58 0.2 25)", swatch: "bg-rose-500" },
|
||||
];
|
||||
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildSoldOutPieConfig({
|
||||
d4: t("soldOutBuckets.d4"),
|
||||
d3: t("soldOutBuckets.d3"),
|
||||
d2: t("soldOutBuckets.d2"),
|
||||
special: t("soldOutBuckets.special"),
|
||||
other: t("soldOutBuckets.other"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const entries: (keyof SoldOutBuckets)[] = ["d4", "d3", "d2", "special", "other"];
|
||||
const total = entries.reduce((s, key) => s + buckets[key], 0);
|
||||
|
||||
if (total === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("noSoldOutNumbers")}</p>;
|
||||
return <DashboardChartEmpty message={t("noSoldOutNumbers")} />;
|
||||
}
|
||||
|
||||
let acc = 0;
|
||||
const parts = entries
|
||||
.filter((e) => buckets[e.key] > 0)
|
||||
.map((e) => {
|
||||
const frac = buckets[e.key] / total;
|
||||
const start = acc;
|
||||
acc += frac;
|
||||
return { ...e, frac, start };
|
||||
});
|
||||
|
||||
const gradientStops =
|
||||
parts.length === 1
|
||||
? `${parts[0].color} 0deg 360deg`
|
||||
: parts
|
||||
.map((p) => {
|
||||
const a0 = p.start * 360;
|
||||
const a1 = (p.start + p.frac) * 360;
|
||||
return `${p.color} ${a0}deg ${a1}deg`;
|
||||
})
|
||||
.join(", ");
|
||||
const pieData = entries
|
||||
.filter((key) => buckets[key] > 0)
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: buckets[key],
|
||||
fill: `var(--color-${key})`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative mx-auto size-40 shrink-0">
|
||||
<div
|
||||
className="size-full rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(from -90deg, ${gradientStops})`,
|
||||
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p className="text-3xl font-bold tabular-nums">{total}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("soldOutTotal")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
{entries.map((e) => {
|
||||
const count = buckets[e.key];
|
||||
const pct = total > 0 ? (count / total) * 100 : 0;
|
||||
return (
|
||||
<li key={e.key}>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-sm", e.swatch)} />
|
||||
{e.label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{count}
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">({pct.toFixed(0)}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: e.color }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[240px] w-full max-w-[320px]">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey="key" />} />
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="key"
|
||||
innerRadius="58%"
|
||||
outerRadius="82%"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{pieData.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
|
||||
return null;
|
||||
}
|
||||
const { cx, cy } = viewBox as { cx: number; cy: number };
|
||||
return (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan className="fill-foreground text-3xl font-bold">{total}</tspan>
|
||||
<tspan x={cx} dy="1.4em" className="fill-muted-foreground text-xs">
|
||||
{t("soldOutTotal")}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { total, pending_review, published } = draw.result_batch_counts;
|
||||
const pendingW = total > 0 ? (pending_review / total) * 100 : 0;
|
||||
const publishedW = total > 0 ? (published / total) * 100 : 0;
|
||||
const otherW = Math.max(0, 100 - pendingW - publishedW);
|
||||
const other = Math.max(0, total - pending_review - published);
|
||||
|
||||
const chartConfig = useMemo(
|
||||
() =>
|
||||
buildBatchProgressConfig({
|
||||
pending: t("batchPending"),
|
||||
published: t("batchPublished"),
|
||||
other: t("batchOther"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const chartData = [{ row: "batches", pending: pending_review, published, other }];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
{pendingW > 0 ? (
|
||||
<div className="bg-amber-500" style={{ width: `${pendingW}%` }} title={t("batchPending")} />
|
||||
) : null}
|
||||
{publishedW > 0 ? (
|
||||
<div className="bg-emerald-600" style={{ width: `${publishedW}%` }} title={t("batchPublished")} />
|
||||
) : null}
|
||||
{otherW > 0 ? <div className="bg-muted-foreground/30" style={{ width: `${otherW}%` }} /> : null}
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-10 w-full">
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, Math.max(total, 1)]} />
|
||||
<YAxis type="category" dataKey="row" hide width={0} />
|
||||
<Bar dataKey="pending" stackId="batch" fill="var(--color-pending)" radius={4} />
|
||||
<Bar dataKey="published" stackId="batch" fill="var(--color-published)" radius={4} />
|
||||
<Bar dataKey="other" stackId="batch" fill="var(--color-other)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
||||
<p className="text-2xl font-bold tabular-nums text-amber-600">{pending_review}</p>
|
||||
@@ -379,54 +502,68 @@ export function SettlementStatusChart({
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const batches = finance.settlement_batches ?? [];
|
||||
const settlementBatches = finance.settlement_batches;
|
||||
|
||||
if (batches.length === 0) {
|
||||
return <p className="py-6 text-center text-sm text-muted-foreground">{t("noSettlementBatches")}</p>;
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of batches) {
|
||||
counts.set(b.status, (counts.get(b.status) ?? 0) + 1);
|
||||
}
|
||||
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const max = Math.max(...entries.map((e) => e[1]));
|
||||
|
||||
const barTone = (status: string): string => {
|
||||
switch (status) {
|
||||
case "pending_review":
|
||||
return "bg-amber-500";
|
||||
case "approved":
|
||||
return "bg-sky-500";
|
||||
case "paid":
|
||||
case "completed":
|
||||
return "bg-emerald-600";
|
||||
case "running":
|
||||
return "bg-blue-500";
|
||||
case "rejected":
|
||||
case "failed":
|
||||
return "bg-rose-500";
|
||||
default:
|
||||
return "bg-violet-500";
|
||||
const entries = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of settlementBatches ?? []) {
|
||||
counts.set(b.status, (counts.get(b.status) ?? 0) + 1);
|
||||
}
|
||||
};
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
}, [settlementBatches]);
|
||||
|
||||
const chartConfig = useMemo(
|
||||
(): ChartConfig =>
|
||||
buildSettlementBarConfig(
|
||||
entries.map(([status]) => ({
|
||||
status,
|
||||
label: status,
|
||||
color: settlementBarColor(status),
|
||||
})),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!settlementBatches || settlementBatches.length === 0) {
|
||||
return <DashboardChartEmpty message={t("noSettlementBatches")} />;
|
||||
}
|
||||
|
||||
const chartData = entries.map(([status, count]) => ({
|
||||
status,
|
||||
count,
|
||||
fill: settlementBarColor(status),
|
||||
}));
|
||||
|
||||
const chartHeight = Math.min(360, Math.max(140, entries.length * 40 + 40));
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{entries.map(([status, count]) => (
|
||||
<li key={status}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<AdminStatusBadge status={status}>{status}</AdminStatusBadge>
|
||||
<span className="text-sm font-medium tabular-nums">{count}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", barTone(status))}
|
||||
style={{ width: `${max > 0 ? (count / max) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto w-full"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 12, bottom: 4, left: 8 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="status"
|
||||
width={108}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="count" radius={4} barSize={16}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={entry.status} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user