From f6c55012bdb505cba63cbd92ff8db0ea263b976f Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Thu, 27 Mar 2025 15:15:06 +0400 Subject: [PATCH 1/5] feat: creating chats for cards and clients, sending and deleting text messages --- src/client/index.ts | 15 ++ src/client/models/BaseMessageSchema.ts | 9 + src/client/models/CardSchema.ts | 2 + src/client/models/ChatSchema.ts | 12 ++ src/client/models/ClientDetailedSchema.ts | 2 + src/client/models/ClientSchema.ts | 2 + src/client/models/CreateChatRequest.ts | 9 + src/client/models/CreateChatResponse.ts | 9 + src/client/models/DeleteMessageResponse.ts | 9 + src/client/models/GetChatRequest.ts | 9 + src/client/models/GetChatResponse.ts | 9 + src/client/models/GetMessagesRequest.ts | 10 ++ src/client/models/GetMessagesResponse.ts | 9 + src/client/models/MessageSchema.ts | 14 ++ src/client/models/SendMessageRequest.ts | 9 + src/client/models/SendMessageResponse.ts | 9 + src/client/models/TgGroupSchema.ts | 9 + src/client/models/TgUserSchema.ts | 11 ++ src/client/services/ChatService.ts | 119 +++++++++++++ .../ActionIconCopy/ActionIconCopy.tsx | 45 +++++ src/components/Chat/Chat.module.css | 20 +++ src/components/Chat/Chat.tsx | 55 ++++++ .../Chat/components/ChatDate/ChatDate.tsx | 20 +++ .../Chat/components/Message/Message.tsx | 78 +++++++++ .../components/Message/hooks/useMessage.tsx | 23 +++ .../components/MessageInput/MessageInput.tsx | 45 +++++ .../cardEditorTabs/ChatTab/ChatTab.tsx | 33 ++++ .../ChatTab/components/ChatWrapper.tsx | 21 +++ .../ChatTab/hooks/useChatTab.tsx | 49 ++++++ .../cardEditorTabs/ClientTab/ClientTab.tsx | 20 ++- src/modules/connectModules.tsx | 2 + src/modules/modules.tsx | 9 + src/pages/ClientsPage/ClientsPage.tsx | 16 +- .../ClientChatButton/ClientChatButton.tsx | 68 ++++++++ .../components/ClientsTable/ClientsTable.tsx | 22 ++- .../ClientsPage/contexts/ChatContext.tsx | 161 ++++++++++++++++++ .../ClientsPage/drawers/ClientChatDrawer.tsx | 24 +++ 37 files changed, 967 insertions(+), 21 deletions(-) create mode 100644 src/client/models/BaseMessageSchema.ts create mode 100644 src/client/models/ChatSchema.ts create mode 100644 src/client/models/CreateChatRequest.ts create mode 100644 src/client/models/CreateChatResponse.ts create mode 100644 src/client/models/DeleteMessageResponse.ts create mode 100644 src/client/models/GetChatRequest.ts create mode 100644 src/client/models/GetChatResponse.ts create mode 100644 src/client/models/GetMessagesRequest.ts create mode 100644 src/client/models/GetMessagesResponse.ts create mode 100644 src/client/models/MessageSchema.ts create mode 100644 src/client/models/SendMessageRequest.ts create mode 100644 src/client/models/SendMessageResponse.ts create mode 100644 src/client/models/TgGroupSchema.ts create mode 100644 src/client/models/TgUserSchema.ts create mode 100644 src/client/services/ChatService.ts create mode 100644 src/components/ActionIconCopy/ActionIconCopy.tsx create mode 100644 src/components/Chat/Chat.module.css create mode 100644 src/components/Chat/Chat.tsx create mode 100644 src/components/Chat/components/ChatDate/ChatDate.tsx create mode 100644 src/components/Chat/components/Message/Message.tsx create mode 100644 src/components/Chat/components/Message/hooks/useMessage.tsx create mode 100644 src/components/Chat/components/MessageInput/MessageInput.tsx create mode 100644 src/modules/cardModules/cardEditorTabs/ChatTab/ChatTab.tsx create mode 100644 src/modules/cardModules/cardEditorTabs/ChatTab/components/ChatWrapper.tsx create mode 100644 src/modules/cardModules/cardEditorTabs/ChatTab/hooks/useChatTab.tsx create mode 100644 src/pages/ClientsPage/components/ClientChatButton/ClientChatButton.tsx create mode 100644 src/pages/ClientsPage/contexts/ChatContext.tsx create mode 100644 src/pages/ClientsPage/drawers/ClientChatDrawer.tsx diff --git a/src/client/index.ts b/src/client/index.ts index a1df743..2c07e16 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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'; diff --git a/src/client/models/BaseMessageSchema.ts b/src/client/models/BaseMessageSchema.ts new file mode 100644 index 0000000..ac9cd04 --- /dev/null +++ b/src/client/models/BaseMessageSchema.ts @@ -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; +}; + diff --git a/src/client/models/CardSchema.ts b/src/client/models/CardSchema.ts index 0759de2..f66884b 100644 --- a/src/client/models/CardSchema.ts +++ b/src/client/models/CardSchema.ts @@ -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; tags?: Array; attributes: Array; + chat: (ChatSchema | null); }; diff --git a/src/client/models/ChatSchema.ts b/src/client/models/ChatSchema.ts new file mode 100644 index 0000000..d5aef89 --- /dev/null +++ b/src/client/models/ChatSchema.ts @@ -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); +}; + diff --git a/src/client/models/ClientDetailedSchema.ts b/src/client/models/ClientDetailedSchema.ts index a183a2a..f166c2b 100644 --- a/src/client/models/ClientDetailedSchema.ts +++ b/src/client/models/ClientDetailedSchema.ts @@ -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; boxes?: Array; }; diff --git a/src/client/models/ClientSchema.ts b/src/client/models/ClientSchema.ts index b59f6af..3c74eb5 100644 --- a/src/client/models/ClientSchema.ts +++ b/src/client/models/ClientSchema.ts @@ -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); }; diff --git a/src/client/models/CreateChatRequest.ts b/src/client/models/CreateChatRequest.ts new file mode 100644 index 0000000..2169420 --- /dev/null +++ b/src/client/models/CreateChatRequest.ts @@ -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); +}; + diff --git a/src/client/models/CreateChatResponse.ts b/src/client/models/CreateChatResponse.ts new file mode 100644 index 0000000..9fbbaed --- /dev/null +++ b/src/client/models/CreateChatResponse.ts @@ -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; +}; + diff --git a/src/client/models/DeleteMessageResponse.ts b/src/client/models/DeleteMessageResponse.ts new file mode 100644 index 0000000..7dfef39 --- /dev/null +++ b/src/client/models/DeleteMessageResponse.ts @@ -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; +}; + diff --git a/src/client/models/GetChatRequest.ts b/src/client/models/GetChatRequest.ts new file mode 100644 index 0000000..7d3340f --- /dev/null +++ b/src/client/models/GetChatRequest.ts @@ -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); +}; + diff --git a/src/client/models/GetChatResponse.ts b/src/client/models/GetChatResponse.ts new file mode 100644 index 0000000..73c192d --- /dev/null +++ b/src/client/models/GetChatResponse.ts @@ -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); +}; + diff --git a/src/client/models/GetMessagesRequest.ts b/src/client/models/GetMessagesRequest.ts new file mode 100644 index 0000000..b2efe10 --- /dev/null +++ b/src/client/models/GetMessagesRequest.ts @@ -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; +}; + diff --git a/src/client/models/GetMessagesResponse.ts b/src/client/models/GetMessagesResponse.ts new file mode 100644 index 0000000..afc29d7 --- /dev/null +++ b/src/client/models/GetMessagesResponse.ts @@ -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; +}; + diff --git a/src/client/models/MessageSchema.ts b/src/client/models/MessageSchema.ts new file mode 100644 index 0000000..e452a3e --- /dev/null +++ b/src/client/models/MessageSchema.ts @@ -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; +}; + diff --git a/src/client/models/SendMessageRequest.ts b/src/client/models/SendMessageRequest.ts new file mode 100644 index 0000000..c1db847 --- /dev/null +++ b/src/client/models/SendMessageRequest.ts @@ -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; +}; + diff --git a/src/client/models/SendMessageResponse.ts b/src/client/models/SendMessageResponse.ts new file mode 100644 index 0000000..f9ebde1 --- /dev/null +++ b/src/client/models/SendMessageResponse.ts @@ -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; +}; + diff --git a/src/client/models/TgGroupSchema.ts b/src/client/models/TgGroupSchema.ts new file mode 100644 index 0000000..c2186d4 --- /dev/null +++ b/src/client/models/TgGroupSchema.ts @@ -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; +}; + diff --git a/src/client/models/TgUserSchema.ts b/src/client/models/TgUserSchema.ts new file mode 100644 index 0000000..ac123b9 --- /dev/null +++ b/src/client/models/TgUserSchema.ts @@ -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; +}; + diff --git a/src/client/services/ChatService.ts b/src/client/services/ChatService.ts new file mode 100644 index 0000000..457c374 --- /dev/null +++ b/src/client/services/ChatService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return __request(OpenAPI, { + method: 'POST', + url: '/chat/messages', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } +} diff --git a/src/components/ActionIconCopy/ActionIconCopy.tsx b/src/components/ActionIconCopy/ActionIconCopy.tsx new file mode 100644 index 0000000..aecab78 --- /dev/null +++ b/src/components/ActionIconCopy/ActionIconCopy.tsx @@ -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 = ({ onCopiedLabel, value }) => { + const clipboard = useClipboard(); + + return ( + + { + console.log("AKLSKLSKSM"); + return clipboard.copy(value); + }} + > + {clipboard.copied ? ( + + ) : ( + + )} + + + ); +}; +export default ActionIconCopy; diff --git a/src/components/Chat/Chat.module.css b/src/components/Chat/Chat.module.css new file mode 100644 index 0000000..97e5cdf --- /dev/null +++ b/src/components/Chat/Chat.module.css @@ -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; +} \ No newline at end of file diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx new file mode 100644 index 0000000..6c6ac35 --- /dev/null +++ b/src/components/Chat/Chat.tsx @@ -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(( + + )); + } + elements.push(( + + )); + prevMessage = currMessage; + } + + return elements; + }; + + return ( + + + + {getChatElements()} + + + + + ); +}; + +export default Chat; diff --git a/src/components/Chat/components/ChatDate/ChatDate.tsx b/src/components/Chat/components/ChatDate/ChatDate.tsx new file mode 100644 index 0000000..0eacbe0 --- /dev/null +++ b/src/components/Chat/components/ChatDate/ChatDate.tsx @@ -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 ( +
+ + + {date.toLocaleDateString("ru-RU", { day: "numeric", month: "long" })} + + +
+ ); +}; + +export default ChatDate; diff --git a/src/components/Chat/components/Message/Message.tsx b/src/components/Chat/components/Message/Message.tsx new file mode 100644 index 0000000..52eef53 --- /dev/null +++ b/src/components/Chat/components/Message/Message.tsx @@ -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: , + }, + ]); + + const getStatusIcon = () => { + const size = em(18); + if (message.status == MessageStatuses.ERROR) { + return ; + } + if (message.status == MessageStatuses.SENDING) { + return ; + } + return ; + }; + + return ( + + + {!isMine && ( +
{message.tgSender!.lastName} {message.tgSender!.firstName}
+ )} +
{message.text}
+ +
+ {formatDateTime(message.createdAt).substring(11, 16)} +
+ {isMine && ( +
+ {getStatusIcon()} +
+ )} +
+
+
+ ); +}; + +export default Message; diff --git a/src/components/Chat/components/Message/hooks/useMessage.tsx b/src/components/Chat/components/Message/hooks/useMessage.tsx new file mode 100644 index 0000000..db0e6a8 --- /dev/null +++ b/src/components/Chat/components/Message/hooks/useMessage.tsx @@ -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; diff --git a/src/components/Chat/components/MessageInput/MessageInput.tsx b/src/components/Chat/components/MessageInput/MessageInput.tsx new file mode 100644 index 0000000..3e5538e --- /dev/null +++ b/src/components/Chat/components/MessageInput/MessageInput.tsx @@ -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({ + initialValues: { + message: "", + }, + }); + + return ( +
sendMessage(values, form))}> + + {chat?.tgGroup?.tgInviteLink && ( + + + + )} + + + +
+ ); +}; + +export default MessageInput; diff --git a/src/modules/cardModules/cardEditorTabs/ChatTab/ChatTab.tsx b/src/modules/cardModules/cardEditorTabs/ChatTab/ChatTab.tsx new file mode 100644 index 0000000..0a7fbfe --- /dev/null +++ b/src/modules/cardModules/cardEditorTabs/ChatTab/ChatTab.tsx @@ -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 ( + + + + Создать чат + + + ); + } + + return ( + + + + ); +}; + +export default ChatTab; diff --git a/src/modules/cardModules/cardEditorTabs/ChatTab/components/ChatWrapper.tsx b/src/modules/cardModules/cardEditorTabs/ChatTab/components/ChatWrapper.tsx new file mode 100644 index 0000000..0c29511 --- /dev/null +++ b/src/modules/cardModules/cardEditorTabs/ChatTab/components/ChatWrapper.tsx @@ -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 ( + + ); +}; + +export default ChatWrapper; diff --git a/src/modules/cardModules/cardEditorTabs/ChatTab/hooks/useChatTab.tsx b/src/modules/cardModules/cardEditorTabs/ChatTab/hooks/useChatTab.tsx new file mode 100644 index 0000000..5373182 --- /dev/null +++ b/src/modules/cardModules/cardEditorTabs/ChatTab/hooks/useChatTab.tsx @@ -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: ( + + Вы уверены что хотите создать чат для сделки {selectedCard?.name}? + + ), + labels: { confirm: "Да", cancel: "Нет" }, + confirmProps: { color: "green" }, + onConfirm: createChat, + }); + }; + + return { + onChatCreateClick, + }; +}; + +export default useChatTab; diff --git a/src/modules/cardModules/cardEditorTabs/ClientTab/ClientTab.tsx b/src/modules/cardModules/cardEditorTabs/ClientTab/ClientTab.tsx index ec5d684..d0c05fc 100644 --- a/src/modules/cardModules/cardEditorTabs/ClientTab/ClientTab.tsx +++ b/src/modules/cardModules/cardEditorTabs/ClientTab/ClientTab.tsx @@ -128,16 +128,18 @@ const ClientTab = () => { value={client} onChange={setClient} withLabel - disabled={!isEqual(initialValues, form.values)} + disabled={!isEqual(initialValues, form.values) || !!card?.chat} /> - - - Сохранить - - + {!card?.chat && ( + + + Сохранить + + + )} {clientDataEditor} diff --git a/src/modules/connectModules.tsx b/src/modules/connectModules.tsx index 3a81211..fd6f5e0 100644 --- a/src/modules/connectModules.tsx +++ b/src/modules/connectModules.tsx @@ -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 = ; modules[ModuleNames.SHIPMENT].tab = ; modules[ModuleNames.MANAGERS].tab = ; + modules[ModuleNames.CHAT].tab = ; return modules; }; diff --git a/src/modules/modules.tsx b/src/modules/modules.tsx index 0b643b4..d3ae3a7 100644 --- a/src/modules/modules.tsx +++ b/src/modules/modules.tsx @@ -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: , } }, + [ModuleNames.CHAT]: { + info: { + label: "Чат", + key: "chat", + icon: , + } + }, }; export const MODULES = connectModules(modules); diff --git a/src/pages/ClientsPage/ClientsPage.tsx b/src/pages/ClientsPage/ClientsPage.tsx index c896dfc..e6e2623 100644 --- a/src/pages/ClientsPage/ClientsPage.tsx +++ b/src/pages/ClientsPage/ClientsPage.tsx @@ -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 = () => { - + + + + ); diff --git a/src/pages/ClientsPage/components/ClientChatButton/ClientChatButton.tsx b/src/pages/ClientsPage/components/ClientChatButton/ClientChatButton.tsx new file mode 100644 index 0000000..ef6cd1e --- /dev/null +++ b/src/pages/ClientsPage/components/ClientChatButton/ClientChatButton.tsx @@ -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: ( + + Вы уверены что хотите создать чат с {client.name}? + + ), + labels: { confirm: "Да", cancel: "Нет" }, + confirmProps: { color: "green" }, + onConfirm: createChat, + }); + }; + + if (client.chat) { + return ( + + setChat(client.chat!)} + variant={"default"}> + + + + ); + } + + return ( + + + + + + ); +}; + +export default ClientChatButton; diff --git a/src/pages/ClientsPage/components/ClientsTable/ClientsTable.tsx b/src/pages/ClientsPage/components/ClientsTable/ClientsTable.tsx index c24cc16..f38db3f 100644 --- a/src/pages/ClientsPage/components/ClientsTable/ClientsTable.tsx +++ b/src/pages/ClientsPage/components/ClientsTable/ClientsTable.tsx @@ -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> = ({ - items, - onDelete, - onChange, -}) => { +type RefetchProps = { + refetch: () => void; +} + +const ClientsTable: FC & RefetchProps> = ({ + items, + onDelete, + onChange, + refetch, + }) => { const columns = useClientsTableColumns(); + const onEditClick = (client: ClientSchema) => { if (!onChange) return; modals.openContextModal({ @@ -26,6 +33,7 @@ const ClientsTable: FC> = ({ }, }); }; + return ( <> > = ({ enableRowActions: true, renderRowActions: ({ row }) => ( + @@ -50,8 +59,7 @@ const ClientsTable: FC> = ({ { - if (onDelete) - onDelete(row.original); + if (onDelete) onDelete(row.original); }} variant={"default"}> diff --git a/src/pages/ClientsPage/contexts/ChatContext.tsx b/src/pages/ClientsPage/contexts/ChatContext.tsx new file mode 100644 index 0000000..23a3a46 --- /dev/null +++ b/src/pages/ClientsPage/contexts/ChatContext.tsx @@ -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; + sendMessage: (values: MessageForm, form: UseFormReturnType) => void; +}; + +const ChatContext = createContext(undefined); + +const useChatContextState = () => { + const [chat, setChat] = useState(null); + const [messages, setMessages] = useState([]); + const [offset, setOffset] = useState(0); + const limit = 20; + const [hasMore, setHasMore] = useState(true); + const scrollRef = useRef(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) => { + 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 = ({ children }) => { + const state = useChatContextState(); + return ( + + {children} + + ); +}; + +export const useChatContext = () => { + const context = useContext(ChatContext); + if (!context) { + throw new Error( + "useChatContext must be used within a ChatContextProvider", + ); + } + return context; +}; diff --git a/src/pages/ClientsPage/drawers/ClientChatDrawer.tsx b/src/pages/ClientsPage/drawers/ClientChatDrawer.tsx new file mode 100644 index 0000000..b3ac99c --- /dev/null +++ b/src/pages/ClientsPage/drawers/ClientChatDrawer.tsx @@ -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 ( + setChat(null)} + position={"right"} + size={"calc(50vw)"} + withCloseButton={false} + > + + + ); +}; + +export default ClientChatDrawer; From 23f0cdc0819bf23ca50d7211bbceff0b83125801 Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Wed, 2 Apr 2025 15:27:57 +0400 Subject: [PATCH 2/5] feat: sending and receiving messages with files, editing text messages --- src/client/index.ts | 13 ++- .../models/Body_send_messages_with_files.ts | 8 ++ src/client/models/EditMessageRequest.ts | 9 ++ ...sageResponse.ts => EditMessageResponse.ts} | 2 +- src/client/models/EditMessageSchema.ts | 10 ++ src/client/models/LoadMessagesResponse.ts | 9 ++ src/client/models/MessageFileSchema.ts | 12 ++ src/client/models/MessageSchema.ts | 3 + .../models/RepeatSendingMessageSchema.ts | 10 ++ .../models/RepeatSendingTextMessageRequest.ts | 9 ++ .../RepeatSendingTextMessageResponse.ts | 9 ++ ...geRequest.ts => SendTextMessageRequest.ts} | 2 +- src/client/models/SendTextMessageResponse.ts | 9 ++ src/client/services/ChatService.ts | 109 ++++++++++++++++-- .../ActionIconCopy/ActionIconCopy.tsx | 6 +- src/components/Chat/Chat.tsx | 9 +- .../components/ChatFile/ChatFile.module.css | 15 +++ .../Chat/components/ChatFile/ChatFile.tsx | 21 ++++ .../ChatFile/components/ChatDocument.tsx | 48 ++++++++ .../ChatFile/components/ChatPhoto.tsx | 44 +++++++ .../components/ChatFile/utils/downloadFile.ts | 14 +++ .../components/ChatFile/utils/getDocLink.ts | 5 + .../Chat/components/Message/Message.tsx | 43 ++++++- .../components/Message/hooks/useMessage.tsx | 25 ++++ .../components/MessageInput/MessageInput.tsx | 86 +++++++++----- .../components/SelectedFile/SelectedFile.tsx | 38 ++++++ src/modals/modals.ts | 9 +- src/modules/modulesFileGen/modulesFileGen.ts | 2 +- .../ClientsPage/contexts/ChatContext.tsx | 108 ++++++++++++++--- 29 files changed, 615 insertions(+), 72 deletions(-) create mode 100644 src/client/models/Body_send_messages_with_files.ts create mode 100644 src/client/models/EditMessageRequest.ts rename src/client/models/{SendMessageResponse.ts => EditMessageResponse.ts} (82%) create mode 100644 src/client/models/EditMessageSchema.ts create mode 100644 src/client/models/LoadMessagesResponse.ts create mode 100644 src/client/models/MessageFileSchema.ts create mode 100644 src/client/models/RepeatSendingMessageSchema.ts create mode 100644 src/client/models/RepeatSendingTextMessageRequest.ts create mode 100644 src/client/models/RepeatSendingTextMessageResponse.ts rename src/client/models/{SendMessageRequest.ts => SendTextMessageRequest.ts} (85%) create mode 100644 src/client/models/SendTextMessageResponse.ts create mode 100644 src/components/Chat/components/ChatFile/ChatFile.module.css create mode 100644 src/components/Chat/components/ChatFile/ChatFile.tsx create mode 100644 src/components/Chat/components/ChatFile/components/ChatDocument.tsx create mode 100644 src/components/Chat/components/ChatFile/components/ChatPhoto.tsx create mode 100644 src/components/Chat/components/ChatFile/utils/downloadFile.ts create mode 100644 src/components/Chat/components/ChatFile/utils/getDocLink.ts create mode 100644 src/components/Chat/components/SelectedFile/SelectedFile.tsx diff --git a/src/client/index.ts b/src/client/index.ts index 2c07e16..f10d6ba 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -40,6 +40,7 @@ export type { BillPaymentStatus } from './models/BillPaymentStatus'; export type { BillStatusUpdateRequest } from './models/BillStatusUpdateRequest'; export type { BoardSchema } from './models/BoardSchema'; export type { Body_parse_deals_excel } from './models/Body_parse_deals_excel'; +export type { Body_send_messages_with_files } from './models/Body_send_messages_with_files'; export type { Body_upload_passport_image } from './models/Body_upload_passport_image'; export type { Body_upload_product_barcode_image } from './models/Body_upload_product_barcode_image'; export type { Body_upload_product_image } from './models/Body_upload_product_image'; @@ -216,6 +217,9 @@ export type { DepartmentSchema } from './models/DepartmentSchema'; export type { DepartmentSectionBaseSchema } from './models/DepartmentSectionBaseSchema'; export type { DepartmentSectionBriefSchema } from './models/DepartmentSectionBriefSchema'; export type { DepartmentSectionSchema } from './models/DepartmentSectionSchema'; +export type { EditMessageRequest } from './models/EditMessageRequest'; +export type { EditMessageResponse } from './models/EditMessageResponse'; +export type { EditMessageSchema } from './models/EditMessageSchema'; export type { FinishPauseByShiftIdResponse } from './models/FinishPauseByShiftIdResponse'; export type { FinishPauseByUserIdResponse } from './models/FinishPauseByUserIdResponse'; export type { FinishShiftByIdResponse } from './models/FinishShiftByIdResponse'; @@ -279,12 +283,14 @@ export type { GetWorkShiftsPlanningDataRequest } from './models/GetWorkShiftsPla export type { GetWorkShiftsResponse } from './models/GetWorkShiftsResponse'; export type { GroupBillRequestSchema } from './models/GroupBillRequestSchema'; export type { HTTPValidationError } from './models/HTTPValidationError'; +export type { LoadMessagesResponse } from './models/LoadMessagesResponse'; export type { LoadReceiptRequest } from './models/LoadReceiptRequest'; export type { LoadReceiptResponse } from './models/LoadReceiptResponse'; 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 { MessageFileSchema } from './models/MessageFileSchema'; export type { MessageSchema } from './models/MessageSchema'; export type { ModuleSchema } from './models/ModuleSchema'; export type { NotificationChannel } from './models/NotificationChannel'; @@ -334,12 +340,15 @@ export type { ProjectGeneralInfoSchema } from './models/ProjectGeneralInfoSchema export type { ProjectSchema } from './models/ProjectSchema'; export type { ReceiptBoxSchema } from './models/ReceiptBoxSchema'; export type { ReceiptPalletSchema } from './models/ReceiptPalletSchema'; +export type { RepeatSendingMessageSchema } from './models/RepeatSendingMessageSchema'; +export type { RepeatSendingTextMessageRequest } from './models/RepeatSendingTextMessageRequest'; +export type { RepeatSendingTextMessageResponse } from './models/RepeatSendingTextMessageResponse'; 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 { SendTextMessageRequest } from './models/SendTextMessageRequest'; +export type { SendTextMessageResponse } from './models/SendTextMessageResponse'; export type { ServiceCategoryReorderRequest } from './models/ServiceCategoryReorderRequest'; export type { ServiceCategoryReorderResponse } from './models/ServiceCategoryReorderResponse'; export type { ServiceCategorySchema } from './models/ServiceCategorySchema'; diff --git a/src/client/models/Body_send_messages_with_files.ts b/src/client/models/Body_send_messages_with_files.ts new file mode 100644 index 0000000..7ae84d3 --- /dev/null +++ b/src/client/models/Body_send_messages_with_files.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_send_messages_with_files = { + files: Array; +}; + diff --git a/src/client/models/EditMessageRequest.ts b/src/client/models/EditMessageRequest.ts new file mode 100644 index 0000000..83116f2 --- /dev/null +++ b/src/client/models/EditMessageRequest.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EditMessageSchema } from './EditMessageSchema'; +export type EditMessageRequest = { + message: EditMessageSchema; +}; + diff --git a/src/client/models/SendMessageResponse.ts b/src/client/models/EditMessageResponse.ts similarity index 82% rename from src/client/models/SendMessageResponse.ts rename to src/client/models/EditMessageResponse.ts index f9ebde1..8ad1d1a 100644 --- a/src/client/models/SendMessageResponse.ts +++ b/src/client/models/EditMessageResponse.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type SendMessageResponse = { +export type EditMessageResponse = { ok: boolean; message: string; }; diff --git a/src/client/models/EditMessageSchema.ts b/src/client/models/EditMessageSchema.ts new file mode 100644 index 0000000..044ce39 --- /dev/null +++ b/src/client/models/EditMessageSchema.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type EditMessageSchema = { + text: string; + chatId: number; + id: number; +}; + diff --git a/src/client/models/LoadMessagesResponse.ts b/src/client/models/LoadMessagesResponse.ts new file mode 100644 index 0000000..e5a0d02 --- /dev/null +++ b/src/client/models/LoadMessagesResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type LoadMessagesResponse = { + ok: boolean; + message: string; +}; + diff --git a/src/client/models/MessageFileSchema.ts b/src/client/models/MessageFileSchema.ts new file mode 100644 index 0000000..81b54ea --- /dev/null +++ b/src/client/models/MessageFileSchema.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type MessageFileSchema = { + id: number; + filePath: string; + type: string; + fileName: string; + fileSize: number; +}; + diff --git a/src/client/models/MessageSchema.ts b/src/client/models/MessageSchema.ts index e452a3e..f465e78 100644 --- a/src/client/models/MessageSchema.ts +++ b/src/client/models/MessageSchema.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { MessageFileSchema } from './MessageFileSchema'; import type { TgUserSchema } from './TgUserSchema'; export type MessageSchema = { text: string; @@ -10,5 +11,7 @@ export type MessageSchema = { createdAt: string; tgSender: (TgUserSchema | null); status: string; + isEdited: boolean; + file?: (MessageFileSchema | null); }; diff --git a/src/client/models/RepeatSendingMessageSchema.ts b/src/client/models/RepeatSendingMessageSchema.ts new file mode 100644 index 0000000..e1e68da --- /dev/null +++ b/src/client/models/RepeatSendingMessageSchema.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type RepeatSendingMessageSchema = { + text: string; + chatId: number; + id: number; +}; + diff --git a/src/client/models/RepeatSendingTextMessageRequest.ts b/src/client/models/RepeatSendingTextMessageRequest.ts new file mode 100644 index 0000000..0aedc81 --- /dev/null +++ b/src/client/models/RepeatSendingTextMessageRequest.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { RepeatSendingMessageSchema } from './RepeatSendingMessageSchema'; +export type RepeatSendingTextMessageRequest = { + message: RepeatSendingMessageSchema; +}; + diff --git a/src/client/models/RepeatSendingTextMessageResponse.ts b/src/client/models/RepeatSendingTextMessageResponse.ts new file mode 100644 index 0000000..8cbd44d --- /dev/null +++ b/src/client/models/RepeatSendingTextMessageResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type RepeatSendingTextMessageResponse = { + ok: boolean; + message: string; +}; + diff --git a/src/client/models/SendMessageRequest.ts b/src/client/models/SendTextMessageRequest.ts similarity index 85% rename from src/client/models/SendMessageRequest.ts rename to src/client/models/SendTextMessageRequest.ts index c1db847..c6d0925 100644 --- a/src/client/models/SendMessageRequest.ts +++ b/src/client/models/SendTextMessageRequest.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ import type { BaseMessageSchema } from './BaseMessageSchema'; -export type SendMessageRequest = { +export type SendTextMessageRequest = { message: BaseMessageSchema; }; diff --git a/src/client/models/SendTextMessageResponse.ts b/src/client/models/SendTextMessageResponse.ts new file mode 100644 index 0000000..b611d07 --- /dev/null +++ b/src/client/models/SendTextMessageResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SendTextMessageResponse = { + ok: boolean; + message: string; +}; + diff --git a/src/client/services/ChatService.ts b/src/client/services/ChatService.ts index 457c374..80da7aa 100644 --- a/src/client/services/ChatService.ts +++ b/src/client/services/ChatService.ts @@ -2,29 +2,35 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { Body_send_messages_with_files } from '../models/Body_send_messages_with_files'; import type { CreateChatRequest } from '../models/CreateChatRequest'; import type { CreateChatResponse } from '../models/CreateChatResponse'; import type { DeleteMessageResponse } from '../models/DeleteMessageResponse'; +import type { EditMessageRequest } from '../models/EditMessageRequest'; +import type { EditMessageResponse } from '../models/EditMessageResponse'; 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 { LoadMessagesResponse } from '../models/LoadMessagesResponse'; +import type { RepeatSendingTextMessageRequest } from '../models/RepeatSendingTextMessageRequest'; +import type { RepeatSendingTextMessageResponse } from '../models/RepeatSendingTextMessageResponse'; +import type { SendTextMessageRequest } from '../models/SendTextMessageRequest'; +import type { SendTextMessageResponse } from '../models/SendTextMessageResponse'; 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 + * Send Text Message + * @returns SendTextMessageResponse Successful Response * @throws ApiError */ - public static sendMessage({ + public static sendTextMessage({ requestBody, }: { - requestBody: SendMessageRequest, - }): CancelablePromise { + requestBody: SendTextMessageRequest, + }): CancelablePromise { return __request(OpenAPI, { method: 'POST', url: '/chat/message', @@ -35,6 +41,74 @@ export class ChatService { }, }); } + /** + * Edit Message + * @returns EditMessageResponse Successful Response + * @throws ApiError + */ + public static editMessage({ + requestBody, + }: { + requestBody: EditMessageRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/chat/message', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Repeat Sending Text Message + * @returns RepeatSendingTextMessageResponse Successful Response + * @throws ApiError + */ + public static repeatSendingTextMessage({ + requestBody, + }: { + requestBody: RepeatSendingTextMessageRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/chat/message/repeat', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Send Messages With Files + * @returns LoadMessagesResponse Successful Response + * @throws ApiError + */ + public static sendMessagesWithFiles({ + chatId, + caption, + formData, + }: { + chatId: number, + caption: string, + formData: Body_send_messages_with_files, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/chat/message/files', + query: { + 'chat_id': chatId, + 'caption': caption, + }, + formData: formData, + mediaType: 'multipart/form-data', + errors: { + 422: `Validation Error`, + }, + }); + } /** * Delete Message * @returns DeleteMessageResponse Successful Response @@ -116,4 +190,25 @@ export class ChatService { }, }); } + /** + * Get Tg File + * @returns any Successful Response + * @throws ApiError + */ + public static getTgFileChatTgFileFileIdGet({ + fileId, + }: { + fileId: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/chat/tg-file/{file_id}', + path: { + 'file_id': fileId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } } diff --git a/src/components/ActionIconCopy/ActionIconCopy.tsx b/src/components/ActionIconCopy/ActionIconCopy.tsx index aecab78..1181dc5 100644 --- a/src/components/ActionIconCopy/ActionIconCopy.tsx +++ b/src/components/ActionIconCopy/ActionIconCopy.tsx @@ -15,17 +15,13 @@ export const ActionIconCopy: FC = ({ onCopiedLabel, value }) => { { - console.log("AKLSKLSKSM"); - return clipboard.copy(value); - }} + onClick={() => clipboard.copy(value)} > {clipboard.copied ? ( { /> )); } - elements.push(( + elements.push( - )); + ); prevMessage = currMessage; } @@ -38,12 +38,13 @@ const Chat = () => { }; return ( - + - + {getChatElements()} diff --git a/src/components/Chat/components/ChatFile/ChatFile.module.css b/src/components/Chat/components/ChatFile/ChatFile.module.css new file mode 100644 index 0000000..468ff12 --- /dev/null +++ b/src/components/Chat/components/ChatFile/ChatFile.module.css @@ -0,0 +1,15 @@ +.file-circe { + @mixin light { + background-color: var(--mantine-color-gray-0); + } + @mixin dark { + background-color: var(--mantine-color-dark-4); + } + border-radius: 50%; + align-items: center; + justify-items: center; + padding: 0.8rem; + width: 3rem; + height: 3rem; + cursor: pointer; +} diff --git a/src/components/Chat/components/ChatFile/ChatFile.tsx b/src/components/Chat/components/ChatFile/ChatFile.tsx new file mode 100644 index 0000000..f2fcabb --- /dev/null +++ b/src/components/Chat/components/ChatFile/ChatFile.tsx @@ -0,0 +1,21 @@ +import { MessageFileSchema } from "../../../../client"; +import ChatDocument from "./components/ChatDocument.tsx"; +import ChatPhoto from "./components/ChatPhoto.tsx"; + + +type Props = { + file: MessageFileSchema; +} + +const ChatFile = ({ file }: Props) => { + if (file.type === "photo") { + return ( + + ); + } + return ( + + ); +}; + +export default ChatFile; diff --git a/src/components/Chat/components/ChatFile/components/ChatDocument.tsx b/src/components/Chat/components/ChatFile/components/ChatDocument.tsx new file mode 100644 index 0000000..957bebc --- /dev/null +++ b/src/components/Chat/components/ChatFile/components/ChatDocument.tsx @@ -0,0 +1,48 @@ +import { Center, Group, rem, Stack, Text } from "@mantine/core"; +import { IconFileFilled } from "@tabler/icons-react"; +import { MessageFileSchema } from "../../../../../client"; +import styles from "../ChatFile.module.css"; +import classNames from "classnames"; +import downloadFile from "../utils/downloadFile.ts"; + +type Props = { + file: MessageFileSchema; +} + +const ChatDocument = ({ file }: Props) => { + const round = (value: number) => Math.round(value * 10) / 10; + + const getFileSize = () => { + const BYTES_IN_KB = 1_024; + const BYTES_IN_MB = 1_048_576; + const BYTES_IN_GB = 1_073_741_824; + + if (file.fileSize < BYTES_IN_KB) { + return `${file.fileSize} B`; + } + if (file.fileSize < BYTES_IN_MB) { + return `${round(file.fileSize / BYTES_IN_KB)} KB`; + } + if (file.fileSize < BYTES_IN_GB) { + return `${round(file.fileSize / BYTES_IN_MB)} MB`; + } + return `${round(file.fileSize / BYTES_IN_GB)} GB`; + }; + + return ( + +
downloadFile(file)} + > + +
+ + {file.fileName} + {getFileSize()} + +
+ ); +}; + +export default ChatDocument; diff --git a/src/components/Chat/components/ChatFile/components/ChatPhoto.tsx b/src/components/Chat/components/ChatFile/components/ChatPhoto.tsx new file mode 100644 index 0000000..47f5b76 --- /dev/null +++ b/src/components/Chat/components/ChatFile/components/ChatPhoto.tsx @@ -0,0 +1,44 @@ +import { MessageFileSchema } from "../../../../../client"; +import { Box, Image, Modal } from "@mantine/core"; +import getDocLink from "../utils/getDocLink.ts"; +import { useDisclosure } from "@mantine/hooks"; + + +type Props = { + file: MessageFileSchema; +} + +const ChatPhoto = ({ file }: Props) => { + const [opened, { open, close }] = useDisclosure(false); + + const image = ( + + ); + + return ( + <> + + + {image} + + + + {image} + + + ); +}; + +export default ChatPhoto; diff --git a/src/components/Chat/components/ChatFile/utils/downloadFile.ts b/src/components/Chat/components/ChatFile/utils/downloadFile.ts new file mode 100644 index 0000000..ccf5641 --- /dev/null +++ b/src/components/Chat/components/ChatFile/utils/downloadFile.ts @@ -0,0 +1,14 @@ +import { MessageFileSchema } from "../../../../../client"; +import getDocLink from "./getDocLink.ts"; + + +const downloadFile = (file: MessageFileSchema) => { + const link = document.createElement("a"); + link.href = getDocLink(file.id); + link.download = file.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +export default downloadFile; diff --git a/src/components/Chat/components/ChatFile/utils/getDocLink.ts b/src/components/Chat/components/ChatFile/utils/getDocLink.ts new file mode 100644 index 0000000..75bd530 --- /dev/null +++ b/src/components/Chat/components/ChatFile/utils/getDocLink.ts @@ -0,0 +1,5 @@ +const getDocLink = (fileId: number) => { + return `${import.meta.env.VITE_API_URL}/chat/tg-file/${fileId}`; +}; + +export default getDocLink; diff --git a/src/components/Chat/components/Message/Message.tsx b/src/components/Chat/components/Message/Message.tsx index 52eef53..a7e634e 100644 --- a/src/components/Chat/components/Message/Message.tsx +++ b/src/components/Chat/components/Message/Message.tsx @@ -1,11 +1,12 @@ 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 { IconAlertCircle, IconBrandTelegram, IconCheck, IconClock, IconEdit, 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"; +import ChatFile from "../ChatFile/ChatFile.tsx"; type Props = { message: MessageSchema; @@ -20,18 +21,47 @@ enum MessageStatuses { const Message = ({ message }: Props) => { const isMine = !message.tgSender; const isSuccess = message.status == MessageStatuses.SUCCESS; - const { onDeleteMessageClick } = useMessage(); + const isError = message.status == MessageStatuses.ERROR; + + const { + onDeleteMessageClick, + onEditMessageClick, + onRepeatSendingClick, + } = useMessage(); + const { showContextMenu } = useContextMenu(); - const contextMenu = () => showContextMenu([ + const contextMenuSuccessMsg = () => showContextMenu([ { key: "delete", onClick: () => onDeleteMessageClick(message), title: "Удалить", icon: , }, + { + key: "edit", + onClick: () => onEditMessageClick(message), + title: "Редактировать", + icon: , + }, ]); + const contextMenuErrorMsg = () => showContextMenu([ + { + key: "repeatSending", + onClick: () => onRepeatSendingClick(message), + title: "Повторить отправку", + icon: , + }, + ]) + + const getContext = () => { + if (!isMine) return; + + if (isSuccess) return contextMenuSuccessMsg(); + if (isError) return contextMenuErrorMsg(); + } + const getStatusIcon = () => { const size = em(18); if (message.status == MessageStatuses.ERROR) { @@ -49,11 +79,15 @@ const Message = ({ message }: Props) => { py={rem(5)} px={rem(15)} className={classNames(isMine ? styles["message"] : styles["other-message"])} - onContextMenu={isMine && isSuccess ? contextMenu() : undefined} + onContextMenu={getContext()} + maw={em(600)} > {!isMine && (
{message.tgSender!.lastName} {message.tgSender!.firstName}
)} + {message.file && ( + + )}
{message.text}
{ align={"center"} wrap={"nowrap"} > + {message.isEdited && "ред."}
{formatDateTime(message.createdAt).substring(11, 16)}
diff --git a/src/components/Chat/components/Message/hooks/useMessage.tsx b/src/components/Chat/components/Message/hooks/useMessage.tsx index db0e6a8..4f8d695 100644 --- a/src/components/Chat/components/Message/hooks/useMessage.tsx +++ b/src/components/Chat/components/Message/hooks/useMessage.tsx @@ -1,7 +1,9 @@ import { ChatService, MessageSchema } from "../../../../../client"; import { notifications } from "../../../../../shared/lib/notifications.ts"; +import { useChatContext } from "../../../../../pages/ClientsPage/contexts/ChatContext.tsx"; const useMessage = () => { + const { form } = useChatContext(); const onDeleteMessageClick = (message: MessageSchema) => { ChatService.deleteMessage({ @@ -15,8 +17,31 @@ const useMessage = () => { .catch(err => console.log(err)); }; + const onEditMessageClick = (message: MessageSchema) => { + form.setValues({ + messageId: message.id, + message: message.text, + }); + }; + + const onRepeatSendingClick = (message: MessageSchema) => { + ChatService.repeatSendingTextMessage({ + requestBody: { + message, + }, + }) + .then(({ ok, message }) => { + if (!ok) { + notifications.error({ message }); + } + }) + .catch(err => console.log(err)); + }; + return { onDeleteMessageClick, + onEditMessageClick, + onRepeatSendingClick, }; }; diff --git a/src/components/Chat/components/MessageInput/MessageInput.tsx b/src/components/Chat/components/MessageInput/MessageInput.tsx index 3e5538e..64fce61 100644 --- a/src/components/Chat/components/MessageInput/MessageInput.tsx +++ b/src/components/Chat/components/MessageInput/MessageInput.tsx @@ -1,43 +1,71 @@ -import { Button, Group, TextInput, Tooltip } from "@mantine/core"; -import { IconSend2 } from "@tabler/icons-react"; +import { ActionIcon, Button, Divider, Group, Stack, TextInput, Tooltip } from "@mantine/core"; +import { IconCheck, IconPaperclip, IconSend2, IconX } from "@tabler/icons-react"; import { useChatContext } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx"; -import { useForm } from "@mantine/form"; import ActionIconCopy from "../../../ActionIconCopy/ActionIconCopy.tsx"; +import SelectedFile from "../SelectedFile/SelectedFile.tsx"; +import { useMemo } from "react"; -export type MessageForm = { - message: string; -} const MessageInput = () => { - const { sendMessage, chat } = useChatContext(); - const form = useForm({ - initialValues: { - message: "", - }, - }); + const { + submitMessage, + chat, + form, + files, + fileDialog, + } = useChatContext(); + + const getFiles = useMemo(() => { + return files.map(file => ( + + )); + }, [files]); return ( -
sendMessage(values, form))}> - - {chat?.tgGroup?.tgInviteLink && ( - + submitMessage(values))}> + + + {getFiles} + + {chat?.tgGroup?.tgInviteLink && ( + )} + + + + - )} - - - + + {form.values.messageId && ( + + )} + + +
); }; diff --git a/src/components/Chat/components/SelectedFile/SelectedFile.tsx b/src/components/Chat/components/SelectedFile/SelectedFile.tsx new file mode 100644 index 0000000..805f201 --- /dev/null +++ b/src/components/Chat/components/SelectedFile/SelectedFile.tsx @@ -0,0 +1,38 @@ +import { Box, Group, Text } from "@mantine/core"; +import { IconX } from "@tabler/icons-react"; +import { useChatContext } from "../../../../pages/ClientsPage/contexts/ChatContext.tsx"; + +type Props = { + file: File; +} + +const SelectedFile = ({ file }: Props) => { + const { files, filesHandlers } = useChatContext(); + + const onCancelFileClick = () => { + const idx = files.findIndex(f => f.name === file.name && f.type === file.type); + + if (idx < 0) return; + filesHandlers.remove(idx); + }; + + return ( + + + + {file.name} + + + + + + + ); +}; + +export default SelectedFile; \ No newline at end of file diff --git a/src/modals/modals.ts b/src/modals/modals.ts index cfd5cfd..2de725b 100644 --- a/src/modals/modals.ts +++ b/src/modals/modals.ts @@ -3,13 +3,16 @@ import CreateServiceCategoryModal from "../pages/ServicesPage/modals/CreateServi import CreateServiceModal from "../pages/ServicesPage/modals/CreateServiceModal.tsx"; import createProductModal from "../pages/ProductsPage/modals/CreateProductModal/CreateProductModal.tsx"; import ProductFormModal from "../pages/ClientsPage/modals/ClientFormModal/ClientFormModal.tsx"; -import AddCardServiceModal from "../modules/cardModules/cardEditorTabs/ProductAndServiceTab/modals/AddCardServiceModal.tsx"; -import AddCardProductModal from "../modules/cardModules/cardEditorTabs/ProductAndServiceTab/modals/AddCardProductModal.tsx"; +import AddCardServiceModal + from "../modules/cardModules/cardEditorTabs/ProductAndServiceTab/modals/AddCardServiceModal.tsx"; +import AddCardProductModal + from "../modules/cardModules/cardEditorTabs/ProductAndServiceTab/modals/AddCardProductModal.tsx"; import PrintBarcodeModal from "./PrintBarcodeModal/PrintBarcodeModal.tsx"; import AddBarcodeModal from "./AddBarcodeModal/AddBarcodeModal.tsx"; import BarcodeTemplateFormModal from "../pages/BarcodePage/modals/BarcodeTemplateFormModal/BarcodeTemplateFormModal.tsx"; -import ProductServiceFormModal from "../modules/cardModules/cardEditorTabs/ProductAndServiceTab/modals/ProductServiceFormModal.tsx"; +import ProductServiceFormModal + from "../modules/cardModules/cardEditorTabs/ProductAndServiceTab/modals/ProductServiceFormModal.tsx"; import UserFormModal from "../pages/AdminPage/modals/UserFormModal/UserFormModal.tsx"; import EmployeeSelectModal from "./EmployeeSelectModal/EmployeeSelectModal.tsx"; import EmployeeTableModal from "./EmployeeTableModal/EmployeeTableModal.tsx"; diff --git a/src/modules/modulesFileGen/modulesFileGen.ts b/src/modules/modulesFileGen/modulesFileGen.ts index 1bbd604..bd02f09 100644 --- a/src/modules/modulesFileGen/modulesFileGen.ts +++ b/src/modules/modulesFileGen/modulesFileGen.ts @@ -64,7 +64,7 @@ const generateRows = (modules: Module[]) => { }; const tsxContent = template(data); fs.writeFileSync(OUTPUT_PATH, tsxContent); - console.log("File successfully generated."); + console.log("ChatFile successfully generated."); } catch (error) { console.error(error); } diff --git a/src/pages/ClientsPage/contexts/ChatContext.tsx b/src/pages/ClientsPage/contexts/ChatContext.tsx index 23a3a46..6732af5 100644 --- a/src/pages/ClientsPage/contexts/ChatContext.tsx +++ b/src/pages/ClientsPage/contexts/ChatContext.tsx @@ -1,17 +1,31 @@ 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"; +import { useForm, UseFormReturnType } from "@mantine/form"; +import { useDebouncedState, useFileDialog, useListState, UseListStateHandlers } from "@mantine/hooks"; + +export type MessageForm = { + message: string; + messageId?: number; +} + +type FileDialog = { + files: FileList | null; + open: () => void; + reset: () => void; +} type ChatContextState = { chat: ChatSchema | null; setChat: (chat: ChatSchema | null) => void; messages: MessageSchema[]; + form: UseFormReturnType; onScrollPositionChange: (values: { x: number, y: number }) => void; scrollRef: MutableRefObject; - sendMessage: (values: MessageForm, form: UseFormReturnType) => void; + submitMessage: (values: MessageForm) => void; + files: Array; + filesHandlers: UseListStateHandlers; + fileDialog: FileDialog; }; const ChatContext = createContext(undefined); @@ -26,17 +40,30 @@ const useChatContextState = () => { const [isScrollToBottom, setIsScrollToBottom] = useState(true); const [scrollPosition, setScrollPosition] = useDebouncedState<{ x: number, y: number }>({ x: 0, y: 0 }, 400); + const [files, filesHandlers] = useListState([]); + const fileDialog = useFileDialog(); + + useEffect(() => { + filesHandlers.setState(Array.from(fileDialog.files ?? [])); + }, [fileDialog.files]); + + const form = useForm({ + initialValues: { + message: "", + }, + }); + const setChatValue = (chat: ChatSchema | null) => { if (chat) { setOffset(0); setHasMore(true); setScrollPosition({ x: 0, y: 0 }); + filesHandlers.setState([]); + form.reset(); } setChat(chat); }; - console.log(`has more = ${hasMore}`); - const fetchMessages = () => { if (!chat) return; @@ -71,9 +98,7 @@ const useChatContextState = () => { 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" }); } }) @@ -91,25 +116,42 @@ const useChatContextState = () => { 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) => { + const sendMessageWithFiles = (values: MessageForm) => { if (!chat) return; - ChatService.sendMessage({ + ChatService.sendMessagesWithFiles({ + formData: { + files, + }, + chatId: chat.id, + caption: values.message, + }) + .then(({ ok, message }) => { + if (!ok) { + notifications.error({ message }); + return; + } + filesHandlers.setState([]); + form.reset(); + }) + .catch(err => console.log(err)); + }; + + const sendTextMessage = (values: MessageForm) => { + if (!chat) return; + + ChatService.sendTextMessage({ requestBody: { message: { text: values.message, @@ -127,13 +169,49 @@ const useChatContextState = () => { .catch(err => console.log(err)); }; + const editMessage = (values: MessageForm) => { + if (!chat) return; + + ChatService.editMessage({ + requestBody: { + message: { + id: values.messageId!, + text: values.message, + chatId: chat.id, + }, + }, + }) + .then(({ ok, message }) => { + if (!ok) { + notifications.error({ message }); + } + form.reset(); + setIsScrollToBottom(true); + }) + .catch(err => console.log(err)); + }; + + const submitMessage = (values: MessageForm) => { + if (values.messageId) { + editMessage(values); + } else if (files.length === 0) { + sendTextMessage(values); + } else { + sendMessageWithFiles(values); + } + }; + return { chat, setChat: setChatValue, messages, + form, onScrollPositionChange: setScrollPosition, scrollRef, - sendMessage, + submitMessage, + files, + filesHandlers, + fileDialog, }; }; From 2f4f3c312524617f7ec25eb5a31e2d8aacd1930d Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Sun, 6 Apr 2025 11:43:45 +0400 Subject: [PATCH 3/5] feat: chat with infinite scroll --- package.json | 1 + src/client/models/MessageSchema.ts | 2 + src/components/Chat/Chat.tsx | 87 ++++++++----- .../Chat/components/Message/Message.tsx | 12 +- .../components/MessageInput/MessageInput.tsx | 20 +-- .../cardEditorTabs/ChatTab/ChatTab.tsx | 3 +- .../ChatTab/hooks/useChatTab.tsx | 7 +- .../ClientChatButton/ClientChatButton.tsx | 11 +- .../ClientsPage/contexts/ChatContext.tsx | 119 +++++++++--------- 9 files changed, 152 insertions(+), 110 deletions(-) diff --git a/package.json b/package.json index f7524e6..160813a 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "react-imask": "^7.6.1", "react-redux": "^9.1.2", "react-to-print": "^2.15.1", + "react-virtuoso": "^4.12.6", "reactflow": "^11.11.4", "recharts": "^2.13.3", "zod": "^3.23.8" diff --git a/src/client/models/MessageSchema.ts b/src/client/models/MessageSchema.ts index f465e78..2fd8a49 100644 --- a/src/client/models/MessageSchema.ts +++ b/src/client/models/MessageSchema.ts @@ -4,12 +4,14 @@ /* eslint-disable */ import type { MessageFileSchema } from './MessageFileSchema'; import type { TgUserSchema } from './TgUserSchema'; +import type { UserSchema } from './UserSchema'; export type MessageSchema = { text: string; chatId: number; id: number; createdAt: string; tgSender: (TgUserSchema | null); + crmSender: (UserSchema | null); status: string; isEdited: boolean; file?: (MessageFileSchema | null); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 0f5e682..e385999 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -1,53 +1,82 @@ -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 { ReactNode, useCallback } from "react"; import ChatDate from "./components/ChatDate/ChatDate.tsx"; +import MessageInput from "./components/MessageInput/MessageInput.tsx"; +import { Virtuoso } from "react-virtuoso"; +import { Stack } from "@mantine/core"; const Chat = () => { const { messages, - scrollRef, - onScrollPositionChange, + lastMessage, + firstItemIndex, + fetchMoreMessages, } = 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]; + const onFollowOutputHandler = useCallback( + (atBottom: boolean) => { + if (atBottom || lastMessage?.crmSender) { + return "auto"; + } else { + return false; + } + }, + [lastMessage], + ); + const itemContent = useCallback( + (index: number, sessionData: MessageSchema) => { + let dateComponent: ReactNode | null = null; + const msgArrayIdx = index - firstItemIndex; + const currMessage = messages[msgArrayIdx]; + let prevMessage = null; + if (msgArrayIdx > 0) { + prevMessage = messages[msgArrayIdx - 1]; + } if (!prevMessage || prevMessage.createdAt.substring(5, 10) != currMessage.createdAt.substring(5, 10)) { - elements.push(( + dateComponent = ( - )); + ); } - elements.push( - + return ( + + {dateComponent} + + ); - prevMessage = currMessage; - } - - return elements; - }; + }, + [messages], + ); + if (messages.length === 0) { + return ( + + + + ); + } + return ( - - - {getChatElements()} - - + ); diff --git a/src/components/Chat/components/Message/Message.tsx b/src/components/Chat/components/Message/Message.tsx index a7e634e..56e4b8b 100644 --- a/src/components/Chat/components/Message/Message.tsx +++ b/src/components/Chat/components/Message/Message.tsx @@ -53,19 +53,19 @@ const Message = ({ message }: Props) => { title: "Повторить отправку", icon: , }, - ]) + ]); const getContext = () => { - if (!isMine) return; + if (!isMine || message.file) return; if (isSuccess) return contextMenuSuccessMsg(); if (isError) return contextMenuErrorMsg(); - } + }; const getStatusIcon = () => { const size = em(18); if (message.status == MessageStatuses.ERROR) { - return ; + return ; } if (message.status == MessageStatuses.SENDING) { return ; @@ -86,7 +86,9 @@ const Message = ({ message }: Props) => {
{message.tgSender!.lastName} {message.tgSender!.firstName}
)} {message.file && ( - + + + )}
{message.text}
{ @@ -13,20 +12,19 @@ const MessageInput = () => { form, files, fileDialog, + isMessageSending, } = useChatContext(); - const getFiles = useMemo(() => { - return files.map(file => ( - - )); - }, [files]); + const getFiles = files.map(file => ( + + )); return (
submitMessage(values))}> {getFiles} - + {chat?.tgGroup?.tgInviteLink && ( { - {form.values.messageId && (