feat: update messaging UI and online status indicators

This commit is contained in:
Konstantin Makaev 2026-02-20 13:22:40 +03:00
parent e9d20d48eb
commit 2d16c526f5
12 changed files with 833 additions and 90 deletions

View File

@ -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;

View File

@ -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"
}

View File

@ -1,9 +1,9 @@
import { AppShell, PageHeader } from "../../components/AppShell";
import { AppShell } from "../../components/AppShell";
export default function DepartmentsPage() {
return (
<AppShell>
<PageHeader title="Отделы" subtitle="Здесь будут списки отделов и контакты." badge="В разработке" />
<p className="text-sm text-gray-500">Раздел в разработке.</p>
</AppShell>
);
}

View File

@ -1,9 +1,9 @@
import { AppShell, PageHeader } from "../../components/AppShell";
import { AppShell } from "../../components/AppShell";
export default function DocumentsPage() {
return (
<AppShell>
<PageHeader title="Документы" subtitle="Здесь будут договоры, отчёты и файлы." badge="В разработке" />
<p className="text-sm text-gray-500">Раздел в разработке.</p>
</AppShell>
);
}

View File

@ -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 (
<AppShell>
<PageHeader title="Сотрудники" eyebrow="Команда" />
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent"></div>
@ -42,7 +41,6 @@ export default function EmployeesPage() {
if (error) {
return (
<AppShell>
<PageHeader title="Сотрудники" eyebrow="Команда" />
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
@ -58,8 +56,6 @@ export default function EmployeesPage() {
return (
<AppShell>
<PageHeader title="Сотрудники" eyebrow="Команда" badge={`Всего: ${employees.length}`} />
<div className="space-y-3">
{sortedEmployees.map((employee) => {
const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim();
@ -72,12 +68,17 @@ export default function EmployeesPage() {
return (
<article key={employee.id} className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white overflow-hidden">
{employee.avatar ? (
<img src={employee.avatar} alt={fullName} className="h-full w-full object-cover" />
) : (
initials
)}
<div className="relative h-12 w-12">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{employee.avatar ? (
<img src={employee.avatar} alt={fullName} className="h-full w-full object-cover" />
) : (
initials
)}
</div>
{isOnline ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">{fullName}</p>

View File

@ -1,9 +1,9 @@
import { AppShell, PageHeader } from "../../components/AppShell";
import { AppShell } from "../../components/AppShell";
export default function FinancesPage() {
return (
<AppShell>
<PageHeader title="Финансы" subtitle="Здесь появится аналитика и отчётность." badge="В разработке" />
<p className="text-sm text-gray-500">Раздел в разработке.</p>
</AppShell>
);
}

View File

@ -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<Chat | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [messagesLoading, setMessagesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [messageText, setMessageText] = useState("");
const [sending, setSending] = useState(false);
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const [brokenMedia, setBrokenMedia] = useState<Record<number, boolean>>({});
const [mediaPreview, setMediaPreview] = useState<{ type: "image" | "video"; src: string; name: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const messagesViewportRef = useRef<HTMLDivElement | null>(null);
const [initialAnchorId, setInitialAnchorId] = useState<number | null>(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<HTMLInputElement>) => {
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 (
<AppShell>
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка чатов...</p>
</div>
) : error ? (
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : (
<div className="sticky top-22 self-start h-[calc(100vh-7.5rem)] overflow-hidden">
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
{chat ? (
<>
<header className="mb-4 flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{chat.avatar ? (
<img src={resolveAvatarUrl(chat.avatar)} alt={getChatTitle(chat)} className="h-full w-full object-cover" />
) : (
getChatInitials(chat)
)}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{getChatTitle(chat)}</p>
<p className="text-xs text-gray-500">{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}</p>
</div>
</div>
<Link
href="/messages"
aria-label="К списку чатов"
title="К списку чатов"
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
>
<ArrowLeft size={16} />
</Link>
</header>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div ref={messagesViewportRef} className="min-h-0 flex-1 overflow-y-auto rounded-xl bg-gray-50 p-4">
{messagesLoading ? (
<p className="text-center text-sm text-gray-500">Загрузка сообщений...</p>
) : messages.length === 0 ? (
<p className="text-center text-sm text-gray-500">Пока нет сообщений. Напишите первым.</p>
) : (
<div className="flex min-h-full flex-col justify-end">
{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 (
<div key={message.id} data-message-id={message.id} className="mb-3 last:mb-0">
{showDayDivider && currentDate ? (
<div className="sticky top-2 z-10 mb-2 text-center">
<span className="inline-block rounded-full bg-white/95 px-3 py-1 text-xs text-gray-500 shadow-sm ring-1 ring-gray-200 backdrop-blur">
{formatDayDivider(currentDate)}
</span>
</div>
) : null}
<div className={`flex ${isMine ? "justify-end" : "justify-start"}`}>
{!isMine ? (
<div className="relative mr-2 mt-1 h-8 w-8 shrink-0">
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-[10px] font-semibold text-white">
{message.avatar || message.author?.avatar ? (
<img
src={resolveAvatarUrl(message.avatar || message.author?.avatar || "")}
alt={message.author_name || "Автор"}
className="h-full w-full object-cover"
/>
) : (
getMessageInitials(message)
)}
</div>
{message.author?.is_active ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
</div>
) : null}
<div
className={`max-w-[78%] rounded-2xl px-3 py-2 ${
isMine ? "bg-sky-500 text-white" : "bg-white text-gray-900 ring-1 ring-gray-100"
}`}
>
{!isMine ? (
<p className="mb-1 text-[11px] font-medium text-gray-500">
{message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"}
</p>
) : null}
{message.content ? (
<p className="whitespace-pre-wrap text-sm leading-5">{message.content}</p>
) : null}
{message.attachments && message.attachments.length > 0 ? (
<div className="mt-2 space-y-2">
{message.attachments.map((att) => (
<div key={att.id}>
{isImageAttachment(att) ? (
<button
type="button"
onClick={() => setMediaPreview({ type: "image", src: resolveAttachmentUrl(att.file_url), name: att.file_name })}
className="block w-full overflow-hidden rounded-lg"
>
<img
src={resolveAttachmentUrl(att.thumbnail || att.file_url)}
alt={att.file_name}
className="max-h-64 w-full rounded-lg object-cover"
/>
</button>
) : isVideoAttachment(att) ? (
brokenMedia[att.id] ? (
<a
href={resolveAttachmentUrl(att.file_url)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
>
<FileText size={16} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">
Видео не поддерживается в браузере — открыть файл
</span>
</a>
) : (
<button
type="button"
onClick={() => setMediaPreview({ type: "video", src: resolveAttachmentUrl(att.file_url), name: att.file_name })}
className="block w-full overflow-hidden rounded-lg"
>
<video
preload="metadata"
playsInline
muted
src={resolveAttachmentUrl(att.file_url)}
className="max-h-64 w-full rounded-lg bg-black"
onError={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: true }))}
onLoadedData={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: false }))}
/>
</button>
)
) : isAudioAttachment(att) ? (
<audio
controls
preload="metadata"
className="w-full"
onError={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: true }))}
>
<source src={resolveAttachmentUrl(att.file_url)} type={att.mime_type || "audio/mpeg"} />
</audio>
) : (
<a
href={resolveAttachmentUrl(att.file_url)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 text-sm text-sky-700 hover:bg-white"
>
<FileText size={16} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">{att.file_name}</span>
<span className="shrink-0 text-xs text-gray-500">{formatFileSize(att.file_size)}</span>
</a>
)}
</div>
))}
</div>
) : null}
<p className={`mt-1 text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>
{messageTime(message)}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-3 shrink-0 border-t border-gray-100 bg-white pt-3">
{attachedFiles.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-full items-center gap-1 rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-700 ring-1 ring-sky-100"
>
<span className="truncate max-w-[180px]">{file.name}</span>
<button
type="button"
onClick={() => removeAttachedFile(index)}
className="rounded-full p-0.5 hover:bg-sky-100"
aria-label="Удалить файл"
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFilesChange}
className="hidden"
/>
<button
type="button"
onClick={handlePickFiles}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700"
title="Добавить файлы"
>
<Paperclip size={16} />
</button>
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
rows={2}
placeholder="Введите сообщение..."
className="w-full resize-none rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<button
type="button"
onClick={handleSend}
disabled={sending || (!messageText.trim() && attachedFiles.length === 0)}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
title="Отправить"
>
<Send size={16} />
</button>
</div>
</div>
</div>
</>
) : (
<div className="flex h-full min-h-[280px] items-center justify-center rounded-xl bg-gray-50 text-center">
<div>
<MessageCircle size={20} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Диалог не найден</p>
</div>
</div>
)}
</section>
</div>
)}
{mediaPreview ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setMediaPreview(null)}
>
<button
type="button"
onClick={() => setMediaPreview(null)}
className="absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
aria-label="Закрыть предпросмотр"
>
<X size={18} />
</button>
<div className="max-h-full max-w-[92vw]" onClick={(e) => e.stopPropagation()}>
{mediaPreview.type === "image" ? (
<img src={mediaPreview.src} alt={mediaPreview.name} className="max-h-[88vh] max-w-[92vw] rounded-lg object-contain" />
) : (
<video controls autoPlay className="max-h-[88vh] max-w-[92vw] rounded-lg bg-black" src={mediaPreview.src} />
)}
</div>
</div>
) : null}
</AppShell>
);
}

View File

@ -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<Chat[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<AppShell>
<PageHeader title="Сообщения" subtitle="Здесь появятся чаты и диалоги." badge="В разработке" />
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка чатов...</p>
</div>
) : error ? (
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : (
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="relative mb-4">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={search}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
{filteredChats.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-6 text-center">
<p className="text-sm text-gray-500">Чаты не найдены</p>
</div>
) : (
filteredChats.map((chat) => (
<Link
key={chat.id}
href={`/messages/${chat.id}`}
className="block w-full rounded-xl border border-transparent p-3 text-left transition hover:border-gray-200 hover:bg-gray-50"
>
<div className="flex items-start gap-3">
<div className="relative h-10 w-10">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xs font-semibold text-white">
{chat.avatar ? (
<img
src={resolveAvatarUrl(chat.avatar)}
alt={getChatTitle(chat)}
className="h-full w-full object-cover"
/>
) : (
getChatInitials(chat)
)}
</div>
{isChatOnline(chat, user?.id) ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold text-gray-900">{getChatTitle(chat)}</p>
<span className="shrink-0 text-xs text-gray-500">{formatTime(chat.last_message?.created_at)}</span>
</div>
<p className="mt-1 truncate text-xs text-gray-500">
{chat.last_message?.content || "Нет сообщений"}
</p>
</div>
</div>
</Link>
))
)}
</div>
{filteredChats.length === 0 ? (
<div className="mt-4 flex min-h-[120px] items-center justify-center rounded-xl bg-gray-50 text-center">
<div>
<MessageCircle size={20} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Создайте или найдите диалог</p>
</div>
</div>
) : null}
</section>
)}
</AppShell>
);
}

View File

@ -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 (
<AppShell>
<div className="flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white overflow-hidden">
{user?.avatar ? (
<img src={user.avatar} alt={user ? `${user.last_name} ${user.first_name}`.trim() : 'Пользователь'} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
<div className="relative w-full">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-10 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Поиск по ленте"
/>
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs text-sky-700">
<span className="rounded-full bg-sky-50 px-3 py-1">Статья</span>
<span className="rounded-full bg-sky-50 px-3 py-1">Фото</span>
<span className="rounded-full bg-sky-50 px-3 py-1">Ссылка</span>
<span className="rounded-full bg-sky-50 px-3 py-1">Опрос</span>
<span className="rounded-full bg-sky-50 px-3 py-1">Событие</span>
</div>
</div>
<div className="space-y-4">
{posts.length === 0 ? (
<div className="rounded-2xl bg-gray-50 p-8 text-center">
@ -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() {
<article key={post.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<header className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white overflow-hidden">
{post.author?.avatar ? (
<img src={post.author.avatar} alt={authorName} className="h-full w-full object-cover" />
) : (
authorInitials
)}
<div className="relative h-10 w-10">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{post.author?.avatar ? (
<img src={post.author.avatar} alt={authorName} className="h-full w-full object-cover" />
) : (
authorInitials
)}
</div>
{isAuthorOnline ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{authorName}</p>

View File

@ -1,9 +1,9 @@
import { AppShell, PageHeader } from "../../components/AppShell";
import { AppShell } from "../../components/AppShell";
export default function RequestsPage() {
return (
<AppShell>
<PageHeader title="Заявления" subtitle="Здесь появятся заявки и согласования." badge="В разработке" />
<p className="text-sm text-gray-500">Раздел в разработке.</p>
</AppShell>
);
}

View File

@ -68,7 +68,7 @@ function Header() {
<Bell size={18} className="text-gray-600" />
</button>
<div
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white cursor-pointer hover:bg-sky-500 overflow-hidden"
className="ml-1 relative h-10 w-10 cursor-pointer"
title={userName}
onClick={() => {
if (confirm('Выйти из системы?')) {
@ -76,11 +76,16 @@ function Header() {
}
}}
>
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white hover:bg-sky-500">
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
{user?.is_active ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
</div>
</div>
</div>
@ -106,16 +111,21 @@ function LeftNav() {
const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400");
return (
<aside className="hidden w-64 flex-shrink-0 lg:block">
<div className="sticky top-8 space-y-4">
<aside className="hidden w-64 flex-shrink-0 lg:block lg:sticky lg:self-start lg:top-22 lg:max-h-[calc(100vh-5.5rem)] lg:overflow-y-auto">
<div className="space-y-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white overflow-hidden">
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
<div className="relative h-11 w-11">
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
{user?.is_active ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{userName}</p>
@ -138,8 +148,8 @@ function LeftNav() {
function Calendar() {
return (
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
<div className="sticky top-8 space-y-4">
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block lg:sticky lg:self-start lg:top-22 lg:max-h-[calc(100vh-5.5rem)] lg:overflow-y-auto">
<div className="space-y-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
<span>Календарь</span>
@ -226,9 +236,9 @@ export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<Header />
<div className="mx-auto flex min-h-screen max-w-6xl gap-6 px-4 py-8 sm:px-8">
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-6xl gap-6 px-4 py-8 sm:px-8">
<LeftNav />
<main className="flex-1 space-y-6">{children}</main>
<main className="flex-1 min-w-0 space-y-6">{children}</main>
<Calendar />
</div>
</div>

View File

@ -69,6 +69,7 @@ export interface Post {
id: number;
author: User;
content: string;
image?: string;
tags?: string[];
created_at: string;
updated_at: string;
@ -116,7 +117,9 @@ export interface Request {
export interface Chat {
id: number;
name?: string;
chat_type: 'direct' | 'group' | 'department';
avatar?: string | null;
chat_type?: 'direct' | 'group' | 'department';
type?: 'private' | 'group' | 'department' | 'announcement';
participants: User[];
last_message?: Message;
unread_count?: number;
@ -125,11 +128,33 @@ export interface Chat {
export interface Message {
id: number;
chat: number;
sender: User;
chat?: number;
sender?: User;
author?: User;
author_id?: number;
author_name?: string;
avatar?: string;
content: string;
is_read: boolean;
created_at: string;
is_read?: boolean;
created_at?: string;
created?: string;
created_ts?: number;
is_edited?: boolean;
is_deleted?: boolean;
has_attachments?: boolean;
attachments?: MessageAttachment[];
}
export interface MessageAttachment {
id: number;
file_name: string;
file_type?: string;
file_url: string;
file_size?: number;
mime_type?: string;
width?: number;
height?: number;
thumbnail?: string | null;
}
// Calendar types