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 + }, +}