diff --git a/index.html b/index.html
index f4432f5..95eda1c 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,11 @@
-
dispatcher-floss
+
+ Dispatcher FLOSS
diff --git a/package.json b/package.json
index 5b00fb7..b90889e 100644
--- a/package.json
+++ b/package.json
@@ -18,11 +18,12 @@
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-query": "^5.90.5",
"@toolpad/core": "^0.16.0",
- "axios": "^1.12.2",
+ "axios": "^1.13.2",
"i18next": "^25.6.0",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"react": "^19.1.1",
+ "react-apexcharts": "^1.8.0",
"react-dom": "^19.1.1",
"react-i18next": "^16.1.5",
"react-icons": "^5.5.0",
diff --git a/src/app/DashboardPage.tsx b/src/app/DashboardPage.tsx
index 4bcbdea..0850260 100644
--- a/src/app/DashboardPage.tsx
+++ b/src/app/DashboardPage.tsx
@@ -1,31 +1,9 @@
-import { Button, useColorScheme } from "@mui/material"
-import { useTranslation } from "react-i18next"
+import { DashboardStatsSection } from "@/sections/dashboard/DashboardStatsSection"
export const DashboardPage = () => {
- // theme
- const { mode, setMode } = useColorScheme()
-
- // translate
- const { t, i18n } = useTranslation()
- const toggleLang = () => {
- i18n.changeLanguage(i18n.language === "en" ? "uz" : "en")
- }
-
return (
- uy! {JSON.stringify(mode)}
-
-
{t("name")}
-
til: {i18n.language}
-
-
-
+
)
}
diff --git a/src/sections/dashboard/DashboardStatsSection.tsx b/src/sections/dashboard/DashboardStatsSection.tsx
new file mode 100644
index 0000000..0e731e6
--- /dev/null
+++ b/src/sections/dashboard/DashboardStatsSection.tsx
@@ -0,0 +1,47 @@
+import { Grid, useTheme } from "@mui/material"
+import { StatsWidget } from "./DashboardStatsWidget"
+
+export const DashboardStatsSection = () => {
+ const theme = useTheme()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/sections/dashboard/DashboardStatsWidget.tsx b/src/sections/dashboard/DashboardStatsWidget.tsx
new file mode 100644
index 0000000..6893799
--- /dev/null
+++ b/src/sections/dashboard/DashboardStatsWidget.tsx
@@ -0,0 +1,88 @@
+import { Chart, ChartOptions } from "@/shared/ui"
+import { Num } from "@/shared/utils/num"
+import { Box, Card, CardProps, useTheme } from "@mui/material"
+
+type Props = CardProps & {
+ title: string
+ total: number
+ percent: number
+ chart: {
+ colors?: string[]
+ categories: string[]
+ series: number[]
+ options?: ChartOptions
+ }
+}
+
+export const StatsWidget = ({ title, percent, total, chart, sx, ...other }: Props) => {
+ const theme = useTheme()
+ const chartColors = chart.colors ?? [theme.palette.primary.main]
+
+ const renderTrending = () => (
+
+
+ {percent > 0 && "+"}
+ {Num.formatPercent(percent)}
+
+
+
+ last 7 days
+
+
+ )
+
+ const chartOptions: ChartOptions = {
+ chart: { sparkline: { enabled: true } },
+ colors: chartColors,
+ stroke: { width: 2, curve: "smooth" },
+ tooltip: {
+ y: { formatter: (value) => Num.formatNumber(value) || "-", title: { formatter: () => "" } },
+ x: { formatter: (_, opts) => chart.categories[opts.dataPointIndex] },
+ },
+ xaxis: {
+ categories: chart.categories,
+ labels: { show: false },
+ axisBorder: { show: false },
+ axisTicks: { show: false },
+ },
+ yaxis: {
+ labels: { show: false },
+ },
+ grid: {
+ show: false,
+ },
+ ...chart.options,
+ }
+
+ return (
+ ({
+ p: 3,
+ display: "flex",
+ zIndex: "unset",
+ overflow: "unset",
+ alignItems: "center",
+ borderRadius: 2,
+ }),
+ ]}
+ variant="outlined"
+ {...other}
+ >
+
+ {title}
+
+ {Num.formatNumber(total)}
+
+ {renderTrending()}
+
+
+
+
+ )
+}
diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts
index 784b027..ce6c544 100644
--- a/src/shared/config/index.ts
+++ b/src/shared/config/index.ts
@@ -1,2 +1,2 @@
-export * from "./muiTheme"
+export * from "./theme/muiTheme"
export * from "./i18n"
diff --git a/src/shared/config/muiTheme.ts b/src/shared/config/muiTheme.ts
deleted file mode 100644
index fbb08af..0000000
--- a/src/shared/config/muiTheme.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { createTheme } from "@mui/material"
-
-/**
- *
- * @description Global MUI theme configuration
- */
-
-export const muiTheme = createTheme({
- cssVariables: {
- colorSchemeSelector: "data-toolpad-color-scheme",
- },
- colorSchemes: {
- light: {
- palette: {
- background: {
- default: "#F9F9FE",
- paper: "#EEEEF9",
- },
- },
- },
- dark: {
- palette: {
- background: {
- default: "#0B192C",
- paper: "#021526",
- },
- },
- },
- },
- breakpoints: {
- values: {
- xs: 0,
- sm: 600,
- md: 900,
- lg: 1200,
- xl: 1536,
- },
- },
-
- shape: {
- borderRadius: 8,
- },
-
- components: {
- MuiButton: {
- defaultProps: {
- disableElevation: true,
- },
- styleOverrides: {
- root: ({ theme }) => ({
- textTransform: "none",
- }),
- },
- },
- },
-})
diff --git a/src/shared/config/theme/core/palette.ts b/src/shared/config/theme/core/palette.ts
new file mode 100644
index 0000000..ad7762b
--- /dev/null
+++ b/src/shared/config/theme/core/palette.ts
@@ -0,0 +1,99 @@
+export const grey = {
+ 50: "#fafafa",
+ 100: "#f5f5f5",
+ 200: "#eeeeee",
+ 300: "#e0e0e0",
+ 400: "#bdbdbd",
+ 500: "#9e9e9e",
+ 600: "#757575",
+ 700: "#616161",
+ 800: "#424242",
+ 900: "#212121",
+ "500Channel": "#9e9e9e",
+}
+
+export const common = {
+ black: "#000000",
+ white: "#ffffff",
+ blackChannel: "#000000",
+ whiteChannel: "#ffffff",
+}
+
+export const palette = {
+ light: {
+ palette: {
+ primary: {
+ main: "#1976d2",
+ light: "#42a5f5",
+ dark: "#1565c0",
+ contrastText: "#fff",
+ },
+ secondary: {
+ main: "#dc004e",
+ light: "#ff5983",
+ dark: "#9a0036",
+ contrastText: "#fff",
+ },
+ info: {
+ main: "#0288d1",
+ light: "#03a9f4",
+ dark: "#01579b",
+ },
+ success: {
+ main: "#2e7d32",
+ light: "#4caf50",
+ dark: "#1b5e20",
+ },
+ warning: {
+ main: "#ed6c02",
+ light: "#ff9800",
+ dark: "#e65100",
+ },
+ error: {
+ main: "#d32f2f",
+ light: "#ef5350",
+ dark: "#c62828",
+ },
+ grey,
+ common,
+ },
+ },
+ dark: {
+ palette: {
+ primary: {
+ main: "#90caf9",
+ light: "#e3f2fd",
+ dark: "#42a5f5",
+ contrastText: "#000",
+ },
+ secondary: {
+ main: "#f48fb1",
+ light: "#fce4ec",
+ dark: "#ad1457",
+ contrastText: "#000",
+ },
+ info: {
+ main: "#29b6f6",
+ light: "#4fc3f7",
+ dark: "#0288d1",
+ },
+ success: {
+ main: "#66bb6a",
+ light: "#81c784",
+ dark: "#388e3c",
+ },
+ warning: {
+ main: "#ffa726",
+ light: "#ffb74d",
+ dark: "#f57c00",
+ },
+ error: {
+ main: "#ef5350",
+ light: "#e57373",
+ dark: "#c62828",
+ },
+ grey,
+ common,
+ },
+ },
+}
diff --git a/src/shared/config/theme/muiTheme.ts b/src/shared/config/theme/muiTheme.ts
new file mode 100644
index 0000000..51b374b
--- /dev/null
+++ b/src/shared/config/theme/muiTheme.ts
@@ -0,0 +1,21 @@
+import { createTheme } from "@mui/material"
+import { palette } from "./core/palette"
+
+export const muiTheme = createTheme({
+ cssVariables: {
+ colorSchemeSelector: "data-toolpad-color-scheme",
+ },
+ typography: {
+ fontFamily: "Mona Sans, sans-serif",
+ },
+ colorSchemes: palette,
+ breakpoints: {
+ values: {
+ xs: 0,
+ sm: 600,
+ md: 600,
+ lg: 1200,
+ xl: 1536,
+ },
+ },
+})
diff --git a/src/shared/config/theme/types.ts b/src/shared/config/theme/types.ts
new file mode 100644
index 0000000..0cde4c0
--- /dev/null
+++ b/src/shared/config/theme/types.ts
@@ -0,0 +1,4 @@
+export type SchemesRecord = {
+ light: T
+ dark: T
+}
diff --git a/src/shared/ui/chart/Chart.tsx b/src/shared/ui/chart/Chart.tsx
new file mode 100644
index 0000000..4dc8d7f
--- /dev/null
+++ b/src/shared/ui/chart/Chart.tsx
@@ -0,0 +1,35 @@
+import { styled } from "@mui/material/styles"
+import { ChartProps } from "./Types"
+import ReactApexChart from "react-apexcharts"
+
+// ----------------------------------------------------------------------
+
+export const Chart = ({
+ type,
+ series,
+ options = {},
+ slotProps,
+ className,
+ sx,
+ ...other
+}: ChartProps) => {
+ return (
+
+
+
+ )
+}
+
+// ----------------------------------------------------------------------
+
+const ChartRoot = styled("div")(({ theme }) => ({
+ width: "100%",
+ height: "100%",
+ flexShrink: 0,
+ position: "relative",
+ borderRadius: Number(theme.shape.borderRadius) * 1.5,
+ "& .apexcharts-canvas": {
+ width: "100% !important",
+ height: "100% !important",
+ },
+}))
diff --git a/src/shared/ui/chart/Types.ts b/src/shared/ui/chart/Types.ts
new file mode 100644
index 0000000..9f0a37e
--- /dev/null
+++ b/src/shared/ui/chart/Types.ts
@@ -0,0 +1,15 @@
+import type { Theme, SxProps } from "@mui/material/styles"
+import type { Props as ApexProps } from "react-apexcharts"
+
+// ----------------------------------------------------------------------
+
+export type ChartOptions = ApexProps["options"]
+
+export type ChartProps = React.ComponentProps<"div"> &
+ Pick & {
+ options?: ChartOptions
+ sx?: SxProps
+ slotProps?: {
+ loading?: SxProps
+ }
+ }
diff --git a/src/shared/ui/chart/index.ts b/src/shared/ui/chart/index.ts
new file mode 100644
index 0000000..b784e63
--- /dev/null
+++ b/src/shared/ui/chart/index.ts
@@ -0,0 +1,2 @@
+export { Chart } from "./Chart"
+export type { ChartOptions, ChartProps } from "./Types"
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 71a397f..9330e2d 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -1 +1,2 @@
export * from "./my-table"
+export * from "./chart"
diff --git a/src/shared/ui/my-table/index.tsx b/src/shared/ui/my-table/index.tsx
index 68e0dbf..b5dcde0 100644
--- a/src/shared/ui/my-table/index.tsx
+++ b/src/shared/ui/my-table/index.tsx
@@ -1,4 +1,4 @@
-import { DataGrid, type GridRowsProp, type GridColDef, type DataGridProps } from "@mui/x-data-grid"
+import { DataGrid, type DataGridProps } from "@mui/x-data-grid"
import { useTranslation } from "react-i18next"
import { Toolbar } from "@/shared/ui/my-table/Toolbar.tsx"
import { NoResultsOverlay } from "@/shared/ui/my-table/NoResultsOverlay.tsx"
diff --git a/src/shared/utils/data.ts b/src/shared/utils/data.ts
new file mode 100644
index 0000000..e33bc9a
--- /dev/null
+++ b/src/shared/utils/data.ts
@@ -0,0 +1,14 @@
+/**
+ * Utility functions for data manipulation.
+ */
+export const Data = {
+ /**
+ * Checks if the provided value null or undefined and return empty string
+ * @param value
+ * @returns The original value if it's not null/undefined; otherwise, an empty string.
+ */
+ safeEmpty(value: T): string | Exclude {
+ if (value === null || value === undefined) return ""
+ return value as Exclude
+ },
+}
diff --git a/src/shared/utils/num.ts b/src/shared/utils/num.ts
new file mode 100644
index 0000000..4a1bdf2
--- /dev/null
+++ b/src/shared/utils/num.ts
@@ -0,0 +1,98 @@
+export type InputNumberValue = string | number | null | undefined
+
+function validateNumber(inputValue: InputNumberValue): number | null {
+ if (inputValue == null || Number.isNaN(inputValue)) return null
+ return Number(inputValue)
+}
+
+/**
+ * Utility functions for formatting numbers
+ */
+export const Num = {
+ /**
+ * Make number with spaces
+ * @example
+ * ```ts
+ * Num.formatNumber(12345.678) // "12 345,68"
+ * ```
+ * @param {InputNumberValue} inputValue - The number or string to format.
+ * @returns {string} A formatted number string, or an empty string if invalid.
+ */
+ formatNumber(inputValue: InputNumberValue): string | null {
+ const number = validateNumber(inputValue)
+ if (number === null) return null
+
+ const fm = new Intl.NumberFormat("uz-UZ", {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(number)
+
+ return fm
+ },
+
+ /**
+ * Make number with percent
+ * * @example
+ * ```ts
+ * Num.formatPercent(25) // "25%"
+ * ```
+ * @param {InputNumberValue} inputValue - The number or string to format.
+ * @returns {string} A formatted number string, or an empty string if invalid.
+ */
+ formatPercent(inputValue: InputNumberValue): string | null {
+ const number = validateNumber(inputValue)
+ if (number === null) return null
+
+ const fm = new Intl.NumberFormat("uz-UZ", {
+ style: "percent",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 1,
+ }).format(number / 100)
+
+ return fm
+ },
+
+ /**
+ * Make shorten number
+ * @example
+ * ```ts
+ * Num.formatShorten(1500000) // "1,5 mln"
+ * ```
+ * @param {InputNumberValue} inputValue - The number or string to format.
+ * @returns {string} A formatted number string, or an empty string if invalid.
+ */
+ formatShorten(inputValue: InputNumberValue): string | null {
+ const number = validateNumber(inputValue)
+ if (number === null) return null
+
+ const fm = new Intl.NumberFormat("uz-UZ", {
+ notation: "compact",
+ maximumFractionDigits: 2,
+ }).format(number)
+
+ return fm.replace(/[A-Z]/g, (match) => match.toLowerCase())
+ },
+
+ /**
+ * Convert bytes human readable data
+ * @example
+ * ```ts
+ * Num.data(2048) // "2 Kb"
+ * ```
+ * @param {InputNumberValue} inputValue - The number or string to format.
+ * @returns {string} A formatted number string, or an empty string if invalid.
+ */
+ formatData(inputValue: InputNumberValue): string {
+ const number = validateNumber(inputValue)
+ if (number === null || number === 0) return "0 bytes"
+
+ const units = ["bytes", "Kb", "Mb", "Gb", "Tb", "Pb", "Eb", "Zb", "Yb"]
+ const decimal = 2
+ const baseValue = 1024
+
+ const index = Math.floor(Math.log(number) / Math.log(baseValue))
+ const fm = `${parseFloat((number / baseValue ** index).toFixed(decimal))} ${units[index]}`
+
+ return fm
+ },
+}
diff --git a/src/shared/utils/time.ts b/src/shared/utils/time.ts
new file mode 100644
index 0000000..f8fabb3
--- /dev/null
+++ b/src/shared/utils/time.ts
@@ -0,0 +1,269 @@
+import type { Dayjs, OpUnitType } from "dayjs"
+
+import dayjs from "dayjs"
+import duration from "dayjs/plugin/duration"
+import relativeTime from "dayjs/plugin/relativeTime"
+
+// ----------------------------------------------------------------------
+
+/**
+ * @Docs
+ * https://day.js.org/docs/en/display/format
+ */
+
+/**
+ * Default timezones
+ * https://day.js.org/docs/en/timezone/set-default-timezone#docsNav
+ *
+ */
+
+/**
+ * UTC
+ * https://day.js.org/docs/en/plugin/utc
+ * @install
+ * import utc from 'dayjs/plugin/utc';
+ * dayjs.extend(utc);
+ * @usage
+ * dayjs().utc().format()
+ *
+ */
+
+export type DurationProps = {
+ years?: number
+ months?: number
+ days?: number
+ hours?: number
+ minutes?: number
+ seconds?: number
+ milliseconds?: number
+}
+
+dayjs.extend(duration)
+dayjs.extend(relativeTime)
+
+// ----------------------------------------------------------------------
+
+export type DatePickerFormat = Dayjs | Date | string | number | null | undefined
+
+export const formatTimePatterns = {
+ dateTime: "DD MMM YYYY h:mm a", // 17 Apr 2022 12:00 am
+ date: "DD MMM YYYY", // 17 Apr 2022
+ time: "h:mm a", // 12:00 am
+ split: {
+ dateTime: "DD/MM/YYYY h:mm a", // 17/04/2022 12:00 am
+ date: "DD/MM/YYYY", // 17/04/2022
+ },
+ paramCase: {
+ dateTime: "DD-MM-YYYY h:mm a", // 17-04-2022 12:00 am
+ date: "DD-MM-YYYY", // 17-04-2022
+ },
+}
+
+const isValidDate = (date: DatePickerFormat) =>
+ date !== null && date !== undefined && dayjs(date).isValid()
+
+// ----------------------------------------------------------------------
+
+/**
+ * Utility functions for formatting times
+ */
+export const Time = {
+ /**
+ * Returns today's date
+ * @returns {string}
+ */
+ today(template?: string): string {
+ return dayjs(new Date()).startOf("day").format(template)
+ },
+
+ /**
+ * Formats a date into human-readable date-time string
+ * @output 17 Apr 2022 12:00 am
+ */
+ dateTime(date: DatePickerFormat, template?: string): string | null {
+ if (!isValidDate(date)) return null
+
+ return dayjs(date).format(template ?? formatTimePatterns.dateTime)
+ },
+
+ /**
+ * Formats a date into a short date string
+ * @output 17 Apr 2022
+ */
+ date(date: DatePickerFormat, template?: string): string | null {
+ if (!isValidDate(date)) return null
+
+ return dayjs(date).format(template ?? formatTimePatterns.date)
+ },
+
+ /**
+ * Formats a date into a time string
+ * @output 12:00 am
+ */
+ time(date: DatePickerFormat, template?: string): string | null {
+ if (!isValidDate(date)) return null
+
+ return dayjs(date).format(template ?? formatTimePatterns.time)
+ },
+
+ /**
+ * Returns a UNIX timestamp (milliseconds) from a date
+ * @output 1713250100
+ */
+ timestamp(date: DatePickerFormat): number | null {
+ if (!isValidDate(date)) return null
+
+ return dayjs(date).valueOf()
+ },
+
+ /**
+ * Returns a human-readable relative time string
+ * @output a few seconds, 2 years
+ */
+ toNow(date: DatePickerFormat): string | null {
+ if (!isValidDate(date)) return null
+
+ return dayjs(date).toNow(true)
+ },
+
+ /**
+ * Checks whether a date is between two other dates (inclusive)
+ * @output boolean
+ */
+ isBetween(
+ inputDate: DatePickerFormat,
+ startDate: DatePickerFormat,
+ endDate: DatePickerFormat
+ ): boolean {
+ if (!isValidDate(inputDate) || !isValidDate(startDate) || !isValidDate(endDate)) return false
+
+ const formattedInputDate = this.timestamp(inputDate)
+ const formattedStartDate = this.timestamp(startDate)
+ const formattedEndDate = this.timestamp(endDate)
+
+ if (!formattedInputDate || !formattedStartDate || !formattedEndDate) return false
+
+ return formattedInputDate >= formattedStartDate && formattedInputDate <= formattedEndDate
+ },
+
+ /**
+ * Checks if one date is after another
+ * @output boolean
+ */
+ isAfter(startDate: DatePickerFormat, endDate: DatePickerFormat): boolean {
+ if (!isValidDate(startDate) || !isValidDate(endDate)) return false
+
+ return dayjs(startDate).isAfter(endDate)
+ },
+
+ /**
+ * Checks if two dates are the same based on the given unit (default: "year")
+ * @output boolean
+ */
+ isSame(
+ startDate: DatePickerFormat,
+ endDate: DatePickerFormat,
+ unitToCompare?: OpUnitType
+ ): boolean {
+ if (!isValidDate(startDate) || !isValidDate(endDate)) return false
+
+ return dayjs(startDate).isSame(endDate, unitToCompare ?? "year")
+ },
+
+ /**
+ * Creates a human-readable range label between two dates
+ * @output
+ * Same day: 26 Apr 2024
+ * Same month: 25 - 26 Apr 2024
+ * Same month: 25 - 26 Apr 2024
+ * Same year: 25 Apr - 26 May 2024
+ */
+ dateRangeShortLabel(
+ startDate: DatePickerFormat,
+ endDate: DatePickerFormat,
+ initial?: boolean
+ ): string | null {
+ if (!isValidDate(startDate) || !isValidDate(endDate) || this.isAfter(startDate, endDate))
+ return null
+
+ let label = `${this.date(startDate)} - ${this.date(endDate)}`
+
+ if (initial) {
+ return label
+ }
+
+ const isSameYear = this.isSame(startDate, endDate, "year")
+ const isSameMonth = this.isSame(startDate, endDate, "month")
+ const isSameDay = this.isSame(startDate, endDate, "day")
+
+ if (isSameYear && !isSameMonth) {
+ label = `${this.date(startDate, "DD MMM")} - ${this.date(endDate)}`
+ } else if (isSameYear && isSameMonth && !isSameDay) {
+ label = `${this.date(startDate, "DD")} - ${this.date(endDate)}`
+ } else if (isSameYear && isSameMonth && isSameDay) {
+ label = `${this.date(endDate)}`
+ }
+
+ return label
+ },
+
+ /**
+ * Adds duration to the current date and returns ISO string.
+ * @output 2024-05-28T05:55:31+00:00
+ */
+ add({
+ years = 0,
+ months = 0,
+ days = 0,
+ hours = 0,
+ minutes = 0,
+ seconds = 0,
+ milliseconds = 0,
+ }: DurationProps) {
+ const result = dayjs()
+ .add(
+ dayjs.duration({
+ years,
+ months,
+ days,
+ hours,
+ minutes,
+ seconds,
+ milliseconds,
+ })
+ )
+ .format()
+
+ return result
+ },
+
+ /**
+ * Subtracts duration from the current date and returns ISO string
+ * @output 2024-05-28T05:55:31+00:00
+ */
+ sub({
+ years = 0,
+ months = 0,
+ days = 0,
+ hours = 0,
+ minutes = 0,
+ seconds = 0,
+ milliseconds = 0,
+ }: DurationProps) {
+ const result = dayjs()
+ .subtract(
+ dayjs.duration({
+ years,
+ months,
+ days,
+ hours,
+ minutes,
+ seconds,
+ milliseconds,
+ })
+ )
+ .format()
+
+ return result
+ },
+}