feat: profit table and division of charts in statistics

This commit is contained in:
2024-11-17 01:23:04 +04:00
parent 608a063369
commit 3b8c75d3d3
29 changed files with 571 additions and 305 deletions

View File

@@ -1,90 +0,0 @@
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

@@ -1,52 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -18,7 +18,7 @@ const data = [
},
];
export const StatisticsTabSegmentControl: FC<Props> = props => {
export const StatisticsTabSegmentedControl: FC<Props> = props => {
return (
<SegmentedControl
data={data}

View File

@@ -1,31 +0,0 @@
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

@@ -1,72 +1,12 @@
import { useForm } from "@mantine/form";
import { Chart } from "./Chart.tsx";
import { Filters } from "../../components/Filters/Filters.tsx";
import { ProfitChart } from "./components/ProfitChart/ProfitChart.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";
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 className={styles["page-container"]}>
<ProfitChart />
<ProfitTable />
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { DatePickerInput, DatePickerInputProps } from "@mantine/dates";
import { Stack, 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 { ProfitTableSegmentedControl } from "../ProfitTableSegmentedControl/ProfitTableSegmentedControl.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;
groupTableByProps?: {
value: string,
onChange: (value: string) => void,
};
}
export const Filters = (props: FiltersProps) => {
const {
datePickerProps,
clientSelectProps,
onClientClear,
baseMarketplaceSelectProps,
onBaseMarketplaceClear,
dealStatusSelectProps,
onDealStatusClear,
groupTableByProps,
} = props;
return (
<Stack mb={"lg"}>
{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={"Выберите маркетплейс"}
/>
}
{groupTableByProps &&
<>
<Text>Группировать:</Text>
<ProfitTableSegmentedControl
{...groupTableByProps}
orientation={"vertical"}
size={"md"}
w={"100%"}
/>
</>
}
</Stack>
);
};

View File

@@ -0,0 +1,55 @@
import { AreaChart } from "@mantine/charts";
import "@mantine/charts/styles.css";
import PageBlock from "../../../../../../components/PageBlock/PageBlock.tsx";
import { useProfitChart } from "./hooks/useProfitChart.tsx";
import { Skeleton, Stack } from "@mantine/core";
import { ProfitChartFiltersModal } from "../../modals/ProfitChartFiltersModal.tsx";
export const ProfitChart = () => {
const {
profitData,
form,
isLoading,
} = useProfitChart();
const getChartsSeries = [
[
{ name: "profit", label: "Прибыль", color: "indigo.6" },
{ name: "revenue", label: "Выручка", color: "teal.6" },
],
[
{ name: "dealsCount", label: "Количество сделок", color: "indigo.6" },
],
];
const units = ["₽", "шт"];
return (
<PageBlock style={{ flex: 3, minWidth: "500px", padding: "25px" }}>
<ProfitChartFiltersModal
form={form}
/>
<Skeleton visible={isLoading}>
<Stack gap={"xl"}>
{getChartsSeries.map((series, idx) => {
return (
<AreaChart
my={"sm"}
w={"98%"}
h={"39vh"}
data={profitData}
dataKey="date"
unit={units[idx]}
tooltipAnimationDuration={200}
valueFormatter={(value) => new Intl.NumberFormat("ru-RU").format(value)}
series={series}
fillOpacity={0.5}
/>
);
})}
</Stack>
</Skeleton>
</PageBlock>
);
};

View File

@@ -0,0 +1,59 @@
import { ChartFormFilters } from "../../../../../types/ChartFormFilters.ts";
import { useForm } from "@mantine/form";
import { dateToString, getDefaultDates } from "../../../../../utils/dates.ts";
import { useEffect, useState } from "react";
import { ProfitChartDataItem, StatisticsService } from "../../../../../../../client";
export const useProfitChart = () => {
const form = useForm<ChartFormFilters>({
mode: "controlled",
initialValues: {
dateRange: getDefaultDates(),
client: null,
marketplace: null,
dealStatus: null,
},
});
const [profitData, setProfitData] = useState<ProfitChartDataItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
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 = () => {
setIsLoading(true);
StatisticsService.getProfitChartData({
requestBody: getFilters(),
})
.then(res => {
setProfitData(res.data);
})
.catch(err => console.log(err))
.finally(() => setIsLoading(false));
};
useEffect(() => {
if (form.values.dateRange.length < 2 || form.values.dateRange[1] === null) {
return;
}
fetchProfitData();
}, [form.values]);
return {
profitData,
form,
isLoading,
};
};

View File

@@ -0,0 +1,19 @@
import PageBlock from "../../../../../../components/PageBlock/PageBlock.tsx";
import { MantineReactTable } from "mantine-react-table";
import { useProfitTable } from "./hooks/useProfitTable.tsx";
import { Skeleton } from "@mantine/core";
import { ProfitTableFiltersModal } from "../../modals/ProfitTableFiltersModal.tsx";
export const ProfitTable = () => {
const { table, form, isLoading } = useProfitTable();
return (
<PageBlock style={{ flex: 1, padding: "25px" }}>
<ProfitTableFiltersModal form={form} />
<Skeleton visible={isLoading}>
<MantineReactTable table={table} />
</Skeleton>
</PageBlock>
);
};

View File

@@ -0,0 +1,53 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { ProfitTableDataItem } from "../../../../../../../client";
import { GroupStatisticsTable } from "../../ProfitTableSegmentedControl/ProfitTableSegmentedControl.tsx";
import { DealStatus, DealStatusDictionary } from "../../../../../../../shared/enums/DealStatus.ts";
type Props = {
groupTableBy: GroupStatisticsTable;
}
export const useProfitTableColumns = ({ groupTableBy }: Props) => {
const groupedValueHeader = {
[GroupStatisticsTable.BY_DATES]: "Дата",
[GroupStatisticsTable.BY_CLIENTS]: "Клиент",
[GroupStatisticsTable.BY_STATUSES]: "Статус",
[GroupStatisticsTable.BY_MARKETPLACES]: "Маркетплейс",
[GroupStatisticsTable.BY_WAREHOUSES]: "Склад отгрузки",
};
return useMemo<MRT_ColumnDef<ProfitTableDataItem>[]>(
() => [
{
accessorKey: "groupedValue",
header: groupedValueHeader[groupTableBy],
enableSorting: groupTableBy === GroupStatisticsTable.BY_DATES,
Cell: ({ row }) => {
if (groupTableBy === GroupStatisticsTable.BY_STATUSES) {
const statusIndex = row.original.groupedValue as DealStatus;
return DealStatusDictionary[statusIndex];
}
return row.original.groupedValue;
},
},
{
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") + "₽",
},
],
[groupTableBy],
);
};

View File

@@ -0,0 +1,36 @@
import { GroupStatisticsTable } from "../../ProfitTableSegmentedControl/ProfitTableSegmentedControl.tsx";
import { useProfitTableColumns } from "./columns.tsx";
import { useMantineReactTable } from "mantine-react-table";
import { ProfitTableDataItem } from "../../../../../../../client";
import { MRT_Localization_RU } from "mantine-react-table/locales/ru/index.cjs";
type Props = {
groupTableBy: GroupStatisticsTable;
profitData: ProfitTableDataItem[];
}
export const useProfitMantineTable = ({ groupTableBy, profitData }: Props) => {
const columns = useProfitTableColumns({
groupTableBy,
});
const defaultSorting = [{ id: "groupedValue", desc: true }];
const table = useMantineReactTable({
enablePagination: false,
data: profitData,
columns,
enableTopToolbar: false,
enableBottomToolbar: false,
enableSorting: true,
initialState: {
sorting: defaultSorting,
},
localization: MRT_Localization_RU,
enableRowVirtualization: true,
mantineTableContainerProps: { style: { maxHeight: "86vh" } },
});
return { table };
};

View File

@@ -0,0 +1,63 @@
import { useForm } from "@mantine/form";
import { TableFormFilters } from "../../../../../types/TableFormFilters.ts";
import { dateToString, getDefaultDates } from "../../../../../utils/dates.ts";
import { GroupStatisticsTable } from "../../ProfitTableSegmentedControl/ProfitTableSegmentedControl.tsx";
import { useEffect, useState } from "react";
import { ProfitTableDataItem, StatisticsService } from "../../../../../../../client";
import { useProfitMantineTable } from "./useProfitMantineTable.tsx";
export const useProfitTable = () => {
const form = useForm<TableFormFilters>({
mode: "controlled",
initialValues: {
dateRange: getDefaultDates(),
groupTableBy: GroupStatisticsTable.BY_DATES,
},
});
const [isLoading, setIsLoading] = useState(false);
const [profitData, setProfitData] = useState<ProfitTableDataItem[]>([]);
const { table } = useProfitMantineTable({
groupTableBy: form.values.groupTableBy,
profitData,
});
const getFilters = () => {
const dateRange = form.values.dateRange;
return {
dateRange: [
dateToString(dateRange[0]),
dateToString(dateRange[1]),
],
groupTableBy: form.values.groupTableBy,
};
};
const fetchProfitData = () => {
setIsLoading(true);
StatisticsService.getProfitTableData({
requestBody: getFilters(),
})
.then(res => {
setProfitData(res.data);
})
.catch(err => console.log(err))
.finally(() => setIsLoading(false));
};
useEffect(() => {
if (form.values.dateRange.length < 2 || form.values.dateRange[1] === null) {
return;
}
fetchProfitData();
}, [form.values]);
return {
table,
form,
isLoading,
};
};

View File

@@ -33,7 +33,7 @@ const data = [
},
];
export const StatisticsTableSegmentControl: FC<Props> = props => {
export const ProfitTableSegmentedControl: FC<Props> = props => {
return (
<SegmentedControl
data={data}

View File

@@ -0,0 +1,45 @@
import { Button, Group, Modal } from "@mantine/core";
import { IconFilter } from "@tabler/icons-react";
import { Filters } from "../components/Filters/Filters.tsx";
import { UseFormReturnType } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import { ChartFormFilters } from "../../../types/ChartFormFilters.ts";
type Props = {
form: UseFormReturnType<ChartFormFilters>;
}
export const ProfitChartFiltersModal = ({ form }: Props) => {
const [opened, { open, close }] = useDisclosure();
return (
<>
<Button
variant={"default"}
onClick={open}
mb={"lg"}
>
<Group gap={"xs"}>
<IconFilter />
Фильтры графиков
</Group>
</Button>
<Modal
opened={opened}
onClose={close}
title={"Фильтры графиков"}
>
<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)}
/>
</Modal>
</>
);
};

View File

@@ -0,0 +1,43 @@
import { Button, Group, Modal } from "@mantine/core";
import { IconFilter } from "@tabler/icons-react";
import { Filters } from "../components/Filters/Filters.tsx";
import { TableFormFilters } from "../../../types/TableFormFilters.ts";
import { UseFormReturnType } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
type Props = {
form: UseFormReturnType<TableFormFilters>;
}
export const ProfitTableFiltersModal = ({ form }: Props) => {
const [opened, { open, close }] = useDisclosure();
return (
<>
<Button
variant={"default"}
onClick={open}
mb={"lg"}
>
<Group gap={"xs"}>
<IconFilter />
Фильтры таблицы
</Group>
</Button>
<Modal
opened={opened}
onClose={close}
title={"Фильтры таблицы"}
>
<Filters
datePickerProps={form.getInputProps("dateRange")}
groupTableByProps={{
value: form.values.groupTableBy.toString(),
onChange: (value: string) => form.setFieldValue("groupTableBy", parseInt(value)),
}}
/>
</Modal>
</>
);
};

View File

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

View File

@@ -1,4 +1,4 @@
import { GroupStatisticsTable } from "../components/StatisticsTableSegmentControl/StatisticsTableSegmentControl.tsx";
import { GroupStatisticsTable } from "../tabs/ProfitTab/components/ProfitTableSegmentedControl/ProfitTableSegmentedControl.tsx";
export interface TableFormFilters {
dateRange: [Date | null, Date | null];

View File

@@ -5,6 +5,14 @@
gap: rem(10);
}
.page-container {
display: flex;
flex-direction: row;
gap: rem(10);
height: 96vh;
flex-wrap: wrap;
}
.top-panel {
padding: rem(5);
gap: rem(10);

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import {
StatisticsTab,
StatisticsTabSegmentControl,
} from "../components/StatisticsTabSegmentControl/StatisticsTabSegmentControl.tsx";
StatisticsTabSegmentedControl,
} from "../components/StatisticsTabSegmentedControl/StatisticsTabSegmentedControl.tsx";
import styles from "./StatisticsPage.module.css";
import PageBlock from "../../../components/PageBlock/PageBlock.tsx";
import { ProfitTab } from "../tabs/ProfitTab/ProfitTab.tsx";
@@ -25,13 +25,13 @@ export const StatisticsPage = () => {
return (
<div className={styles["container"]}>
<PageBlock>
<StatisticsTabSegmentControl
size={"md"}
value={serviceType.toString()}
onChange={event => setServiceType(parseInt(event))}
/>
</PageBlock>
{/*<PageBlock>*/}
{/* <StatisticsTabSegmentedControl*/}
{/* size={"md"}*/}
{/* value={serviceType.toString()}*/}
{/* onChange={event => setServiceType(parseInt(event))}*/}
{/* />*/}
{/*</PageBlock>*/}
{getBody()}
</div>
);