Merge branch 'dev' into 'main'

Dev

See merge request frontend/admin!9
This commit is contained in:
Nastya 2023-04-02 15:25:42 +00:00
commit c56c7a6e52
25 changed files with 1129 additions and 359 deletions

6
babel.config.js Normal file

@ -0,0 +1,6 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript",
],
};

5
jest.config.js Normal file

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};

@ -35,6 +35,7 @@
"react-numeral": "^1.1.1",
"react-router-dom": "^6.3.0",
"react-scripts": "^5.0.1",
"reconnecting-eventsource": "^1.6.2",
"styled-components": "^5.3.5",
"typescript": "^4.8.2",
"web-vitals": "^2.1.4",

@ -0,0 +1,57 @@
import axios from "axios";
const message = "Artem";
describe("tests", () => {
let statusGetTickets: number;
let dataGetTickets: {};
let statusGetMessages: number;
let dataGetMessages: [];
beforeEach(async () => {
await axios({
method: "post",
url: "https://admin.pena.digital/heruvym/getTickets",
data: {
amt: 20,
page: 0,
status: "open",
},
}).then((result) => {
dataGetTickets = result.data;
statusGetTickets = result.status;
});
await axios({
method: "post",
url: "https://admin.pena.digital/heruvym/getMessages",
data: {
amt: 100,
page: 0,
srch: "",
ticket: "cgg25qsvc9gd0bq9ne7g",
},
}).then((result) => {
dataGetMessages = result.data;
statusGetMessages = result.status;
});
});
// добавляем сообщения тикету с id cgg25qsvc9gd0bq9ne7g , вписываем текст в переменную message и проверяем тест
test("test sending messages to tickets", () => {
expect(statusGetTickets).toEqual(200);
// проверяем кличество тикетов отсалось неизменным
expect(dataGetTickets).toMatchObject({ count: 12 });
expect(statusGetMessages).toBe(200);
expect(dataGetMessages[dataGetMessages.length - 1]).toMatchObject({
files: [],
message: message,
request_screenshot: "",
session_id: "6421ccdad01874dcffa8b128",
shown: {},
ticket_id: "cgg25qsvc9gd0bq9ne7g",
user_id: "6421ccdad01874dcffa8b128",
});
});
});

89
src/api/tickets.ts Normal file

@ -0,0 +1,89 @@
import makeRequest from "@root/kitUI/makeRequest";
import { GetMessagesRequest, GetMessagesResponse, GetTicketsRequest, GetTicketsResponse, SendTicketMessageRequest } from "@root/model/ticket";
import ReconnectingEventSource from "reconnecting-eventsource";
const supportApiUrl = "https://admin.pena.digital/heruvym";
export function subscribeToAllTickets({ onMessage, onError, accessToken }: {
accessToken: string;
onMessage: (e: MessageEvent) => void;
onError: (e: Event) => void;
}) {
const url = `${supportApiUrl}/subscribe?Authorization=${accessToken}`;
const eventSource = createEventSource(onMessage, onError, url);
return () => {
eventSource.close();
};
}
export function subscribeToTicketMessages({ onMessage, onError, accessToken, ticketId }: {
accessToken: string;
ticketId: string;
onMessage: (e: MessageEvent) => void;
onError: (e: Event) => void;
}) {
const url = `${supportApiUrl}/ticket?ticket=${ticketId}&Authorization=${accessToken}`;
const eventSource = createEventSource(onMessage, onError, url);
return () => {
eventSource.close();
};
}
export async function getTickets({ body, signal }: {
body: GetTicketsRequest;
signal: AbortSignal;
}): Promise<GetTicketsResponse> {
return makeRequest({
url: `${supportApiUrl}/getTickets`,
method: "POST",
useToken: true,
body,
signal,
}).then(response => {
const result = (response as any).data as GetTicketsResponse;
return result;
});
}
export async function getTicketMessages({ body, signal }: {
body: GetMessagesRequest;
signal: AbortSignal;
}): Promise<GetMessagesResponse> {
return makeRequest({
url: `${supportApiUrl}/getMessages`,
method: "POST",
useToken: true,
body,
signal,
}).then(response => {
const result = (response as any).data as GetMessagesResponse;
return result;
});
}
export async function sendTicketMessage({ body }: {
body: SendTicketMessageRequest;
}) {
return makeRequest({
url: `${supportApiUrl}/send`,
method: "POST",
useToken: true,
body,
});
}
function createEventSource(onMessage: (e: MessageEvent) => void, onError: (e: Event) => void, url: string) {
const eventSource = new ReconnectingEventSource(url);
eventSource.addEventListener("open", () => console.log(`EventSource connected with ${url}`));
eventSource.addEventListener("close", () => console.log(`EventSource closed with ${url}`));
eventSource.addEventListener("message", onMessage);
eventSource.addEventListener("error", onError);
return eventSource;
}

Binary file not shown.

4
src/index.css Normal file

@ -0,0 +1,4 @@
@font-face {
font-family: "GilroyRegular";
src: local("GilroyRegular"), url(./fonts/GilroyRegular.woff) format("woff");
}

@ -18,7 +18,9 @@ import Entities from "@pages/dashboard/Content/Entities";
import Tariffs from "@pages/dashboard/Content/Tariffs";
import DiscountManagement from "@pages/dashboard/Content/DiscountManagement";
import PromocodeManagement from "@pages/dashboard/Content/PromocodeManagement";
import Support from "@pages/dashboard/Content/Support";
import Support from "@root/pages/dashboard/Content/Support/Support";
import "./index.css";
const componentsArray = [
["/users", <Users />],
@ -26,7 +28,8 @@ const componentsArray = [
["/tariffs", <Tariffs />],
["/discounts", <DiscountManagement />],
["/promocode", <PromocodeManagement />],
["/support", <Support />]
["/support", <Support />],
["/support/:ticketId", <Support />],
]
const container = document.getElementById('root');

@ -5,6 +5,7 @@ interface MakeRequest {
body?: unknown
useToken?: boolean
contentType?: boolean
signal?: AbortSignal
}
export default (props: MakeRequest) => {
@ -22,6 +23,7 @@ function makeRequest({
url,
body,
useToken = true,
signal,
contentType = false
}: MakeRequest) {
//В случае 401 рефреш должен попробовать вызваться 1 раз
@ -33,7 +35,8 @@ function makeRequest({
url: url,
method: method,
headers: headers,
data: body
data: body,
signal,
})
.then(response => {
if (response.data && response.data.accessToken) {

66
src/model/ticket.ts Normal file

@ -0,0 +1,66 @@
export interface CreateTicketRequest {
Title: string;
Message: string;
};
export interface CreateTicketResponse {
Ticket: string;
};
export interface SendTicketMessageRequest {
message: string;
ticket: string;
lang: string;
files: string[];
};
export type TicketStatus = "open"; // TODO
export interface GetTicketsRequest {
amt: number;
/** Пагинация начинается с индекса 0 */
page: number;
srch?: string;
status?: TicketStatus;
};
export interface GetTicketsResponse {
count: number;
data: Ticket[] | null;
};
export interface Ticket {
id: string;
user: string;
sess: string;
ans: string;
state: string;
top_message: TicketMessage;
title: string;
created_at: string;
updated_at: string;
rate: number;
};
export interface TicketMessage {
id: string;
ticket_id: string;
user_id: string,
session_id: string;
message: string;
files: string[],
shown: { [key: string]: number; },
request_screenshot: string,
created_at: string;
};
export interface GetMessagesRequest {
amt: number;
page: number;
srch?: string;
ticket: string;
};
export type GetMessagesResponse = TicketMessage[];

@ -0,0 +1,213 @@
import { Box, IconButton, InputAdornment, TextField, Typography, useMediaQuery, useTheme } from "@mui/material";
import { addOrUpdateMessages, clearMessages, setMessages, useMessageStore } from "@root/stores/messages";
import Message from "./Message";
import SendIcon from "@mui/icons-material/Send";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import { KeyboardEvent, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { GetMessagesRequest, TicketMessage } from "@root/model/ticket";
import { getTicketMessages, sendTicketMessage, subscribeToTicketMessages } from "@root/api/tickets";
import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets";
export default function Chat() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const tickets = useTicketStore(state => state.tickets);
const messages = useMessageStore(state => state.messages);
const [messageField, setMessageField] = useState<string>("");
const ticketId = useParams().ticketId;
const chatBoxRef = useRef<HTMLDivElement>(null);
const ticket = tickets.find(ticket => ticket.id === ticketId);
useEffect(function scrollOnNewMessage() {
scrollToBottom();
}, [messages]);
useEffect(function fetchTicketMessages() {
if (!ticketId) return;
const getTicketsBody: GetMessagesRequest = {
amt: 100, // TODO use pagination
page: 0,
ticket: ticketId,
};
const controller = new AbortController();
getTicketMessages({
body: getTicketsBody,
signal: controller.signal,
}).then(result => {
console.log("GetMessagesResponse", result);
setMessages(result);
}).catch(error => {
console.log("Error fetching tickets", error);
enqueueSnackbar(error.message);
});
return () => {
controller.abort();
clearMessages();
};
}, [ticketId]);
useEffect(function subscribeToMessages() {
if (!ticketId) return;
const token = localStorage.getItem("AT");
if (!token) return;
const unsubscribe = subscribeToTicketMessages({
ticketId,
accessToken: token,
onMessage(event) {
try {
const newMessage = JSON.parse(event.data) as TicketMessage;
console.log("SSE: parsed newMessage:", newMessage);
addOrUpdateMessages([newMessage]);
} catch (error) {
console.log("SSE: couldn't parse:", event.data);
console.log("Error parsing message SSE", error);
}
},
onError(event) {
console.log("SSE Error:", event);
},
});
return () => {
unsubscribe();
};
}, [ticketId]);
function scrollToBottom() {
if (!chatBoxRef.current) return;
chatBoxRef.current.scroll({
left: 0,
top: chatBoxRef.current.scrollHeight,
behavior: "smooth",
});
}
function handleSendMessage() {
if (!ticket || !messageField) return;
sendTicketMessage({
body: {
files: [],
lang: "ru",
message: messageField,
ticket: ticket.id,
}
});
setMessageField("");
}
function handleAddAttachment() {
}
function handleTextfieldKeyPress(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}
const sortedMessages = messages.sort(sortMessagesByTime);
return (
<Box sx={{
border: "1px solid",
borderColor: theme.palette.grayDark.main,
height: "600px",
borderRadius: "3px",
p: "8px",
display: "flex",
flex: upMd ? "2 0 0" : undefined,
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: "8px",
}}>
{ticket ?
<>
<Typography>{ticket.title}</Typography>
<Box
ref={chatBoxRef}
sx={{
width: "100%",
backgroundColor: "#46474a",
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: "12px",
p: "8px",
overflow: "auto",
colorScheme: "dark",
}}
>
{sortedMessages.map(message =>
<Message key={message.id} message={message} isSelf={ticket.user !== message.user_id} />
)}
</Box>
<TextField
value={messageField}
onChange={e => setMessageField(e.target.value)}
onKeyPress={handleTextfieldKeyPress}
id="message-input"
placeholder="Написать сообщение"
fullWidth
multiline
maxRows={8}
InputProps={{
style: {
backgroundColor: theme.palette.content.main,
color: theme.palette.secondary.main,
},
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleSendMessage}
sx={{
height: "45px",
width: "45px",
p: 0,
}}
>
<SendIcon sx={{ color: theme.palette.golden.main }} />
</IconButton>
<IconButton
onClick={handleAddAttachment}
sx={{
height: "45px",
width: "45px",
p: 0,
}}
>
<AttachFileIcon sx={{ color: theme.palette.golden.main }} />
</IconButton>
</InputAdornment>
)
}}
InputLabelProps={{
style: {
color: theme.palette.secondary.main,
}
}}
/>
</>
:
<Typography>Выберите тикет</Typography>}
</Box>
);
}
function sortMessagesByTime(message1: TicketMessage, message2: TicketMessage) {
const date1 = new Date(message1.created_at).getTime();
const date2 = new Date(message2.created_at).getTime();
return date1 - date2;
}

@ -0,0 +1,45 @@
import { Box, Typography, useTheme } from "@mui/material";
import { TicketMessage } from "@root/model/ticket";
interface Props {
message: TicketMessage;
isSelf?: boolean;
}
export default function Message({ message, isSelf }: Props) {
const theme = useTheme();
const time = (
<Typography sx={{
fontSize: "12px",
alignSelf: "end",
}}>
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Typography>
);
return (
<Box sx={{
display: "flex",
justifyContent: isSelf ? "end" : "start",
gap: "6px",
}}>
{isSelf && time}
<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">
{message.message}
</Typography>
</Box>
{!isSelf && time}
</Box>
);
}

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

@ -0,0 +1,17 @@
import { useTheme } from "@mui/material";
interface Props {
isExpanded: boolean;
}
export default function ExpandIcon({ isExpanded }: Props) {
const theme = useTheme();
return (
<svg style={{ transform: isExpanded ? "rotate(180deg)" : undefined }} xmlns="http://www.w3.org/2000/svg" width="32" height="33" viewBox="0 0 32 33" fill="none">
<path stroke={isExpanded ? theme.palette.golden.main : theme.palette.goldenDark.main} d="M16 28.7949C22.6274 28.7949 28 23.4223 28 16.7949C28 10.1675 22.6274 4.79492 16 4.79492C9.37258 4.79492 4 10.1675 4 16.7949C4 23.4223 9.37258 28.7949 16 28.7949Z" strokeWidth="2" strokeMiterlimit="10" />
<path stroke={isExpanded ? theme.palette.golden.main : theme.palette.goldenDark.main} d="M20.5 15.2949L16 20.2949L11.5 15.2949" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

@ -1,41 +0,0 @@
import * as React from "react";
import { Box, Pagination } from "@mui/material";
import theme from "../../../../../theme";
const Users: React.FC = () => {
return (
<React.Fragment>
<Box sx={{
width: "100%",
border: "1px solid",
borderColor: theme.palette.grayDark.main,
borderRadius: "3px",
padding: "10px"
}}>
<Box sx={{
borderRadius: "3px",
padding: "10px",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
<Pagination
count = {41}
sx ={{
"& .MuiPaginationItem-root": {
color: theme.palette.secondary.main
},
'.Mui-selected': {
backgroundColor: theme.palette.grayDark.main,
}
}}
/>
</Box>
</Box>
</React.Fragment>
);
}
export default Users;

@ -0,0 +1,95 @@
import { Box, useMediaQuery, useTheme } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import Chat from "./Chat/Chat";
import Collapse from "./Collapse";
import TicketList from "./TicketList/TicketList";
import { getTickets, subscribeToAllTickets } from "@root/api/tickets";
import { GetTicketsRequest, Ticket } from "@root/model/ticket";
import { clearTickets, updateTickets } from "@root/stores/tickets";
import { enqueueSnackbar } from "notistack";
import { clearMessages } from "@root/stores/messages";
const TICKETS_PER_PAGE = 20;
export default function Support() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const [currentPage, setCurrentPage] = useState<number>(0);
const fetchingStateRef = useRef<"idle" | "fetching" | "all fetched">("idle");
useEffect(function fetchTickets() {
const getTicketsBody: GetTicketsRequest = {
amt: TICKETS_PER_PAGE,
page: currentPage,
status: "open",
};
const controller = new AbortController();
fetchingStateRef.current = "fetching";
getTickets({
body: getTicketsBody,
signal: controller.signal,
}).then(result => {
console.log("GetTicketsResponse", result);
if (result.data) {
updateTickets(result.data);
fetchingStateRef.current = "idle";
} else fetchingStateRef.current = "all fetched";
}).catch(error => {
console.log("Error fetching tickets", error);
enqueueSnackbar(error.message);
});
return () => controller.abort();
}, [currentPage]);
useEffect(function subscribeToTickets() {
const token = localStorage.getItem("AT");
if (!token) return;
const unsubscribe = subscribeToAllTickets({
accessToken: token,
onMessage(event) {
try {
const newTicket = JSON.parse(event.data) as Ticket;
console.log("SSE: parsed newTicket:", newTicket);
updateTickets([newTicket]);
} catch (error) {
console.log("SSE: couldn't parse:", event.data);
console.log("Error parsing ticket SSE", error);
}
},
onError(event) {
console.log("SSE Error:", event);
}
});
return () => {
unsubscribe();
clearMessages();
clearTickets();
};
}, []);
const incrementCurrentPage = () => setCurrentPage(prev => prev + 1);
const ticketList = <TicketList fetchingStateRef={fetchingStateRef} incrementCurrentPage={incrementCurrentPage} />;
return (
<Box sx={{
display: "flex",
width: "100%",
flexDirection: upMd ? "row" : "column",
gap: "12px",
}}>
{!upMd &&
<Collapse headerText="Тикеты">
{ticketList}
</Collapse>
}
<Chat />
{upMd && ticketList}
</Box>
);
}

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

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

@ -1,306 +0,0 @@
import * as React from "react";
import { Box, Button } from "@mui/material";
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined';
import CircleIcon from '@mui/icons-material/Circle';
import theme from "../../../../theme";
import { green } from '@mui/material/colors';
import Pagination from "./Pagination";
const Users: React.FC = () => {
return (
<React.Fragment>
<Box sx={{
width: "100%",
display: "flex",
justifyContent: "space-between"
}}>
<Box sx={{
border: "1px solid",
borderColor: theme.palette.grayDark.main,
width: "53%",
height: "100%",
borderRadius: "3px"
}}></Box>
<Box sx={{
width: "max(40%, 460px)",
height: "540px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "center",
}}>
<Box sx={{
width: "100%",
border: "1px solid",
borderColor: theme.palette.grayDark.main,
borderRadius: "3px",
padding: "10px"
}}>
<Button
variant = "contained"
sx={{
backgroundColor: theme.palette.grayDark.main,
width: "100%",
height: "45px",
fontSize: "15px",
fontWeight: "normal",
textTransform: "capitalize",
"&:hover": {
backgroundColor: theme.palette.menu.main
}
}}>
Поиск
<SearchOutlinedIcon />
</Button>
<Button
variant = "text"
sx={{
width: "100%",
height: "35px",
fontSize: "14px",
fontWeight: "normal",
color: theme.palette.secondary.main,
border: "1px solid",
borderColor: theme.palette.golden.main,
borderRadius: 0,
"&:hover": {
backgroundColor: theme.palette.menu.main
}
}}>
ЗАКРЫТЬ ТИКЕТ
<HighlightOffOutlinedIcon />
</Button>
</Box>
<Box sx={{
width: "100%",
height: "350px",
border: "1px solid",
borderColor: theme.palette.grayDark.main,
borderRadius: "3px",
overflow: "auto",
overflowY: "auto",
padding: "10px"
}}>
<Box sx={{
minHeight: "70px",
display: "flex",
justifyContent: "space-between"
}}>
<Box sx={{
width: "150px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
10.09.2022
</Box>
<Box sx={{
width: "100%",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
ДЕНЬГИ НЕ ПРИШЛИ
</Box>
<Box sx={{
width: "30px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
<CircleIcon sx={{
color: green[ 700 ],
transform: "scale(0.8)"
}} />
</Box>
<Box sx={{
width: "150px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
ИНФО
</Box>
</Box>
<Box sx={{
minHeight: "70px",
display: "flex",
justifyContent: "space-between",
border: "1px solid",
borderColor: theme.palette.golden.main,
backgroundColor: theme.palette.goldenMedium.main
}}>
<Box sx={{
width: "150px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
09.09.2022
</Box>
<Box sx={{
width: "100%",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
ВЫВОД
</Box>
<Box sx={{
width: "30px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
<CircleIcon sx={{
color: green[ 700 ],
transform: "scale(0.8)"
}} />
</Box>
<Box sx={{
width: "150px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
ИНФО
</Box>
</Box>
<Box sx={{
minHeight: "70px",
display: "flex",
justifyContent: "space-between",
border: "1px solid",
borderColor: theme.palette.golden.main,
backgroundColor: theme.palette.goldenMedium.main
}}>
<Box sx={{
width: "150px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
09.09.2022
</Box>
<Box sx={{
width: "100%",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
ЗДРАВСТВУЙТЕ, МОЖНО ЛИ ОПЛАТИТЬ ЛИЦОМ НЕ ДОСТИГШИМ 18 ЛЕТ, ОПЛАТИТЬ 300 РУБЛЕЙ ЧЕРЕЗ КИВИ
</Box>
<Box sx={{
width: "30px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
<CircleIcon sx={{
color: green[ 700 ],
transform: "scale(0.8)"
}} />
</Box>
<Box sx={{
width: "150px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
ИНФО
</Box>
</Box>
<Box sx={{
minHeight: "70px",
display: "flex",
justifyContent: "space-between"
}}>
<Box sx={{
width: "150px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
07.09.2022
</Box>
<Box sx={{
width: "100%",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
ПРОБЛЕМЫ С ВЫВОДОМ
</Box>
<Box sx={{
width: "30px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
<CircleIcon sx={{
color: green[ 700 ],
transform: "scale(0.8)"
}} />
</Box>
<Box sx={{
width: "150px",
padding: "10px",
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
ИНФО
</Box>
</Box>
</Box>
<Pagination />
</Box>
</Box>
</React.Fragment>
);
}
export default Users;

33
src/stores/messages.ts Normal file

@ -0,0 +1,33 @@
import { TicketMessage } from "@root/model/ticket";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { testMessages } from "./mocks/messages";
interface MessageStore {
messages: TicketMessage[];
}
export const useMessageStore = create<MessageStore>()(
devtools(
(set, get) => ({
messages: testMessages,
}),
{
name: "Message store (admin)"
}
)
);
export const setMessages = (messages: TicketMessage[]) => useMessageStore.setState(({ messages }));
export const addOrUpdateMessages = (receivedMessages: TicketMessage[]) => {
const state = useMessageStore.getState();
const messageIdToMessageMap: { [messageId: string]: TicketMessage; } = {};
[...state.messages, ...receivedMessages].forEach(message => messageIdToMessageMap[message.id] = message);
useMessageStore.setState({ messages: Object.values(messageIdToMessageMap) });
};
export const clearMessages = () => useMessageStore.setState({ messages: [] });

@ -0,0 +1,94 @@
import { TicketMessage } from "@root/model/ticket";
import { nanoid } from "nanoid";
export const testMessages: TicketMessage[] = [
{
"id": nanoid(),
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "Lorem ipsum",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-09T12:16:52.73Z"
},
{
"id": nanoid(),
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-10T15:51:52.73Z"
},
{
"id": nanoid(),
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut ",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-10T19:23:52.73Z"
},
{
"id": nanoid(),
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-10T13:16:52.73Z"
},
{
"id": nanoid(),
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-10T13:16:52.73Z"
},
{
"id": nanoid(),
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-10T13:16:52.73Z"
},
{
"id": nanoid(),
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-10T13:16:52.73Z"
},
{
"id": nanoid(),
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-10T13:16:52.73Z"
},
]

@ -0,0 +1,49 @@
import { Ticket } from "@root/model/ticket";
export const testTickets: Ticket[] = [
{
"id": "cg5irh4vc9g7b3n3tcrg",
"user": "6407625ed01874dcffa8b008",
"sess": "6407625ed01874dcffa8b008",
"ans": "",
"state": "open",
"top_message": {
"id": "cg5irh4vc9g7b3n3tcs0",
"ticket_id": "cg5irh4vc9g7b3n3tcrg",
"user_id": "6407625ed01874dcffa8b008",
"session_id": "6407625ed01874dcffa8b008",
"message": "text",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-10T13:16:52.73Z"
},
"title": "textual ticket",
"created_at": "2023-03-10T13:16:52.73Z",
"updated_at": "2023-03-10T13:16:52.73Z",
"rate": -1
},
{
"id": "cg55nssvc9g7gddpnsug",
"user": "",
"sess": "",
"ans": "",
"state": "open",
"top_message": {
"id": "cg55nssvc9g7gddpnsv0",
"ticket_id": "cg55nssvc9g7gddpnsug",
"user_id": "",
"session_id": "",
"message": "text",
"files": [],
"shown": {},
"request_screenshot": "",
"created_at": "2023-03-09T22:21:39.822Z"
},
"title": "textual ticket",
"created_at": "2023-03-09T22:21:39.822Z",
"updated_at": "2023-03-09T22:21:39.822Z",
"rate": -1
}
];

30
src/stores/tickets.ts Normal file

@ -0,0 +1,30 @@
import { Ticket } from "@root/model/ticket";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface TicketStore {
tickets: Ticket[];
}
export const useTicketStore = create<TicketStore>()(
devtools(
(set, get) => ({
tickets: [],
}),
{
name: "Tickets store (admin)"
}
)
);
export const updateTickets = (receivedTickets: Ticket[]) => {
const state = useTicketStore.getState();
const ticketIdToTicketMap: { [ticketId: string]: Ticket; } = {};
[...state.tickets, ...receivedTickets].forEach(ticket => ticketIdToTicketMap[ticket.id] = ticket);
useTicketStore.setState({ tickets: Object.values(ticketIdToTicketMap) });
};
export const clearTickets = () => useTicketStore.setState({ tickets: [] });

@ -1,5 +1,5 @@
import { Theme } from '@mui/material/styles';
import {createTheme, PaletteColorOptions} from "@mui/material";
import { createTheme, PaletteColorOptions, ThemeOptions } from "@mui/material";
import { deepmerge } from '@mui/utils';
//import { createTheme } from "./types";
@ -17,7 +17,7 @@ declare module '@mui/material/styles' {
interface Theme {
palette: {
primary: {
main: string
main: string;
},
secondary: {
main: string;
@ -54,8 +54,8 @@ declare module '@mui/material/styles' {
},
caption: {
main: string;
}
}
};
};
}
interface PaletteOptions {
@ -125,8 +125,8 @@ const paletteColor = {
main: "#2a2b1d"
}
},
}
const theme = {
};
const theme: ThemeOptions = {
typography: {
body1: {
fontFamily: fontFamily
@ -211,9 +211,15 @@ const theme = {
}
}
]
}
},
MuiButtonBase: {
styleOverrides: {
root: {
fontFamily,
fontSize: "16px",
}
},
},
},
};
export default createTheme(deepmerge(paletteColor, theme));

31
src/utils/throttle.ts Normal file

@ -0,0 +1,31 @@
type ThrottledFunction<T extends (...args: any) => any> = (...args: Parameters<T>) => void;
export function throttle<T extends (...args: any) => any>(func: T, ms: number): ThrottledFunction<T> {
let isThrottled = false;
let savedArgs: Parameters<T> | null;
let savedThis: any;
function wrapper(this: any, ...args: Parameters<T>) {
if (isThrottled) {
savedArgs = args;
savedThis = this;
return;
}
func.apply(this, args);
isThrottled = true;
setTimeout(function () {
isThrottled = false;
if (savedArgs) {
wrapper.apply(savedThis, savedArgs);
savedArgs = savedThis = null;
}
}, ms);
}
return wrapper;
}