refactored analytics page, fixed ts

This commit is contained in:
aleksandr-raw 2024-04-01 22:09:26 +04:00
parent af70d8b2c6
commit 47d199ab0b
9 changed files with 153 additions and 112 deletions

@ -23,13 +23,18 @@ export type QuestionsResponse = {
questions: Record<string, Record<string, number>>; questions: Record<string, Record<string, number>>;
}; };
type TRequest = {
to: number;
from: number;
};
export const getDevices = async ( export const getDevices = async (
quizId: string, quizId: string,
to: number, to: number,
from: number, from: number,
): Promise<[DevicesResponse | null, string?]> => { ): Promise<[DevicesResponse | null, string?]> => {
try { try {
const devicesResponse = await makeRequest<unknown, DevicesResponse>({ const devicesResponse = await makeRequest<TRequest, DevicesResponse>({
method: "POST", method: "POST",
url: `${apiUrl}/${quizId}/devices`, url: `${apiUrl}/${quizId}/devices`,
withCredentials: true, withCredentials: true,
@ -50,7 +55,7 @@ export const getGeneral = async (
from: number, from: number,
): Promise<[GeneralResponse | null, string?]> => { ): Promise<[GeneralResponse | null, string?]> => {
try { try {
const generalResponse = await makeRequest<unknown, GeneralResponse>({ const generalResponse = await makeRequest<TRequest, GeneralResponse>({
method: "POST", method: "POST",
url: `${apiUrl}/${quizId}/general`, url: `${apiUrl}/${quizId}/general`,
withCredentials: true, withCredentials: true,
@ -71,7 +76,7 @@ export const getQuestions = async (
from: number, from: number,
): Promise<[QuestionsResponse | null, string?]> => { ): Promise<[QuestionsResponse | null, string?]> => {
try { try {
const questionsResponse = await makeRequest<unknown, QuestionsResponse>({ const questionsResponse = await makeRequest<TRequest, QuestionsResponse>({
method: "POST", method: "POST",
url: `${apiUrl}/${quizId}/questions`, url: `${apiUrl}/${quizId}/questions`,
withCredentials: true, withCredentials: true,

@ -1,15 +1,14 @@
import { useLayoutEffect, useState } from "react"; import * as React from "react";
import { ReactNode, useLayoutEffect, useState } from "react";
import { import {
Box, Box,
Button, Button,
IconButton, IconButton,
Paper,
Typography, Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers"; import { DatePicker } from "@mui/x-date-pickers";
import { LineChart } from "@mui/x-charts";
import moment from "moment"; import moment from "moment";
import { useQuizStore } from "@root/quizes/store"; import { useQuizStore } from "@root/quizes/store";
import { useAnalytics } from "@utils/hooks/useAnalytics"; import { useAnalytics } from "@utils/hooks/useAnalytics";
@ -18,7 +17,7 @@ import HeaderFull from "@ui_kit/Header/HeaderFull";
import SectionWrapper from "@ui_kit/SectionWrapper"; import SectionWrapper from "@ui_kit/SectionWrapper";
import { General } from "./General"; import { General } from "./General";
import { AnswersStatistics } from "./Answers"; import { AnswersStatistics } from "./Answers/AnswersStatistics";
import { Devices } from "./Devices"; import { Devices } from "./Devices";
import CalendarIcon from "@icons/CalendarIcon"; import CalendarIcon from "@icons/CalendarIcon";
@ -26,11 +25,10 @@ import { redirect } from "react-router-dom";
export default function Analytics() { export default function Analytics() {
const { editQuizId } = useQuizStore(); const { editQuizId } = useQuizStore();
const [isOpen, setOpen] = useState<boolean>(false);
const [isOpen, setOpen] = useState(false); const [isOpenEnd, setOpenEnd] = useState<boolean>(false);
const [isOpenEnd, setOpenEnd] = useState(false); const [to, setTo] = useState<moment.Moment | null>(null);
const [to, setTo] = useState(null); const [from, setFrom] = useState<moment.Moment | null>(null);
const [from, setFrom] = useState(null);
const { devices, general, questions } = useAnalytics({ const { devices, general, questions } = useAnalytics({
quizId: editQuizId?.toString(), quizId: editQuizId?.toString(),
@ -42,6 +40,7 @@ export default function Analytics() {
setTo(null); setTo(null);
setFrom(null); setFrom(null);
}; };
useLayoutEffect(() => { useLayoutEffect(() => {
if (editQuizId === undefined) redirect("/list"); if (editQuizId === undefined) redirect("/list");
}, [editQuizId]); }, [editQuizId]);
@ -52,12 +51,14 @@ export default function Analytics() {
const handleClose = () => { const handleClose = () => {
setOpen(false); setOpen(false);
}; };
const handleOpen = () => { const handleOpen = () => {
setOpen(true); setOpen(true);
}; };
const onAdornmentClick = () => { const onAdornmentClick = () => {
setOpen((old) => !old); setOpen((old) => !old);
if (isOpenEnd === true) { if (isOpenEnd) {
handleCloseEnd(); handleCloseEnd();
} }
}; };
@ -65,18 +66,17 @@ export default function Analytics() {
const handleCloseEnd = () => { const handleCloseEnd = () => {
setOpenEnd(false); setOpenEnd(false);
}; };
const handleOpenEnd = () => { const handleOpenEnd = () => {
setOpenEnd(true); setOpenEnd(true);
}; };
const onAdornmentClickEnd = () => { const onAdornmentClickEnd = () => {
setOpenEnd((old) => !old); setOpenEnd((old) => !old);
if (isOpen === true) { if (isOpen) {
handleClose(); handleClose();
} }
}; };
console.log("questions", questions);
console.log("general", general);
console.log("devices", devices);
const now = moment(); const now = moment();
return ( return (
@ -113,7 +113,6 @@ export default function Analytics() {
// defaultValue={now} // defaultValue={now}
sx={{ sx={{
width: isMobile ? "146px" : "169px", width: isMobile ? "146px" : "169px",
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
borderRadius: "10px", borderRadius: "10px",
fontSize: "16px", fontSize: "16px",
@ -123,13 +122,15 @@ export default function Analytics() {
}, },
}} }}
slotProps={{ slotProps={{
//@ts-ignore
//TODO: fix types in @mui/x-date-pickers
textField: { textField: {
InputProps: { InputProps: {
endAdornment: ( endAdornment: (
<IconButton onClick={onAdornmentClick}> <IconButton onClick={onAdornmentClick}>
<CalendarIcon /> <CalendarIcon />
</IconButton> </IconButton>
), ) as ReactNode,
}, },
}, },
}} }}
@ -146,8 +147,6 @@ export default function Analytics() {
color: "4D4D4D", color: "4D4D4D",
}} }}
> >
value={from}
onChange={(newValue) => setValue(setFrom)}
Дата окончания Дата окончания
</Typography> </Typography>
<DatePicker <DatePicker
@ -176,6 +175,8 @@ export default function Analytics() {
}, },
}, },
}} }}
value={from}
onChange={(newValue) => setFrom(newValue)}
/> />
</Box> </Box>
</Box> </Box>

@ -1,15 +1,16 @@
import { useState } from "react"; import { FC, useState } from "react";
import type { PaginationRenderItemParams } from "@mui/material";
import { import {
Box, Box,
Paper, ButtonBase,
Typography, Input,
LinearProgress, LinearProgress,
Pagination as MuiPagination, Pagination as MuiPagination,
PaginationItem, PaginationItem,
Input, Paper,
ButtonBase, Typography,
useTheme,
useMediaQuery, useMediaQuery,
useTheme,
} from "@mui/material"; } from "@mui/material";
import { ReactComponent as DoubleCheckIcon } from "@icons/Analytics/doubleCheck.svg"; import { ReactComponent as DoubleCheckIcon } from "@icons/Analytics/doubleCheck.svg";
@ -17,14 +18,16 @@ import { ReactComponent as NextIcon } from "@icons/Analytics/next.svg";
import { ReactComponent as LeftArrowIcon } from "@icons/Analytics/leftArrow.svg"; import { ReactComponent as LeftArrowIcon } from "@icons/Analytics/leftArrow.svg";
import { ReactComponent as RightArrowIcon } from "@icons/Analytics/rightArrow.svg"; import { ReactComponent as RightArrowIcon } from "@icons/Analytics/rightArrow.svg";
import type { PaginationRenderItemParams } from "@mui/material";
type AnswerProps = { type AnswerProps = {
title: string; title: string;
percent: number; percent: number;
highlight?: boolean; highlight?: boolean;
}; };
type AnswersProps = {
data: Record<string, Record<string, number>> | null;
};
const ANSWERS_MOCK: Record<string, number> = { const ANSWERS_MOCK: Record<string, number> = {
"Добавьте ответ": 67, "Добавьте ответ": 67,
"Вопрос пропущен": 7, "Вопрос пропущен": 7,
@ -186,16 +189,16 @@ const Pagination = () => {
); );
}; };
export const Answers = (props) => { export const Answers: FC<AnswersProps> = ({ data }) => {
const theme = useTheme(); const theme = useTheme();
console.log(props.data); if (!data) {
if (Object.keys(props.data).length === 0)
return ( return (
<Typography textAlign="center" m="10px 0"> <Typography textAlign="center" m="10px 0">
нет данных об ответах нет данных об ответах
</Typography> </Typography>
); );
}
return ( return (
<Box sx={{ flexGrow: 1 }}> <Box sx={{ flexGrow: 1 }}>
<Paper <Paper
@ -250,14 +253,24 @@ export const Answers = (props) => {
<NextIcon /> <NextIcon />
</ButtonBase> </ButtonBase>
</Box> </Box>
{Object.entries(props.data).map(([title, percent], index) => ( {/*{Object.entries(data).map(([title, percent], index) => (*/}
<Answer {/* <Answer*/}
key={title} {/* key={title}*/}
title={title} {/* title={title}*/}
percent={percent} {/* percent={percent}*/}
highlight={!index} {/* highlight={!index}*/}
/> {/* />*/}
))} {/*))}*/}
{Object.entries(data).map(([title, values], index) =>
Object.entries(values).map(([subTitle, percent]) => (
<Answer
key={subTitle}
title={subTitle}
percent={percent}
highlight={!index}
/>
)),
)}
</Paper> </Paper>
<Pagination /> <Pagination />
</Box> </Box>

@ -1,24 +1,20 @@
import { import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
Box,
ButtonBase,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { Answers } from "./Answers"; import { Answers } from "./Answers";
import { QuestionsResponse } from "@api/statistic";
import { FC } from "react";
import { Funnel } from "./Funnel"; import { Funnel } from "./Funnel";
import { Results } from "./Results"; import { Results } from "./Results";
import { ReactComponent as OpenIcon } from "@icons/Analytics/open.svg"; type AnswersStatisticsProps = {
data: QuestionsResponse | null;
};
export const AnswersStatistics = (props) => { export const AnswersStatistics: FC<AnswersStatisticsProps> = ({ data }) => {
const theme = useTheme(); const theme = useTheme();
const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1150)); const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1150));
const isMobile = useMediaQuery(theme.breakpoints.down(850)); const isMobile = useMediaQuery(theme.breakpoints.down(850));
console.log(props);
return ( return (
<Box sx={{ marginTop: "120px" }}> <Box sx={{ marginTop: "120px" }}>
<Typography <Typography
@ -59,10 +55,10 @@ export const AnswersStatistics = (props) => {
gap: "40px", gap: "40px",
}} }}
> >
<Answers data={props.data?.Questions || {}} /> <Answers data={data?.questions || null} />
<Funnel data={props.data?.Funnel || {}} /> <Funnel data={data?.funnel || null} />
</Box> </Box>
<Results data={props.data?.Results || {}} /> <Results data={data?.results || null} />
</Box> </Box>
); );
}; };

@ -1,19 +1,22 @@
import { useEffect, useState } from "react"; import { FC, useEffect } from "react";
import { import {
Box, Box,
LinearProgress,
Paper, Paper,
Typography, Typography,
LinearProgress,
useTheme,
useMediaQuery, useMediaQuery,
useTheme,
} from "@mui/material"; } from "@mui/material";
import { enqueueSnackbar } from "notistack";
type FunnelItemProps = { type FunnelItemProps = {
title: string; title: string;
percent: number; percent: number;
}; };
type FunnelProps = {
data: number[] | null;
};
const FUNNEL_MOCK: Record<string, number> = { const FUNNEL_MOCK: Record<string, number> = {
"Стартовая страница": 100, "Стартовая страница": 100,
"Воронка квиза": 0, "Воронка квиза": 0,
@ -100,11 +103,10 @@ const FunnelItem = ({ title, percent }: FunnelItemProps) => {
); );
}; };
export const Funnel = (props) => { export const Funnel: FC<FunnelProps> = ({ data }) => {
const theme = useTheme(); const theme = useTheme();
const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1150)); const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1150));
const isMobile = useMediaQuery(theme.breakpoints.down(850)); const isMobile = useMediaQuery(theme.breakpoints.down(850));
console.log(props);
useEffect(() => { useEffect(() => {
// const requestFunnel = async () => { // const requestFunnel = async () => {
// const [funnelResponse, funnelError] = await getGeneral("14761"); // const [funnelResponse, funnelError] = await getGeneral("14761");
@ -121,7 +123,7 @@ export const Funnel = (props) => {
// requestFunnel(); // requestFunnel();
}, []); }, []);
if (Object.keys(props.data).length === 0) if (!data)
return ( return (
<Typography textAlign="center" m="10px 0"> <Typography textAlign="center" m="10px 0">
нет данных о разделах нет данных о разделах
@ -141,7 +143,7 @@ export const Funnel = (props) => {
<FunnelItem <FunnelItem
key={title} key={title}
title={title} title={title}
percent={index > 0 ? props.data[index - 1] : percent} percent={index > 0 ? data[index - 1] : percent}
/> />
))} ))}
</Paper> </Paper>

@ -1,11 +1,11 @@
import { useState } from "react";
import { import {
Box, Box,
LinearProgress,
Paper, Paper,
Typography, Typography,
LinearProgress,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { FC } from "react";
type ResultProps = { type ResultProps = {
title: string; title: string;
@ -13,6 +13,10 @@ type ResultProps = {
highlight?: boolean; highlight?: boolean;
}; };
type ResultsProps = {
data: Record<string, number> | null;
};
const RESULTS_MOCK: Record<string, number> = { const RESULTS_MOCK: Record<string, number> = {
"Заголовок результата": 100, "Заголовок результата": 100,
"Результат пропущен": 7, "Результат пропущен": 7,
@ -69,10 +73,10 @@ const Result = ({ title, percent, highlight }: ResultProps) => {
); );
}; };
export const Results = (props) => { export const Results: FC<ResultsProps> = ({ data }) => {
const theme = useTheme(); const theme = useTheme();
if (Object.keys(props.data).length === 0) if (!data)
return ( return (
<Typography margin="20px 0 0 0" textAlign="center" m="10px 0"> <Typography margin="20px 0 0 0" textAlign="center" m="10px 0">
нет данных о результатах нет данных о результатах
@ -98,7 +102,7 @@ export const Results = (props) => {
marginTop: "30px", marginTop: "30px",
}} }}
> >
{Object.entries(props.data).map(([title, percent], index) => ( {Object.entries(data).map(([title, percent], index) => (
<Result <Result
key={title} key={title}
title={title} title={title}

@ -1,15 +1,16 @@
import { useEffect, useState } from "react"; import { FC } from "react";
import { Box, Paper, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Paper, Typography, useMediaQuery, useTheme } from "@mui/material";
import { PieChart } from "@mui/x-charts"; import { PieChart } from "@mui/x-charts";
import { enqueueSnackbar } from "notistack";
import { getDevices } from "@api/statistic";
import type { DevicesResponse } from "@api/statistic"; import type { DevicesResponse } from "@api/statistic";
type DeviceProps = { type DeviceProps = {
title: string; title: string;
devices: Record<string, number>; devices: Record<string, number> | null;
};
type DevicesProps = {
data: DevicesResponse | null;
}; };
const COLORS: Record<number, string> = { const COLORS: Record<number, string> = {
@ -27,9 +28,10 @@ const DEVICES_MOCK: DevicesResponse = {
const Device = ({ title, devices }: DeviceProps) => { const Device = ({ title, devices }: DeviceProps) => {
const theme = useTheme(); const theme = useTheme();
console.log("devices ", devices); if (!devices) {
if (devices === undefined || Object.keys(devices).length === 0)
return <Typography>{title} - нет данных</Typography>; return <Typography>{title} - нет данных</Typography>;
}
const data = Object.entries(devices).map(([id, value], index) => ({ const data = Object.entries(devices).map(([id, value], index) => ({
id, id,
value, value,
@ -98,8 +100,7 @@ const Device = ({ title, devices }: DeviceProps) => {
); );
}; };
export const Devices = ({ data = {} }) => { export const Devices: FC<DevicesProps> = ({ data }) => {
const [devices, setDevices] = useState<DevicesResponse>(data);
const theme = useTheme(); const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(700)); const isMobile = useMediaQuery(theme.breakpoints.down(700));
@ -150,9 +151,9 @@ export const Devices = ({ data = {} }) => {
marginTop: "30px", marginTop: "30px",
}} }}
> >
<Device title="Устройства" devices={devices.device} /> <Device title="Устройства" devices={data?.device || null} />
<Device title="Операционные системы" devices={devices.os} /> <Device title="Операционные системы" devices={data?.os || null} />
<Device title="Браузеры" devices={devices.browser} /> <Device title="Браузеры" devices={data?.browser || null} />
</Box> </Box>
</Box> </Box>
); );

@ -1,19 +1,20 @@
import { useEffect, useState } from "react";
import { Box, Paper, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Paper, Typography, useMediaQuery, useTheme } from "@mui/material";
import { LineChart } from "@mui/x-charts"; import { LineChart } from "@mui/x-charts";
import { enqueueSnackbar } from "notistack";
import { getGeneral } from "@api/statistic";
import type { GeneralResponse } from "@api/statistic"; import type { GeneralResponse } from "@api/statistic";
import { FC } from "react";
type GeneralProps = { type GeneralItemsProps = {
title: string; title: string;
general: Record<string, number>; general: Record<string, number>;
color: string; color: string;
numberType: "sum" | "percent"; numberType: "sum" | "percent";
}; };
type GeneralProps = {
data: GeneralResponse | null;
};
const COLORS: Record<number, string> = { const COLORS: Record<number, string> = {
0: "#61BB1A", 0: "#61BB1A",
1: "#7E2AEA", 1: "#7E2AEA",
@ -28,7 +29,12 @@ const GENERAL_MOCK: GeneralResponse = {
conversation: { 100: 50, 1000: 50, 10000: 50 }, conversation: { 100: 50, 1000: 50, 10000: 50 },
}; };
const GeneralItem = ({ title, general, color, numberType }: GeneralProps) => { const GeneralItem = ({
title,
general,
color,
numberType,
}: GeneralItemsProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(700)); const isMobile = useMediaQuery(theme.breakpoints.down(700));
@ -65,17 +71,18 @@ const GeneralItem = ({ title, general, color, numberType }: GeneralProps) => {
); );
}; };
export const General = (props: any) => { export const General: FC<GeneralProps> = ({ data }) => {
const theme = useTheme(); const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(700)); const isMobile = useMediaQuery(theme.breakpoints.down(700));
if (Object.keys(props.data).length === 0) if (!data) {
return ( return (
<Typography textAlign="center" m="10px 0"> <Typography textAlign="center" m="10px 0">
нет данных о ключевых метриках нет данных о ключевых метриках
</Typography> </Typography>
); );
}
return ( return (
<Box sx={{ marginTop: "45px" }}> <Box sx={{ marginTop: "45px" }}>
<Typography <Typography
@ -103,25 +110,25 @@ export const General = (props: any) => {
<GeneralItem <GeneralItem
title="Открыли квиз" title="Открыли квиз"
numberType="sum" numberType="sum"
general={props.data.open || { 0: 0 }} general={data.open || { 0: 0 }}
color={COLORS[0]} color={COLORS[0]}
/> />
<GeneralItem <GeneralItem
title="Получено заявок" title="Получено заявок"
numberType="sum" numberType="sum"
general={props.data.result || { 0: 0 }} general={data.result || { 0: 0 }}
color={COLORS[1]} color={COLORS[1]}
/> />
<GeneralItem <GeneralItem
title="Конверсия" title="Конверсия"
numberType="percent" numberType="percent"
general={props.data.conversation || { 0: 0 }} general={data.conversation || { 0: 0 }}
color={COLORS[2]} color={COLORS[2]}
/> />
<GeneralItem <GeneralItem
title="Среднее время прохождения квиза" title="Среднее время прохождения квиза"
numberType="percent" numberType="percent"
general={props.data.avtime || { 0: 0 }} general={data.avtime || { 0: 0 }}
color={COLORS[3]} color={COLORS[3]}
/> />
</Box> </Box>

@ -1,32 +1,44 @@
import { getGeneral, getDevices, getQuestions } from "@api/statistic";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import moment from "moment"; import moment from "moment";
import {
DevicesResponse,
GeneralResponse,
getDevices,
getGeneral,
getQuestions,
QuestionsResponse,
} from "@api/statistic";
interface Props { interface useAnalyticsProps {
quizId: string; quizId: string | undefined;
to: number; to: moment.Moment | null;
from: number; from: moment.Moment | null;
} }
export function useAnalytics({ quizId, to, from }: Props) { export function useAnalytics({ quizId, to, from }: useAnalyticsProps) {
const formatTo = to === null ? 0 : moment(to).unix(); const formatTo = to === null ? 0 : to.unix();
const formatFrom = from === null ? 0 : moment(from).unix(); const formatFrom = from === null ? 0 : from.unix();
console.log(to, from);
if (quizId === undefined) return {}; const [devices, setDevices] = useState<DevicesResponse | null>(null);
const [devices, setDevices] = useState({}); const [general, setGeneral] = useState<GeneralResponse | null>(null);
const [general, setGeneral] = useState({}); const [questions, setQuestions] = useState<QuestionsResponse | null>(null);
const [questions, setQuestions] = useState({});
useEffect(() => { useEffect(() => {
if (!quizId) return;
(async () => { (async () => {
const gottenGeneral = await getGeneral(quizId, formatTo, formatFrom); const [gottenGeneral] = await getGeneral(quizId, formatTo, formatFrom);
const gottenDevices = await getDevices(quizId, formatTo, formatFrom); const [gottenDevices] = await getDevices(quizId, formatTo, formatFrom);
const gottenQuestions = await getQuestions(quizId, formatTo, formatFrom); const [gottenQuestions] = await getQuestions(
setDevices(gottenGeneral[0]); quizId,
setGeneral(gottenDevices[0]); formatTo,
setQuestions(gottenQuestions[0]); formatFrom,
);
setGeneral(gottenGeneral);
setDevices(gottenDevices);
setQuestions(gottenQuestions);
})(); })();
}, [to, from]); }, [quizId, to, from]);
return { devices, general, questions }; return { devices, general, questions };
} }