commit
c56c7a6e52
6
babel.config.js
Normal file
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
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",
|
||||
|
57
src/__tests__/tickets.test.ts
Normal file
57
src/__tests__/tickets.test.ts
Normal file
@ -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
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;
|
||||
}
|
BIN
src/fonts/GilroyRegular.woff
Normal file
BIN
src/fonts/GilroyRegular.woff
Normal file
Binary file not shown.
4
src/index.css
Normal file
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
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[];
|
213
src/pages/dashboard/Content/Support/Chat/Chat.tsx
Normal file
213
src/pages/dashboard/Content/Support/Chat/Chat.tsx
Normal file
@ -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;
|
||||
}
|
45
src/pages/dashboard/Content/Support/Chat/Message.tsx
Normal file
45
src/pages/dashboard/Content/Support/Chat/Message.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
53
src/pages/dashboard/Content/Support/Collapse.tsx
Normal file
53
src/pages/dashboard/Content/Support/Collapse.tsx
Normal file
@ -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 >
|
||||
|
||||
);
|
||||
}
|
17
src/pages/dashboard/Content/Support/ExpandIcon.tsx
Normal file
17
src/pages/dashboard/Content/Support/ExpandIcon.tsx
Normal file
@ -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;
|
95
src/pages/dashboard/Content/Support/Support.tsx
Normal file
95
src/pages/dashboard/Content/Support/Support.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
126
src/pages/dashboard/Content/Support/TicketList/TicketList.tsx
Normal file
126
src/pages/dashboard/Content/Support/TicketList/TicketList.tsx
Normal file
@ -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
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: [] });
|
94
src/stores/mocks/messages.ts
Normal file
94
src/stores/mocks/messages.ts
Normal file
@ -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"
|
||||
},
|
||||
]
|
49
src/stores/mocks/tickets.ts
Normal file
49
src/stores/mocks/tickets.ts
Normal file
@ -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
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: [] });
|
24
src/theme.ts
24
src/theme.ts
@ -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
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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user