Merge branch 'dev' into 'staging'

add promocode datafetching

See merge request frontend/admin!57
This commit is contained in:
Nastya 2024-03-11 22:04:47 +00:00
commit 9f908a92c3
44 changed files with 2151 additions and 891 deletions

@ -26,6 +26,7 @@
"axios": "^1.4.0",
"craco": "^0.0.3",
"cypress": "^12.17.2",
"date-fns": "^3.3.1",
"dayjs": "^1.11.5",
"formik": "^2.2.9",
"immer": "^10.0.2",
@ -41,6 +42,7 @@
"reconnecting-eventsource": "^1.6.2",
"start-server-and-test": "^2.0.0",
"styled-components": "^5.3.5",
"swr": "^2.2.5",
"typescript": "^4.8.2",
"use-debounce": "^9.0.4",
"web-vitals": "^2.1.4",

@ -0,0 +1,50 @@
import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
type RawDetail = {
Key: string;
Value: number | string | RawDetail[];
};
type History = {
id: string;
userId: string;
comment: string;
key: string;
rawDetails: RawDetail[];
isDeleted: boolean;
createdAt: string;
updatedAt: string;
};
type HistoryResponse = {
records: History[];
totalPages: number;
};
const baseUrl = process.env.REACT_APP_DOMAIN + "/customer";
const getUserHistory = async (
accountId: string,
page: number
): Promise<[HistoryResponse | null, string?]> => {
try {
const historyResponse = await makeRequest<never, HistoryResponse>({
method: "GET",
url:
baseUrl +
`/history?page=${page}&limit=${100}&accountID=${accountId}&type=payCart`,
});
return [historyResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка при получении пользователей. ${error}`];
}
};
export const historyApi = {
getUserHistory,
};

43
src/api/history/swr.ts Normal file

@ -0,0 +1,43 @@
import { useState } from "react";
import useSWRInfinite from "swr/infinite";
import { enqueueSnackbar } from "notistack";
import { historyApi } from "./requests";
export function useHistory(accountId: string) {
const [currentPage, setCurrentPage] = useState<number>(1);
const swrResponse = useSWRInfinite(
() => `history-${currentPage}`,
async () => {
const [historyResponse, error] = await historyApi.getUserHistory(
accountId,
currentPage
);
if (error) {
throw new Error(error);
}
if (!historyResponse) {
throw new Error("Empty history data");
}
if (currentPage < historyResponse.totalPages) {
setCurrentPage((page) => page + 1);
}
return historyResponse;
},
{
onError(err) {
console.log("Error fetching users", err);
enqueueSnackbar(err.message, { variant: "error" });
},
focusThrottleInterval: 60e3,
keepPreviousData: true,
}
);
return swrResponse;
}

@ -0,0 +1,63 @@
import { makeRequest } from "@frontend/kitui";
import { CreatePromocodeBody, GetPromocodeListBody, Promocode, PromocodeList } from "@root/model/promocodes";
import { parseAxiosError } from "@root/utils/parse-error";
const baseUrl = process.env.REACT_APP_DOMAIN + "/codeword/promocode";
const getPromocodeList = async (body: GetPromocodeListBody) => {
try {
const promocodeListResponse = await makeRequest<
GetPromocodeListBody,
PromocodeList
>({
url: baseUrl + "/getList",
method: "POST",
body,
useToken: false,
});
return promocodeListResponse;
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
throw new Error(`Ошибка при получении списка промокодов. ${error}`);
}
};
const createPromocode = async (body: CreatePromocodeBody) => {
try {
const createPromocodeResponse = await makeRequest<
CreatePromocodeBody,
Promocode
>({
url: baseUrl + "/create",
method: "POST",
body,
useToken: false,
});
return createPromocodeResponse;
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
throw new Error(`Ошибка создания промокода. ${error}`);
}
};
const deletePromocode = async (id: string): Promise<void> => {
try {
await makeRequest<never, never>({
url: `${baseUrl}/${id}`,
method: "DELETE",
useToken: false,
});
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
throw new Error(`Ошибка удаления промокода. ${error}`);
}
};
export const promocodeApi = {
getPromocodeList,
createPromocode,
deletePromocode,
};

81
src/api/promocode/swr.ts Normal file

@ -0,0 +1,81 @@
import { CreatePromocodeBody, PromocodeList } from "@root/model/promocodes";
import { enqueueSnackbar } from "notistack";
import { useCallback, useRef } from "react";
import useSwr, { mutate } from "swr";
import { promocodeApi } from "./requests";
export function usePromocodes(page: number, pageSize: number) {
const promocodesCountRef = useRef<number>(0);
const swrResponse = useSwr(
["promocodes", page, pageSize],
async (key) => {
const result = await promocodeApi.getPromocodeList({
limit: key[2],
filter: {
active: true,
},
page: key[1],
});
promocodesCountRef.current = result.count;
return result;
},
{
onError(err) {
console.log("Error fetching promocodes", err);
enqueueSnackbar(err.message, { variant: "error" });
},
focusThrottleInterval: 60e3,
keepPreviousData: true,
}
);
const createPromocode = useCallback(async function (body: CreatePromocodeBody) {
try {
await promocodeApi.createPromocode(body);
mutate(["promocodes", page, pageSize]);
} catch (error) {
console.log("Error creating promocode", error);
if (error instanceof Error) enqueueSnackbar(error.message, { variant: "error" });
}
}, [page, pageSize]);
const deletePromocode = useCallback(async function (id: string) {
try {
await mutate<PromocodeList | undefined, void>(
["promocodes", page, pageSize],
promocodeApi.deletePromocode(id),
{
optimisticData(currentData, displayedData) {
if (!displayedData) return;
return {
count: displayedData.count - 1,
items: displayedData.items.filter((item) => item.id !== id),
};
},
rollbackOnError: true,
populateCache(result, currentData) {
if (!currentData) return;
return {
count: currentData.count - 1,
items: currentData.items.filter((item) => item.id !== id),
};
},
}
);
} catch (error) {
console.log("Error deleting promocode", error);
if (error instanceof Error) enqueueSnackbar(error.message, { variant: "error" });
}
}, [page, pageSize]);
return {
...swrResponse,
createPromocode,
deletePromocode,
promocodesCount: promocodesCountRef.current,
};
}

@ -1,70 +0,0 @@
import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
import type { UserType } from "@root/api/roles";
type RegisteredUsersResponse = {
tatalPages: number;
users: UserType[];
};
const baseUrl = process.env.REACT_APP_DOMAIN + "/user";
export const getUserInfo = async (
id: string
): Promise<[UserType | null, string?]> => {
try {
const userInfoResponse = await makeRequest<never, UserType>({
url: `${baseUrl}/${id}`,
method: "GET",
useToken: true,
});
return [userInfoResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка получения информации о пользователе. ${error}`];
}
};
export const getRegisteredUsers = async (): Promise<
[RegisteredUsersResponse | null, string?]
> => {
try {
const registeredUsersResponse = await makeRequest<
never,
RegisteredUsersResponse
>({
method: "get",
url: baseUrl + "/",
});
return [registeredUsersResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка при получении пользователей. ${error}`];
}
};
export const getManagersList = async (): Promise<
[RegisteredUsersResponse | null, string?]
> => {
try {
const managersListResponse = await makeRequest<
never,
RegisteredUsersResponse
>({
method: "get",
url: baseUrl + "/",
});
return [managersListResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка при получении менеджеров. ${error}`];
}
};

89
src/api/user/requests.ts Normal file

@ -0,0 +1,89 @@
import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
import type { UserType } from "@root/api/roles";
export type UsersListResponse = {
totalPages: number;
users: UserType[];
};
const baseUrl = process.env.REACT_APP_DOMAIN + "/user";
const getUserInfo = async (id: string): Promise<[UserType | null, string?]> => {
try {
const userInfoResponse = await makeRequest<never, UserType>({
url: `${baseUrl}/${id}`,
method: "GET",
useToken: true,
});
return [userInfoResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка получения информации о пользователе. ${error}`];
}
};
const getUserList = async (
page = 1,
limit = 10
): Promise<[UsersListResponse | null, string?]> => {
try {
const userResponse = await makeRequest<never, UsersListResponse>({
method: "get",
url: baseUrl + `/?page=${page}&limit=${limit}`,
});
return [userResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка при получении пользователей. ${error}`];
}
};
const getManagerList = async (
page = 1,
limit = 10
): Promise<[UsersListResponse | null, string?]> => {
try {
const managerResponse = await makeRequest<never, UsersListResponse>({
method: "get",
url: baseUrl + `/?page=${page}&limit=${limit}`,
});
return [managerResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка при получении менеджеров. ${error}`];
}
};
const getAdminList = async (
page = 1,
limit = 10
): Promise<[UsersListResponse | null, string?]> => {
try {
const adminResponse = await makeRequest<never, UsersListResponse>({
method: "get",
url: baseUrl + `/?page=${page}&limit=${limit}`,
});
return [adminResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка при получении админов. ${error}`];
}
};
export const userApi = {
getUserInfo,
getUserList,
getManagerList,
getAdminList,
};

104
src/api/user/swr.ts Normal file

@ -0,0 +1,104 @@
import { useRef } from "react";
import useSwr from "swr";
import { enqueueSnackbar } from "notistack";
import { userApi } from "./requests";
export function useAdmins(page: number, pageSize: number) {
const adminPagesRef = useRef<number>(0);
const swrResponse = useSwr(
["admin", page, pageSize],
async ([_, page, pageSize]) => {
const [adminResponse, error] = await userApi.getManagerList(
page,
pageSize
);
if (error) {
throw new Error(error);
}
adminPagesRef.current = adminResponse?.totalPages || 1;
return adminResponse;
},
{
onError(err) {
console.log("Error fetching users", err);
enqueueSnackbar(err.message, { variant: "error" });
},
focusThrottleInterval: 60e3,
keepPreviousData: true,
}
);
return {
...swrResponse,
adminPages: adminPagesRef.current,
};
}
export function useManagers(page: number, pageSize: number) {
const managerPagesRef = useRef<number>(0);
const swrResponse = useSwr(
["manager", page, pageSize],
async ([_, page, pageSize]) => {
const [managerResponse, error] = await userApi.getManagerList(
page,
pageSize
);
if (error) {
throw new Error(error);
}
managerPagesRef.current = managerResponse?.totalPages || 1;
return managerResponse;
},
{
onError(err) {
console.log("Error fetching users", err);
enqueueSnackbar(err.message, { variant: "error" });
},
focusThrottleInterval: 60e3,
keepPreviousData: true,
}
);
return {
...swrResponse,
managerPages: managerPagesRef.current,
};
}
export function useUsers(page: number, pageSize: number) {
const userPagesRef = useRef<number>(0);
const swrResponse = useSwr(
["users", page, pageSize],
async ([_, page, pageSize]) => {
const [userResponse, error] = await userApi.getUserList(page, pageSize);
if (error) {
throw new Error(error);
}
userPagesRef.current = userResponse?.totalPages || 1;
return userResponse;
},
{
onError(err) {
console.log("Error fetching users", err);
enqueueSnackbar(err.message, { variant: "error" });
},
focusThrottleInterval: 60e3,
keepPreviousData: true,
}
);
return {
...swrResponse,
userPagesCount: userPagesRef.current,
};
}

@ -0,0 +1,74 @@
<svg
width="35"
height="33"
viewBox="0 0 35 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Close">
<g id="Rectangle 57" opacity="0.3" filter="url(#filter0_d_4080_12482)">
<rect x="6" y="4" width="24" height="24" rx="12" fill="#9A9AAF"></rect>
</g>
<g id="Group 331">
<path
id="Vector 586"
d="M22.8516 10.9517L12.9521 20.8512"
stroke="#FDFDFF"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
id="Vector 587"
d="M22.8516 20.8462L12.9521 10.9467"
stroke="#FDFDFF"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</g>
</g>
<defs>
<filter
id="filter0_d_4080_12482"
x="0"
y="0"
width="36"
height="36"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
></feColorMatrix>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_4080_12482"
></feMorphology>
<feOffset dy="2"></feOffset>
<feGaussianBlur stdDeviation="2.5"></feGaussianBlur>
<feComposite in2="hardAlpha" operator="out"></feComposite>
<feColorMatrix
type="matrix"
values="0 0 0 0 0.416562 0 0 0 0 0.452406 0 0 0 0 0.775 0 0 0 0.18 0"
></feColorMatrix>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_4080_12482"
></feBlend>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_4080_12482"
result="shape"
></feBlend>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -20,9 +20,10 @@ import Users from "@pages/dashboard/Content/Users";
import Entities from "@pages/dashboard/Content/Entities";
import Tariffs from "@pages/dashboard/Content/Tariffs";
import DiscountManagement from "@root/pages/dashboard/Content/DiscountManagement/DiscountManagement";
import PromocodeManagement from "@pages/dashboard/Content/PromocodeManagement";
import { PromocodeManagement } from "@root/pages/dashboard/Content/PromocodeManagement";
import { SettingRoles } from "@pages/Setting/SettingRoles";
import Support from "@pages/dashboard/Content/Support/Support";
import ChatImageNewWindow from "@pages/dashboard/Content/Support/ChatImageNewWindow";
import theme from "./theme";
import "./index.css";
@ -104,9 +105,14 @@ root.render(
}
/>
{componentsArray.map((element) => (
<Route key={element[0]} path={element[0]} element={element[1]} />
<Route
key={element[0]}
path={element[0]}
element={element[1]}
/>
))}
</Route>
<Route path={"/image/:srcImage"} element={<ChatImageNewWindow />} />
<Route path="*" element={<Error404 />} />
</Routes>

@ -11,7 +11,7 @@ import {
Alert,
Checkbox,
FormControlLabel,
useTheme
useTheme, useMediaQuery
} from "@mui/material";
import Input from "@kitUI/input";
import { useState } from "react";
@ -29,6 +29,7 @@ import { currencyFormatter } from "@root/utils/currencyFormatter";
export default function Cart() {
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down(400));
let discounts = useDiscountStore(state => state.discounts);
const cartData = useCartStore((store) => store.cartData);
const tariffs = useTariffStore(state => state.tariffs);
@ -84,6 +85,7 @@ export default function Cart() {
alignItems: "center",
justifyContent: "space-between",
gap: "20px",
flexDirection: mobile ? "column" : undefined
}}
>
<FormControlLabel
@ -143,7 +145,7 @@ export default function Cart() {
padding: "3px",
display: "flex",
flexDirection: "column",
ml: "auto",
ml: mobile ? 0 : "auto",
}}
>
<Input

41
src/model/promocodes.ts Normal file

@ -0,0 +1,41 @@
export type CreatePromocodeBody = {
codeword: string;
description: string;
greetings: string;
dueTo: number;
activationCount: number;
bonus: {
privilege: {
privilegeID: string;
amount: number;
};
discount: {
layer: number;
factor: number;
target: string;
threshold: number;
};
};
};
export type GetPromocodeListBody = {
page: number;
limit: number;
filter: {
active: boolean;
text?: string;
};
};
export type Promocode = CreatePromocodeBody & {
id: string;
outdated: boolean;
offLimit: boolean;
delete: boolean;
createdAt: string;
};
export type PromocodeList = {
count: number;
items: Promocode[];
};

@ -8,7 +8,7 @@ import {
Checkbox,
Typography,
FormControlLabel,
Button,
Button, useMediaQuery,
} from "@mui/material";
import Logo from "@pages/Logo";
import OutlinedInput from "@kitUI/outlinedInput";
@ -44,7 +44,7 @@ function validate(values: Values) {
const SigninForm = () => {
const theme = useTheme();
const navigate = useNavigate();
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const initialValues: Values = {
email: "",
password: "",
@ -99,6 +99,7 @@ const SigninForm = () => {
"> *": {
marginTop: "15px",
},
padding: isMobile ? "0 16px" : undefined
}}
>
<Logo />

@ -1,6 +1,6 @@
import { KeyboardEvent, useRef, useState } from "react";
import { enqueueSnackbar } from "notistack";
import { Box, IconButton, TextField, Tooltip, Typography } from "@mui/material";
import {Box, IconButton, TextField, Tooltip, Typography, useMediaQuery, useTheme} from "@mui/material";
import ModeEditOutlineOutlinedIcon from "@mui/icons-material/ModeEditOutlineOutlined";
import { PrivilegeWithAmount } from "@frontend/kitui";
import { putPrivilege } from "@root/api/privilegies";
@ -16,6 +16,8 @@ export const СardPrivilege = ({ privilege }: CardPrivilege) => {
const [inputOpen, setInputOpen] = useState<boolean>(false);
const [inputValue, setInputValue] = useState<string>("");
const priceRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down(600));
const translationType = {
count: "за единицу",
@ -24,6 +26,7 @@ export const СardPrivilege = ({ privilege }: CardPrivilege) => {
};
const putPrivileges = async () => {
const [_, putedPrivilegeError] = await putPrivilege({
name: privilege.name,
privilegeId: privilege.privilegeId,
@ -79,7 +82,7 @@ export const СardPrivilege = ({ privilege }: CardPrivilege) => {
}}
>
<Box sx={{ display: "flex", borderRight: "1px solid gray" }}>
<Box sx={{ width: "200px", py: "25px" }}>
<Box sx={{ width: mobile ? "120px" : "200px", py: "25px" }}>
<Typography
variant="h6"
sx={{
@ -120,7 +123,7 @@ export const СardPrivilege = ({ privilege }: CardPrivilege) => {
</IconButton>
</Box>
</Box>
<Box sx={{ width: "600px", display: "flex", justifyContent: "space-around" }}>
<Box sx={{ maxWidth: "600px", width: "100%", display: "flex", alignItems: mobile ? "center" : undefined, justifyContent: "space-around", flexDirection: mobile ? "column" : "row", gap: "5px" }}>
{inputOpen ? (
<TextField
type="number"
@ -130,7 +133,9 @@ export const СardPrivilege = ({ privilege }: CardPrivilege) => {
onChange={(event) => setInputValue(event.target.value)}
sx={{
alignItems: "center",
width: "400px",
maxWidth: "400px",
width: "100%",
marginLeft: "5px",
"& .MuiInputBase-root": {
backgroundColor: "#F2F3F7",
height: "48px",

@ -1,14 +1,14 @@
import { useState } from "react";
import {
Button,
Checkbox,
FormControl,
ListItemText,
MenuItem,
Select,
SelectChangeEvent,
TextField,
Button,
Checkbox,
FormControl,
ListItemText,
MenuItem,
Select,
SelectChangeEvent,
TextField, useMediaQuery, useTheme,
} from "@mui/material";
import { MOCK_DATA_USERS } from "@root/api/roles";
@ -24,7 +24,8 @@ const MenuProps = {
export default function CreateForm() {
const [personName, setPersonName] = useState<string[]>([]);
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down(600));
const handleChange = (event: SelectChangeEvent<typeof personName>) => {
const {
target: { value },
@ -39,7 +40,8 @@ export default function CreateForm() {
fullWidth
sx={{
alignItems: "center",
width: "400px",
maxWidth: "400px",
width: "100%",
"& .MuiInputBase-root": {
backgroundColor: "#F2F3F7",
height: "48px",
@ -54,8 +56,8 @@ export default function CreateForm() {
},
}}
/>
<Button sx={{ ml: "5px", bgcolor: "#fe9903", color: "white" }}>Отправить</Button>
<FormControl sx={{ ml: "25px", width: "80%" }}>
<Button sx={{ ml: "5px", bgcolor: "#fe9903", color: "white", minWidth: "85px" }}>Отправить</Button>
<FormControl sx={{ ml: mobile ? "10px" : "25px", width: "80%" }}>
<Select
sx={{ bgcolor: "white", height: "48px" }}
labelId="demo-multiple-checkbox-label"

@ -55,7 +55,8 @@ export default function DeleteForm() {
fullWidth
sx={{
alignItems: "center",
width: "400px",
maxWidth: "400px",
width: "100%",
"& .MuiInputBase-root": {
backgroundColor: "#F2F3F7",
height: "48px",

@ -1,4 +1,14 @@
import { AccordionDetails, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
import {
AccordionDetails,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
useMediaQuery,
useTheme
} from "@mui/material";
import { CustomWrapper } from "@root/kitUI/CustomWrapper";
@ -9,15 +19,19 @@ import { PrivilegesWrapper } from "./PrivilegiesWrapper";
import theme from "../../theme";
export const SettingRoles = (): JSX.Element => {
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down(600));
return (
<AccordionDetails sx={{ width: "890px" }}>
<AccordionDetails sx={{ maxWidth: "890px",
width: "100%", }}>
<CustomWrapper
text="Роли"
children={
<>
<Table
sx={{
width: "890px",
maxWidth: "890px",
width: "100%",
border: "2px solid",
borderColor: "gray",
}}
@ -52,8 +66,10 @@ export const SettingRoles = (): JSX.Element => {
alignItems: "center",
borderTop: "2px solid",
borderColor: theme.palette.grayLight.main,
height: "100px",
height: mobile ? undefined : "100px",
cursor: "pointer",
flexDirection: mobile ? "column" : "row",
gap: "5px"
}}
>
<FormCreateRoles />
@ -63,7 +79,8 @@ export const SettingRoles = (): JSX.Element => {
<Table
sx={{
mt: "30px",
width: "890px",
maxWidth: "890px",
width: "100%",
border: "2px solid",
borderColor: "gray",
}}
@ -98,8 +115,10 @@ export const SettingRoles = (): JSX.Element => {
alignItems: "center",
borderTop: "2px solid",
borderColor: theme.palette.grayLight.main,
height: "100px",
cursor: "pointer",
height: mobile ? undefined : "100px",
cursor: "pointer",
flexDirection: mobile ? "column" : "row",
gap: "5px"
}}
>
<FormDeleteRoles />
@ -110,7 +129,8 @@ export const SettingRoles = (): JSX.Element => {
}
/>
<PrivilegesWrapper text="Привилегии" sx={{ mt: "50px" }} />
<PrivilegesWrapper text="Привилегии" sx={{ mt: "50px", maxWidth: "890px",
width: "100%", }} />
</AccordionDetails>
);
};

@ -1,6 +1,6 @@
import { enqueueSnackbar } from "notistack";
import { GridSelectionModel } from "@mui/x-data-grid";
import { Box, Button } from "@mui/material";
import {Box, Button, useMediaQuery, useTheme} from "@mui/material";
import { changeDiscount } from "@root/api/discounts";
import { findDiscountsById } from "@root/stores/discounts";
@ -11,6 +11,8 @@ interface Props {
selectedRows: GridSelectionModel;
}
export default function DiscountDataGrid({ selectedRows }: Props) {
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down(400));
const changeData = async (isActive: boolean) => {
let done = 0;
let fatal = 0;
@ -46,7 +48,13 @@ export default function DiscountDataGrid({ selectedRows }: Props) {
};
return (
<Box width="400px" display="flex" justifyContent="space-between">
<Box
sx={{
width: mobile ? "250px" : "400px",
display: "flex",
justifyContent: "space-between",
flexDirection: mobile ? "column" : undefined,
gap: "10px"}}>
<Button onClick={() => changeData(false)}>Активировать</Button>
<Button onClick={() => changeData(true)}>Деактивировать</Button>
</Box>

@ -1,436 +0,0 @@
import { Box, Typography, TextField, Checkbox, Button } from "@mui/material";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DesktopDatePicker } from "@mui/x-date-pickers/DesktopDatePicker";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
import TableContainer from "@mui/material/TableContainer";
import Paper from "@mui/material/Paper";
import { DataGrid, GridColDef, GridToolbar } from "@mui/x-data-grid";
import MenuItem from "@mui/material/MenuItem";
import Select, { SelectChangeEvent } from "@mui/material/Select";
import theme from "../../../theme";
import { usePromocodeStore } from "../../../stores/promocodes";
import { useRef, useState } from "react";
import { ServiceType } from "@root/model/tariff";
const columns: GridColDef[] = [
{
field: "id",
headerName: "ID",
width: 30,
sortable: false,
},
{
field: "name",
headerName: "Название промокода",
width: 200,
sortable: false,
},
{
field: "endless",
headerName: "Бесконечный",
width: 120,
sortable: false,
},
{
field: "from",
headerName: "От",
width: 120,
sortable: false,
},
{
field: "dueTo",
headerName: "До",
width: 120,
sortable: false,
},
{
field: "privileges",
headerName: "Привилегии",
width: 210,
sortable: false,
}
];
const PromocodeManagement: React.FC = () => {
const [checkboxState, setCheckboxState] = useState<boolean>(false);
const toggleCheckbox = () => { setCheckboxState(!checkboxState); };
const [value1, setValue1] = useState<Date>(new Date());
const [value2, setValue2] = useState<Date>(new Date());
const [service, setService] = useState<ServiceType>("templategen");
const handleChange = (event: SelectChangeEvent) => {
setService(event.target.value as ServiceType);
};
const promocodes = usePromocodeStore(state => state.promocodes);
const addPromocodes = usePromocodeStore(state => state.addPromocodes);
function createPromocode() {
// TODO
}
// const promocodeArrayConverted = promocodes.map((item) => {
// const dateFrom = item.from ? new Date(Number(item.from)) : "";
// const dateDueTo = item.from ? new Date(Number(item.dueTo)) : "";
// const strFrom = dateFrom
// ? `${dateFrom.getDate()}.${dateFrom.getMonth()}.${dateFrom.getFullYear()}`
// : "-";
// const strDueTo = dateDueTo
// ? `${dateDueTo.getDate()}.${dateDueTo.getMonth()}.${dateDueTo.getFullYear()}`
// : "-";
// if (item.privileges.length) {
// const result = item.privileges.reduce((acc, privilege) => {
// acc = acc
// ? `${acc}, ${privilege.serviceKey} - ${privilege.discount}%`
// : `${privilege.serviceKey} - ${privilege.discount * 100}%`;
// return acc;
// }, "");
// return { ...item, privileges: result, from: strFrom, dueTo: strDueTo };
// } else {
// return { ...item, from: strFrom, dueTo: strDueTo };
// }
// });
// const createPromocode = (name: string, discount: number) => {
// const newPromocode = {
// id: new Date().getTime(),
// name,
// endless: checkboxState,
// from: checkboxState ? "" : new Date(value1).getTime() + "",
// dueTo: checkboxState ? "" : new Date(value2).getTime() + "",
// privileges: [{
// good: service,
// discount: discount / 100
// }]
// };
// const promocodeArrayUpdated = [...promocodes, newPromocode];
// addPromocodes(promocodeArrayUpdated);
// };
const promocodeGridData = promocodes.map(procomode => {
// TODO
})
const fieldName = useRef<HTMLInputElement | null>(null);
const fieldDiscount = useRef<HTMLInputElement | null>(null);
// const checkFields = () => {
// if (fieldName.current != null && fieldDiscount.current != null) {
// createPromocode(fieldName.current.value, Number(fieldDiscount.current.value));
// }
// };
return (
<>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Typography
variant="subtitle1"
sx={{
width: "90%",
height: "60px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
color: theme.palette.secondary.main
}}>
ПРОМОКОД
</Typography>
<Box sx={{
display: "flex",
flexDirection: "column",
justifyContent: "left",
alignItems: "left",
marginTop: "15px",
}}>
{/*<Typography */}
{/* variant="h4" */}
{/* sx={{*/}
{/* width: "90%",*/}
{/* height: "40px",*/}
{/* fontWeight: "normal",*/}
{/* color: theme.palette.grayDisabled.main,*/}
{/* marginTop: "35px"*/}
{/*}}>*/}
{/* Название:*/}
{/*</Typography>*/}
<TextField
id="standard-basic"
label={"Название"}
variant="filled"
color="secondary"
sx={{
height: "30px",
}}
InputProps={{
style: {
backgroundColor: theme.palette.content.main,
color: theme.palette.secondary.main,
}
}}
InputLabelProps={{
style: {
color: theme.palette.secondary.main
}
}}
inputRef={fieldName}
/>
<Typography
variant="h4"
sx={{
width: "90%",
height: "40px",
fontWeight: "normal",
color: theme.palette.grayDisabled.main,
marginTop: "75px"
}}>
Условия:
</Typography>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={service}
label="Age"
onChange={handleChange}
sx={{
color: theme.palette.secondary.main,
border: "1px solid",
borderColor: theme.palette.secondary.main,
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.secondary.main
},
".MuiSvgIcon-root ": {
fill: theme.palette.secondary.main,
}
}}
>
<MenuItem value={"Шаблонизатор документов"}>Шаблонизатор</MenuItem>
<MenuItem value={"Опросник"}>Опросник</MenuItem>
<MenuItem value={"Аналитика сокращателя"}>Аналитика сокращателя</MenuItem>
<MenuItem value={"АБ тесты"}>АБ тесты</MenuItem>
</Select>
<TextField
id="standard-basic"
label={"Процент скидки"}
variant="filled"
color="secondary"
sx={{
marginTop: "15px"
}}
InputProps={{
style: {
backgroundColor: theme.palette.content.main,
color: theme.palette.secondary.main,
}
}}
InputLabelProps={{
style: {
color: theme.palette.secondary.main
}
}}
inputRef={fieldDiscount}
/>
<TableContainer component={Paper} sx={{
width: "100%",
marginTop: "35px",
backgroundColor: theme.palette.content.main
}}>
<Table aria-label="simple table">
<TableBody>
<TableRow sx={{ border: "1px solid white" }} >
<TableCell component="th" scope="row" sx={{ color: theme.palette.secondary.main }}>
Работает, если заплатите 100500 денег
</TableCell>
</TableRow>
<TableRow sx={{ border: "1px solid white" }} >
<TableCell component="th" scope="row" sx={{ color: theme.palette.secondary.main }}>
Вы должны будете продать душу дьяволу
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
<Typography
variant="h4"
sx={{
width: "90%",
height: "40px",
fontWeight: "normal",
color: theme.palette.grayDisabled.main,
marginTop: "55px"
}}>
Дата действия:
</Typography>
<Box
sx={{
width: "100%",
display: "flex",
flexWrap: 'wrap'
}}
>
<Typography sx={{
width: "35px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "left",
}}>С</Typography>
<DesktopDatePicker
inputFormat="DD/MM/YYYY"
value={value1}
onChange={(e) => { if (e) { setValue1(e); } }}
renderInput={(params) => <TextField {...params} />}
InputProps={{
sx: {
height: "40px",
color: theme.palette.secondary.main,
border: "1px solid",
borderColor: theme.palette.secondary.main,
"& .MuiSvgIcon-root": { color: theme.palette.secondary.main }
}
}}
/>
<Typography sx={{
width: "65px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}>по</Typography>
<DesktopDatePicker
inputFormat="DD/MM/YYYY"
value={value2}
onChange={(e) => { if (e) { setValue2(e); } }}
renderInput={(params) => <TextField {...params} />}
InputProps={{
sx: {
height: "40px",
color: theme.palette.secondary.main,
border: "1px solid",
borderColor: theme.palette.secondary.main,
"& .MuiSvgIcon-root": { color: theme.palette.secondary.main }
}
}}
/>
</Box>
<Box sx={{
display: "flex",
width: "90%",
marginTop: theme.spacing(2),
}}>
<Box sx={{
width: "20px",
height: "42px",
display: "flex",
flexDirection: "column",
justifyContent: "left",
alignItems: "left",
marginRight: theme.spacing(1)
}}>
<Checkbox sx={{
color: theme.palette.secondary.main,
"&.Mui-checked": {
color: theme.palette.secondary.main,
},
}} onClick={() => toggleCheckbox()} />
</Box>
<Box sx={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center"
}}>
Бессрочно
</Box>
</Box>
<Box sx={{
width: "90%",
marginTop: "55px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center"
}}>
<Button
variant="contained"
sx={{
backgroundColor: theme.palette.menu.main,
height: "52px",
fontWeight: "normal",
fontSize: "17px",
"&:hover": {
backgroundColor: theme.palette.grayMedium.main
}
}}
onClick={createPromocode} >
Cоздать
</Button>
</Box>
</Box>
<Box style={{ width: "80%", marginTop: "55px" }}>
<Box style={{ height: 400 }}>
<DataGrid
checkboxSelection={true}
rows={promocodeGridData}
columns={columns}
sx={{
color: theme.palette.secondary.main,
"& .MuiDataGrid-iconSeparator": {
display: "none"
},
"& .css-levciy-MuiTablePagination-displayedRows": {
color: theme.palette.secondary.main
},
"& .MuiSvgIcon-root": {
color: theme.palette.secondary.main
},
"& .MuiTablePagination-selectLabel": {
color: theme.palette.secondary.main
},
"& .MuiInputBase-root": {
color: theme.palette.secondary.main
},
"& .MuiButton-text": {
color: theme.palette.secondary.main
},
}}
components={{ Toolbar: GridToolbar }}
onSelectionModelChange={(ids) => console.log("datagrid select")}
/>
</Box>
</Box>
</LocalizationProvider>
</>
);
};
export default PromocodeManagement;

@ -0,0 +1,372 @@
import {
Button,
FormControlLabel,
MenuItem,
Radio,
RadioGroup,
Select,
TextField,
Typography,
} from "@mui/material";
import { DesktopDatePicker } from "@mui/x-date-pickers/DesktopDatePicker";
import { Field, Form, Formik } from "formik";
import moment from "moment";
import { useEffect, useState } from "react";
import { requestPrivileges } from "@root/services/privilegies.service";
import { usePrivilegeStore } from "@root/stores/privilegesStore";
import { SERVICE_LIST } from "@root/model/privilege";
import theme from "@root/theme";
import type { TextFieldProps } from "@mui/material";
import { CreatePromocodeBody } from "@root/model/promocodes";
import type { ChangeEvent } from "react";
type BonusType = "discount" | "privilege";
type FormValues = {
codeword: string;
description: string;
greetings: string;
dueTo: number;
activationCount: number;
privilegeId: string;
amount: number;
layer: 1 | 2;
factor: number;
target: string;
threshold: number;
};
const initialValues: FormValues = {
codeword: "",
description: "",
greetings: "",
dueTo: 0,
activationCount: 0,
privilegeId: "",
amount: 0,
layer: 1,
factor: 0,
target: "",
threshold: 0,
};
type Props = {
createPromocode: (body: CreatePromocodeBody) => Promise<void>;
};
export const CreatePromocodeForm = ({ createPromocode }: Props) => {
const [bonusType, setBonusType] = useState<BonusType>("discount");
const { privileges } = usePrivilegeStore();
useEffect(() => {
requestPrivileges();
}, []);
const submitForm = (values: FormValues) => {
const body = {
...values,
threshold: values.layer === 1 ? values.threshold : values.threshold * 100,
};
const factorFromDiscountValue = 1 - body.factor / 100;
return createPromocode({
codeword: body.codeword,
description: body.description,
greetings: body.greetings,
dueTo: body.dueTo,
activationCount: body.activationCount,
bonus: {
privilege: { privilegeID: body.privilegeId, amount: body.amount },
discount: {
layer: body.layer,
factor: factorFromDiscountValue,
target: body.target,
threshold: body.threshold,
},
},
});
};
return (
<Formik initialValues={initialValues} onSubmit={submitForm}>
{({ values, handleChange, handleBlur, setFieldValue }) => (
<Form
style={{
width: "100%",
maxWidth: "600px",
padding: "0 10px",
}}
>
<CustomTextField
name="codeword"
label="Кодовое слово"
required
onChange={handleChange}
/>
<CustomTextField
name="description"
label="Описание"
required
onChange={handleChange}
/>
<CustomTextField
name="greetings"
label="Приветственное сообщение"
required
onChange={handleChange}
/>
<Typography
variant="h4"
sx={{
height: "40px",
fontWeight: "normal",
marginTop: "15px",
color: theme.palette.secondary.main,
}}
>
Время существования промокода
</Typography>
<Field
name="dueTo"
as={DesktopDatePicker}
inputFormat="DD/MM/YYYY"
value={values.dueTo ? new Date(Number(values.dueTo) * 1000) : null}
onChange={(date: Date | null) => {
if (date) {
setFieldValue("dueTo", moment(date).unix() || null);
}
}}
renderInput={(params: TextFieldProps) => <TextField {...params} />}
InputProps={{
sx: {
height: "40px",
color: theme.palette.secondary.main,
border: "1px solid",
borderColor: theme.palette.secondary.main,
"& .MuiSvgIcon-root": { color: theme.palette.secondary.main },
},
}}
/>
<CustomTextField
name="activationCount"
label="Количество активаций промокода"
onChange={({ target }) =>
setFieldValue(
"activationCount",
Number(target.value.replace(/\D/g, ""))
)
}
/>
<RadioGroup
row
name="bonusType"
value={bonusType}
sx={{ marginTop: "15px" }}
onChange={({ target }: React.ChangeEvent<HTMLInputElement>) => {
setBonusType(target.value as BonusType);
}}
onBlur={handleBlur}
>
<FormControlLabel
value="discount"
control={<Radio color="secondary" />}
label="Скидка"
/>
<FormControlLabel
value="privilege"
control={<Radio color="secondary" />}
label="Привилегия"
/>
</RadioGroup>
{bonusType === "discount" && (
<>
<RadioGroup
row
name="layer"
value={values.layer}
sx={{ marginTop: "15px" }}
onChange={({ target }: React.ChangeEvent<HTMLInputElement>) => {
setFieldValue("target", "");
setFieldValue("layer", Number(target.value));
}}
onBlur={handleBlur}
>
<FormControlLabel
value="1"
control={<Radio color="secondary" />}
label="Привилегия"
/>
<FormControlLabel
value="2"
control={<Radio color="secondary" />}
label="Сервис"
/>
</RadioGroup>
<CustomTextField
name="factor"
label="Процент скидки"
required
onChange={({ target }) => {
setFieldValue(
"factor",
Number(target.value.replace(/\D/g, ""))
);
}}
/>
<Typography
variant="h4"
sx={{
height: "40px",
fontWeight: "normal",
marginTop: "15px",
padding: "0 12px",
color: theme.palette.secondary.main,
}}
>
{values.layer === 1 ? "Выбор привилегии" : "Выбор сервиса"}
</Typography>
<Field
name="target"
as={Select}
label={values.layer === 1 ? "Привилегия" : "Сервис"}
sx={{
width: "100%",
border: "2px solid",
color: theme.palette.secondary.main,
borderColor: theme.palette.secondary.main,
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
border: "none",
},
".MuiSvgIcon-root ": { fill: theme.palette.secondary.main },
}}
children={
values.layer === 1
? privileges.map(({ name, privilegeId }) => (
<MenuItem key={privilegeId} value={privilegeId}>
{name}
</MenuItem>
))
: SERVICE_LIST.map(({ displayName, serviceKey }) => (
<MenuItem key={serviceKey} value={serviceKey}>
{displayName}
</MenuItem>
))
}
/>
<CustomTextField
name="threshold"
label="При каком значении применяется скидка"
onChange={({ target }) =>
setFieldValue(
"threshold",
Number(target.value.replace(/\D/g, ""))
)
}
/>
</>
)}
{bonusType === "privilege" && (
<>
<Typography
variant="h4"
sx={{
height: "40px",
fontWeight: "normal",
marginTop: "15px",
padding: "0 12px",
color: theme.palette.secondary.main,
}}
>
Выбор привилегии
</Typography>
<Field
name="privilegeId"
as={Select}
label="Привилегия"
sx={{
width: "100%",
border: "2px solid",
color: theme.palette.secondary.main,
borderColor: theme.palette.secondary.main,
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
border: "none",
},
".MuiSvgIcon-root ": { fill: theme.palette.secondary.main },
}}
children={privileges.map(({ name, privilegeId }) => (
<MenuItem key={privilegeId} value={privilegeId}>
{name}
</MenuItem>
))}
/>
<CustomTextField
name="amount"
label="Количество"
required
onChange={({ target }) =>
setFieldValue(
"amount",
Number(target.value.replace(/\D/g, ""))
)
}
/>
</>
)}
<Button
variant="contained"
sx={{
display: "block",
padding: "10px",
margin: "15px auto 0",
fontWeight: "normal",
fontSize: "18px",
backgroundColor: theme.palette.menu.main,
"&:hover": { backgroundColor: theme.palette.grayMedium.main },
}}
type="submit"
>
Cоздать
</Button>
</Form>
)}
</Formik>
);
};
type CustomTextFieldProps = {
name: string;
label: string;
required?: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
};
const CustomTextField = ({
name,
label,
required = false,
onChange,
}: CustomTextFieldProps) => (
<Field
name={name}
label={label}
required={required}
variant="filled"
color="secondary"
as={TextField}
onChange={onChange}
sx={{ width: "100%", marginTop: "15px" }}
InputProps={{
style: {
backgroundColor: theme.palette.content.main,
color: theme.palette.secondary.main,
},
}}
InputLabelProps={{
style: { color: theme.palette.secondary.main },
}}
/>
);

@ -0,0 +1,78 @@
import { Box, Typography, useTheme } from "@mui/material";
import { DataGrid, GridLoadingOverlay, GridToolbar } from "@mui/x-data-grid";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { usePromocodes } from "@root/api/promocode/swr";
import { fadeIn } from "@root/utils/style/keyframes";
import { useState } from "react";
import { CreatePromocodeForm } from "./CreatePromocodeForm";
import { usePromocodeGridColDef } from "./usePromocodeGridColDef";
export const PromocodeManagement = () => {
const theme = useTheme();
const [page, setPage] = useState<number>(0);
const [pageSize, setPageSize] = useState<number>(10);
const { data, error, isValidating, promocodesCount, deletePromocode, createPromocode } = usePromocodes(page, pageSize);
const columns = usePromocodeGridColDef(deletePromocode);
if (error) return <Typography>Ошибка загрузки промокодов</Typography>;
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Typography
variant="subtitle1"
sx={{
width: "90%",
height: "60px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
textTransform: "uppercase",
color: theme.palette.secondary.main,
}}
>
Создание промокода
</Typography>
<CreatePromocodeForm createPromocode={createPromocode} />
<Box style={{ width: "80%", marginTop: "55px" }}>
<DataGrid
disableSelectionOnClick={true}
rows={data?.items ?? []}
columns={columns}
sx={{
color: theme.palette.secondary.main,
"& .MuiDataGrid-iconSeparator": { display: "none" },
"& .css-levciy-MuiTablePagination-displayedRows": {
color: theme.palette.secondary.main,
},
"& .MuiSvgIcon-root": { color: theme.palette.secondary.main },
"& .MuiTablePagination-selectLabel": {
color: theme.palette.secondary.main,
},
"& .MuiInputBase-root": { color: theme.palette.secondary.main },
"& .MuiButton-text": { color: theme.palette.secondary.main },
"& .MuiDataGrid-overlay": {
backgroundColor: "rgba(255, 255, 255, 0.1)",
animation: `${fadeIn} 0.5s ease-out`,
},
}}
components={{
Toolbar: GridToolbar,
LoadingOverlay: GridLoadingOverlay,
}}
loading={isValidating}
paginationMode="server"
page={page}
onPageChange={setPage}
rowCount={promocodesCount}
pageSize={pageSize}
onPageSizeChange={setPageSize}
rowsPerPageOptions={[10, 25, 50, 100]}
autoHeight
/>
</Box>
</LocalizationProvider>
);
};

@ -0,0 +1,59 @@
import DeleteIcon from '@mui/icons-material/Delete';
import { IconButton } from "@mui/material";
import { GridColDef } from "@mui/x-data-grid";
import { Promocode } from "@root/model/promocodes";
import { useMemo } from "react";
export function usePromocodeGridColDef(deletePromocode: (id: string) => void) {
return useMemo<GridColDef<Promocode, string | number, string>[]>(() => [
{
field: "id",
headerName: "ID",
width: 30,
sortable: false,
valueGetter: ({ row }) => row.id,
},
{
field: "codeword",
headerName: "Кодовое слово",
width: 160,
sortable: false,
valueGetter: ({ row }) => row.codeword,
},
{
field: "factor",
headerName: "Коэф. скидки",
width: 120,
sortable: false,
valueGetter: ({ row }) => row.bonus.discount.factor,
},
{
field: "activationCount",
headerName: "Кол-во активаций",
width: 140,
sortable: false,
valueGetter: ({ row }) => row.activationCount,
},
{
field: "dueTo",
headerName: "Истекает",
width: 160,
sortable: false,
valueGetter: ({ row }) => row.dueTo * 1000,
valueFormatter: ({ value }) => new Date(value).toLocaleString(),
},
{
field: "delete",
headerName: "",
width: 60,
sortable: false,
renderCell: (params) => {
return (
<IconButton onClick={() => deletePromocode(params.row.id)}>
<DeleteIcon />
</IconButton>
);
},
},
], [deletePromocode]);
}

@ -42,11 +42,21 @@ const columns: GridColDef<UserType, string>[] = [
interface Props {
handleSelectionChange: (selectionModel: GridSelectionModel) => void;
users: UserType[];
page: number;
setPage: (page: number) => void;
pageSize: number;
pagesCount: number;
onPageSizeChange?: (count: number) => void;
}
export default function ServiceUsersDG({
handleSelectionChange,
users = [],
page,
setPage,
pageSize = 10,
pagesCount = 1,
onPageSizeChange,
}: Props) {
const navigate = useNavigate();
@ -60,6 +70,13 @@ export default function ServiceUsersDG({
rows={users}
columns={columns}
components={{ Toolbar: GridToolbar }}
rowCount={pageSize * pagesCount}
rowsPerPageOptions={[10, 25, 50, 100]}
paginationMode="server"
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={onPageSizeChange}
onSelectionModelChange={handleSelectionChange}
onCellClick={({ row }, event) => {
event.stopPropagation();

@ -9,8 +9,39 @@ import { TicketMessage } from "@root/model/ticket";
import { sendTicketMessage } from "@root/api/tickets";
import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets";
import { getMessageFromFetchError, throttle, useEventListener, useSSESubscription, useTicketMessages, useToken } from "@frontend/kitui";
import { getMessageFromFetchError, makeRequest, throttle, useEventListener, useSSESubscription, useTicketMessages, useToken } from "@frontend/kitui";
import ChatImage from "./ChatImage";
import ChatDocument from "./ChatDocument";
import ChatVideo from "./ChatVideo";
import ChatMessage from "./ChatMessage";
import { ACCEPT_SEND_MEDIA_TYPES_MAP, MAX_FILE_SIZE, MAX_PHOTO_SIZE, MAX_VIDEO_SIZE } from "./fileUpload";
const tooLarge = "Файл слишком большой"
const checkAcceptableMediaType = (file: File) => {
if (file === null) return ""
const segments = file?.name.split('.');
const extension = segments[segments.length - 1];
const type = extension.toLowerCase();
console.log(type)
switch (type) {
case ACCEPT_SEND_MEDIA_TYPES_MAP.document.find(name => name === type):
if (file.size > MAX_FILE_SIZE) return tooLarge
return ""
case ACCEPT_SEND_MEDIA_TYPES_MAP.picture.find(name => name === type):
if (file.size > MAX_PHOTO_SIZE) return tooLarge
return ""
case ACCEPT_SEND_MEDIA_TYPES_MAP.video.find(name => name === type):
if (file.size > MAX_VIDEO_SIZE) return tooLarge
return ""
default:
return "Не удалось отправить файл. Недопустимый тип"
}
}
export default function Chat() {
const token = useToken();
@ -26,6 +57,8 @@ export default function Chat() {
const isPreventAutoscroll = useMessageStore(state => state.isPreventAutoscroll);
const fetchState = useMessageStore(state => state.ticketMessagesFetchState);
const lastMessageId = useMessageStore(state => state.lastMessageId);
const fileInputRef = useRef<HTMLInputElement>(null);
const [disableFileButton, setDisableFileButton] = useState(false);
const ticket = tickets.find(ticket => ticket.id === ticketId);
@ -107,7 +140,47 @@ export default function Chat() {
setMessageField("");
}
function handleAddAttachment() { }
const sendFile = async (file: File) => {
if (file === undefined) return true;
// const isFileTypeAccepted = ACCEPT_SEND_FILE_TYPES_MAP.some(
// fileType => file.name.toLowerCase().endsWith(fileType)
// );
// console.log(file.name.toLowerCase().endsWith(".png"))
// if (!isFileTypeAccepted) return setModalWarningType("errorType");
let data;
const ticketId = ticket?.id
if (ticketId !== undefined) {
try {
const body = new FormData();
body.append(file.name, file);
body.append("ticket", ticketId);
await makeRequest({
url: process.env.REACT_APP_DOMAIN + "/heruvym/sendFiles",
body: body,
method: "POST",
});
} catch (error: any) {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
}
return true;
}
};
const sendFileHC = async (file: File) => {
console.log(file)
const check = checkAcceptableMediaType(file)
if (check.length > 0) {
enqueueSnackbar(check)
return
}
setDisableFileButton(true)
await sendFile(file)
setDisableFileButton(false)
console.log(disableFileButton)
};
function handleTextfieldKeyPress(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
@ -145,9 +218,66 @@ export default function Chat() {
colorScheme: "dark",
}}
>
{ticket && messages.map(message =>
<Message key={message.id} message={message} isSelf={ticket.user !== message.user_id} />
)}
{ticket &&
messages.map((message) => {
const isFileVideo = () => {
if (message.files) {
return (ACCEPT_SEND_MEDIA_TYPES_MAP.video.some((fileType) =>
message.files[0].toLowerCase().endsWith(fileType),
))
}
};
const isFileImage = () => {
if (message.files) {
return (ACCEPT_SEND_MEDIA_TYPES_MAP.picture.some((fileType) =>
message.files[0].toLowerCase().endsWith(fileType),
))
}
};
const isFileDocument = () => {
if (message.files) {
return (ACCEPT_SEND_MEDIA_TYPES_MAP.document.some((fileType) =>
message.files[0].toLowerCase().endsWith(fileType),
))
}
};
if (message.files !== null && message.files.length > 0 && isFileImage()) {
return <ChatImage
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={ticket.user !== message.user_id}
/>
}
if (message.files !== null && message.files.length > 0 && isFileVideo()) {
return <ChatVideo
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={ticket.user !== message.user_id}
/>
}
if (message.files !== null && message.files.length > 0 && isFileDocument()) {
return <ChatDocument
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={ticket.user !== message.user_id}
/>
}
return <ChatMessage
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={ticket.user !== message.user_id}
/>
})
}
</Box>
{ticket &&
<TextField
@ -177,13 +307,25 @@ export default function Chat() {
<SendIcon sx={{ color: theme.palette.golden.main }} />
</IconButton>
<IconButton
onClick={handleAddAttachment}
onClick={() => {
console.log(disableFileButton)
if (!disableFileButton) fileInputRef.current?.click()
}}
sx={{
height: "45px",
width: "45px",
p: 0,
}}
>
<input
ref={fileInputRef}
id="fileinput"
onChange={(e) => {
if (e.target.files?.[0]) sendFileHC(e.target.files?.[0]);
}}
style={{ display: "none" }}
type="file"
/>
<AttachFileIcon sx={{ color: theme.palette.golden.main }} />
</IconButton>
</InputAdornment>

@ -0,0 +1,61 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import DownloadIcon from '@mui/icons-material/Download';
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatDocument({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const date = new Date(createdAt);
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography sx={{
fontSize: "12px",
alignSelf: "end",
}}>
{new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography>
<Box
sx={{
backgroundColor: "#2a2b2c",
p: "12px",
border: `1px solid ${theme.palette.golden.main}`,
borderRadius: "20px",
borderTopLeftRadius: isSelf ? "20px" : 0,
borderTopRightRadius: isSelf ? 0 : "20px",
maxWidth: "90%",
}}
>
<Link
download
href={`https://storage.yandexcloud.net/pair/${file}`}
style={{
color: "#7E2AEA",
display: "flex",
gap: "10px",
}}
>
<DownloadIcon/>
</Link>
</Box>
</Box>
);
}

@ -0,0 +1,67 @@
import {
Box,
ButtonBase,
Link,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatImage({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography sx={{
fontSize: "12px",
alignSelf: "end",
}}>
{new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography>
<Box
sx={{
backgroundColor: "#2a2b2c",
p: "12px",
border: `1px solid ${theme.palette.golden.main}`,
borderRadius: "20px",
borderTopLeftRadius: isSelf ? "20px" : 0,
borderTopRightRadius: isSelf ? 0 : "20px",
maxWidth: "90%",
}}
>
<ButtonBase target="_blank" href={`/image/${file}`}>
<Box
component="img"
sx={{
height: "217px",
width: "217px",
}}
src={`https://storage.yandexcloud.net/pair/${file}`}
/>
</ButtonBase>
</Box>
</Box>
);
}

@ -0,0 +1,52 @@
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
text: string;
createdAt: string;
}
export default function ChatMessage({
unAuthenticated = false,
isSelf,
text,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography sx={{
fontSize: "12px",
alignSelf: "end",
}}>
{new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography>
<Box
sx={{
backgroundColor: "#2a2b2c",
p: "12px",
border: `1px solid ${theme.palette.golden.main}`,
borderRadius: "20px",
borderTopLeftRadius: isSelf ? "20px" : 0,
borderTopRightRadius: isSelf ? 0 : "20px",
maxWidth: "90%",
}}
>
<Typography fontSize="14px" sx={{ wordBreak: "break-word" }}>
{text}
</Typography>
</Box>
</Box>
);
}

@ -0,0 +1,68 @@
import {
Box,
ButtonBase,
Link,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatImage({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography sx={{
fontSize: "12px",
alignSelf: "end",
}}>
{new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography>
<Box
sx={{
backgroundColor: "#2a2b2c",
p: "12px",
border: `1px solid ${theme.palette.golden.main}`,
borderRadius: "20px",
borderTopLeftRadius: isSelf ? "20px" : 0,
borderTopRightRadius: isSelf ? 0 : "20px",
maxWidth: "90%",
}}
>
<Box
component="video"
sx={{
height: "217px",
width: "217px",
}}
controls
>
<source src={`https://storage.yandexcloud.net/pair/${file}`} />
</Box>
</Box>
</Box>
);
}

@ -0,0 +1,9 @@
export const MAX_FILE_SIZE = 10485760;
export const MAX_PHOTO_SIZE = 5242880;
export const MAX_VIDEO_SIZE = 52428800;
export const ACCEPT_SEND_MEDIA_TYPES_MAP = {
picture: ["jpg", "png"],
video: ["mp4"],
document: ["doc", "docx", "pdf", "txt", "xlsx", "csv"],
} as const;

@ -0,0 +1,20 @@
import { Box } from "@mui/material";
import { useLocation } from "react-router-dom";
export default function ChatImageNewWindow() {
const location = useLocation();
console.log(location);
const srcImage = location.pathname.split("image/")[1];
return (
<>
<Box
component="img"
sx={{
maxHeight: "100vh",
maxWidth: "100vw",
}}
src={`https://storage.yandexcloud.net/pair/${srcImage}`}
/>
</>
);
}

@ -2,52 +2,52 @@ import { ReactNode, useState } from "react";
import { Box, Typography, useTheme } from "@mui/material";
import ExpandIcon from "./ExpandIcon";
interface Props {
headerText: string;
children: ReactNode;
headerText: string;
children: (callback: () => void) => ReactNode;
}
export default function Collapse({ headerText, children }: Props) {
const theme = useTheme();
const [isExpanded, setIsExpanded] = useState<boolean>(false);
const theme = useTheme();
const [isExpanded, setIsExpanded] = useState<boolean>(false);
return (
return (
<Box
sx={{
position: "relative",
}}
>
<Box
onClick={() => setIsExpanded((prev) => !prev)}
sx={{
height: "72px",
p: "16px",
backgroundColor: theme.palette.menu.main,
borderRadius: "12px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
cursor: "pointer",
userSelect: "none",
}}
>
<Typography variant="h4">{headerText}</Typography>
<ExpandIcon isExpanded={isExpanded} />
</Box>
{isExpanded && (
<Box
sx={{
position: "relative",
}}
sx={{
mt: "8px",
position: "absolute",
zIndex: 100,
backgroundColor: theme.palette.content.main,
width: "100%",
}}
>
<Box
onClick={() => setIsExpanded(prev => !prev)}
sx={{
height: "72px",
p: "16px",
backgroundColor: theme.palette.menu.main,
borderRadius: "12px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
cursor: "pointer",
userSelect: "none",
}}
>
<Typography variant="h4">{headerText}</Typography>
<ExpandIcon isExpanded={isExpanded} />
</Box>
{isExpanded &&
<Box sx={{
mt: "8px",
position: "absolute",
zIndex: 100,
backgroundColor: theme.palette.content.main,
width: "100%",
}}>
{children}
</Box>
}
</Box >
);
}
{children(() => setIsExpanded(false))}
</Box>
)}
</Box>
);
}

@ -1,61 +1,103 @@
import { useState, useEffect } from "react";
import { Box, useMediaQuery, useTheme } from "@mui/material";
import Chat from "./Chat/Chat";
import Collapse from "./Collapse";
import TicketList from "./TicketList/TicketList";
import { Ticket } from "@root/model/ticket";
import { clearTickets, setTicketsFetchState, updateTickets, useTicketStore } from "@root/stores/tickets";
import {
clearTickets,
setTicketsFetchState,
updateTickets,
useTicketStore,
} from "@root/stores/tickets";
import { enqueueSnackbar } from "notistack";
import { clearMessageState } from "@root/stores/messages";
import { getMessageFromFetchError, useSSESubscription, useTicketsFetcher, useToken } from "@frontend/kitui";
import {
getMessageFromFetchError,
useSSESubscription,
useTicketsFetcher,
useToken,
} from "@frontend/kitui";
import ModalUser from "@root/pages/dashboard/ModalUser";
export default function Support() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage);
const ticketApiPage = useTicketStore((state) => state.apiPage);
const token = useToken();
const [openUserModal, setOpenUserModal] = useState<boolean>(false);
const [activeUserId, setActiveUserId] = useState<string>("");
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage);
const ticketApiPage = useTicketStore((state) => state.apiPage);
const token = useToken();
useTicketsFetcher({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets",
ticketsPerPage,
ticketApiPage,
onSuccess: result => {
if (result.data) updateTickets(result.data);
},
onError: (error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
},
onFetchStateChange: setTicketsFetchState,
});
useTicketsFetcher({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets",
ticketsPerPage,
ticketApiPage,
onSuccess: (result) => {
if (result.data) updateTickets(result.data);
},
onError: (error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
},
onFetchStateChange: setTicketsFetchState,
});
useSSESubscription<Ticket>({
enabled: Boolean(token),
url: process.env.REACT_APP_DOMAIN + `/heruvym/subscribe?Authorization=${token}`,
onNewData: updateTickets,
onDisconnect: () => {
clearMessageState();
clearTickets();
},
marker: "ticket"
});
useSSESubscription<Ticket>({
enabled: Boolean(token),
url:
process.env.REACT_APP_DOMAIN +
`/heruvym/subscribe?Authorization=${token}`,
onNewData: updateTickets,
onDisconnect: () => {
clearMessageState();
clearTickets();
},
marker: "ticket",
});
return (
<Box
sx={{
display: "flex",
width: "100%",
flexDirection: upMd ? "row" : "column",
gap: "12px",
}}
>
{!upMd && (
<Collapse headerText="Тикеты">
<TicketList />
</Collapse>
)}
<Chat />
{upMd && <TicketList />}
</Box>
);
useEffect(() => {
if (!openUserModal) {
setActiveUserId("");
}
}, [openUserModal]);
useEffect(() => {
if (activeUserId) {
setOpenUserModal(true);
return;
}
setOpenUserModal(false);
}, [activeUserId]);
return (
<Box
sx={{
display: "flex",
width: "100%",
flexDirection: upMd ? "row" : "column",
gap: "12px",
}}
>
{!upMd && (
<Collapse headerText="Тикеты">
{(closeCollapse) => (
<TicketList
closeCollapse={closeCollapse}
setActiveUserId={setActiveUserId}
/>
)}
</Collapse>
)}
<Chat />
{upMd && <TicketList setActiveUserId={setActiveUserId} />}
<ModalUser
open={openUserModal}
onClose={() => setOpenUserModal(false)}
userId={activeUserId}
/>
</Box>
);
}

@ -1,91 +1,108 @@
import CircleIcon from "@mui/icons-material/Circle";
import { Box, Card, CardActionArea, CardContent, CardHeader, Divider, Typography, useTheme } from "@mui/material";
import {
Box,
Card,
CardActionArea,
CardContent,
CardHeader,
Divider,
Typography,
useTheme,
} from "@mui/material";
import { green } from "@mui/material/colors";
import { Ticket } from "@root/model/ticket";
import { useNavigate, useParams } from "react-router-dom";
const flexCenterSx = {
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "10px",
};
interface Props {
ticket: Ticket;
ticket: Ticket;
setActiveUserId: (userId: string) => void;
}
export default function TicketItem({ ticket }: Props) {
const theme = useTheme();
const navigate = useNavigate();
const ticketId = useParams().ticketId;
export default function TicketItem({ ticket, setActiveUserId }: Props) {
const theme = useTheme();
const navigate = useNavigate();
const ticketId = useParams().ticketId;
const isUnread = ticket.user === ticket.top_message.user_id;
const isSelected = ticket.id === ticketId;
const isUnread = ticket.user === ticket.top_message.user_id;
const isSelected = ticket.id === ticketId;
const unreadSx = {
border: "1px solid",
borderColor: theme.palette.golden.main,
backgroundColor: theme.palette.goldenMedium.main
};
const unreadSx = {
border: "1px solid",
borderColor: theme.palette.golden.main,
backgroundColor: theme.palette.goldenMedium.main,
};
const selectedSx = {
border: `2px solid ${theme.palette.secondary.main}`,
};
const selectedSx = {
border: `2px solid ${theme.palette.secondary.main}`,
};
function handleCardClick() {
navigate(`/support/${ticket.id}`);
}
function handleCardClick() {
navigate(`/support/${ticket.id}`);
}
return (
<Card sx={{
minHeight: "70px",
return (
<Card
sx={{
minHeight: "70px",
backgroundColor: "transparent",
color: "white",
...(isUnread && unreadSx),
...(isSelected && selectedSx),
}}
>
<CardActionArea onClick={handleCardClick}>
<CardHeader
title={<Typography>{ticket.title}</Typography>}
disableTypography
sx={{
textAlign: "center",
p: "4px",
}}
/>
<Divider />
<CardContent
sx={{
display: "flex",
justifyContent: "space-between",
backgroundColor: "transparent",
color: "white",
...(isUnread && unreadSx),
...(isSelected && selectedSx),
}}>
<CardActionArea onClick={handleCardClick}>
<CardHeader
title={<Typography>{ticket.title}</Typography>}
disableTypography
sx={{
textAlign: "center",
p: "4px",
}}
/>
<Divider />
<CardContent sx={{
display: "flex",
justifyContent: "space-between",
backgroundColor: "transparent",
p: 0,
}}>
<Box sx={flexCenterSx}>
{new Date(ticket.top_message.created_at).toLocaleDateString()}
</Box>
<Box sx={{
...flexCenterSx,
overflow: "hidden",
whiteSpace: "nowrap",
display: "block",
flexGrow: 1,
}}>
{ticket.top_message.message}
</Box>
<Box sx={flexCenterSx}>
<CircleIcon sx={{
color: green[700],
transform: "scale(0.8)"
}} />
</Box>
<Box sx={flexCenterSx}>
ИНФО
</Box>
</CardContent>
</CardActionArea>
</Card>
);
}
p: 0,
}}
>
<Box sx={flexCenterSx}>
{new Date(ticket.top_message.created_at).toLocaleDateString()}
</Box>
<Box
sx={{
...flexCenterSx,
overflow: "hidden",
whiteSpace: "nowrap",
display: "block",
flexGrow: 1,
}}
>
{ticket.top_message.message}
</Box>
<Box sx={flexCenterSx}>
<CircleIcon
sx={{
color: green[700],
transform: "scale(0.8)",
}}
/>
</Box>
<Box sx={flexCenterSx} onClick={() => setActiveUserId(ticket.user)}>
ИНФО
</Box>
</CardContent>
</CardActionArea>
</Card>
);
}

@ -1,122 +1,146 @@
import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined';
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
import HighlightOffOutlinedIcon from "@mui/icons-material/HighlightOffOutlined";
import SearchOutlinedIcon from "@mui/icons-material/SearchOutlined";
import { Box, Button, useMediaQuery, useTheme } from "@mui/material";
import { Ticket } from "@root/model/ticket";
import { incrementTicketsApiPage, useTicketStore } from "@root/stores/tickets";
import { useEffect, useRef } from "react";
import TicketItem from "./TicketItem";
import { throttle } from '@frontend/kitui';
import { throttle } from "@frontend/kitui";
type TicketListProps = {
closeCollapse?: () => void;
setActiveUserId: (id: string) => void;
};
export default function TicketList() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const tickets = useTicketStore(state => state.tickets);
const ticketsFetchState = useTicketStore(state => state.ticketsFetchState);
const ticketsBoxRef = useRef<HTMLDivElement>(null);
export default function TicketList({
closeCollapse,
setActiveUserId,
}: TicketListProps) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const tickets = useTicketStore((state) => state.tickets);
const ticketsFetchState = useTicketStore((state) => state.ticketsFetchState);
const ticketsBoxRef = useRef<HTMLDivElement>(null);
useEffect(function updateCurrentPageOnScroll() {
if (!ticketsBoxRef.current) return;
useEffect(
function updateCurrentPageOnScroll() {
if (!ticketsBoxRef.current) return;
const ticketsBox = ticketsBoxRef.current;
const scrollHandler = () => {
const scrollBottom = ticketsBox.scrollHeight - ticketsBox.scrollTop - ticketsBox.clientHeight;
if (
scrollBottom < ticketsBox.clientHeight &&
ticketsFetchState === "idle"
) incrementTicketsApiPage();
};
const ticketsBox = ticketsBoxRef.current;
const scrollHandler = () => {
const scrollBottom =
ticketsBox.scrollHeight -
ticketsBox.scrollTop -
ticketsBox.clientHeight;
if (
scrollBottom < ticketsBox.clientHeight &&
ticketsFetchState === "idle"
)
incrementTicketsApiPage();
};
const throttledScrollHandler = throttle(scrollHandler, 200);
ticketsBox.addEventListener("scroll", throttledScrollHandler);
const throttledScrollHandler = throttle(scrollHandler, 200);
ticketsBox.addEventListener("scroll", throttledScrollHandler);
return () => {
ticketsBox.removeEventListener("scroll", throttledScrollHandler);
};
}, [ticketsFetchState]);
return () => {
ticketsBox.removeEventListener("scroll", throttledScrollHandler);
};
},
[ticketsFetchState]
);
const sortedTickets = tickets.sort(sortTicketsByUpdateTime).sort(sortTicketsByUnread);
const sortedTickets = tickets
.sort(sortTicketsByUpdateTime)
.sort(sortTicketsByUnread);
return (
<Box sx={{
display: "flex",
flex: upMd ? "3 0 0" : undefined,
maxWidth: upMd ? "400px" : undefined,
maxHeight: "600px",
flexDirection: "column",
alignItems: "center",
}}>
<Box sx={{
width: "100%",
border: "1px solid",
borderColor: theme.palette.grayDark.main,
borderRadius: "3px",
padding: "10px"
}}>
<Button
variant="contained"
sx={{
backgroundColor: theme.palette.grayDark.main,
width: "100%",
height: "45px",
fontSize: "15px",
fontWeight: "normal",
textTransform: "capitalize",
"&:hover": {
backgroundColor: theme.palette.menu.main
}
}}>
Поиск
<SearchOutlinedIcon />
</Button>
<Button
variant="text"
sx={{
width: "100%",
height: "35px",
fontSize: "14px",
fontWeight: "normal",
color: theme.palette.secondary.main,
border: "1px solid",
borderColor: theme.palette.golden.main,
borderRadius: 0,
"&:hover": {
backgroundColor: theme.palette.menu.main
}
}}>
ЗАКРЫТЬ ТИКЕТ
<HighlightOffOutlinedIcon />
</Button>
</Box>
<Box
ref={ticketsBoxRef}
sx={{
width: "100%",
border: "1px solid",
borderColor: theme.palette.grayDark.main,
borderRadius: "3px",
overflow: "auto",
overflowY: "auto",
padding: "10px",
colorScheme: "dark",
}}
>
{sortedTickets.map(ticket =>
<TicketItem ticket={ticket} key={ticket.id} />
)}
</Box>
</Box>
);
return (
<Box
sx={{
display: "flex",
flex: upMd ? "3 0 0" : undefined,
maxWidth: upMd ? "400px" : undefined,
maxHeight: "600px",
flexDirection: "column",
alignItems: "center",
}}
>
<Box
sx={{
width: "100%",
border: "1px solid",
borderColor: theme.palette.grayDark.main,
borderRadius: "3px",
padding: "10px",
}}
>
<Button
variant="contained"
sx={{
backgroundColor: theme.palette.grayDark.main,
width: "100%",
height: "45px",
fontSize: "15px",
fontWeight: "normal",
textTransform: "capitalize",
"&:hover": {
backgroundColor: theme.palette.menu.main,
},
}}
>
Поиск
<SearchOutlinedIcon />
</Button>
<Button
variant="text"
sx={{
width: "100%",
height: "35px",
fontSize: "14px",
fontWeight: "normal",
color: theme.palette.secondary.main,
border: "1px solid",
borderColor: theme.palette.golden.main,
borderRadius: 0,
"&:hover": {
backgroundColor: theme.palette.menu.main,
},
}}
>
ЗАКРЫТЬ ТИКЕТ
<HighlightOffOutlinedIcon />
</Button>
</Box>
<Box
ref={ticketsBoxRef}
sx={{
width: "100%",
border: "1px solid",
borderColor: theme.palette.grayDark.main,
borderRadius: "3px",
overflow: "auto",
overflowY: "auto",
padding: "10px",
colorScheme: "dark",
}}
>
{sortedTickets.map((ticket) => (
<Box key={ticket.id} onClick={closeCollapse}>
<TicketItem ticket={ticket} setActiveUserId={setActiveUserId} />
</Box>
))}
</Box>
</Box>
);
}
function sortTicketsByUpdateTime(ticket1: Ticket, ticket2: Ticket) {
const date1 = new Date(ticket1.updated_at).getTime();
const date2 = new Date(ticket2.updated_at).getTime();
return date2 - date1;
const date1 = new Date(ticket1.updated_at).getTime();
const date2 = new Date(ticket2.updated_at).getTime();
return date2 - date1;
}
function sortTicketsByUnread(ticket1: Ticket, ticket2: Ticket) {
const isUnread1 = ticket1.user === ticket1.top_message.user_id;
const isUnread2 = ticket2.user === ticket2.top_message.user_id;
return Number(isUnread2) - Number(isUnread1);
const isUnread1 = ticket1.user === ticket1.top_message.user_id;
const isUnread2 = ticket2.user === ticket2.top_message.user_id;
return Number(isUnread2) - Number(isUnread1);
}

@ -187,6 +187,7 @@ export default function CreateTariff() {
data-cy={`select-option-${privilege.description}`}
key={privilege.description}
value={privilege._id}
sx={{whiteSpace: "normal", wordBreak: "break-world"}}
>
{privilege.serviceKey}:{privilege.description}
</MenuItem>

@ -44,7 +44,7 @@ export default function EditModal() {
updatedTariff.name = nameField;
updatedTariff.price = price;
updatedTariff.description = descriptionField;
updatedTariff.order = orderField;
updatedTariff.order = parseInt(orderField);
const [_, putedTariffError] = await putTariff(updatedTariff);

@ -23,14 +23,24 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ConditionalRender from "@root/pages/Setting/ConditionalRender";
import ModalUser from "@root/pages/dashboard/ModalUser";
import ServiceUsersDG from "./ServiceUsersDG";
import { getRegisteredUsers, getManagersList } from "@root/api/user";
import { useUsers, useManagers, useAdmins } from "@root/api/user/swr";
import { getRoles } from "@root/api/privilegies";
import { getRoles_mock, TMockData } from "../../../api/roles";
import theme from "../../../theme";
import type { UserType } from "../../../api/roles";
type Pages = {
adminPage: number;
managerPage: number;
userPage: number;
};
type PagesSize = {
adminPageSize: number;
managerPageSize: number;
userPageSize: number;
};
const Users: React.FC = () => {
const radioboxes = ["admin", "manager", "user"];
@ -39,11 +49,11 @@ const Users: React.FC = () => {
const navigate = useNavigate();
const [data, setData] = React.useState<TMockData>([]);
const [mockData, setMockData] = React.useState<TMockData>([]);
const handleChangeData = () => {
getRoles_mock().then((mockdata) => {
setData(mockdata);
setMockData(mockdata);
setAccordionText(mockdata[0].desc || "");
});
};
@ -53,7 +63,7 @@ const Users: React.FC = () => {
const handleChange = (value: string) => {
setSelectedValue(value);
setAccordionText(data.find(({ name }) => name === value)?.desc || "");
setAccordionText(mockData.find(({ name }) => name === value)?.desc || "");
if (selectedValue === "manager") {
}
@ -64,12 +74,33 @@ const Users: React.FC = () => {
};
const [roles, setRoles] = React.useState<TMockData>([]);
const [users, setUsers] = React.useState<UserType[]>([]);
const [manager, setManager] = React.useState<UserType[]>([]);
const [page, setPage] = useState<Pages>({
adminPage: 0,
managerPage: 0,
userPage: 0,
});
const [pageSize, setPageSize] = useState<PagesSize>({
adminPageSize: 10,
managerPageSize: 10,
userPageSize: 10,
});
const [openUserModal, setOpenUserModal] = useState<boolean>(false);
const [activeUserId, setActiveUserId] = useState<string>("");
const { userId } = useParams();
const { data: adminData, adminPages } = useAdmins(
page.adminPage + 1,
pageSize.adminPageSize
);
const { data: managerData, managerPages } = useManagers(
page.managerPage + 1,
pageSize.managerPageSize
);
const { data: userData, userPagesCount } = useUsers(
page.userPage + 1,
pageSize.userPageSize
);
useEffect(() => {
handleChangeData();
@ -88,18 +119,6 @@ const Users: React.FC = () => {
}, [userId]);
useEffect(() => {
getManagersList().then(([managersListResponse]) => {
if (managersListResponse) {
setManager(managersListResponse.users);
}
});
getRegisteredUsers().then(([registeredUsersResponse]) => {
if (registeredUsersResponse) {
setUsers(registeredUsersResponse.users);
}
});
getRoles().then(([rolesResponse]) => {
if (rolesResponse) {
setRoles(rolesResponse);
@ -107,7 +126,9 @@ const Users: React.FC = () => {
});
}, [selectedValue]);
const [selectedTariffs, setSelectedTariffs] = useState<GridSelectionModel>([]);
const [selectedTariffs, setSelectedTariffs] = useState<GridSelectionModel>(
[]
);
return (
<React.Fragment>
<Button
@ -142,7 +163,9 @@ const Users: React.FC = () => {
<AccordionSummary
sx={{ display: "flex" }}
onClick={handleToggleAccordion}
expandIcon={<ExpandMoreIcon sx={{ color: theme.palette.secondary.main }} />}
expandIcon={
<ExpandMoreIcon sx={{ color: theme.palette.secondary.main }} />
}
aria-controls="panel1a-content"
id="panel1a-header"
>
@ -155,7 +178,7 @@ const Users: React.FC = () => {
{accordionText}
</Typography>
</AccordionSummary>
<AccordionDetails>
<AccordionDetails sx={{overflowX: "auto"}}>
<Table
sx={{
width: "100%",
@ -206,8 +229,8 @@ const Users: React.FC = () => {
</TableHead>
<TableBody>
{data.length ? (
data.map(function (item, index) {
{mockData.length ? (
mockData.map(function (item, index) {
return (
<TableRow
sx={{
@ -390,11 +413,61 @@ const Users: React.FC = () => {
<ConditionalRender
isLoading={false}
role={selectedValue}
childrenManager={<ServiceUsersDG users={manager} handleSelectionChange={setSelectedTariffs} />}
childrenUser={<ServiceUsersDG users={users} handleSelectionChange={setSelectedTariffs} />}
childrenAdmin={
<ServiceUsersDG
users={adminData?.users.length ? adminData.users : []}
page={page.adminPage}
setPage={(adminPage) =>
setPage((pages) => ({ ...pages, adminPage }))
}
pagesCount={adminPages}
pageSize={pageSize.adminPageSize}
handleSelectionChange={setSelectedTariffs}
onPageSizeChange={(adminPageSize) =>
setPageSize((pageSize) => ({ ...pageSize, adminPageSize }))
}
/>
}
childrenManager={
<ServiceUsersDG
users={managerData?.users.length ? managerData.users : []}
page={page.managerPage}
setPage={(managerPage) =>
setPage((pages) => ({ ...pages, managerPage }))
}
pagesCount={managerPages}
pageSize={pageSize.managerPageSize}
handleSelectionChange={setSelectedTariffs}
onPageSizeChange={(managerPageSize) =>
setPageSize((pageSize) => ({ ...pageSize, managerPageSize }))
}
/>
}
childrenUser={
<ServiceUsersDG
users={userData?.users.length ? userData.users : []}
page={page.userPage}
setPage={(userPage) =>
setPage((pages) => ({ ...pages, userPage }))
}
pagesCount={userPagesCount}
pageSize={pageSize.userPageSize}
handleSelectionChange={setSelectedTariffs}
onPageSizeChange={(userPageSize) =>
setPageSize((pageSize) => ({ ...pageSize, userPageSize }))
}
/>
}
/>
</Box>
<ModalUser open={openUserModal} setOpen={setOpenUserModal} userId={activeUserId} />
<ModalUser
open={openUserModal}
userId={activeUserId}
onClose={() => {
setOpenUserModal(false);
navigate(-1);
}}
/>
</React.Fragment>
);
};

@ -172,9 +172,9 @@ const Navigation = (props: Props) => {
};
const Menu: React.FC = () => {
const tablet = useMediaQuery("(max-width:600px)");
const tablet = useMediaQuery("(max-width:900px)");
const mobile = useMediaQuery("(max-width:340px)");
const mobile = useMediaQuery("(max-width:600px)");
const theme = useTheme();
const [open, setOpen] = React.useState(tablet ? false : true);

@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { Box, useTheme, useMediaQuery } from "@mui/material";
import { DataGrid } from "@mui/x-data-grid";
import { format } from "date-fns";
import { useHistory } from "@root/api/history/swr";
import { scrollBlock } from "@root/utils/scrollBlock";
import forwardIcon from "@root/assets/icons/forward.svg";
@ -9,6 +11,10 @@ import forwardIcon from "@root/assets/icons/forward.svg";
import type { ChangeEvent } from "react";
import type { GridColDef } from "@mui/x-data-grid";
type PurchaseTabProps = {
userId: string;
};
const COLUMNS: GridColDef[] = [
{
field: "date",
@ -68,12 +74,22 @@ const ROWS = [
},
];
export const PurchaseTab = () => {
export const PurchaseTab = ({ userId }: PurchaseTabProps) => {
const [canScrollToRight, setCanScrollToRight] = useState<boolean>(true);
const [canScrollToLeft, setCanScrollToLeft] = useState<boolean>(false);
const theme = useTheme();
const smallScreen = useMediaQuery(theme.breakpoints.down(830));
const gridContainer = useRef<HTMLDivElement>(null);
const { data: historyData } = useHistory(userId);
const rows =
historyData?.[0].records.map((history) => ({
id: history.id,
date: format(history.updatedAt, "dd.MM.yyyy"),
time: format(history.updatedAt, "HH:mm"),
product: "",
amount: "",
})) ?? [];
useEffect(() => {
const handleScroll = (nativeEvent: unknown) => {
@ -145,10 +161,8 @@ export const PurchaseTab = () => {
}}
>
<DataGrid
rows={ROWS}
rows={rows}
columns={COLUMNS}
pageSize={5}
rowsPerPageOptions={[5]}
hideFooter
disableColumnMenu
disableSelectionOnClick
@ -239,3 +253,62 @@ export const PurchaseTab = () => {
</Box>
);
};
const a = {
id: "65e4f1b157004756bc5bb15c",
userId: "64eb6ce57047f28fdabf69ec",
comment: "Успешная оплата корзины",
key: "payCart",
rawDetails: [
[
{ Key: "id", Value: "65e4f1881747c1eea8007d3b" },
{
Key: "name",
Value:
"Количество Заявок, Скрытие шильдика в опроснике, 2024-03-03T21:54:16.434Z",
},
{ Key: "price", Value: 0 },
{ Key: "iscustom", Value: true },
{
Key: "privileges",
Value: [
[
{ Key: "id", Value: "" },
{ Key: "name", Value: "Количество Заявок" },
{ Key: "privilegeid", Value: "quizCnt" },
{ Key: "servicekey", Value: "squiz" },
{
Key: "description",
Value: "Количество полных прохождений опросов",
},
{ Key: "amount", Value: 100 },
{ Key: "type", Value: "count" },
{ Key: "value", Value: "заявка" },
{ Key: "price", Value: 2000 },
],
[
{ Key: "id", Value: "" },
{ Key: "name", Value: "Скрытие шильдика в опроснике" },
{ Key: "privilegeid", Value: "squizHideBadge" },
{ Key: "servicekey", Value: "squiz" },
{
Key: "description",
Value: "Количество дней скрытия шильдика в опроснике",
},
{ Key: "amount", Value: 30 },
{ Key: "type", Value: "day" },
{ Key: "value", Value: "день" },
{ Key: "price", Value: 0 },
],
],
},
{ Key: "deleted", Value: false },
{ Key: "createdat", Value: "2024-03-03T21:54:16.825Z" },
{ Key: "updatedat", Value: "2024-03-03T21:54:16.825Z" },
{ Key: "deletedat", Value: null },
],
],
isDeleted: false,
createdAt: "2024-03-03T21:54:57.433Z",
updatedAt: "2024-03-03T21:54:57.433Z",
};

@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { Box, Typography, useTheme, useMediaQuery } from "@mui/material";
import { getUserInfo } from "@root/api/user";
import { userApi } from "@root/api/user/requests";
import { getAccountInfo } from "@root/api/account";
import type { UserType } from "@root/api/roles";
@ -19,7 +19,7 @@ export const UserTab = ({ userId }: UserTabProps) => {
useEffect(() => {
if (userId) {
getUserInfo(userId).then(([userInfo]) => setUser(userInfo));
userApi.getUserInfo(userId).then(([userInfo]) => setUser(userInfo));
getAccountInfo(userId).then(([accountsInfo]) => setAccount(accountsInfo));
}
}, []);

@ -1,5 +1,4 @@
import { useState } from "react";
import { Link, useLinkClickHandler } from "react-router-dom";
import {
Box,
Modal,
@ -11,7 +10,6 @@ import {
useTheme,
useMediaQuery,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import { UserTab } from "./UserTab";
import { PurchaseTab } from "./PurchaseTab";
@ -22,6 +20,7 @@ import { ReactComponent as UserIcon } from "@root/assets/icons/user.svg";
import { ReactComponent as PackageIcon } from "@root/assets/icons/package.svg";
import { ReactComponent as TransactionsIcon } from "@root/assets/icons/transactions.svg";
import { ReactComponent as CheckIcon } from "@root/assets/icons/check.svg";
import { ReactComponent as CloseIcon } from "@root/assets/icons/close.svg";
import forwardIcon from "@root/assets/icons/forward.svg";
@ -44,17 +43,16 @@ const TABS = [
type ModalUserProps = {
open: boolean;
setOpen: (isOpened: boolean) => void;
onClose: () => void;
userId: string;
};
const ModalUser = ({ open, setOpen, userId }: ModalUserProps) => {
const ModalUser = ({ open, onClose, userId }: ModalUserProps) => {
const [value, setValue] = useState<number>(0);
const [openNavigation, setOpenNavigation] = useState<boolean>(false);
const theme = useTheme();
const tablet = useMediaQuery(theme.breakpoints.down(1070));
const mobile = useMediaQuery(theme.breakpoints.down(700));
const navigate = useNavigate();
return (
<>
@ -63,10 +61,7 @@ const ModalUser = ({ open, setOpen, userId }: ModalUserProps) => {
aria-labelledby="transition-modal-title"
aria-describedby="transition-modal-description"
open
onClose={() => {
setOpen(false);
navigate(-1);
}}
onClose={onClose}
closeAfterTransition
BackdropComponent={Backdrop}
BackdropProps={{
@ -93,6 +88,14 @@ const ModalUser = ({ open, setOpen, userId }: ModalUserProps) => {
overflowX: "hidden",
}}
>
{mobile && (
<Box
onClick={onClose}
sx={{ position: "absolute", top: "10px", right: "5px" }}
>
<CloseIcon />
</Box>
)}
<Typography
id="transition-modal-title"
variant="caption"
@ -188,7 +191,7 @@ const ModalUser = ({ open, setOpen, userId }: ModalUserProps) => {
}}
>
{value === 0 && <UserTab userId={userId} />}
{value === 1 && <PurchaseTab />}
{value === 1 && <PurchaseTab userId={userId} />}
{value === 2 && <TransactionsTab />}
{value === 3 && <VerificationTab userId={userId} />}
</Box>

@ -1,33 +0,0 @@
import { Promocode } from "@root/model/cart";
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
interface PromocodeStore {
promocodes: Promocode[];
addPromocodes: (newPromocodes: Promocode[]) => void;
deletePromocodes: (promocodeIdsToDelete: string[]) => void;
}
export const usePromocodeStore = create<PromocodeStore>()(
devtools(
// persist(
(set, get) => ({
promocodes: [],
addPromocodes: newPromocodes => set(state => (
{ promocodes: [...state.promocodes, ...newPromocodes] }
)),
deletePromocodes: promocodeIdsToDelete => set(state => (
{ promocodes: state.promocodes.filter(promocode => !promocodeIdsToDelete.includes(promocode.id)) }
)),
}),
// {
// name: "promocodes",
// getStorage: () => localStorage,
// }
// ),
{
name: "Promocode store"
}
)
);

@ -0,0 +1,11 @@
import { keyframes } from "@emotion/react";
export const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`;

@ -4327,6 +4327,11 @@ cli-truncate@^2.1.0:
slice-ansi "^3.0.0"
string-width "^4.2.0"
client-only@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
cliui@^7.0.2:
version "7.0.4"
resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz"
@ -5023,6 +5028,11 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
date-fns@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.3.1.tgz#7581daca0892d139736697717a168afbb908cfed"
integrity sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==
dayjs@^1.10.4:
version "1.11.9"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a"
@ -11801,6 +11811,14 @@ svgo@^2.7.0:
picocolors "^1.0.0"
stable "^0.1.8"
swr@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.5.tgz#063eea0e9939f947227d5ca760cc53696f46446b"
integrity sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==
dependencies:
client-only "^0.0.1"
use-sync-external-store "^1.2.0"
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz"
@ -12360,7 +12378,7 @@ use-debounce@^9.0.4:
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-9.0.4.tgz#51d25d856fbdfeb537553972ce3943b897f1ac85"
integrity sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==
use-sync-external-store@1.2.0:
use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==