diff --git a/dashboard/src/components/charts/index.ts b/dashboard/src/components/charts/index.ts
@@ -0,0 +1 @@
+export * from "./pie";
diff --git a/dashboard/src/components/charts/pie/index.tsx b/dashboard/src/components/charts/pie/index.tsx
@@ -0,0 +1,67 @@
+import { FC, useState } from 'react';
+import { Pie } from '@visx/shape';
+import { Group } from '@visx/group';
+import { Text } from '@visx/text';
+
+export type PieChartDataRecord = {
+ symbol: string,
+ amount: number,
+ color: string
+};
+
+export interface PieChartProps {
+ data: PieChartDataRecord[];
+ width: number;
+ height: number;
+ total: number;
+ label: string;
+}
+
+export const PieChart: FC<PieChartProps> = ({ data, width, height, total, label }) => {
+ const half = width / 2;
+ const [active, setActive] = useState<PieChartDataRecord | null>(null);
+
+ return (
+ <svg width={width} height={height}>
+ <Group top={half} left={half}>
+ <Pie
+ data={data}
+ pieValue={(data) => data.amount}
+ outerRadius={half}
+ innerRadius={({ data }) => (active && (active.symbol === data.symbol) ? half - 14 : half - 8)}
+ padAngle={0.01}
+ >
+ {pie => {
+ return pie.arcs.map(arc => {
+ return (
+ <g
+ key={arc.data.symbol}
+ onMouseEnter={() => setActive(arc.data)}
+ onMouseLeave={() => setActive(null)}
+ >
+ <path d={pie.path(arc) ?? ''} className={arc.data.color}></path>
+ </g>
+ )
+ })
+ }}
+ </Pie>
+ <Text
+ textAnchor="middle"
+ fontSize={30}
+ dy={-10}
+ className='fill-primary'
+ >
+ {active ? active.amount : total}
+ </Text>
+ <Text
+ textAnchor="middle"
+ fontSize={15}
+ dy={10}
+ className='fill-muted-foreground'
+ >
+ {label}
+ </Text>
+ </Group>
+ </svg>
+ );
+};
diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts
@@ -9,5 +9,6 @@ export * from "./entity-table";
export * from "./copy-button";
export * from "./page";
export * from "./stacks";
-export * from "./stats-widgets";
+export * from "./widgets";
+export * from "./charts";
export * from "./support-us";
diff --git a/dashboard/src/components/stats-widgets/mini-widget/index.tsx b/dashboard/src/components/stats-widgets/mini-widget/index.tsx
@@ -1,30 +0,0 @@
-import {
- Card,
- CardContent,
- CardFooter,
- CardHeader,
- CardTitle
-} from '@marzneshin/components'
-import { FC } from 'react'
-
-export interface MiniWidgetProps {
- title: JSX.Element | string;
- content: JSX.Element | string;
- footer?: JSX.Element | string;
-}
-
-export const MiniWidget: FC<MiniWidgetProps> = ({ footer, content, title }) => {
- return (
- <Card>
- <CardHeader>
- <CardTitle>
- {title}
- </CardTitle>
- </CardHeader>
- <CardContent>
- {content}
- </CardContent>
- {footer && <CardFooter> {footer} </CardFooter>}
- </Card>
- )
-}
diff --git a/dashboard/src/components/stats-widgets/index.ts b/dashboard/src/components/widgets/index.ts
rename from dashboard/src/components/stats-widgets/index.ts
rename to dashboard/src/components/widgets/index.ts
diff --git a/dashboard/src/components/widgets/mini-widget/index.tsx b/dashboard/src/components/widgets/mini-widget/index.tsx
@@ -0,0 +1,30 @@
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@marzneshin/components';
+import type { FC, PropsWithChildren } from 'react';
+
+export interface MiniWidgetProps extends PropsWithChildren {
+ title: JSX.Element | string;
+ content?: JSX.Element | string;
+ footer?: JSX.Element | string;
+}
+
+export const MiniWidget: FC<MiniWidgetProps> = ({ footer, content, children, title }) => {
+ return (
+ <Card>
+ <CardHeader className="p-4">
+ <CardTitle className="flex flex-row justify-start items-center gap-3 text-lg">
+ {title}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="p-4">
+ {content || children}
+ </CardContent>
+ {footer && <CardFooter> {footer} </CardFooter>}
+ </Card>
+ )
+}
diff --git a/dashboard/src/features/users/services/index.ts b/dashboard/src/features/users/services/index.ts
@@ -1,7 +1,8 @@
-export * from './users.query'
-export * from './update-user.mutate'
-export * from './create-user.mutate'
-export * from './delete-user.mutate'
-export * from './user-usage-reset.command'
-export * from './user-subscription-revoke.command'
-export * from './user-status.mutate'
+export * from "./users.query";
+export * from "./users-stats.query";
+export * from "./update-user.mutate";
+export * from "./create-user.mutate";
+export * from "./delete-user.mutate";
+export * from "./user-usage-reset.command";
+export * from "./user-subscription-revoke.command";
+export * from "./user-status.mutate";
diff --git a/dashboard/src/features/users/widgets/users-stats-widget/index.tsx b/dashboard/src/features/users/widgets/users-stats-widget/index.tsx
@@ -1,96 +1,50 @@
-import { MiniWidget } from '@marzneshin/components/stats-widgets';
-import { UsersIcon } from 'lucide-react';
-import { Pie } from '@visx/shape';
-import { Group } from '@visx/group';
-import { Text } from '@visx/text';
import { FC } from 'react';
-import { useUsersStatsQuery } from '../../services/users-stats.query';
-import { useState } from 'react';
+import { UsersIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
-import { UsersStatsLegend } from './legend'
+import {
+ MiniWidget,
+ PieChart,
+} from "@marzneshin/components";
+import { UsersStatsLegend } from './legend';
-const Header = () => {
- const { t } = useTranslation()
- return (
- <div className='flex flex-row justify-start items-center gap-3'>
- <UsersIcon />
- {t('users')}
- </div>
- )
+interface UsersStatsWidgetProps {
+ active: number;
+ online: number;
+ expired: number;
+ on_hold: number;
+ limited: number;
}
-type DataItem = { symbol: string, amount: number, color: string }
-
-const Content = () => {
- const width = 200;
- const half = width / 2;
- const { data: stats } = useUsersStatsQuery()
- const [active, setActive] = useState<DataItem | null>(null);
- const { t } = useTranslation()
+export const UsersStatsWidget: FC<UsersStatsWidgetProps> = ({
+ active, online, expired, on_hold, limited
+}) => {
+ const { t } = useTranslation();
- const data: DataItem[] = [
- { symbol: "Active", amount: stats.active, color: "fill-blue-500" },
- { symbol: "Online", amount: stats.online, color: "fill-emerald-500" },
- { symbol: "Expired", amount: stats.expired, color: "fill-gray-600" },
- { symbol: "On Hold", amount: stats.on_hold, color: "fill-amber-500" },
- { symbol: "Limited", amount: stats.limited, color: "fill-red-500" },
+ const data = [
+ { symbol: "Active", amount: active, color: "fill-blue-500" },
+ { symbol: "Online", amount: online, color: "fill-emerald-500" },
+ { symbol: "Expired", amount: expired, color: "fill-gray-600" },
+ { symbol: "On Hold", amount: on_hold, color: "fill-purple-500" },
+ { symbol: "Limited", amount: limited, color: "fill-red-500" },
];
- return (
- <div className='flex flex-row gap-5 justify-between'>
- <svg width={width} height={width}>
- <Group top={half} left={half}>
- <Pie
- data={data}
- pieValue={(data) => data.amount}
- outerRadius={half}
- innerRadius={({ data }) => (active && (active.symbol === data.symbol) ? half - 14 : half - 8)}
- padAngle={0.01}
- >
- {pie => {
- return pie.arcs.map(arc => {
- return (
- <g
- key={arc.data.symbol}
- onMouseEnter={() => setActive(arc.data)}
- onMouseLeave={() => setActive(null)}
- >
- <path d={pie.path(arc) ?? ''} className={arc.data.color}></path>
- </g>
- )
- })
- }}
- </Pie>
- <Text
- textAnchor="middle"
- fontSize={30}
- dy={-10}
- className='fill-primary'
- >
- {active ? active.amount : stats.total}
- </Text>
- <Text
- textAnchor="middle"
- fontSize={15}
- dy={10}
- className='fill-muted-foreground'
- >
- {t('users')}
- </Text>
- </Group>
- </svg>
- <UsersStatsLegend />
- </div >
- )
-}
+ const total = active + online + expired + on_hold + limited
-const Widget: FC = () => {
return (
<MiniWidget
- title={<Header />}
- content={<Content />}
- />
- )
-}
+ title={<> <UsersIcon /> {t('users')} </>}
+ >
+ <div className='flex md:flex-row flex-col items-center md:items-start gap-5 justify-between'>
+ <PieChart
+ data={data}
+ width={200}
+ height={200}
+ total={total}
+ label={t('users')}
+ />
+ <UsersStatsLegend />
+ </div>
+ </MiniWidget>
+ );
+};
-export const UsersStatsWidget = Widget;
diff --git a/dashboard/src/features/users/widgets/users-stats-widget/legend.tsx b/dashboard/src/features/users/widgets/users-stats-widget/legend.tsx
@@ -3,16 +3,17 @@ import { scaleOrdinal } from '@visx/scale';
import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend';
const legendGlyphSize = 15;
+
const ordinalColorScale = scaleOrdinal({
domain: ["Active", "Online", "Expired", "On Hold", "Limited"],
- range: ['#3b82f6', '#10b981', '#4b5563', '#f59e0b', '#ef4444'],
+ range: ['#3b82f6', '#10b981', '#4b5563', '#A855F7', '#ef4444'],
});
export const UsersStatsLegend: FC = () => {
return (
<LegendOrdinal scale={ordinalColorScale}>
{(labels) => (
- <div className="border border-solid border-[rgba(255, 255, 255, 0.3)] rounded-lg flex flex-col h-full p-3">
+ <div className="border border-solid border-[rgba(255, 255, 255, 0.3)] rounded-lg flex flex-row flex-wrap md:flex-col h-full p-3">
{labels.map((label, i) => (
<LegendItem
key={`legend-quantile-${i}`}
diff --git a/dashboard/src/features/users/widgets/users-stats-widget/users-stats-widget.stories.ts b/dashboard/src/features/users/widgets/users-stats-widget/users-stats-widget.stories.ts
@@ -0,0 +1,26 @@
+import { UsersStatsWidget } from "./index"
+import type { Meta, StoryObj } from "@storybook/react";
+
+const meta: Meta<typeof UsersStatsWidget> = {
+ title: "Features/Users/Users Stats",
+ component: UsersStatsWidget,
+ tags: ["autodocs"]
+};
+
+export default meta;
+
+type WidgetStory = StoryObj<typeof UsersStatsWidget>;
+
+/**
+ * Users chart widget which shows the active,
+ * online, expired, on hold, and limited users.
+ * */
+export const UsersChart: WidgetStory = {
+ args: {
+ active: 50,
+ online: 30,
+ expired: 10,
+ on_hold: 5,
+ limited: 5,
+ }
+}
diff --git a/dashboard/src/routes/_dashboard/index.tsx b/dashboard/src/routes/_dashboard/index.tsx
@@ -6,12 +6,14 @@ import {
CardTitle,
Page,
} from '@marzneshin/components';
-import { UsersStatsWidget } from '@marzneshin/features/users';
+import { UsersStatsWidget, useUsersStatsQuery } from '@marzneshin/features/users';
import { useTranslation } from 'react-i18next';
import { FC } from 'react';
export const DashboardPage: FC = () => {
const { t } = useTranslation();
+ const { data: stats } = useUsersStatsQuery()
+
return (
<Page>
<Card className="border-0 sm:w-screen md:w-full">
@@ -21,7 +23,7 @@ export const DashboardPage: FC = () => {
</CardTitle>
</CardHeader>
<CardContent className="md:flex">
- <UsersStatsWidget />
+ <UsersStatsWidget {...stats} />
</CardContent>
</Card>
</Page>