diff --git a/app/src/admin/colors.ts b/app/src/admin/colors.ts new file mode 100644 index 0000000..4fcf9f5 --- /dev/null +++ b/app/src/admin/colors.ts @@ -0,0 +1,50 @@ +export const modelColorMapper: Record = { + "gpt-3.5-turbo": "#34bf49", + "gpt-3.5-turbo-instruct": "#34bf49", + "gpt-3.5-turbo-0613": "#34bf49", + "gpt-3.5-turbo-0301": "#34bf49", + dalle: "#e4e5e5", + + "gpt-3.5-turbo-16k": "#0abf53", + "gpt-3.5-turbo-16k-0613": "#0abf53", + "gpt-3.5-turbo-16k-0301": "#0abf53", + + "gpt-4": "#8e43e7", + "gpt-4-0613": "#8e43e7", + "gpt-4-0314": "#8e43e7", + "gpt-4-v": "#8e43e7", + "gpt-4-dalle": "#8e43e7", + + "gpt-4-32k": "#8329f1", + "gpt-4-32k-0613": "#8329f1", + "gpt-4-32k-0314": "#8329f1", + + "claude-1": "#ff9d3b", + "claude-1-100k": "#ff9d3b", + "claude-slack": "#ff9d3b", + "claude-2": "#ff840b", + "claude-2-100k": "#ff840b", + + "spark-desk-v1.5": "#06b3e8", + "spark-desk-v2": "#06b3e8", + "spark-desk-v3": "#06b3e8", + + "chat-bison-001": "#f82a53", + + "bing-creative": "#2673e7", + "bing-balance": "#2673e7", + "bing-precise": "#2673e7", + + "zhipu-chatglm-pro": "#008272", + "zhipu-chatglm-std": "#008272", + "zhipu-chatglm-lite": "#008272", + + "qwen-plus": "#615ced", + "qwen-plus-net": "#615ced", + "qwen-turbo": "#716cfd", + "qwen-turbo-net": "#716cfd", +}; + +export function getModelColor(model: string): string { + return modelColorMapper[model] || "#000000"; +} diff --git a/app/src/admin/types.ts b/app/src/admin/types.ts new file mode 100644 index 0000000..cb9f6cd --- /dev/null +++ b/app/src/admin/types.ts @@ -0,0 +1,22 @@ +export type ModelChartResponse = { + date: string[]; + value: { + model: string; + data: number[]; + }[]; +}; + +export type RequestChartResponse = { + date: string[]; + value: number[]; +}; + +export type BillingChartResponse = { + date: string[]; + value: number[]; +}; + +export type ErrorChartResponse = { + date: string[]; + value: number[]; +}; diff --git a/app/src/assets/admin/all.less b/app/src/assets/admin/all.less index 06f2337..e1e93b2 100644 --- a/app/src/assets/admin/all.less +++ b/app/src/assets/admin/all.less @@ -1,4 +1,5 @@ @import "menu"; +@import "dashboard"; .admin-page { position: relative; diff --git a/app/src/assets/admin/dashboard.less b/app/src/assets/admin/dashboard.less new file mode 100644 index 0000000..5901274 --- /dev/null +++ b/app/src/assets/admin/dashboard.less @@ -0,0 +1,120 @@ +.dashboard { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 0.5rem 0; + + & > * { + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } +} + +.info-boxes { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + width: 100%; + height: max-content; + padding: 1rem 2rem; + + @media (max-width: 668px) { + flex-direction: column; + padding: 1rem; + + .info-box { + width: calc(100% - 1rem) !important; + max-width: none !important; + margin: 0 0.5rem 1rem !important; + + &:last-child { + margin-bottom: 0; + } + } + } + + .info-box { + display: flex; + flex-direction: row; + flex-grow: 1; + height: max-content; + padding: 0.75rem 1.5rem; + border-radius: var(--radius); + background: hsl(var(--background-container)); + border: 1px solid hsl(var(--border)); + user-select: none; + max-width: 460px; + + margin-right: 1.5rem; + margin-left: auto; + + &:last-child { + margin-right: auto; + } + + & > * { + flex-shrink: 0; + } + + .box-wrapper { + flex-grow: 1; + + .box-title { + font-size: 1rem; + margin-bottom: 0.5rem; + } + + .box-value { + font-size: 1.5rem; + font-weight: 600; + + &.money::after, + .box-subvalue { + font-size: 1rem; + font-weight: normal; + margin-left: 0.5rem; + content: 'CNY'; + } + } + } + + .box-icon { + width: max-content; + height: max-content; + transform: translate(0.25rem, 0.25rem); + border-radius: 0.25rem; + + svg { + width: 2rem; + height: 2rem; + stroke-width: 1.5; + } + } + } +} + +.chart-boxes { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding: 1rem 1.5rem; + + .chart-box { + width: calc(50% - 1rem); + height: max-content; + max-height: 420px; + + padding: 1rem 2rem; + margin: 0.5rem; + border-radius: var(--radius); + background: hsl(var(--background-container)); + border: 1px solid hsl(var(--border)); + user-select: none; + box-shadow: 0 0 1rem 0 hsla(var(--foreground), 0.1); + } +} diff --git a/app/src/assets/admin/menu.less b/app/src/assets/admin/menu.less index 059ff9d..361a996 100644 --- a/app/src/assets/admin/menu.less +++ b/app/src/assets/admin/menu.less @@ -12,15 +12,13 @@ transition-property: width, background, box-shadow, opacity; border-right: 0; opacity: 0; - - &.close { - display: none; - } + pointer-events: none; &.open { width: 260px; border-right: 1px solid hsl(var(--border)); opacity: 1; + pointer-events: all; } .menu-item { @@ -43,17 +41,42 @@ } &.active { - background: var(--conversation-card-hover); + background: var(--conversation-card-active); } & > * { flex-shrink: 0; } - & svg { + .menu-item-title { + font-size: 1rem; + } + + .menu-item-icon { width: 1.5rem; height: 1.5rem; margin-right: 0.5rem; } } + + @media (max-width: 668px) { + &.open { + width: 100%; + border-right: 0; + } + } +} + +.admin-content { + flex-grow: 1; + height: max-content; + min-height: calc(100vh - 56px); + overflow-x: hidden; + overflow-y: auto; + touch-action: pan-y; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } } diff --git a/app/src/components/Markdown.tsx b/app/src/components/Markdown.tsx index 362415f..30176d9 100644 --- a/app/src/components/Markdown.tsx +++ b/app/src/components/Markdown.tsx @@ -36,6 +36,8 @@ const LanguageMap: Record = { html: "htmlbars", js: "javascript", ts: "typescript", + jsx: "javascript", + tsx: "typescript", rs: "rust", }; diff --git a/app/src/components/ThemeProvider.tsx b/app/src/components/ThemeProvider.tsx index f7cb501..db8095c 100644 --- a/app/src/components/ThemeProvider.tsx +++ b/app/src/components/ThemeProvider.tsx @@ -3,6 +3,7 @@ import { Moon, Sun } from "lucide-react"; import { Button } from "./ui/button"; import { getMemory, setMemory } from "@/utils/memory.ts"; +import { themeEvent } from "@/events/theme.ts"; type Theme = "dark" | "light" | "system"; @@ -27,6 +28,7 @@ export function activeTheme(theme: Theme) { root.classList.add(theme); setMemory("theme", theme); + themeEvent.emit(theme); } const initialState: ThemeProviderState = { diff --git a/app/src/components/admin/ChartBox.tsx b/app/src/components/admin/ChartBox.tsx new file mode 100644 index 0000000..c3e0ae4 --- /dev/null +++ b/app/src/components/admin/ChartBox.tsx @@ -0,0 +1,105 @@ +import ModelChart from "@/components/admin/assemblies/ModelChart.tsx"; +import { useEffect, useState } from "react"; +import {BillingChartResponse, ErrorChartResponse, ModelChartResponse, RequestChartResponse} from "@/admin/types.ts"; + +import {ArcElement, Chart, Filler, LineElement, PointElement} from "chart.js"; +import { + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +} from "chart.js"; +import { useSelector } from "react-redux"; +import { selectMenu } from "@/store/menu.ts"; +import { getMemory } from "@/utils/memory.ts"; +import { themeEvent } from "@/events/theme.ts"; +import RequestChart from "@/components/admin/assemblies/RequestChart.tsx"; +import BillingChart from "@/components/admin/assemblies/BillingChart.tsx"; +import ErrorChart from "@/components/admin/assemblies/ErrorChart.tsx"; + +Chart.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, + ArcElement, + PointElement, + LineElement, + Filler, +); + +function resize(task: number): number { + Object.values(Chart.instances).forEach((chart) => { + chart.resize(); + }); + + return Number( + setTimeout(() => { + clearTimeout(task); + window.addEventListener("resize", () => { + Object.values(Chart.instances).forEach((chart) => { + chart.resize(); + }); + }); + }, 500), + ); +} + +function ChartBox() { + const open = useSelector(selectMenu); + let timeout: number = 0; + + useEffect(() => { + timeout = resize(timeout); + + return () => { + clearTimeout(timeout); + }; + }, [open]); + + useEffect(() => { + + }, []); + + const [dark, setDark] = useState(getMemory("theme") === "dark"); + themeEvent.bind((theme: string) => setDark(theme === "dark")); + + const [model, setModel] = useState({ + date: [], value: [], + }); + + const [request, setRequest] = useState({ + date: [], value: [], + }); + + const [billing, setBilling] = useState({ + date: [], value: [], + }); + + const [error, setError] = useState({ + date: [], value: [], + }); + + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +} + +export default ChartBox; diff --git a/app/src/components/admin/InfoBox.tsx b/app/src/components/admin/InfoBox.tsx new file mode 100644 index 0000000..7d28a6f --- /dev/null +++ b/app/src/components/admin/InfoBox.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { CircleDollarSign, Users2, Wallet } from "lucide-react"; + +function InfoBox() { + const { t } = useTranslation(); + const [form, setForm] = useState({ + today: 0, + month: 0, + users: 0, + }); + + return ( +
+
+
+
{t("admin.billing-today")}
+
{form.today}
+
+
+ +
+
+ +
+
+
{t("admin.billing-month")}
+
{form.month}
+
+
+ +
+
+ +
+
+
{t("admin.subscription-users")}
+
+ {form.users} + {t("admin.seat")} +
+
+
+ +
+
+
+ ); +} + +export default InfoBox; diff --git a/app/src/components/admin/MenuBar.tsx b/app/src/components/admin/MenuBar.tsx index 12dac19..439efb0 100644 --- a/app/src/components/admin/MenuBar.tsx +++ b/app/src/components/admin/MenuBar.tsx @@ -1,50 +1,54 @@ -import {useSelector} from "react-redux"; -import {selectMenu} from "@/store/menu.ts"; -import React, {useEffect, useMemo, useState} from "react"; -import {LayoutDashboard, Settings} from "lucide-react"; +import { useSelector } from "react-redux"; +import { selectMenu } from "@/store/menu.ts"; +import React, { useMemo } from "react"; +import { LayoutDashboard, Settings } from "lucide-react"; import router from "@/router.tsx"; -import {useLocation} from "react-router-dom"; +import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; type MenuItemProps = { title: string; icon: React.ReactNode; path: string; -} +}; function MenuItem({ title, icon, path }: MenuItemProps) { const location = useLocation(); - const active = useMemo(() => ( - location.pathname === `/admin${path}` || (location.pathname + "/") === `/admin${path}` - ), [location.pathname, path]); + const active = useMemo( + () => + location.pathname === `/admin${path}` || + location.pathname + "/" === `/admin${path}`, + [location.pathname, path], + ); return ( -
router.navigate(`/admin${path}`)} +
router.navigate(`/admin${path}`)} > -
- {icon} -
-
- {title} -
+
{icon}
+
{title}
- ) + ); } function MenuBar() { + const { t } = useTranslation(); const open = useSelector(selectMenu); - const [close, setClose] = useState(false); - useEffect(() => { - if (open) setClose(false); - else setTimeout(() => setClose(true), 200); - }, [open]); - return ( -
- } path={"/"} /> - } path={"/config"} /> +
+ } + path={"/"} + /> + } + path={"/config"} + />
- ) + ); } export default MenuBar; diff --git a/app/src/components/admin/assemblies/BillingChart.tsx b/app/src/components/admin/assemblies/BillingChart.tsx new file mode 100644 index 0000000..1cb2a82 --- /dev/null +++ b/app/src/components/admin/assemblies/BillingChart.tsx @@ -0,0 +1,71 @@ +import {useTranslation} from "react-i18next"; +import {useMemo} from "react"; +import { Line } from "react-chartjs-2"; + +type BillingChartProps = { + labels: string[]; + datasets: number[]; + dark?: boolean; +}; +function BillingChart({ labels, datasets, dark }: BillingChartProps) { + const { t } = useTranslation(); + const data = useMemo(() => { + return { + labels, + datasets: [ + { + label: 'CNY', + fill: true, + data: datasets, + backgroundColor: "rgba(255,205,111,0.78)", + }, + ], + }; + }, [labels, datasets]); + + const options = useMemo(() => { + const text = dark ? "#fff" : "#000"; + + return { + scales: { + x: { + stacked: true, + grid: { + drawBorder: false, + display: false, + }, + }, + y: { + beginAtZero: true, + stacked: true, + grid: { + drawBorder: false, + display: false, + }, + }, + }, + plugins: { + title: { + display: false, + }, + legend: { + display: true, + labels: { + color: text, + }, + }, + }, + color: text, + borderWidth: 0, + }; + }, [dark]); + + return ( +
+

{t("admin.billing-chart")}

+ +
+ ); +} + +export default BillingChart; diff --git a/app/src/components/admin/assemblies/ErrorChart.tsx b/app/src/components/admin/assemblies/ErrorChart.tsx new file mode 100644 index 0000000..e5393a8 --- /dev/null +++ b/app/src/components/admin/assemblies/ErrorChart.tsx @@ -0,0 +1,71 @@ +import {useTranslation} from "react-i18next"; +import {useMemo} from "react"; +import { Line } from "react-chartjs-2"; + +type ErrorChartProps = { + labels: string[]; + datasets: number[]; + dark?: boolean; +}; +function ErrorChart({ labels, datasets, dark }: ErrorChartProps) { + const { t } = useTranslation(); + const data = useMemo(() => { + return { + labels, + datasets: [ + { + label: t("admin.times"), + fill: true, + data: datasets, + backgroundColor: "rgba(255,85,85,0.6)", + }, + ], + }; + }, [labels, datasets]); + + const options = useMemo(() => { + const text = dark ? "#fff" : "#000"; + + return { + scales: { + x: { + stacked: true, + grid: { + drawBorder: false, + display: false, + }, + }, + y: { + beginAtZero: true, + stacked: true, + grid: { + drawBorder: false, + display: false, + }, + }, + }, + plugins: { + title: { + display: false, + }, + legend: { + display: true, + labels: { + color: text, + }, + }, + }, + color: text, + borderWidth: 0, + }; + }, [dark]); + + return ( +
+

{t("admin.error-chart")}

+ +
+ ); +} + +export default ErrorChart; diff --git a/app/src/components/admin/assemblies/ModelChart.tsx b/app/src/components/admin/assemblies/ModelChart.tsx new file mode 100644 index 0000000..e6d75eb --- /dev/null +++ b/app/src/components/admin/assemblies/ModelChart.tsx @@ -0,0 +1,77 @@ +import { Bar } from "react-chartjs-2"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { getModelColor } from "@/admin/colors.ts"; + +type ModelChartProps = { + labels: string[]; + datasets: { + model: string; + data: number[]; + }[]; + dark?: boolean; +}; +function ModelChart({ labels, datasets, dark }: ModelChartProps) { + const { t } = useTranslation(); + const data = useMemo(() => { + return { + labels, + datasets: datasets.map((dataset) => { + return { + label: dataset.model, + data: dataset.data, + backgroundColor: getModelColor(dataset.model), + }; + }), + }; + }, [labels, datasets]); + + const options = useMemo(() => { + const text = dark ? "#fff" : "#000"; + + return { + scales: { + x: { + stacked: true, + grid: { + drawBorder: false, + display: false, + }, + }, + y: { + beginAtZero: true, + stacked: true, + grid: { + drawBorder: false, + display: false, + }, + }, + }, + plugins: { + title: { + display: false, + }, + legend: { + display: true, + labels: { + color: text, + }, + }, + }, + color: text, + borderWidth: 0, + defaultFontColor: text, + defaultFontSize: 16, + defaultFontFamily: "Andika", + }; + }, [dark]); + + return ( + <> +

{t("admin.model-chart")}

+ + + ); +} + +export default ModelChart; diff --git a/app/src/components/admin/assemblies/RequestChart.tsx b/app/src/components/admin/assemblies/RequestChart.tsx new file mode 100644 index 0000000..d4da2a0 --- /dev/null +++ b/app/src/components/admin/assemblies/RequestChart.tsx @@ -0,0 +1,72 @@ +import {useTranslation} from "react-i18next"; +import {useMemo} from "react"; +import { Line } from "react-chartjs-2"; + +type RequestChartProps = { + labels: string[]; + datasets: number[]; + dark?: boolean; +}; +function RequestChart({ labels, datasets, dark }: RequestChartProps) { + const { t } = useTranslation(); + const data = useMemo(() => { + return { + labels, + datasets: [ + { + label: t('admin.requests'), + fill: true, + data: datasets, + borderColor: "rgba(109,179,255,1)", + backgroundColor: "rgba(109,179,255,0.5)", + }, + ], + }; + }, [labels, datasets]); + + const options = useMemo(() => { + const text = dark ? "#fff" : "#000"; + + return { + scales: { + x: { + stacked: true, + grid: { + drawBorder: false, + display: false, + }, + }, + y: { + beginAtZero: true, + stacked: true, + grid: { + drawBorder: false, + display: false, + }, + }, + }, + plugins: { + title: { + display: false, + }, + legend: { + display: true, + labels: { + color: text, + }, + }, + }, + color: text, + borderWidth: 0, + }; + }, [dark]); + + return ( +
+

{t("admin.request-chart")}

+ +
+ ); +} + +export default RequestChart; diff --git a/app/src/components/home/ChatSpace.tsx b/app/src/components/home/ChatSpace.tsx index 79d2db61..174d147 100644 --- a/app/src/components/home/ChatSpace.tsx +++ b/app/src/components/home/ChatSpace.tsx @@ -19,11 +19,14 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog.tsx"; +import { getLanguage } from "@/i18n.ts"; function ChatSpace() { const [open, setOpen] = useState(false); const { t } = useTranslation(); const subscription = useSelector(isSubscribedSelector); + + const cn = getLanguage() === "cn"; return (
); diff --git a/app/src/components/home/SideBar.tsx b/app/src/components/home/SideBar.tsx index d26a228..bab9c49 100644 --- a/app/src/components/home/SideBar.tsx +++ b/app/src/components/home/SideBar.tsx @@ -16,7 +16,7 @@ import { updateConversationList, } from "@/conversation/history.ts"; import { Button } from "@/components/ui/button.tsx"; -import {selectMenu, setMenu} from "@/store/menu.ts"; +import { selectMenu, setMenu } from "@/store/menu.ts"; import { Copy, Eraser, diff --git a/app/src/events/theme.ts b/app/src/events/theme.ts new file mode 100644 index 0000000..221b6e1 --- /dev/null +++ b/app/src/events/theme.ts @@ -0,0 +1,5 @@ +import { EventCommitter } from "@/events/struct.ts"; + +export const themeEvent = new EventCommitter({ + name: "theme", +}); diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 93e131a..8008428 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -29,6 +29,7 @@ const resources = { "Request failed. Please check your network and try again.", close: "Close", edit: "Edit", + pricing: "Model Pricing", conversation: { title: "Conversation", empty: "Empty", @@ -255,6 +256,20 @@ const resources = { "Failed to generate article, please check your network and try again.", "download-format": "Download {{name}} format", }, + admin: { + dashboard: "Dashboard", + settings: "Settings", + "billing-today": "Billing Today", + "billing-month": "Billing Month", + "subscription-users": "Subscription Users", + seat: "Seat", + "model-chart": "Model Usage Statistics", + "request-chart": "Request Statistics", + "billing-chart": "Revenue Statistics", + "error-chart": "Error Statistics", + requests: "Requests", + times: "Times", + }, }, }, cn: { @@ -278,6 +293,7 @@ const resources = { "request-failed": "请求失败,请检查您的网络并重试。", close: "关闭", edit: "编辑", + pricing: "模型定价表", conversation: { title: "对话", empty: "空空如也", @@ -491,6 +507,20 @@ const resources = { "generate-failed-prompt": "文章生成失败,请检查您的网络并重试。", "download-format": "下载 {{name}} 格式", }, + admin: { + dashboard: "仪表盘", + settings: "设置", + "billing-today": "今日入账", + "billing-month": "本月入账", + "subscription-users": "订阅用户", + seat: "位", + "model-chart": "模型使用统计", + "request-chart": "请求量统计", + "billing-chart": "收入统计", + "error-chart": "错误统计", + requests: "请求量", + times: "异常次数", + }, }, }, ru: { @@ -516,6 +546,7 @@ const resources = { "Ошибка запроса. Пожалуйста, проверьте свою сеть и попробуйте еще раз.", close: "Закрыть", edit: "Редактировать", + pricing: "Тарифы моделей", conversation: { title: "Разговор", empty: "Пусто", @@ -743,6 +774,20 @@ const resources = { "Не удалось сгенерировать статью. Пожалуйста, проверьте свою сеть и попробуйте еще раз.", "download-format": "Загрузить {{name}} формат", }, + admin: { + dashboard: "Панель управления", + settings: "Настройки", + "billing-today": "Сегодняшний доход", + "billing-month": "Доход за месяц", + "subscription-users": "Подписчики", + seat: "место", + "model-chart": "Статистика использования моделей", + "request-chart": "Статистика запросов", + "billing-chart": "Статистика доходов", + "error-chart": "Статистика ошибок", + requests: "Запросы", + times: "Количество ошибок", + }, }, }, }; @@ -753,7 +798,7 @@ i18n .use(initReactI18next) .init({ resources, - lng: getStorage(), + lng: getLanguage(), fallbackLng: "en", interpolation: { escapeValue: false, // react already safes from xss @@ -763,7 +808,7 @@ i18n export default i18n; -export function getStorage(): string { +export function getLanguage(): string { const storage = getMemory("language"); if (storage && supportedLanguages.includes(storage)) { return storage; diff --git a/app/src/router.tsx b/app/src/router.tsx index 5ccf419..5a492a2 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -7,7 +7,9 @@ import { lazy, Suspense } from "react"; const Generation = lazy(() => import("@/routes/Generation.tsx")); const Sharing = lazy(() => import("@/routes/Sharing.tsx")); const Article = lazy(() => import("@/routes/Article.tsx")); + const Admin = lazy(() => import("@/routes/Admin.tsx")); +const Dashboard = lazy(() => import("@/routes/admin/DashBoard.tsx")); const router = createBrowserRouter([ { @@ -60,7 +62,17 @@ const router = createBrowserRouter([ ), - children: [], + children: [ + { + id: "admin-dashboard", + path: "", + element: ( + + + + ), + }, + ], ErrorBoundary: NotFound, }, ]); diff --git a/app/src/routes/Admin.tsx b/app/src/routes/Admin.tsx index 015553f..6795db0 100644 --- a/app/src/routes/Admin.tsx +++ b/app/src/routes/Admin.tsx @@ -1,12 +1,16 @@ import "@/assets/admin/all.less"; import MenuBar from "@/components/admin/MenuBar.tsx"; +import { Outlet } from "react-router-dom"; function Admin() { return (
+
+ +
- ) + ); } export default Admin; diff --git a/app/src/routes/admin/DashBoard.tsx b/app/src/routes/admin/DashBoard.tsx new file mode 100644 index 0000000..00c7b7a --- /dev/null +++ b/app/src/routes/admin/DashBoard.tsx @@ -0,0 +1,13 @@ +import InfoBox from "@/components/admin/InfoBox.tsx"; +import ChartBox from "@/components/admin/ChartBox.tsx"; + +function DashBoard() { + return ( +
+ + +
+ ); +} + +export default DashBoard;