From 2d16c526f5abf309bb13d079eb9519ccebbdacf2 Mon Sep 17 00:00:00 2001 From: Konstantin Makaev Date: Fri, 20 Feb 2026 13:22:40 +0300 Subject: [PATCH] feat: update messaging UI and online status indicators --- frontend/next.config.ts | 3 + frontend/package-lock.json | 12 - frontend/src/app/departments/page.tsx | 4 +- frontend/src/app/documents/page.tsx | 4 +- frontend/src/app/employees/page.tsx | 23 +- frontend/src/app/finances/page.tsx | 4 +- frontend/src/app/messages/[chatId]/page.tsx | 571 ++++++++++++++++++++ frontend/src/app/messages/page.tsx | 167 +++++- frontend/src/app/page.tsx | 50 +- frontend/src/app/requests/page.tsx | 4 +- frontend/src/components/AppShell.tsx | 46 +- frontend/src/types/api.ts | 35 +- 12 files changed, 833 insertions(+), 90 deletions(-) create mode 100644 frontend/src/app/messages/[chatId]/page.tsx diff --git a/frontend/next.config.ts b/frontend/next.config.ts index b1bd11d0..0f3f4a66 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -11,6 +11,9 @@ const nextConfig: NextConfig = { NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL, NEXT_PUBLIC_BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL, }, + + // Полностью отключаем Next.js Dev Tools UI (кружок "N") + devIndicators: false, }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 925ed6d6..3a2363f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -65,7 +65,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1469,7 +1468,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1525,7 +1523,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2006,7 +2003,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2324,7 +2320,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2872,7 +2867,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3050,7 +3044,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5108,7 +5101,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5117,7 +5109,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5763,7 +5754,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -5915,7 +5905,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6176,7 +6165,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/app/departments/page.tsx b/frontend/src/app/departments/page.tsx index fe8d4191..e98ae9f3 100644 --- a/frontend/src/app/departments/page.tsx +++ b/frontend/src/app/departments/page.tsx @@ -1,9 +1,9 @@ -import { AppShell, PageHeader } from "../../components/AppShell"; +import { AppShell } from "../../components/AppShell"; export default function DepartmentsPage() { return ( - +

Раздел в разработке.

); } diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 600c97b4..0486a39e 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -1,9 +1,9 @@ -import { AppShell, PageHeader } from "../../components/AppShell"; +import { AppShell } from "../../components/AppShell"; export default function DocumentsPage() { return ( - +

Раздел в разработке.

); } diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 93dd4f9f..ee93f6eb 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { AppShell, PageHeader } from "../../components/AppShell"; +import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { useEffect, useState } from "react"; import type { User } from "@/types/api"; @@ -28,7 +28,6 @@ export default function EmployeesPage() { if (loading) { return ( -
@@ -42,7 +41,6 @@ export default function EmployeesPage() { if (error) { return ( -

{error}

@@ -58,8 +56,6 @@ export default function EmployeesPage() { return ( - -
{sortedEmployees.map((employee) => { const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim(); @@ -72,12 +68,17 @@ export default function EmployeesPage() { return (
-
- {employee.avatar ? ( - {fullName} - ) : ( - initials - )} +
+
+ {employee.avatar ? ( + {fullName} + ) : ( + initials + )} +
+ {isOnline ? ( + + ) : null}

{fullName}

diff --git a/frontend/src/app/finances/page.tsx b/frontend/src/app/finances/page.tsx index 3dfb42af..64ef1237 100644 --- a/frontend/src/app/finances/page.tsx +++ b/frontend/src/app/finances/page.tsx @@ -1,9 +1,9 @@ -import { AppShell, PageHeader } from "../../components/AppShell"; +import { AppShell } from "../../components/AppShell"; export default function FinancesPage() { return ( - +

Раздел в разработке.

); } diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx new file mode 100644 index 00000000..945a0a1f --- /dev/null +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -0,0 +1,571 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText } from "lucide-react"; +import { AppShell } from "../../../components/AppShell"; +import { apiClient } from "@/lib/api"; +import type { Chat, Message, MessageAttachment } from "@/types/api"; +import { useUser } from "@/contexts/UserContext"; + +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + +function getChatTitle(chat: Chat): string { + return chat.name?.trim() || "Диалог"; +} + +function getChatInitials(chat: Chat): string { + const title = getChatTitle(chat); + return ( + title + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() || "") + .join("") || "Ч" + ); +} + +function getMessageInitials(message: Message): string { + const name = (message.author_name || "").trim(); + if (!name) return "С"; + const parts = name.split(" ").filter(Boolean); + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase(); +} + +function formatTime(date?: string): string { + if (!date) return ""; + const d = new Date(date); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); +} + +function getMessageDate(message: Message): Date | null { + if (message.created_at) { + const d = new Date(message.created_at); + return Number.isNaN(d.getTime()) ? null : d; + } + + if (message.created_ts) { + const d = new Date(message.created_ts); + return Number.isNaN(d.getTime()) ? null : d; + } + + return null; +} + +function formatDayDivider(date: Date): string { + return date.toLocaleDateString("ru-RU", { + day: "2-digit", + month: "long", + year: "numeric", + }); +} + +function messageTime(message: Message): string { + const d = getMessageDate(message); + if (!d) return ""; + + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + if (isToday) { + return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + } + + return d.toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function isSameDay(a: Date | null, b: Date | null): boolean { + if (!a || !b) return false; + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function formatFileSize(size?: number): string { + if (!size || size <= 0) return ""; + const kb = size / 1024; + if (kb < 1024) return `${Math.round(kb)} КБ`; + const mb = kb / 1024; + return `${mb.toFixed(1)} МБ`; +} + +function isImageAttachment(att: MessageAttachment): boolean { + const mime = (att.mime_type || "").toLowerCase(); + const type = (att.file_type || "").toLowerCase(); + const name = (att.file_name || "").toLowerCase(); + const byExt = /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(name); + return mime.startsWith("image/") || type === "image" || byExt; +} + +function isVideoAttachment(att: MessageAttachment): boolean { + const mime = (att.mime_type || "").toLowerCase(); + const type = (att.file_type || "").toLowerCase(); + const name = (att.file_name || "").toLowerCase(); + const byExt = /\.(mp4|webm|mov|m4v|avi|mkv|3gp|mpeg|mpg)$/i.test(name); + return mime.startsWith("video/") || type === "video" || byExt; +} + +function isAudioAttachment(att: MessageAttachment): boolean { + const mime = (att.mime_type || "").toLowerCase(); + const type = (att.file_type || "").toLowerCase(); + return mime.startsWith("audio/") || type === "audio"; +} + +function resolveAttachmentUrl(url?: string | null): string { + if (!url) return ""; + if (/^https?:\/\//i.test(url)) return encodeURI(url); + if (url.startsWith("//")) return encodeURI(`https:${url}`); + if (url.startsWith("/") && BACKEND_URL) { + return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}${url}`); + } + return encodeURI(url); +} + +function resolveAvatarUrl(url?: string | null): string { + if (!url) return ""; + if (/^https?:\/\//i.test(url)) return encodeURI(url); + if (url.startsWith("//")) return encodeURI(`https:${url}`); + if (url.startsWith("/") && BACKEND_URL) { + return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}${url}`); + } + return encodeURI(url); +} + +export default function MessageDialogPage() { + const params = useParams<{ chatId: string }>(); + const chatId = Number(params.chatId); + const { user } = useUser(); + + const [chat, setChat] = useState(null); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [messagesLoading, setMessagesLoading] = useState(false); + const [error, setError] = useState(null); + const [messageText, setMessageText] = useState(""); + const [sending, setSending] = useState(false); + const [attachedFiles, setAttachedFiles] = useState([]); + const [brokenMedia, setBrokenMedia] = useState>({}); + const [mediaPreview, setMediaPreview] = useState<{ type: "image" | "video"; src: string; name: string } | null>(null); + const fileInputRef = useRef(null); + const messagesViewportRef = useRef(null); + const [initialAnchorId, setInitialAnchorId] = useState(null); + + useEffect(() => { + async function loadChats() { + try { + setLoading(true); + setError(null); + const response = await apiClient.getChats(); + const items = response.results || []; + setChat(items.find((c) => c.id === chatId) || null); + } catch (e: any) { + setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); + } finally { + setLoading(false); + } + } + + loadChats(); + }, [chatId]); + + useEffect(() => { + async function loadMessages() { + if (!chatId || Number.isNaN(chatId) || !chat) { + setMessages([]); + setInitialAnchorId(null); + return; + } + + try { + setMessagesLoading(true); + const around = await apiClient.getChatMessagesAround(chatId, { limit: 40 }); + const aroundMessages = around.messages || []; + + if (aroundMessages.length > 0) { + setMessages(aroundMessages); + setInitialAnchorId(around.anchor_id ?? null); + } else { + const response = await apiClient.getChatMessages(chatId, { limit: 50 }); + setMessages(response.messages || []); + setInitialAnchorId(null); + } + } catch (e: any) { + if (String(e?.message || "").includes("403")) { + setError("Нет доступа к этому чату"); + setMessages([]); + } else { + console.error("Ошибка загрузки сообщений:", e); + } + setInitialAnchorId(null); + } finally { + setMessagesLoading(false); + } + } + + loadMessages(); + }, [chatId, chat]); + + useEffect(() => { + if (messagesLoading || !messagesViewportRef.current || messages.length === 0) return; + + const viewport = messagesViewportRef.current; + + if (initialAnchorId) { + const el = viewport.querySelector(`[data-message-id="${initialAnchorId}"]`) as HTMLElement | null; + if (el) { + el.scrollIntoView({ block: "center" }); + return; + } + } + + viewport.scrollTop = viewport.scrollHeight; + }, [messagesLoading, messages, initialAnchorId]); + + const handleSend = async () => { + const text = messageText.trim(); + if (!chatId || sending) return; + if (!text && attachedFiles.length === 0) return; + + try { + setSending(true); + const sent = attachedFiles.length + ? await apiClient.sendMessageWithFiles(chatId, text, attachedFiles) + : await apiClient.sendMessage(chatId, text); + setMessages((prev) => [...prev, sent]); + setMessageText(""); + setAttachedFiles([]); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } catch (e) { + console.error("Ошибка отправки сообщения:", e); + } finally { + setSending(false); + } + }; + + const handlePickFiles = () => { + fileInputRef.current?.click(); + }; + + const handleFilesChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (!files.length) return; + setAttachedFiles((prev) => [...prev, ...files]); + }; + + const removeAttachedFile = (index: number) => { + setAttachedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setMediaPreview(null); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + return ( + + {loading ? ( +
+
+

Загрузка чатов...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+
+ {chat ? ( + <> +
+
+
+ {chat.avatar ? ( + {getChatTitle(chat)} + ) : ( + getChatInitials(chat) + )} +
+
+

{getChatTitle(chat)}

+

{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}

+
+
+ + + + +
+ +
+
+ {messagesLoading ? ( +

Загрузка сообщений...

+ ) : messages.length === 0 ? ( +

Пока нет сообщений. Напишите первым.

+ ) : ( +
+ {messages.map((message, index) => { + const currentDate = getMessageDate(message); + const prevDate = index > 0 ? getMessageDate(messages[index - 1]) : null; + const showDayDivider = index === 0 || !isSameDay(currentDate, prevDate); + + const isMine = + user?.id && + (message.author_id === user.id || message.author?.id === user.id || message.sender?.id === user.id); + + return ( +
+ {showDayDivider && currentDate ? ( +
+ + {formatDayDivider(currentDate)} + +
+ ) : null} + +
+ {!isMine ? ( +
+
+ {message.avatar || message.author?.avatar ? ( + {message.author_name + ) : ( + getMessageInitials(message) + )} +
+ {message.author?.is_active ? ( + + ) : null} +
+ ) : null} + +
+ {!isMine ? ( +

+ {message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"} +

+ ) : null} + + {message.content ? ( +

{message.content}

+ ) : null} + + {message.attachments && message.attachments.length > 0 ? ( +
+ {message.attachments.map((att) => ( +
+ {isImageAttachment(att) ? ( + + ) : isVideoAttachment(att) ? ( + brokenMedia[att.id] ? ( + + + + Видео не поддерживается в браузере — открыть файл + + + ) : ( + + ) + ) : isAudioAttachment(att) ? ( + + ) : ( + + + {att.file_name} + {formatFileSize(att.file_size)} + + )} +
+ ))} +
+ ) : null} + +

+ {messageTime(message)} +

+
+
+
+ ); + })} +
+ )} +
+ +
+ {attachedFiles.length > 0 ? ( +
+ {attachedFiles.map((file, index) => ( + + {file.name} + + + ))} +
+ ) : null} + +
+ + +