feat: creating chats for cards and clients, sending and deleting text messages

This commit is contained in:
2025-03-27 15:15:06 +04:00
parent 3bbdacdd68
commit f6c55012bd
37 changed files with 967 additions and 21 deletions

View File

@@ -31,6 +31,7 @@ export type { BaseCardTagSchema } from './models/BaseCardTagSchema';
export type { BaseEnumListSchema } from './models/BaseEnumListSchema';
export type { BaseEnumSchema } from './models/BaseEnumSchema';
export type { BaseMarketplaceSchema } from './models/BaseMarketplaceSchema';
export type { BaseMessageSchema } from './models/BaseMessageSchema';
export type { BaseProjectSchema } from './models/BaseProjectSchema';
export type { BaseShippingWarehouseSchema } from './models/BaseShippingWarehouseSchema';
export type { BaseStatusSchema } from './models/BaseStatusSchema';
@@ -114,6 +115,7 @@ export type { CardUpdateServiceQuantityRequest } from './models/CardUpdateServic
export type { CardUpdateServiceQuantityResponse } from './models/CardUpdateServiceQuantityResponse';
export type { CardUpdateServiceRequest } from './models/CardUpdateServiceRequest';
export type { CardUpdateServiceResponse } from './models/CardUpdateServiceResponse';
export type { ChatSchema } from './models/ChatSchema';
export type { CityBreakdownFromExcelSchema } from './models/CityBreakdownFromExcelSchema';
export type { ClientCreateRequest } from './models/ClientCreateRequest';
export type { ClientCreateResponse } from './models/ClientCreateResponse';
@@ -142,6 +144,8 @@ export type { CreateCardBillResponse } from './models/CreateCardBillResponse';
export type { CreateCardGroupRequest } from './models/CreateCardGroupRequest';
export type { CreateCardsFromExcelRequest } from './models/CreateCardsFromExcelRequest';
export type { CreateCardsFromExcelResponse } from './models/CreateCardsFromExcelResponse';
export type { CreateChatRequest } from './models/CreateChatRequest';
export type { CreateChatResponse } from './models/CreateChatResponse';
export type { CreateDepartmentRequest } from './models/CreateDepartmentRequest';
export type { CreateDepartmentResponse } from './models/CreateDepartmentResponse';
export type { CreateDepartmentSectionRequest } from './models/CreateDepartmentSectionRequest';
@@ -185,6 +189,7 @@ export type { DeleteDepartmentResponse } from './models/DeleteDepartmentResponse
export type { DeleteDepartmentSectionResponse } from './models/DeleteDepartmentSectionResponse';
export type { DeleteMarketplaceRequest } from './models/DeleteMarketplaceRequest';
export type { DeleteMarketplaceResponse } from './models/DeleteMarketplaceResponse';
export type { DeleteMessageResponse } from './models/DeleteMessageResponse';
export type { DeletePalletResponse } from './models/DeletePalletResponse';
export type { DeletePaymentRecordRequest } from './models/DeletePaymentRecordRequest';
export type { DeletePaymentRecordResponse } from './models/DeletePaymentRecordResponse';
@@ -243,11 +248,15 @@ export type { GetBoardsResponse } from './models/GetBoardsResponse';
export type { GetCardBillById } from './models/GetCardBillById';
export type { GetCardProductsBarcodesPdfRequest } from './models/GetCardProductsBarcodesPdfRequest';
export type { GetCardProductsBarcodesPdfResponse } from './models/GetCardProductsBarcodesPdfResponse';
export type { GetChatRequest } from './models/GetChatRequest';
export type { GetChatResponse } from './models/GetChatResponse';
export type { GetClientMarketplacesRequest } from './models/GetClientMarketplacesRequest';
export type { GetClientMarketplacesResponse } from './models/GetClientMarketplacesResponse';
export type { GetDepartmentSectionsResponse } from './models/GetDepartmentSectionsResponse';
export type { GetDepartmentsResponse } from './models/GetDepartmentsResponse';
export type { GetManagersResponse } from './models/GetManagersResponse';
export type { GetMessagesRequest } from './models/GetMessagesRequest';
export type { GetMessagesResponse } from './models/GetMessagesResponse';
export type { GetPaymentRecordsResponse } from './models/GetPaymentRecordsResponse';
export type { GetPlannedWorkShiftsResponse } from './models/GetPlannedWorkShiftsResponse';
export type { GetProductBarcodePdfRequest } from './models/GetProductBarcodePdfRequest';
@@ -276,6 +285,7 @@ export type { ManageEmployeeRequest } from './models/ManageEmployeeRequest';
export type { ManageEmployeeResponse } from './models/ManageEmployeeResponse';
export type { MarketplaceCreateSchema } from './models/MarketplaceCreateSchema';
export type { MarketplaceSchema } from './models/MarketplaceSchema';
export type { MessageSchema } from './models/MessageSchema';
export type { ModuleSchema } from './models/ModuleSchema';
export type { NotificationChannel } from './models/NotificationChannel';
export type { OptionalShippingWarehouseSchema } from './models/OptionalShippingWarehouseSchema';
@@ -328,6 +338,8 @@ export type { ResidualBoxSchema } from './models/ResidualBoxSchema';
export type { ResidualPalletSchema } from './models/ResidualPalletSchema';
export type { ResidualProductSchema } from './models/ResidualProductSchema';
export type { RoleSchema } from './models/RoleSchema';
export type { SendMessageRequest } from './models/SendMessageRequest';
export type { SendMessageResponse } from './models/SendMessageResponse';
export type { ServiceCategoryReorderRequest } from './models/ServiceCategoryReorderRequest';
export type { ServiceCategoryReorderResponse } from './models/ServiceCategoryReorderResponse';
export type { ServiceCategorySchema } from './models/ServiceCategorySchema';
@@ -359,6 +371,8 @@ export type { SwitchTagRequest } from './models/SwitchTagRequest';
export type { SwitchTagResponse } from './models/SwitchTagResponse';
export type { SynchronizeMarketplaceRequest } from './models/SynchronizeMarketplaceRequest';
export type { TaskInfoResponse } from './models/TaskInfoResponse';
export type { TgGroupSchema } from './models/TgGroupSchema';
export type { TgUserSchema } from './models/TgUserSchema';
export type { TimeTrackingData } from './models/TimeTrackingData';
export type { TimeTrackingRecord } from './models/TimeTrackingRecord';
export type { TransactionSchemaBase } from './models/TransactionSchemaBase';
@@ -435,6 +449,7 @@ export { BoardService } from './services/BoardService';
export { CardService } from './services/CardService';
export { CardGroupService } from './services/CardGroupService';
export { CardTagService } from './services/CardTagService';
export { ChatService } from './services/ChatService';
export { ClientService } from './services/ClientService';
export { DepartmentService } from './services/DepartmentService';
export { MarketplaceService } from './services/MarketplaceService';

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type BaseMessageSchema = {
text: string;
chatId: number;
};

View File

@@ -12,6 +12,7 @@ import type { CardProductSchema } from './CardProductSchema';
import type { CardServiceSchema } from './CardServiceSchema';
import type { CardStatusHistorySchema } from './CardStatusHistorySchema';
import type { CardTagSchema } from './CardTagSchema';
import type { ChatSchema } from './ChatSchema';
import type { ClientSchema } from './ClientSchema';
import type { PalletSchema } from './PalletSchema';
import type { ShippingWarehouseSchema } from './ShippingWarehouseSchema';
@@ -42,5 +43,6 @@ export type CardSchema = {
employees?: Array<CardEmployeesSchema>;
tags?: Array<CardTagSchema>;
attributes: Array<CardAttributeSchema>;
chat: (ChatSchema | null);
};

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TgGroupSchema } from './TgGroupSchema';
export type ChatSchema = {
id: number;
clientId: (number | null);
cardId: (number | null);
tgGroup: (TgGroupSchema | null);
};

View File

@@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */
import type { BarcodeTemplateSchema } from './BarcodeTemplateSchema';
import type { ChatSchema } from './ChatSchema';
import type { ClientDetailsSchema } from './ClientDetailsSchema';
import type { ResidualBoxSchema } from './ResidualBoxSchema';
import type { ResidualPalletSchema } from './ResidualPalletSchema';
@@ -13,6 +14,7 @@ export type ClientDetailedSchema = {
barcodeTemplate?: (BarcodeTemplateSchema | null);
comment?: (string | null);
details?: (ClientDetailsSchema | null);
chat?: (ChatSchema | null);
pallets?: Array<ResidualPalletSchema>;
boxes?: Array<ResidualBoxSchema>;
};

View File

@@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */
import type { BarcodeTemplateSchema } from './BarcodeTemplateSchema';
import type { ChatSchema } from './ChatSchema';
import type { ClientDetailsSchema } from './ClientDetailsSchema';
export type ClientSchema = {
id: number;
@@ -11,5 +12,6 @@ export type ClientSchema = {
barcodeTemplate?: (BarcodeTemplateSchema | null);
comment?: (string | null);
details?: (ClientDetailsSchema | null);
chat?: (ChatSchema | null);
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreateChatRequest = {
clientId: number;
cardId: (number | null);
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreateChatResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DeleteMessageResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type GetChatRequest = {
clientId: number;
cardId: (number | null);
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ChatSchema } from './ChatSchema';
export type GetChatResponse = {
chat: (ChatSchema | null);
};

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type GetMessagesRequest = {
chatId: number;
offset: number;
limit: number;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { MessageSchema } from './MessageSchema';
export type GetMessagesResponse = {
messages: Array<MessageSchema>;
};

View File

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TgUserSchema } from './TgUserSchema';
export type MessageSchema = {
text: string;
chatId: number;
id: number;
createdAt: string;
tgSender: (TgUserSchema | null);
status: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { BaseMessageSchema } from './BaseMessageSchema';
export type SendMessageRequest = {
message: BaseMessageSchema;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type SendMessageResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TgGroupSchema = {
tgGroupId: number;
tgInviteLink: string;
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TgUserSchema = {
id: number;
firstName: string;
lastName: string;
username: string;
};

View File

@@ -0,0 +1,119 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CreateChatRequest } from '../models/CreateChatRequest';
import type { CreateChatResponse } from '../models/CreateChatResponse';
import type { DeleteMessageResponse } from '../models/DeleteMessageResponse';
import type { GetChatRequest } from '../models/GetChatRequest';
import type { GetChatResponse } from '../models/GetChatResponse';
import type { GetMessagesRequest } from '../models/GetMessagesRequest';
import type { GetMessagesResponse } from '../models/GetMessagesResponse';
import type { SendMessageRequest } from '../models/SendMessageRequest';
import type { SendMessageResponse } from '../models/SendMessageResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class ChatService {
/**
* Send Message
* @returns SendMessageResponse Successful Response
* @throws ApiError
*/
public static sendMessage({
requestBody,
}: {
requestBody: SendMessageRequest,
}): CancelablePromise<SendMessageResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/chat/message',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete Message
* @returns DeleteMessageResponse Successful Response
* @throws ApiError
*/
public static deleteMessage({
messageId,
}: {
messageId: number,
}): CancelablePromise<DeleteMessageResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/chat/message/{message_id}',
path: {
'message_id': messageId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Chat
* @returns GetChatResponse Successful Response
* @throws ApiError
*/
public static getChat({
requestBody,
}: {
requestBody: GetChatRequest,
}): CancelablePromise<GetChatResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/chat/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Create Chat
* @returns CreateChatResponse Successful Response
* @throws ApiError
*/
public static createChat({
requestBody,
}: {
requestBody: CreateChatRequest,
}): CancelablePromise<CreateChatResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/chat/create',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Messages
* @returns GetMessagesResponse Successful Response
* @throws ApiError
*/
public static getMessages({
requestBody,
}: {
requestBody: GetMessagesRequest,
}): CancelablePromise<GetMessagesResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/chat/messages',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -0,0 +1,45 @@
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { FC } from "react";
import { useClipboard } from "@mantine/hooks";
type Props = {
value: string;
onCopiedLabel: string;
};
export const ActionIconCopy: FC<Props> = ({ onCopiedLabel, value }) => {
const clipboard = useClipboard();
return (
<Tooltip
label={onCopiedLabel}
offset={5}
position="bottom"
radius="xl"
transitionProps={{ duration: 100, transition: "slide-down" }}
opened={clipboard.copied}>
<ActionIcon
variant={"default"}
size="lg"
onClick={() => {
console.log("AKLSKLSKSM");
return clipboard.copy(value);
}}
>
{clipboard.copied ? (
<IconCheck
style={{ width: rem(20), height: rem(20) }}
stroke={1.5}
/>
) : (
<IconCopy
style={{ width: rem(20), height: rem(20) }}
stroke={1.5}
/>
)}
</ActionIcon>
</Tooltip>
);
};
export default ActionIconCopy;

View File

@@ -0,0 +1,20 @@
.message {
@mixin light {
background-color: var(--mantine-color-gray-2);
}
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
border-radius: 1em;
}
.other-message {
@mixin light {
background-color: var(--mantine-color-gray-3);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
border-radius: 1em;
}

View File

@@ -0,0 +1,55 @@
import { ScrollArea, Stack } from "@mantine/core";
import Message from "./components/Message/Message.tsx";
import MessageInput from "./components/MessageInput/MessageInput.tsx";
import { useChatContext } from "../../pages/ClientsPage/contexts/ChatContext.tsx";
import { MessageSchema } from "../../client";
import { ReactNode } from "react";
import ChatDate from "./components/ChatDate/ChatDate.tsx";
const Chat = () => {
const {
messages,
scrollRef,
onScrollPositionChange,
} = useChatContext();
const getChatElements = (): ReactNode[] => {
const elements: ReactNode[] = [];
let prevMessage: MessageSchema | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
const currMessage = messages[i];
if (!prevMessage || prevMessage.createdAt.substring(5, 10) != currMessage.createdAt.substring(5, 10)) {
elements.push((
<ChatDate
key={currMessage.id + "date"}
date={new Date(currMessage.createdAt)}
/>
));
}
elements.push((
<Message key={currMessage.id + "msg"} message={currMessage} />
));
prevMessage = currMessage;
}
return elements;
};
return (
<Stack>
<ScrollArea
viewportRef={scrollRef}
onScrollPositionChange={onScrollPositionChange}
>
<Stack h={"91vh"} pr={"md"} gap={"sm"}>
{getChatElements()}
</Stack>
</ScrollArea>
<MessageInput />
</Stack>
);
};
export default Chat;

View File

@@ -0,0 +1,20 @@
import { Box, Center, Pill } from "@mantine/core";
import styles from "../../Chat.module.css";
type Props = {
date: Date;
}
const ChatDate = ({ date }: Props) => {
return (
<Center>
<Pill size={"md"} className={styles["message"]}>
<Box>
{date.toLocaleDateString("ru-RU", { day: "numeric", month: "long" })}
</Box>
</Pill>
</Center>
);
};
export default ChatDate;

View File

@@ -0,0 +1,78 @@
import { MessageSchema } from "../../../../client";
import { Box, Center, em, Flex, Group, rem } from "@mantine/core";
import { formatDateTime } from "../../../../types/utils.ts";
import { IconAlertCircle, IconCheck, IconClock, IconTrash } from "@tabler/icons-react";
import { useContextMenu } from "mantine-contextmenu";
import useMessage from "./hooks/useMessage.tsx";
import styles from "../../Chat.module.css";
import classNames from "classnames";
type Props = {
message: MessageSchema;
}
enum MessageStatuses {
SUCCESS = "SUCCESS",
ERROR = "ERROR",
SENDING = "SENDING",
}
const Message = ({ message }: Props) => {
const isMine = !message.tgSender;
const isSuccess = message.status == MessageStatuses.SUCCESS;
const { onDeleteMessageClick } = useMessage();
const { showContextMenu } = useContextMenu();
const contextMenu = () => showContextMenu([
{
key: "delete",
onClick: () => onDeleteMessageClick(message),
title: "Удалить",
icon: <IconTrash />,
},
]);
const getStatusIcon = () => {
const size = em(18);
if (message.status == MessageStatuses.ERROR) {
return <IconAlertCircle size={size} />;
}
if (message.status == MessageStatuses.SENDING) {
return <IconClock size={size} />;
}
return <IconCheck size={size} />;
};
return (
<Flex justify={isMine ? "flex-end" : "flex-start"}>
<Box
py={rem(5)}
px={rem(15)}
className={classNames(isMine ? styles["message"] : styles["other-message"])}
onContextMenu={isMine && isSuccess ? contextMenu() : undefined}
>
{!isMine && (
<div>{message.tgSender!.lastName} {message.tgSender!.firstName}</div>
)}
<div>{message.text}</div>
<Group
gap={em(5)}
justify={"flex-end"}
align={"center"}
wrap={"nowrap"}
>
<Center>
{formatDateTime(message.createdAt).substring(11, 16)}
</Center>
{isMine && (
<Center>
{getStatusIcon()}
</Center>
)}
</Group>
</Box>
</Flex>
);
};
export default Message;

View File

@@ -0,0 +1,23 @@
import { ChatService, MessageSchema } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
const useMessage = () => {
const onDeleteMessageClick = (message: MessageSchema) => {
ChatService.deleteMessage({
messageId: message.id,
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
}
})
.catch(err => console.log(err));
};
return {
onDeleteMessageClick,
};
};
export default useMessage;

View File

@@ -0,0 +1,45 @@
import { Button, Group, TextInput, Tooltip } from "@mantine/core";
import { IconSend2 } from "@tabler/icons-react";
import { useChatContext } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx";
import { useForm } from "@mantine/form";
import ActionIconCopy from "../../../ActionIconCopy/ActionIconCopy.tsx";
export type MessageForm = {
message: string;
}
const MessageInput = () => {
const { sendMessage, chat } = useChatContext();
const form = useForm<MessageForm>({
initialValues: {
message: "",
},
});
return (
<form onSubmit={form.onSubmit(values => sendMessage(values, form))}>
<Group wrap={"nowrap"} align={"center"}>
{chat?.tgGroup?.tgInviteLink && (
<Tooltip label={"Ссылка-приглашение"}>
<ActionIconCopy
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
value={chat.tgGroup.tgInviteLink}
/>
</Tooltip>
)}
<TextInput
{...form.getInputProps("message")}
w={"100%"}
/>
<Button
variant={"default"}
type="submit"
>
<IconSend2 />
</Button>
</Group>
</form>
);
};
export default MessageInput;

View File

@@ -0,0 +1,33 @@
import { ChatContextProvider } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx";
import ChatWrapper from "./components/ChatWrapper.tsx";
import { useCardPageContext } from "../../../../pages/CardsPage/contexts/CardPageContext.tsx";
import { Stack } from "@mantine/core";
import InlineButton from "../../../../components/InlineButton/InlineButton.tsx";
import { IconMessagePlus } from "@tabler/icons-react";
import useChatTab from "./hooks/useChatTab.tsx";
const ChatTab = () => {
const { selectedCard } = useCardPageContext();
const { onChatCreateClick } = useChatTab();
if (!selectedCard?.chat) {
return (
<Stack>
<InlineButton
onClick={onChatCreateClick}
>
<IconMessagePlus />
Создать чат
</InlineButton>
</Stack>
);
}
return (
<ChatContextProvider>
<ChatWrapper />
</ChatContextProvider>
);
};
export default ChatTab;

View File

@@ -0,0 +1,21 @@
import { useChatContext } from "../../../../../pages/ClientsPage/contexts/ChatContext.tsx";
import { useEffect } from "react";
import { useCardPageContext } from "../../../../../pages/CardsPage/contexts/CardPageContext.tsx";
import Chat from "../../../../../components/Chat/Chat.tsx";
const ChatWrapper = () => {
const { setChat } = useChatContext();
const { selectedCard } = useCardPageContext();
if (!selectedCard) return;
useEffect(() => {
setChat(selectedCard.chat);
}, []);
return (
<Chat />
);
};
export default ChatWrapper;

View File

@@ -0,0 +1,49 @@
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { ChatService } from "../../../../../client";
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { useCardPageContext } from "../../../../../pages/CardsPage/contexts/CardPageContext.tsx";
const useChatTab = () => {
const { selectedCard, refetchCard } = useCardPageContext();
const createChat = () => {
if (!selectedCard?.clientId) {
notifications.error({ message: "Ошибка создания чата, не указан клиент" });
return;
}
ChatService.createChat({
requestBody: {
clientId: selectedCard.clientId,
cardId: selectedCard.id,
},
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
refetchCard();
})
.catch(err => console.log(err));
};
const onChatCreateClick = () => {
modals.openConfirmModal({
title: "Создание чата",
children: (
<Text size="sm">
Вы уверены что хотите создать чат для сделки {selectedCard?.name}?
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "green" },
onConfirm: createChat,
});
};
return {
onChatCreateClick,
};
};
export default useChatTab;

View File

@@ -128,16 +128,18 @@ const ClientTab = () => {
value={client}
onChange={setClient}
withLabel
disabled={!isEqual(initialValues, form.values)}
disabled={!isEqual(initialValues, form.values) || !!card?.chat}
/>
<Group>
<InlineButton
onClick={handleSelectClient}
disabled={!isEditorDisabled()}
>
Сохранить
</InlineButton>
</Group>
{!card?.chat && (
<Group>
<InlineButton
onClick={handleSelectClient}
disabled={!isEditorDisabled()}
>
Сохранить
</InlineButton>
</Group>
)}
</Stack>
</Fieldset>
{clientDataEditor}

View File

@@ -5,6 +5,7 @@ import ProductAndServiceTab from "./cardModules/cardEditorTabs/ProductAndService
import EmployeesTab from "./cardModules/cardEditorTabs/EmployeesTab/EmployeesTab.tsx";
import ShippingTab from "./cardModules/cardEditorTabs/ShippingTab/ShippingTab.tsx";
import ManagerTab from "./cardModules/cardEditorTabs/ManagersTab/ManagersTab.tsx";
import ChatTab from "./cardModules/cardEditorTabs/ChatTab/ChatTab.tsx";
const connectModules = (modules: ModulesType) => {
@@ -13,6 +14,7 @@ const connectModules = (modules: ModulesType) => {
modules[ModuleNames.EMPLOYEES].tab = <EmployeesTab />;
modules[ModuleNames.SHIPMENT].tab = <ShippingTab />;
modules[ModuleNames.MANAGERS].tab = <ManagerTab />;
modules[ModuleNames.CHAT].tab = <ChatTab />;
return modules;
};

View File

@@ -4,6 +4,7 @@ import {
IconCubeSend,
IconUsersGroup,
IconUserCog,
IconMessage,
} from "@tabler/icons-react";
import ModulesType from "./types.tsx";
import connectModules from "./connectModules.tsx";
@@ -14,6 +15,7 @@ export enum ModuleNames {
SHIPMENT = "shipment",
EMPLOYEES = "employees",
MANAGERS = "managers",
CHAT = "chat",
}
const modules: ModulesType = {
@@ -52,6 +54,13 @@ const modules: ModulesType = {
icon: <IconUserCog />,
}
},
[ModuleNames.CHAT]: {
info: {
label: "Чат",
key: "chat",
icon: <IconMessage />,
}
},
};
export const MODULES = connectModules(modules);

View File

@@ -7,6 +7,8 @@ import { Button } from "@mantine/core";
import { modals } from "@mantine/modals";
import { ClientSchema, ClientService } from "../../client";
import { notifications } from "../../shared/lib/notifications.ts";
import { ChatContextProvider } from "./contexts/ChatContext.tsx";
import ClientChatDrawer from "./drawers/ClientChatDrawer.tsx";
const ClientsPage: FC = () => {
const { clients, refetch } = useClientsList();
@@ -62,11 +64,15 @@ const ClientsPage: FC = () => {
</div>
</PageBlock>
<PageBlock>
<ClientsTable
onChange={onChange}
onDelete={onDelete}
items={clients}
/>
<ChatContextProvider>
<ClientsTable
onChange={onChange}
onDelete={onDelete}
items={clients}
refetch={refetch}
/>
<ClientChatDrawer />
</ChatContextProvider>
</PageBlock>
</div>
);

View File

@@ -0,0 +1,68 @@
import { ChatService, ClientSchema } from "../../../../client";
import { ActionIcon, Text, Tooltip } from "@mantine/core";
import { IconMessage, IconMessagePlus } from "@tabler/icons-react";
import { useChatContext } from "../../contexts/ChatContext.tsx";
import { modals } from "@mantine/modals";
import { notifications } from "../../../../shared/lib/notifications.ts";
type Props = {
client: ClientSchema;
refetch: () => void;
}
const ClientChatButton = ({ client, refetch }: Props) => {
const { setChat } = useChatContext();
const createChat = () => {
console.log("Creating chat")
ChatService.createChat({
requestBody: {
clientId: client.id,
cardId: null,
},
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
refetch();
})
.catch(err => console.log(err));
};
const onCreateChatClick = () => {
modals.openConfirmModal({
title: "Создание чата",
children: (
<Text size="sm">
Вы уверены что хотите создать чат с {client.name}?
</Text>
),
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "green" },
onConfirm: createChat,
});
};
if (client.chat) {
return (
<Tooltip label="Открыть чат">
<ActionIcon
onClick={() => setChat(client.chat!)}
variant={"default"}>
<IconMessage />
</ActionIcon>
</Tooltip>
);
}
return (
<Tooltip label="Создать чат">
<ActionIcon
onClick={onCreateChatClick}
variant={"default"}>
<IconMessagePlus />
</ActionIcon>
</Tooltip>
);
};
export default ClientChatButton;

View File

@@ -7,13 +7,20 @@ import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { modals } from "@mantine/modals";
import ClientChatButton from "../ClientChatButton/ClientChatButton.tsx";
const ClientsTable: FC<CRUDTableProps<ClientSchema>> = ({
items,
onDelete,
onChange,
}) => {
type RefetchProps = {
refetch: () => void;
}
const ClientsTable: FC<CRUDTableProps<ClientSchema> & RefetchProps> = ({
items,
onDelete,
onChange,
refetch,
}) => {
const columns = useClientsTableColumns();
const onEditClick = (client: ClientSchema) => {
if (!onChange) return;
modals.openContextModal({
@@ -26,6 +33,7 @@ const ClientsTable: FC<CRUDTableProps<ClientSchema>> = ({
},
});
};
return (
<>
<BaseTable
@@ -38,6 +46,7 @@ const ClientsTable: FC<CRUDTableProps<ClientSchema>> = ({
enableRowActions: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<ClientChatButton client={row.original} refetch={refetch}/>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() =>
@@ -50,8 +59,7 @@ const ClientsTable: FC<CRUDTableProps<ClientSchema>> = ({
<Tooltip label="Удалить">
<ActionIcon
onClick={() => {
if (onDelete)
onDelete(row.original);
if (onDelete) onDelete(row.original);
}}
variant={"default"}>
<IconTrash />

View File

@@ -0,0 +1,161 @@
import React, { createContext, FC, MutableRefObject, useContext, useEffect, useRef, useState } from "react";
import { ChatSchema, ChatService, MessageSchema } from "../../../client";
import { notifications } from "../../../shared/lib/notifications.ts";
import { MessageForm } from "../../../components/Chat/components/MessageInput/MessageInput.tsx";
import { UseFormReturnType } from "@mantine/form";
import { useDebouncedState } from "@mantine/hooks";
type ChatContextState = {
chat: ChatSchema | null;
setChat: (chat: ChatSchema | null) => void;
messages: MessageSchema[];
onScrollPositionChange: (values: { x: number, y: number }) => void;
scrollRef: MutableRefObject<HTMLDivElement | null>;
sendMessage: (values: MessageForm, form: UseFormReturnType<MessageForm>) => void;
};
const ChatContext = createContext<ChatContextState | undefined>(undefined);
const useChatContextState = () => {
const [chat, setChat] = useState<ChatSchema | null>(null);
const [messages, setMessages] = useState<MessageSchema[]>([]);
const [offset, setOffset] = useState(0);
const limit = 20;
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement | null>(null);
const [isScrollToBottom, setIsScrollToBottom] = useState(true);
const [scrollPosition, setScrollPosition] = useDebouncedState<{ x: number, y: number }>({ x: 0, y: 0 }, 400);
const setChatValue = (chat: ChatSchema | null) => {
if (chat) {
setOffset(0);
setHasMore(true);
setScrollPosition({ x: 0, y: 0 });
}
setChat(chat);
};
console.log(`has more = ${hasMore}`);
const fetchMessages = () => {
if (!chat) return;
ChatService.getMessages({
requestBody: {
chatId: chat.id,
offset,
limit,
},
})
.then(({ messages: newMessages }) => {
if (newMessages.length < limit) setHasMore(false);
setMessages([...newMessages]);
setOffset((prev) => prev + limit);
})
.catch(err => console.log(err));
};
const fetchMessagesOnScroll = () => {
if (!chat) return;
ChatService.getMessages({
requestBody: {
chatId: chat.id,
offset,
limit,
},
})
.then(({ messages: newMessages }) => {
console.log(newMessages);
if (newMessages.length < limit) setHasMore(false);
setMessages((prev) => [...prev, ...newMessages]);
setOffset((prev) => prev + limit);
if (scrollRef.current) {
console.log("FETCH ON SCROLL")
const prevPosition = limit / offset * scrollRef.current.scrollHeight;
console.log(`PREV POSITION = ${prevPosition}`)
scrollRef.current.scrollTo({ top: prevPosition, behavior: "instant" });
}
})
.catch(err => console.log(err));
};
useEffect(() => {
fetchMessages();
const interval = setInterval(fetchMessages, 2000);
return () => clearInterval(interval);
}, [chat]);
useEffect(() => {
if (scrollRef.current && isScrollToBottom && chat) {
setIsScrollToBottom(false);
console.log("Scroll to the bottom");
console.log(`scrollHeight = ${scrollRef.current.scrollHeight}`);
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}
}, [messages, isScrollToBottom]);
useEffect(() => {
if (!chat) return;
console.log(`handleScroll ${Math.abs(scrollPosition.y - 200)}`);
if (Math.abs(scrollPosition.y - 200) <= 200 && hasMore) {
console.log("handleScroll WORK");
fetchMessagesOnScroll();
}
}, [scrollPosition]);
const sendMessage = (values: MessageForm, form: UseFormReturnType<MessageForm>) => {
if (!chat) return;
ChatService.sendMessage({
requestBody: {
message: {
text: values.message,
chatId: chat.id,
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
}
form.reset();
setIsScrollToBottom(true);
})
.catch(err => console.log(err));
};
return {
chat,
setChat: setChatValue,
messages,
onScrollPositionChange: setScrollPosition,
scrollRef,
sendMessage,
};
};
type ChatContextProviderProps = {
children: React.ReactNode;
};
export const ChatContextProvider: FC<ChatContextProviderProps> = ({ children }) => {
const state = useChatContextState();
return (
<ChatContext.Provider value={state}>
{children}
</ChatContext.Provider>
);
};
export const useChatContext = () => {
const context = useContext(ChatContext);
if (!context) {
throw new Error(
"useChatContext must be used within a ChatContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,24 @@
import { useChatContext } from "../contexts/ChatContext.tsx";
import { Drawer } from "@mantine/core";
import Chat from "../../../components/Chat/Chat.tsx";
const ClientChatDrawer = () => {
const { chat, setChat } = useChatContext();
if (!chat) return;
return (
<Drawer
opened={!!chat}
onClose={() => setChat(null)}
position={"right"}
size={"calc(50vw)"}
withCloseButton={false}
>
<Chat />
</Drawer>
);
};
export default ClientChatDrawer;