- ) : (
- initials
- )}
+
{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 ? (
+
+ ) : (
+
+
+ {chat ? (
+ <>
+
+
+
+ {chat.avatar ? (
+
})
+ ) : (
+ 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 ? (
+

+ ) : (
+ 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}
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+ )}
+
+ {mediaPreview ? (
+ setMediaPreview(null)}
+ >
+
+
+
e.stopPropagation()}>
+ {mediaPreview.type === "image" ? (
+

+ ) : (
+
+ )}
+
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx
index e601431f..6824855a 100644
--- a/frontend/src/app/messages/page.tsx
+++ b/frontend/src/app/messages/page.tsx
@@ -1,9 +1,172 @@
-import { AppShell, PageHeader } from "../../components/AppShell";
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { AppShell } from "../../components/AppShell";
+import { apiClient } from "@/lib/api";
+import type { Chat } from "@/types/api";
+import { Search, MessageCircle } from "lucide-react";
+import Link from "next/link";
+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 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);
+}
+
+function formatTime(date?: string): string {
+ if (!date) return "";
+ const d = new Date(date);
+ if (Number.isNaN(d.getTime())) 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.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
+}
+
+function isChatOnline(chat: Chat, currentUserId?: number): boolean {
+ const chatKind = chat.chat_type || chat.type;
+ if (chatKind !== "direct" && chatKind !== "private") return false;
+ const otherParticipant = (chat.participants || []).find((p) => p.id !== currentUserId);
+ return Boolean(otherParticipant?.is_active);
+}
export default function MessagesPage() {
+ const { user } = useUser();
+ const [chats, setChats] = useState([]);
+ const [search, setSearch] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ async function loadChats() {
+ try {
+ setLoading(true);
+ setError(null);
+ const response = await apiClient.getChats();
+ const items = response.results || [];
+ setChats(items);
+ } catch (e: any) {
+ console.error("Ошибка загрузки чатов:", e);
+ setError("Не удалось загрузить чаты");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ loadChats();
+ }, []);
+
+ const filteredChats = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return chats;
+
+ return chats.filter((chat) => {
+ const title = getChatTitle(chat).toLowerCase();
+ const lastMessage = chat.last_message?.content?.toLowerCase() || "";
+ return title.includes(q) || lastMessage.includes(q);
+ });
+ }, [chats, search]);
+
return (
-
+ {loading ? (
+
+ ) : error ? (
+
+ ) : (
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Поиск по чатам"
+ className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
+ />
+
+
+
+ {filteredChats.length === 0 ? (
+
+ ) : (
+ filteredChats.map((chat) => (
+
+
+
+
+ {chat.avatar ? (
+
})
+ ) : (
+ getChatInitials(chat)
+ )}
+
+ {isChatOnline(chat, user?.id) ? (
+
+ ) : null}
+
+
+
+
{getChatTitle(chat)}
+
{formatTime(chat.last_message?.created_at)}
+
+
+ {chat.last_message?.content || "Нет сообщений"}
+
+
+
+
+ ))
+ )}
+
+
+ {filteredChats.length === 0 ? (
+
+
+
+
Создайте или найдите диалог
+
+
+ ) : null}
+
+ )}
);
}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index c485b7ed..edabc9ad 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Heart, MessageSquare, Search, Star } from "lucide-react";
+import { Heart, MessageSquare } from "lucide-react";
import { AppShell } from "../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useState } from "react";
@@ -56,33 +56,6 @@ export default function Home() {
}
return (
-
-
-
- {user?.avatar ? (
-

- ) : (
- userInitials
- )}
-
-
-
-
-
-
-
- Статья
- Фото
- Ссылка
- Опрос
- Событие
-
-
-
{posts.length === 0 ? (
@@ -96,6 +69,10 @@ export default function Home() {
const authorInitials = post.author
? `${post.author.last_name?.[0] || ''}${post.author.first_name?.[0] || ''}`
: 'А';
+ const isAuthorOnline =
+ typeof post.author?.is_active === 'boolean'
+ ? post.author.is_active
+ : Boolean(post.author?.id && user?.id && post.author.id === user.id && user.is_active);
// Форматируем дату
const postDate = new Date(post.created_at);
@@ -112,12 +89,17 @@ export default function Home() {