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", "axios": "^1.4.0",
"craco": "^0.0.3", "craco": "^0.0.3",
"cypress": "^12.17.2", "cypress": "^12.17.2",
"date-fns": "^3.3.1",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"formik": "^2.2.9", "formik": "^2.2.9",
"immer": "^10.0.2", "immer": "^10.0.2",
@ -41,6 +42,7 @@
"reconnecting-eventsource": "^1.6.2", "reconnecting-eventsource": "^1.6.2",
"start-server-and-test": "^2.0.0", "start-server-and-test": "^2.0.0",
"styled-components": "^5.3.5", "styled-components": "^5.3.5",
"swr": "^2.2.5",
"typescript": "^4.8.2", "typescript": "^4.8.2",
"use-debounce": "^9.0.4", "use-debounce": "^9.0.4",
"web-vitals": "^2.1.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 Entities from "@pages/dashboard/Content/Entities";
import Tariffs from "@pages/dashboard/Content/Tariffs"; import Tariffs from "@pages/dashboard/Content/Tariffs";
import DiscountManagement from "@root/pages/dashboard/Content/DiscountManagement/DiscountManagement"; 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 { SettingRoles } from "@pages/Setting/SettingRoles";
import Support from "@pages/dashboard/Content/Support/Support"; import Support from "@pages/dashboard/Content/Support/Support";
import ChatImageNewWindow from "@pages/dashboard/Content/Support/ChatImageNewWindow";
import theme from "./theme"; import theme from "./theme";
import "./index.css"; import "./index.css";
@ -104,9 +105,14 @@ root.render(
} }
/> />
{componentsArray.map((element) => ( {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>
<Route path={"/image/:srcImage"} element={<ChatImageNewWindow />} />
<Route path="*" element={<Error404 />} /> <Route path="*" element={<Error404 />} />
</Routes> </Routes>

@ -11,7 +11,7 @@ import {
Alert, Alert,
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
useTheme useTheme, useMediaQuery
} from "@mui/material"; } from "@mui/material";
import Input from "@kitUI/input"; import Input from "@kitUI/input";
import { useState } from "react"; import { useState } from "react";
@ -29,6 +29,7 @@ import { currencyFormatter } from "@root/utils/currencyFormatter";
export default function Cart() { export default function Cart() {
const theme = useTheme(); const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down(400));
let discounts = useDiscountStore(state => state.discounts); let discounts = useDiscountStore(state => state.discounts);
const cartData = useCartStore((store) => store.cartData); const cartData = useCartStore((store) => store.cartData);
const tariffs = useTariffStore(state => state.tariffs); const tariffs = useTariffStore(state => state.tariffs);
@ -84,6 +85,7 @@ export default function Cart() {
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: "20px", gap: "20px",
flexDirection: mobile ? "column" : undefined
}} }}
> >
<FormControlLabel <FormControlLabel
@ -143,7 +145,7 @@ export default function Cart() {
padding: "3px", padding: "3px",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
ml: "auto", ml: mobile ? 0 : "auto",
}} }}
> >
<Input <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, Checkbox,
Typography, Typography,
FormControlLabel, FormControlLabel,
Button, Button, useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import Logo from "@pages/Logo"; import Logo from "@pages/Logo";
import OutlinedInput from "@kitUI/outlinedInput"; import OutlinedInput from "@kitUI/outlinedInput";
@ -44,7 +44,7 @@ function validate(values: Values) {
const SigninForm = () => { const SigninForm = () => {
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const initialValues: Values = { const initialValues: Values = {
email: "", email: "",
password: "", password: "",
@ -99,6 +99,7 @@ const SigninForm = () => {
"> *": { "> *": {
marginTop: "15px", marginTop: "15px",
}, },
padding: isMobile ? "0 16px" : undefined
}} }}
> >
<Logo /> <Logo />

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

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

@ -55,7 +55,8 @@ export default function DeleteForm() {
fullWidth fullWidth
sx={{ sx={{
alignItems: "center", alignItems: "center",
width: "400px", maxWidth: "400px",
width: "100%",
"& .MuiInputBase-root": { "& .MuiInputBase-root": {
backgroundColor: "#F2F3F7", backgroundColor: "#F2F3F7",
height: "48px", 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"; import { CustomWrapper } from "@root/kitUI/CustomWrapper";
@ -9,15 +19,19 @@ import { PrivilegesWrapper } from "./PrivilegiesWrapper";
import theme from "../../theme"; import theme from "../../theme";
export const SettingRoles = (): JSX.Element => { export const SettingRoles = (): JSX.Element => {
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down(600));
return ( return (
<AccordionDetails sx={{ width: "890px" }}> <AccordionDetails sx={{ maxWidth: "890px",
width: "100%", }}>
<CustomWrapper <CustomWrapper
text="Роли" text="Роли"
children={ children={
<> <>
<Table <Table
sx={{ sx={{
width: "890px", maxWidth: "890px",
width: "100%",
border: "2px solid", border: "2px solid",
borderColor: "gray", borderColor: "gray",
}} }}
@ -52,8 +66,10 @@ export const SettingRoles = (): JSX.Element => {
alignItems: "center", alignItems: "center",
borderTop: "2px solid", borderTop: "2px solid",
borderColor: theme.palette.grayLight.main, borderColor: theme.palette.grayLight.main,
height: "100px", height: mobile ? undefined : "100px",
cursor: "pointer", cursor: "pointer",
flexDirection: mobile ? "column" : "row",
gap: "5px"
}} }}
> >
<FormCreateRoles /> <FormCreateRoles />
@ -63,7 +79,8 @@ export const SettingRoles = (): JSX.Element => {
<Table <Table
sx={{ sx={{
mt: "30px", mt: "30px",
width: "890px", maxWidth: "890px",
width: "100%",
border: "2px solid", border: "2px solid",
borderColor: "gray", borderColor: "gray",
}} }}
@ -98,8 +115,10 @@ export const SettingRoles = (): JSX.Element => {
alignItems: "center", alignItems: "center",
borderTop: "2px solid", borderTop: "2px solid",
borderColor: theme.palette.grayLight.main, borderColor: theme.palette.grayLight.main,
height: "100px", height: mobile ? undefined : "100px",
cursor: "pointer", cursor: "pointer",
flexDirection: mobile ? "column" : "row",
gap: "5px"
}} }}
> >
<FormDeleteRoles /> <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> </AccordionDetails>
); );
}; };

@ -1,6 +1,6 @@
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { GridSelectionModel } from "@mui/x-data-grid"; 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 { changeDiscount } from "@root/api/discounts";
import { findDiscountsById } from "@root/stores/discounts"; import { findDiscountsById } from "@root/stores/discounts";
@ -11,6 +11,8 @@ interface Props {
selectedRows: GridSelectionModel; selectedRows: GridSelectionModel;
} }
export default function DiscountDataGrid({ selectedRows }: Props) { export default function DiscountDataGrid({ selectedRows }: Props) {
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down(400));
const changeData = async (isActive: boolean) => { const changeData = async (isActive: boolean) => {
let done = 0; let done = 0;
let fatal = 0; let fatal = 0;
@ -46,7 +48,13 @@ export default function DiscountDataGrid({ selectedRows }: Props) {
}; };
return ( 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(false)}>Активировать</Button>
<Button onClick={() => changeData(true)}>Деактивировать</Button> <Button onClick={() => changeData(true)}>Деактивировать</Button>
</Box> </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 { interface Props {
handleSelectionChange: (selectionModel: GridSelectionModel) => void; handleSelectionChange: (selectionModel: GridSelectionModel) => void;
users: UserType[]; users: UserType[];
page: number;
setPage: (page: number) => void;
pageSize: number;
pagesCount: number;
onPageSizeChange?: (count: number) => void;
} }
export default function ServiceUsersDG({ export default function ServiceUsersDG({
handleSelectionChange, handleSelectionChange,
users = [], users = [],
page,
setPage,
pageSize = 10,
pagesCount = 1,
onPageSizeChange,
}: Props) { }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -60,6 +70,13 @@ export default function ServiceUsersDG({
rows={users} rows={users}
columns={columns} columns={columns}
components={{ Toolbar: GridToolbar }} components={{ Toolbar: GridToolbar }}
rowCount={pageSize * pagesCount}
rowsPerPageOptions={[10, 25, 50, 100]}
paginationMode="server"
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={onPageSizeChange}
onSelectionModelChange={handleSelectionChange} onSelectionModelChange={handleSelectionChange}
onCellClick={({ row }, event) => { onCellClick={({ row }, event) => {
event.stopPropagation(); event.stopPropagation();

@ -9,8 +9,39 @@ import { TicketMessage } from "@root/model/ticket";
import { sendTicketMessage } from "@root/api/tickets"; import { sendTicketMessage } from "@root/api/tickets";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets"; 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() { export default function Chat() {
const token = useToken(); const token = useToken();
@ -26,6 +57,8 @@ export default function Chat() {
const isPreventAutoscroll = useMessageStore(state => state.isPreventAutoscroll); const isPreventAutoscroll = useMessageStore(state => state.isPreventAutoscroll);
const fetchState = useMessageStore(state => state.ticketMessagesFetchState); const fetchState = useMessageStore(state => state.ticketMessagesFetchState);
const lastMessageId = useMessageStore(state => state.lastMessageId); const lastMessageId = useMessageStore(state => state.lastMessageId);
const fileInputRef = useRef<HTMLInputElement>(null);
const [disableFileButton, setDisableFileButton] = useState(false);
const ticket = tickets.find(ticket => ticket.id === ticketId); const ticket = tickets.find(ticket => ticket.id === ticketId);
@ -107,7 +140,47 @@ export default function Chat() {
setMessageField(""); 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) { function handleTextfieldKeyPress(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
@ -145,9 +218,66 @@ export default function Chat() {
colorScheme: "dark", colorScheme: "dark",
}} }}
> >
{ticket && messages.map(message => {ticket &&
<Message key={message.id} message={message} isSelf={ticket.user !== message.user_id} /> 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> </Box>
{ticket && {ticket &&
<TextField <TextField
@ -177,13 +307,25 @@ export default function Chat() {
<SendIcon sx={{ color: theme.palette.golden.main }} /> <SendIcon sx={{ color: theme.palette.golden.main }} />
</IconButton> </IconButton>
<IconButton <IconButton
onClick={handleAddAttachment} onClick={() => {
console.log(disableFileButton)
if (!disableFileButton) fileInputRef.current?.click()
}}
sx={{ sx={{
height: "45px", height: "45px",
width: "45px", width: "45px",
p: 0, 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 }} /> <AttachFileIcon sx={{ color: theme.palette.golden.main }} />
</IconButton> </IconButton>
</InputAdornment> </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 { Box, Typography, useTheme } from "@mui/material";
import ExpandIcon from "./ExpandIcon"; import ExpandIcon from "./ExpandIcon";
interface Props { interface Props {
headerText: string; headerText: string;
children: ReactNode; children: (callback: () => void) => ReactNode;
} }
export default function Collapse({ headerText, children }: Props) { export default function Collapse({ headerText, children }: Props) {
const theme = useTheme(); const theme = useTheme();
const [isExpanded, setIsExpanded] = useState<boolean>(false); 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 <Box
sx={{ sx={{
position: "relative", mt: "8px",
}} position: "absolute",
zIndex: 100,
backgroundColor: theme.palette.content.main,
width: "100%",
}}
> >
<Box {children(() => setIsExpanded(false))}
onClick={() => setIsExpanded(prev => !prev)} </Box>
sx={{ )}
height: "72px", </Box>
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 >
);
}

@ -1,61 +1,103 @@
import { useState, useEffect } from "react";
import { Box, useMediaQuery, useTheme } from "@mui/material"; import { Box, useMediaQuery, useTheme } from "@mui/material";
import Chat from "./Chat/Chat"; import Chat from "./Chat/Chat";
import Collapse from "./Collapse"; import Collapse from "./Collapse";
import TicketList from "./TicketList/TicketList"; import TicketList from "./TicketList/TicketList";
import { Ticket } from "@root/model/ticket"; 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 { enqueueSnackbar } from "notistack";
import { clearMessageState } from "@root/stores/messages"; 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() { export default function Support() {
const theme = useTheme(); const [openUserModal, setOpenUserModal] = useState<boolean>(false);
const upMd = useMediaQuery(theme.breakpoints.up("md")); const [activeUserId, setActiveUserId] = useState<string>("");
const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage); const theme = useTheme();
const ticketApiPage = useTicketStore((state) => state.apiPage); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const token = useToken(); const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage);
const ticketApiPage = useTicketStore((state) => state.apiPage);
const token = useToken();
useTicketsFetcher({ useTicketsFetcher({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets", url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets",
ticketsPerPage, ticketsPerPage,
ticketApiPage, ticketApiPage,
onSuccess: result => { onSuccess: (result) => {
if (result.data) updateTickets(result.data); if (result.data) updateTickets(result.data);
}, },
onError: (error: Error) => { onError: (error: Error) => {
const message = getMessageFromFetchError(error); const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message); if (message) enqueueSnackbar(message);
}, },
onFetchStateChange: setTicketsFetchState, onFetchStateChange: setTicketsFetchState,
}); });
useSSESubscription<Ticket>({ useSSESubscription<Ticket>({
enabled: Boolean(token), enabled: Boolean(token),
url: process.env.REACT_APP_DOMAIN + `/heruvym/subscribe?Authorization=${token}`, url:
onNewData: updateTickets, process.env.REACT_APP_DOMAIN +
onDisconnect: () => { `/heruvym/subscribe?Authorization=${token}`,
clearMessageState(); onNewData: updateTickets,
clearTickets(); onDisconnect: () => {
}, clearMessageState();
marker: "ticket" clearTickets();
}); },
marker: "ticket",
});
return ( useEffect(() => {
<Box if (!openUserModal) {
sx={{ setActiveUserId("");
display: "flex", }
width: "100%", }, [openUserModal]);
flexDirection: upMd ? "row" : "column",
gap: "12px", useEffect(() => {
}} if (activeUserId) {
> setOpenUserModal(true);
{!upMd && (
<Collapse headerText="Тикеты"> return;
<TicketList /> }
</Collapse>
)} setOpenUserModal(false);
<Chat /> }, [activeUserId]);
{upMd && <TicketList />}
</Box> 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 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 { green } from "@mui/material/colors";
import { Ticket } from "@root/model/ticket"; import { Ticket } from "@root/model/ticket";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
const flexCenterSx = { const flexCenterSx = {
textAlign: "center", textAlign: "center",
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
padding: "10px", padding: "10px",
}; };
interface Props { interface Props {
ticket: Ticket; ticket: Ticket;
setActiveUserId: (userId: string) => void;
} }
export default function TicketItem({ ticket }: Props) { export default function TicketItem({ ticket, setActiveUserId }: Props) {
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const ticketId = useParams().ticketId; const ticketId = useParams().ticketId;
const isUnread = ticket.user === ticket.top_message.user_id; const isUnread = ticket.user === ticket.top_message.user_id;
const isSelected = ticket.id === ticketId; const isSelected = ticket.id === ticketId;
const unreadSx = { const unreadSx = {
border: "1px solid", border: "1px solid",
borderColor: theme.palette.golden.main, borderColor: theme.palette.golden.main,
backgroundColor: theme.palette.goldenMedium.main backgroundColor: theme.palette.goldenMedium.main,
}; };
const selectedSx = { const selectedSx = {
border: `2px solid ${theme.palette.secondary.main}`, border: `2px solid ${theme.palette.secondary.main}`,
}; };
function handleCardClick() { function handleCardClick() {
navigate(`/support/${ticket.id}`); navigate(`/support/${ticket.id}`);
} }
return ( return (
<Card sx={{ <Card
minHeight: "70px", 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", backgroundColor: "transparent",
color: "white", p: 0,
...(isUnread && unreadSx), }}
...(isSelected && selectedSx), >
}}> <Box sx={flexCenterSx}>
<CardActionArea onClick={handleCardClick}> {new Date(ticket.top_message.created_at).toLocaleDateString()}
<CardHeader </Box>
title={<Typography>{ticket.title}</Typography>} <Box
disableTypography sx={{
sx={{ ...flexCenterSx,
textAlign: "center", overflow: "hidden",
p: "4px", whiteSpace: "nowrap",
}} display: "block",
/> flexGrow: 1,
<Divider /> }}
<CardContent sx={{ >
display: "flex", {ticket.top_message.message}
justifyContent: "space-between", </Box>
backgroundColor: "transparent", <Box sx={flexCenterSx}>
p: 0, <CircleIcon
}}> sx={{
<Box sx={flexCenterSx}> color: green[700],
{new Date(ticket.top_message.created_at).toLocaleDateString()} transform: "scale(0.8)",
</Box> }}
<Box sx={{ />
...flexCenterSx, </Box>
overflow: "hidden", <Box sx={flexCenterSx} onClick={() => setActiveUserId(ticket.user)}>
whiteSpace: "nowrap", ИНФО
display: "block", </Box>
flexGrow: 1, </CardContent>
}}> </CardActionArea>
{ticket.top_message.message} </Card>
</Box> );
<Box sx={flexCenterSx}> }
<CircleIcon sx={{
color: green[700],
transform: "scale(0.8)"
}} />
</Box>
<Box sx={flexCenterSx}>
ИНФО
</Box>
</CardContent>
</CardActionArea>
</Card>
);
}

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

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

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

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

@ -172,9 +172,9 @@ const Navigation = (props: Props) => {
}; };
const Menu: React.FC = () => { 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 theme = useTheme();
const [open, setOpen] = React.useState(tablet ? false : true); const [open, setOpen] = React.useState(tablet ? false : true);

@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Box, useTheme, useMediaQuery } from "@mui/material"; import { Box, useTheme, useMediaQuery } from "@mui/material";
import { DataGrid } from "@mui/x-data-grid"; 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 { scrollBlock } from "@root/utils/scrollBlock";
import forwardIcon from "@root/assets/icons/forward.svg"; 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 { ChangeEvent } from "react";
import type { GridColDef } from "@mui/x-data-grid"; import type { GridColDef } from "@mui/x-data-grid";
type PurchaseTabProps = {
userId: string;
};
const COLUMNS: GridColDef[] = [ const COLUMNS: GridColDef[] = [
{ {
field: "date", field: "date",
@ -68,12 +74,22 @@ const ROWS = [
}, },
]; ];
export const PurchaseTab = () => { export const PurchaseTab = ({ userId }: PurchaseTabProps) => {
const [canScrollToRight, setCanScrollToRight] = useState<boolean>(true); const [canScrollToRight, setCanScrollToRight] = useState<boolean>(true);
const [canScrollToLeft, setCanScrollToLeft] = useState<boolean>(false); const [canScrollToLeft, setCanScrollToLeft] = useState<boolean>(false);
const theme = useTheme(); const theme = useTheme();
const smallScreen = useMediaQuery(theme.breakpoints.down(830)); const smallScreen = useMediaQuery(theme.breakpoints.down(830));
const gridContainer = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
const handleScroll = (nativeEvent: unknown) => { const handleScroll = (nativeEvent: unknown) => {
@ -145,10 +161,8 @@ export const PurchaseTab = () => {
}} }}
> >
<DataGrid <DataGrid
rows={ROWS} rows={rows}
columns={COLUMNS} columns={COLUMNS}
pageSize={5}
rowsPerPageOptions={[5]}
hideFooter hideFooter
disableColumnMenu disableColumnMenu
disableSelectionOnClick disableSelectionOnClick
@ -239,3 +253,62 @@ export const PurchaseTab = () => {
</Box> </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 { useState, useEffect } from "react";
import { Box, Typography, useTheme, useMediaQuery } from "@mui/material"; 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 { getAccountInfo } from "@root/api/account";
import type { UserType } from "@root/api/roles"; import type { UserType } from "@root/api/roles";
@ -19,7 +19,7 @@ export const UserTab = ({ userId }: UserTabProps) => {
useEffect(() => { useEffect(() => {
if (userId) { if (userId) {
getUserInfo(userId).then(([userInfo]) => setUser(userInfo)); userApi.getUserInfo(userId).then(([userInfo]) => setUser(userInfo));
getAccountInfo(userId).then(([accountsInfo]) => setAccount(accountsInfo)); getAccountInfo(userId).then(([accountsInfo]) => setAccount(accountsInfo));
} }
}, []); }, []);

@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useLinkClickHandler } from "react-router-dom";
import { import {
Box, Box,
Modal, Modal,
@ -11,7 +10,6 @@ import {
useTheme, useTheme,
useMediaQuery, useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { UserTab } from "./UserTab"; import { UserTab } from "./UserTab";
import { PurchaseTab } from "./PurchaseTab"; 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 PackageIcon } from "@root/assets/icons/package.svg";
import { ReactComponent as TransactionsIcon } from "@root/assets/icons/transactions.svg"; import { ReactComponent as TransactionsIcon } from "@root/assets/icons/transactions.svg";
import { ReactComponent as CheckIcon } from "@root/assets/icons/check.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"; import forwardIcon from "@root/assets/icons/forward.svg";
@ -44,17 +43,16 @@ const TABS = [
type ModalUserProps = { type ModalUserProps = {
open: boolean; open: boolean;
setOpen: (isOpened: boolean) => void; onClose: () => void;
userId: string; userId: string;
}; };
const ModalUser = ({ open, setOpen, userId }: ModalUserProps) => { const ModalUser = ({ open, onClose, userId }: ModalUserProps) => {
const [value, setValue] = useState<number>(0); const [value, setValue] = useState<number>(0);
const [openNavigation, setOpenNavigation] = useState<boolean>(false); const [openNavigation, setOpenNavigation] = useState<boolean>(false);
const theme = useTheme(); const theme = useTheme();
const tablet = useMediaQuery(theme.breakpoints.down(1070)); const tablet = useMediaQuery(theme.breakpoints.down(1070));
const mobile = useMediaQuery(theme.breakpoints.down(700)); const mobile = useMediaQuery(theme.breakpoints.down(700));
const navigate = useNavigate();
return ( return (
<> <>
@ -63,10 +61,7 @@ const ModalUser = ({ open, setOpen, userId }: ModalUserProps) => {
aria-labelledby="transition-modal-title" aria-labelledby="transition-modal-title"
aria-describedby="transition-modal-description" aria-describedby="transition-modal-description"
open open
onClose={() => { onClose={onClose}
setOpen(false);
navigate(-1);
}}
closeAfterTransition closeAfterTransition
BackdropComponent={Backdrop} BackdropComponent={Backdrop}
BackdropProps={{ BackdropProps={{
@ -93,6 +88,14 @@ const ModalUser = ({ open, setOpen, userId }: ModalUserProps) => {
overflowX: "hidden", overflowX: "hidden",
}} }}
> >
{mobile && (
<Box
onClick={onClose}
sx={{ position: "absolute", top: "10px", right: "5px" }}
>
<CloseIcon />
</Box>
)}
<Typography <Typography
id="transition-modal-title" id="transition-modal-title"
variant="caption" variant="caption"
@ -188,7 +191,7 @@ const ModalUser = ({ open, setOpen, userId }: ModalUserProps) => {
}} }}
> >
{value === 0 && <UserTab userId={userId} />} {value === 0 && <UserTab userId={userId} />}
{value === 1 && <PurchaseTab />} {value === 1 && <PurchaseTab userId={userId} />}
{value === 2 && <TransactionsTab />} {value === 2 && <TransactionsTab />}
{value === 3 && <VerificationTab userId={userId} />} {value === 3 && <VerificationTab userId={userId} />}
</Box> </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" slice-ansi "^3.0.0"
string-width "^4.2.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: cliui@^7.0.2:
version "7.0.4" version "7.0.4"
resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz" 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-mimetype "^2.3.0"
whatwg-url "^8.0.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: dayjs@^1.10.4:
version "1.11.9" version "1.11.9"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a"
@ -11801,6 +11811,14 @@ svgo@^2.7.0:
picocolors "^1.0.0" picocolors "^1.0.0"
stable "^0.1.8" 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: symbol-tree@^3.2.4:
version "3.2.4" version "3.2.4"
resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz" 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" resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-9.0.4.tgz#51d25d856fbdfeb537553972ce3943b897f1ac85"
integrity sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ== 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" version "1.2.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==