diff --git a/package.json b/package.json index beef21f..6e3b53b 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,9 @@ "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@hello-pangea/dnd": "^17.0.0", + "@mantine/charts": "^7.13.5", "@mantine/core": "^7.11.2", - "@mantine/dates": "^7.11.2", + "@mantine/dates": "^7.13.5", "@mantine/dropzone": "^7.11.2", "@mantine/form": "^7.11.2", "@mantine/hooks": "^7.11.2", @@ -51,6 +52,7 @@ "react-redux": "^9.1.2", "react-to-print": "^2.15.1", "reactflow": "^11.11.4", + "recharts": "^2.13.3", "zod": "^3.23.8" }, "devDependencies": { diff --git a/src/client/index.ts b/src/client/index.ts index 16fcf4f..24b729c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -166,6 +166,8 @@ export type { GetProductBarcodePdfRequest } from './models/GetProductBarcodePdfR export type { GetProductBarcodePdfResponse } from './models/GetProductBarcodePdfResponse'; export type { GetProductBarcodeRequest } from './models/GetProductBarcodeRequest'; export type { GetProductBarcodeResponse } from './models/GetProductBarcodeResponse'; +export type { GetProfitDataRequest } from './models/GetProfitDataRequest'; +export type { GetProfitDataResponse } from './models/GetProfitDataResponse'; export type { GetServiceKitSchema } from './models/GetServiceKitSchema'; export type { GetTimeTrackingRecordsRequest } from './models/GetTimeTrackingRecordsRequest'; export type { GetTimeTrackingRecordsResponse } from './models/GetTimeTrackingRecordsResponse'; @@ -199,6 +201,7 @@ export type { ProductUpdateRequest } from './models/ProductUpdateRequest'; export type { ProductUpdateResponse } from './models/ProductUpdateResponse'; export type { ProductUploadBarcodeImageResponse } from './models/ProductUploadBarcodeImageResponse'; export type { ProductUploadImageResponse } from './models/ProductUploadImageResponse'; +export type { ProfitDataItem } from './models/ProfitDataItem'; export type { RoleSchema } from './models/RoleSchema'; export type { ServiceCategoryPriceSchema } from './models/ServiceCategoryPriceSchema'; export type { ServiceCategoryReorderRequest } from './models/ServiceCategoryReorderRequest'; @@ -260,6 +263,7 @@ export { ProductService } from './services/ProductService'; export { RoleService } from './services/RoleService'; export { ServiceService } from './services/ServiceService'; export { ShippingWarehouseService } from './services/ShippingWarehouseService'; +export { StatisticsService } from './services/StatisticsService'; export { TaskService } from './services/TaskService'; export { TimeTrackingService } from './services/TimeTrackingService'; export { UserService } from './services/UserService'; diff --git a/src/client/models/GetProfitDataRequest.ts b/src/client/models/GetProfitDataRequest.ts new file mode 100644 index 0000000..47fb791 --- /dev/null +++ b/src/client/models/GetProfitDataRequest.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type GetProfitDataRequest = { + dateRange: any[]; + clientId: number; + baseMarketplaceKey: string; + dealStatusId: number; +}; + diff --git a/src/client/models/GetProfitDataResponse.ts b/src/client/models/GetProfitDataResponse.ts new file mode 100644 index 0000000..2f7eb91 --- /dev/null +++ b/src/client/models/GetProfitDataResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ProfitDataItem } from './ProfitDataItem'; +export type GetProfitDataResponse = { + data: Array; +}; + diff --git a/src/client/models/ProfitDataItem.ts b/src/client/models/ProfitDataItem.ts new file mode 100644 index 0000000..d2fdee8 --- /dev/null +++ b/src/client/models/ProfitDataItem.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ProfitDataItem = { + date: string; + revenue: number; + profit: number; + dealsCount: number; +}; + diff --git a/src/client/services/StatisticsService.ts b/src/client/services/StatisticsService.ts new file mode 100644 index 0000000..b729913 --- /dev/null +++ b/src/client/services/StatisticsService.ts @@ -0,0 +1,31 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { GetProfitDataRequest } from '../models/GetProfitDataRequest'; +import type { GetProfitDataResponse } from '../models/GetProfitDataResponse'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class StatisticsService { + /** + * Get Profit Data + * @returns GetProfitDataResponse Successful Response + * @throws ApiError + */ + public static getProfitData({ + requestBody, + }: { + requestBody: GetProfitDataRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/statistics/get-profit-data', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } +} diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index dc4ecbf..fac1aaf 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -4,7 +4,7 @@ import { IconBarcode, IconBox, IconBuildingWarehouse, - IconCash, + IconCash, IconChartDots, IconDashboard, IconFileBarcode, IconHome2, @@ -93,6 +93,11 @@ const mockdata = [ label: "Маркетплейсы", href: "/marketplaces", }, + { + icon: IconChartDots, + label: "Статистика", + href: "/statistics", + } ]; export function Navbar() { diff --git a/src/pages/StatisticsPage/components/Filters/Filters.tsx b/src/pages/StatisticsPage/components/Filters/Filters.tsx new file mode 100644 index 0000000..9db79f9 --- /dev/null +++ b/src/pages/StatisticsPage/components/Filters/Filters.tsx @@ -0,0 +1,90 @@ +import PageBlock from "../../../../components/PageBlock/PageBlock.tsx"; +import { DatePickerInput, DatePickerInputProps } from "@mantine/dates"; +import { Group, Text } from "@mantine/core"; +import ClientSelectNew from "../../../../components/Selects/ClientSelectNew/ClientSelectNew.tsx"; +import { BaseMarketplaceSchema, ClientSchema } from "../../../../client"; +import { ObjectSelectProps } from "../../../../components/ObjectSelect/ObjectSelect.tsx"; +import BaseMarketplaceSelect from "../../../../components/Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx"; +import DealStatusSelect from "../../../DealsPage/components/DealStatusSelect/DealStatusSelect.tsx"; +import { DealStatusType } from "../../../../shared/enums/DealStatus.ts"; +import { StatisticsTableSegmentControl } from "../StatisticsTableSegmentControl/StatisticsTableSegmentControl.tsx"; + + +type FiltersProps = { + datePickerProps?: DatePickerInputProps<"range">; + + clientSelectProps?: Omit, "data">; + onClientClear?: () => void; + + baseMarketplaceSelectProps?: Omit< + ObjectSelectProps, + "data" | "getValueFn" | "getLabelFn" + >; + onBaseMarketplaceClear?: () => void; + + dealStatusSelectProps?: Omit, "data">; + onDealStatusClear?: () => void; +} + +export const Filters = (props: FiltersProps) => { + const { + datePickerProps, + clientSelectProps, + onClientClear, + baseMarketplaceSelectProps, + onBaseMarketplaceClear, + dealStatusSelectProps, + onDealStatusClear, + } = props; + + return ( + + + {datePickerProps && + + } + {dealStatusSelectProps && + + } + {clientSelectProps && + + } + {baseMarketplaceSelectProps && + + } + { + <> + + Группировать: + + setGroupTableBy(parseInt(event))} + /> + + } + + + ); +}; \ No newline at end of file diff --git a/src/pages/StatisticsPage/components/ProfitTable/ProfitTable.tsx b/src/pages/StatisticsPage/components/ProfitTable/ProfitTable.tsx new file mode 100644 index 0000000..4be9973 --- /dev/null +++ b/src/pages/StatisticsPage/components/ProfitTable/ProfitTable.tsx @@ -0,0 +1,52 @@ +import { useProfitTableColumns } from "./columns.tsx"; +import { ProfitDataItem } from "../../../../client"; +import PageBlock from "../../../../components/PageBlock/PageBlock.tsx"; +import { GroupStatisticsTable } from "../StatisticsTableSegmentControl/StatisticsTableSegmentControl.tsx"; +import styles from "../../ui/StatisticsPage.module.css"; +import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import { MRT_Localization_RU } from "mantine-react-table/locales/ru/index.cjs"; +import { Filters } from "../Filters/Filters.tsx"; +import { useForm } from "@mantine/form"; +import { getDefaultDates } from "../../utils/dates.ts"; +import { TableFormFilters } from "../../types/TableFormFilters.ts"; + +type Props = { + data: ProfitDataItem[]; +} + +export const ProfitTable = ({ data }: Props) => { + const form = useForm({ + mode: "controlled", + initialValues: { + dateRange: getDefaultDates(), + groupTableBy: GroupStatisticsTable.BY_DATES, + }, + }); + + const columns = useProfitTableColumns(); + const defaultSorting = [{ id: "date", desc: true }]; + + const table = useMantineReactTable({ + localization: MRT_Localization_RU, + enablePagination: true, + data, + columns, + enableTopToolbar: false, + enableBottomToolbar: true, + enableSorting: true, + initialState: { + sorting: defaultSorting, + }, + }); + + return ( +
+ + + + + + +
+ ); +}; \ No newline at end of file diff --git a/src/pages/StatisticsPage/components/ProfitTable/columns.tsx b/src/pages/StatisticsPage/components/ProfitTable/columns.tsx new file mode 100644 index 0000000..4708d87 --- /dev/null +++ b/src/pages/StatisticsPage/components/ProfitTable/columns.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { ProfitTableRow } from "../../types/ProfitTableRow.ts"; + +export const useProfitTableColumns = () => { + return useMemo[]>( + () => [ + { + accessorKey: "date", + header: "Дата", + }, + { + accessorKey: "dealsCount", + header: "Кол-во сделок" + }, + { + accessorKey: "profit", + header: "Прибыль", + Cell: ({ row }) => + row.original.profit.toLocaleString("ru-RU") + "₽", + }, + { + accessorKey: "revenue", + header: "Выручка", + Cell: ({ row }) => + row.original.revenue.toLocaleString("ru-RU") + "₽", + }, + ], + [] + ); +}; diff --git a/src/pages/StatisticsPage/components/StatisticsTabSegmentControl/StatisticsTabSegmentControl.tsx b/src/pages/StatisticsPage/components/StatisticsTabSegmentControl/StatisticsTabSegmentControl.tsx new file mode 100644 index 0000000..14100a7 --- /dev/null +++ b/src/pages/StatisticsPage/components/StatisticsTabSegmentControl/StatisticsTabSegmentControl.tsx @@ -0,0 +1,28 @@ +import { SegmentedControl, SegmentedControlProps } from "@mantine/core"; +import { FC } from "react"; + +export enum StatisticsTab { + PROFIT, + SALARIES, +} + +type Props = Omit; +const data = [ + { + label: "Выручка по сделкам", + value: StatisticsTab.PROFIT.toString(), + }, + { + label: "Зарплаты", + value: StatisticsTab.SALARIES.toString(), + }, +]; + +export const StatisticsTabSegmentControl: FC = props => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/pages/StatisticsPage/components/StatisticsTableSegmentControl/StatisticsTableSegmentControl.tsx b/src/pages/StatisticsPage/components/StatisticsTableSegmentControl/StatisticsTableSegmentControl.tsx new file mode 100644 index 0000000..b901735 --- /dev/null +++ b/src/pages/StatisticsPage/components/StatisticsTableSegmentControl/StatisticsTableSegmentControl.tsx @@ -0,0 +1,43 @@ +import { SegmentedControl, SegmentedControlProps } from "@mantine/core"; +import { FC } from "react"; + +export enum GroupStatisticsTable { + BY_DATES, + BY_CLIENTS, + BY_STATUSES, + BY_WAREHOUSES, + BY_MARKETPLACES, +} + +type Props = Omit; +const data = [ + { + label: "По датам", + value: GroupStatisticsTable.BY_DATES.toString(), + }, + { + label: "По клиентам", + value: GroupStatisticsTable.BY_CLIENTS.toString(), + }, + { + label: "По статусам", + value: GroupStatisticsTable.BY_STATUSES.toString(), + }, + { + label: "По складам отгрузки", + value: GroupStatisticsTable.BY_WAREHOUSES.toString(), + }, + { + label: "По маркетплейсам", + value: GroupStatisticsTable.BY_MARKETPLACES.toString(), + }, +]; + +export const StatisticsTableSegmentControl: FC = props => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/pages/StatisticsPage/index.ts b/src/pages/StatisticsPage/index.ts new file mode 100644 index 0000000..568eecd --- /dev/null +++ b/src/pages/StatisticsPage/index.ts @@ -0,0 +1 @@ +export { StatisticsPage } from "./ui/StatisticsPage.tsx"; diff --git a/src/pages/StatisticsPage/tabs/ProfitTab/Chart.tsx b/src/pages/StatisticsPage/tabs/ProfitTab/Chart.tsx new file mode 100644 index 0000000..2019612 --- /dev/null +++ b/src/pages/StatisticsPage/tabs/ProfitTab/Chart.tsx @@ -0,0 +1,31 @@ +import { AreaChart } from "@mantine/charts"; +import "@mantine/charts/styles.css"; +import PageBlock from "../../../../components/PageBlock/PageBlock.tsx"; +import { ProfitDataItem } from "../../../../client"; + +type Props = { + data: ProfitDataItem[]; +} + +export const Chart = ({ data }: Props) => { + return ( + + new Intl.NumberFormat("ru-RU").format(value)} + series={[ + { name: "profit", label: "Прибыль", color: "indigo.6" }, + { name: "revenue", label: "Выручка", color: "teal.6" }, + ]} + fillOpacity={0.5} + /> + + ); +}; \ No newline at end of file diff --git a/src/pages/StatisticsPage/tabs/ProfitTab/ProfitTab.tsx b/src/pages/StatisticsPage/tabs/ProfitTab/ProfitTab.tsx new file mode 100644 index 0000000..a9e7309 --- /dev/null +++ b/src/pages/StatisticsPage/tabs/ProfitTab/ProfitTab.tsx @@ -0,0 +1,72 @@ +import { useForm } from "@mantine/form"; +import { Chart } from "./Chart.tsx"; +import { Filters } from "../../components/Filters/Filters.tsx"; +import styles from "../../ui/StatisticsPage.module.css"; +import { ChartFormFilters } from "../../types/ChartFormFilters.ts"; +import { useEffect, useState } from "react"; +import { ProfitDataItem, StatisticsService } from "../../../../client"; +import { dateToString, getDefaultDates } from "../../utils/dates.ts"; +import { ProfitTable } from "../../components/ProfitTable/ProfitTable.tsx"; + +export const ProfitTab = () => { + const form = useForm({ + mode: "controlled", + initialValues: { + dateRange: getDefaultDates(), + client: null, + marketplace: null, + dealStatus: null, + }, + }); + const [profitData, setProfitData] = useState([]); + + const getFilters = () => { + const dateRange = form.values.dateRange; + + return { + dateRange: [ + dateToString(dateRange[0]), + dateToString(dateRange[1]), + ], + clientId: form.values.client?.id ?? -1, + baseMarketplaceKey: form.values.marketplace?.key ?? "all", + dealStatusId: form.values.dealStatus?.id ?? -1, + }; + }; + + const fetchProfitData = () => { + StatisticsService.getProfitData({ + requestBody: getFilters(), + }) + .then(res => { + setProfitData(res.data); + }) + .catch(err => console.log(err)); + }; + + useEffect(() => { + if (form.values.dateRange.length < 2 || form.values.dateRange[1] === null) { + return; + } + + console.log(form.values); + + fetchProfitData(); + }, [form.values]); + + return ( +
+ form.setFieldValue("client", null)} + baseMarketplaceSelectProps={form.getInputProps("marketplace")} + onBaseMarketplaceClear={() => form.setFieldValue("marketplace", null)} + dealStatusSelectProps={form.getInputProps("dealStatus")} + onDealStatusClear={() => form.setFieldValue("dealStatus", null)} + /> + + +
+ ); +}; \ No newline at end of file diff --git a/src/pages/StatisticsPage/types/ChartFormFilters.ts b/src/pages/StatisticsPage/types/ChartFormFilters.ts new file mode 100644 index 0000000..3815461 --- /dev/null +++ b/src/pages/StatisticsPage/types/ChartFormFilters.ts @@ -0,0 +1,9 @@ +import { BaseMarketplaceSchema, ClientSchema } from "../../../client"; +import { DealStatusType } from "../../../shared/enums/DealStatus.ts"; + +export interface ChartFormFilters { + dateRange: [Date | null, Date | null]; + client: ClientSchema | null; + marketplace: BaseMarketplaceSchema | null; + dealStatus: DealStatusType | null; +} \ No newline at end of file diff --git a/src/pages/StatisticsPage/types/ProfitTableRow.ts b/src/pages/StatisticsPage/types/ProfitTableRow.ts new file mode 100644 index 0000000..f775a01 --- /dev/null +++ b/src/pages/StatisticsPage/types/ProfitTableRow.ts @@ -0,0 +1,6 @@ +export type ProfitTableRow = { + date: string; + dealsCount: number; + profit: number; + revenue: number; +} \ No newline at end of file diff --git a/src/pages/StatisticsPage/types/TableFormFilters.ts b/src/pages/StatisticsPage/types/TableFormFilters.ts new file mode 100644 index 0000000..eb9b802 --- /dev/null +++ b/src/pages/StatisticsPage/types/TableFormFilters.ts @@ -0,0 +1,6 @@ +import { GroupStatisticsTable } from "../components/StatisticsTableSegmentControl/StatisticsTableSegmentControl.tsx"; + +export interface TableFormFilters { + dateRange: [Date | null, Date | null]; + groupTableBy: GroupStatisticsTable; +} \ No newline at end of file diff --git a/src/pages/StatisticsPage/ui/StatisticsPage.module.css b/src/pages/StatisticsPage/ui/StatisticsPage.module.css new file mode 100644 index 0000000..61ac571 --- /dev/null +++ b/src/pages/StatisticsPage/ui/StatisticsPage.module.css @@ -0,0 +1,12 @@ +.container { + display: flex; + flex-direction: column; + flex: 1; + gap: rem(10); +} + +.top-panel { + padding: rem(5); + gap: rem(10); + display: flex; +} \ No newline at end of file diff --git a/src/pages/StatisticsPage/ui/StatisticsPage.tsx b/src/pages/StatisticsPage/ui/StatisticsPage.tsx new file mode 100644 index 0000000..3436bf2 --- /dev/null +++ b/src/pages/StatisticsPage/ui/StatisticsPage.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { + StatisticsTab, + StatisticsTabSegmentControl, +} from "../components/StatisticsTabSegmentControl/StatisticsTabSegmentControl.tsx"; +import styles from "./StatisticsPage.module.css"; +import PageBlock from "../../../components/PageBlock/PageBlock.tsx"; +import { ProfitTab } from "../tabs/ProfitTab/ProfitTab.tsx"; + +export const StatisticsPage = () => { + const [serviceType, setServiceType] = useState(StatisticsTab.PROFIT); + + const getBody = () => { + switch (serviceType) { + case StatisticsTab.PROFIT: + return ( + + ); + case StatisticsTab.SALARIES: + return ( + <>Статистика по ЗП + ); + } + }; + + return ( +
+ + setServiceType(parseInt(event))} + /> + + {getBody()} +
+ ); +}; \ No newline at end of file diff --git a/src/pages/StatisticsPage/utils/dates.ts b/src/pages/StatisticsPage/utils/dates.ts new file mode 100644 index 0000000..f25830d --- /dev/null +++ b/src/pages/StatisticsPage/utils/dates.ts @@ -0,0 +1,15 @@ +export const getDefaultDates = (): [Date, Date] => { + const dateTo = new Date(); + const dateFrom = new Date(); + dateFrom.setDate(dateTo.getDate() - 28); + return [dateFrom, dateTo]; +}; + +export const dateToString = (date: Date | null) => { + if (date === null) return null; + const month = date.getMonth() + 1; + const day = date.getDate(); + const monthStr = month < 10 ? `0${month}` : month; + const dayStr = day < 10 ? `0${day}` : day; + return `${date.getFullYear()}-${monthStr}-${dayStr}`; +}; diff --git a/src/routes/statistics.lazy.tsx b/src/routes/statistics.lazy.tsx new file mode 100644 index 0000000..f5fef96 --- /dev/null +++ b/src/routes/statistics.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { StatisticsPage } from "../pages/StatisticsPage"; + +export const Route = createLazyFileRoute("/statistics")({ + component: StatisticsPage, +});