feat: profit chart in statistics

This commit is contained in:
2024-11-15 15:19:14 +04:00
parent 90530f0530
commit 608a063369
22 changed files with 515 additions and 2 deletions

View File

@@ -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": {

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -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<ProfitDataItem>;
};

View File

@@ -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;
};

View File

@@ -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<GetProfitDataResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/statistics/get-profit-data',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -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() {

View File

@@ -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<ObjectSelectProps<ClientSchema>, "data">;
onClientClear?: () => void;
baseMarketplaceSelectProps?: Omit<
ObjectSelectProps<BaseMarketplaceSchema>,
"data" | "getValueFn" | "getLabelFn"
>;
onBaseMarketplaceClear?: () => void;
dealStatusSelectProps?: Omit<ObjectSelectProps<DealStatusType>, "data">;
onDealStatusClear?: () => void;
}
export const Filters = (props: FiltersProps) => {
const {
datePickerProps,
clientSelectProps,
onClientClear,
baseMarketplaceSelectProps,
onBaseMarketplaceClear,
dealStatusSelectProps,
onDealStatusClear,
} = props;
return (
<PageBlock>
<Group>
{datePickerProps &&
<DatePickerInput
{...datePickerProps}
type="range"
placeholder="Выберите даты"
maxDate={new Date()}
/>
}
{dealStatusSelectProps &&
<DealStatusSelect
{...dealStatusSelectProps}
onClear={onDealStatusClear}
clearable
placeholder={"Выберите статус"}
/>
}
{clientSelectProps &&
<ClientSelectNew
{...clientSelectProps}
onClear={onClientClear}
clearable
searchable
placeholder={"Выберите клиента"}
/>
}
{baseMarketplaceSelectProps &&
<BaseMarketplaceSelect
{...baseMarketplaceSelectProps}
onClear={onBaseMarketplaceClear}
clearable
placeholder={"Выберите маркетплейс"}
/>
}
{
<>
<Text>
Группировать:
</Text>
<StatisticsTableSegmentControl
value={groupTableBy.toString()}
onChange={event => setGroupTableBy(parseInt(event))}
/>
</>
}
</Group>
</PageBlock>
);
};

View File

@@ -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<TableFormFilters>({
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 (
<div className={styles["container"]}>
<PageBlock>
<Filters />
</PageBlock>
<PageBlock>
<MantineReactTable table={table} />
</PageBlock>
</div>
);
};

View File

@@ -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<MRT_ColumnDef<ProfitTableRow>[]>(
() => [
{
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") + "₽",
},
],
[]
);
};

View File

@@ -0,0 +1,28 @@
import { SegmentedControl, SegmentedControlProps } from "@mantine/core";
import { FC } from "react";
export enum StatisticsTab {
PROFIT,
SALARIES,
}
type Props = Omit<SegmentedControlProps, "data">;
const data = [
{
label: "Выручка по сделкам",
value: StatisticsTab.PROFIT.toString(),
},
{
label: "Зарплаты",
value: StatisticsTab.SALARIES.toString(),
},
];
export const StatisticsTabSegmentControl: FC<Props> = props => {
return (
<SegmentedControl
data={data}
{...props}
/>
);
};

View File

@@ -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<SegmentedControlProps, "data">;
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> = props => {
return (
<SegmentedControl
data={data}
{...props}
/>
);
};

View File

@@ -0,0 +1 @@
export { StatisticsPage } from "./ui/StatisticsPage.tsx";

View File

@@ -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 (
<PageBlock>
<AreaChart
mx={"lg"}
my={"sm"}
w={"98%"}
h={"50vh"}
data={data}
dataKey="date"
unit="₽"
tooltipAnimationDuration={200}
valueFormatter={(value) => new Intl.NumberFormat("ru-RU").format(value)}
series={[
{ name: "profit", label: "Прибыль", color: "indigo.6" },
{ name: "revenue", label: "Выручка", color: "teal.6" },
]}
fillOpacity={0.5}
/>
</PageBlock>
);
};

View File

@@ -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<ChartFormFilters>({
mode: "controlled",
initialValues: {
dateRange: getDefaultDates(),
client: null,
marketplace: null,
dealStatus: null,
},
});
const [profitData, setProfitData] = useState<ProfitDataItem[]>([]);
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 (
<div className={styles["container"]}>
<Filters
datePickerProps={form.getInputProps("dateRange")}
clientSelectProps={form.getInputProps("client")}
onClientClear={() => form.setFieldValue("client", null)}
baseMarketplaceSelectProps={form.getInputProps("marketplace")}
onBaseMarketplaceClear={() => form.setFieldValue("marketplace", null)}
dealStatusSelectProps={form.getInputProps("dealStatus")}
onDealStatusClear={() => form.setFieldValue("dealStatus", null)}
/>
<Chart data={profitData} />
<ProfitTable data={profitData} />
</div>
);
};

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
export type ProfitTableRow = {
date: string;
dealsCount: number;
profit: number;
revenue: number;
}

View File

@@ -0,0 +1,6 @@
import { GroupStatisticsTable } from "../components/StatisticsTableSegmentControl/StatisticsTableSegmentControl.tsx";
export interface TableFormFilters {
dateRange: [Date | null, Date | null];
groupTableBy: GroupStatisticsTable;
}

View File

@@ -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;
}

View File

@@ -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 (
<ProfitTab />
);
case StatisticsTab.SALARIES:
return (
<>Статистика по ЗП</>
);
}
};
return (
<div className={styles["container"]}>
<PageBlock>
<StatisticsTabSegmentControl
size={"md"}
value={serviceType.toString()}
onChange={event => setServiceType(parseInt(event))}
/>
</PageBlock>
{getBody()}
</div>
);
};

View File

@@ -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}`;
};

View File

@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { StatisticsPage } from "../pages/StatisticsPage";
export const Route = createLazyFileRoute("/statistics")({
component: StatisticsPage,
});