Альфа версия

This commit is contained in:
Konstantin Makaev 2026-02-24 13:11:32 +03:00
parent 523f09074d
commit 38ab1afa7c
17 changed files with 3947 additions and 350 deletions

View File

@ -220,7 +220,7 @@ class PollSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""Создание голосования с опциями"""
from communications.models import Chat, Message
from communications.models import Chat, Message, PollOption
from datetime import timedelta
from django.utils import timezone
import logging
@ -237,7 +237,11 @@ class PollSerializer(serializers.ModelSerializer):
logger.info(f"[PollSerializer.create] options_data: {options_data}, chat_id: {chat_id}")
# Получаем автора (может быть передан через perform_create или взять из запроса)
author = validated_data.pop('author', None) or self.context.get('request').user
request = self.context.get('request')
request_user = getattr(request, 'user', None)
author = validated_data.pop('author', None) or request_user
if author is None:
raise serializers.ValidationError({'author': 'Author is required'})
# Создаем сообщение в чате
try:
@ -267,7 +271,8 @@ class PollSerializer(serializers.ModelSerializer):
# Создаем опции
for index, option_text in enumerate(options_data):
is_correct = (correct_option_index is not None and index == correct_option_index)
poll.options.create(
PollOption.objects.create(
poll=poll,
text=option_text,
position=index,
is_correct=is_correct
@ -283,16 +288,19 @@ class PollSerializer(serializers.ModelSerializer):
message_data = serialize_message(message)
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
f'chat_{chat.id}',
{
'type': 'chat_message',
'chat_id': chat.id,
'payload': message_data
}
)
chat_id_value = getattr(chat, 'id', chat_id)
if channel_layer is not None:
async_to_sync(channel_layer.group_send)(
f'chat_{chat_id_value}',
{
'type': 'chat_message',
'chat_id': chat_id_value,
'payload': message_data
}
)
logger.info(f"[PollSerializer.create] WebSocket notification sent for message {message.id}")
message_id_value = getattr(message, 'id', None)
logger.info(f"[PollSerializer.create] WebSocket notification sent for message {message_id_value}")
return poll

View File

@ -13,7 +13,10 @@ Endpoints:
- Миграция с FBV на ViewSets завершена: 14 января 2026
- Удаление legacy кода: 15 января 2026
"""
# pyright: reportAttributeAccessIssue=false, reportCallIssue=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false
import logging
from typing import Any, cast
from datetime import datetime, timezone as dt_tz
from django.contrib.auth import get_user_model
from django.db import transaction
@ -64,13 +67,16 @@ class ChatViewSet(viewsets.ModelViewSet):
"""
permission_classes = [IsAuthenticated]
# Явно задаем базовые атрибуты для корректной типизации DRF/Pylance
queryset = Chat.objects.none()
serializer_class = ChatDetailSerializer
def get_serializer_class(self):
def get_serializer_class(self): # pyright: ignore[reportIncompatibleMethodOverride]
if self.action == 'list':
return ChatListSerializer
return ChatDetailSerializer
def get_queryset(self):
def get_queryset(self): # pyright: ignore[reportIncompatibleMethodOverride]
"""Чаты доступные пользователю с аннотацией непрочитанных"""
user = self.request.user
@ -454,8 +460,11 @@ class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser, JSONParser]
# Явно задаем базовые атрибуты для корректной типизации DRF/Pylance
queryset = Message.objects.none()
serializer_class = MessageDetailSerializer
def get_serializer_class(self):
def get_serializer_class(self): # pyright: ignore[reportIncompatibleMethodOverride]
if self.action in ['create', 'upload']:
return MessageCreateSerializer
elif self.action == 'update' or self.action == 'partial_update':
@ -464,7 +473,7 @@ class MessageViewSet(viewsets.ModelViewSet):
return MessageListSerializer
return MessageDetailSerializer
def get_queryset(self):
def get_queryset(self): # pyright: ignore[reportIncompatibleMethodOverride]
"""Сообщения доступные пользователю"""
user = self.request.user
@ -879,9 +888,10 @@ class MessageViewSet(viewsets.ModelViewSet):
"""Переслать сообщения в другой чат"""
serializer = ForwardMessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
message_ids = serializer.validated_data['message_ids']
target_chat_id = serializer.validated_data['target_chat_id']
validated = cast(dict[str, Any], serializer.validated_data)
message_ids = validated['message_ids']
target_chat_id = validated['target_chat_id']
# Получаем целевой чат
try:
@ -940,8 +950,9 @@ class MessageViewSet(viewsets.ModelViewSet):
"""Массовое удаление сообщений"""
serializer = BulkDeleteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
message_ids = serializer.validated_data['message_ids']
validated = cast(dict[str, Any], serializer.validated_data)
message_ids = validated['message_ids']
# Удаляем только свои сообщения
deleted_count = Message.objects.filter(

View File

@ -120,7 +120,12 @@ class PostListSerializer(serializers.ModelSerializer):
class PostSerializer(PostListSerializer):
"""Сериализатор для деталей поста с комментариями"""
"""Сериализатор для деталей поста с комментариями.
image/attachment наследуются от PostListSerializer как SerializerMethodField (read-only).
Запись файлов происходит вручную во view через request.FILES
(PostViewSet._attach_files), по аналогии с upload в communications.
"""
comments = serializers.SerializerMethodField()
user_has_liked = serializers.SerializerMethodField()

View File

@ -22,7 +22,12 @@ from ..permissions import (
user_is_dept_head,
user_is_staffish,
)
from .serializers import CommentSerializer, PostListSerializer, PostSerializer
from .serializers import (
AuthorMiniSerializer,
CommentSerializer,
PostListSerializer,
PostSerializer,
)
Employee = get_user_model()
@ -59,6 +64,7 @@ class PostViewSet(viewsets.ModelViewSet):
.annotate(comments_count=Count("comments", distinct=True))
.order_by("-pinned", "-created_at")
)
serializer_class = PostSerializer
parser_classes = [MultiPartParser, FormParser, JSONParser]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["title", "body"]
@ -98,7 +104,9 @@ class PostViewSet(viewsets.ModelViewSet):
# --- CREATE: смотрим на тип из тела запроса ---
if self.action == "create":
t = (self.request.data.get("type") or "").strip()
req_data = getattr(self.request, "data", {})
raw_type = req_data.get("type") if hasattr(req_data, "get") else ""
t = (raw_type or "").strip()
if t == TYPE_COMPANY:
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
# department → департаментное право на создание
@ -132,9 +140,6 @@ class PostViewSet(viewsets.ModelViewSet):
# дефолт: просто аутентификация
return [IsAuthenticated()]
def get_serializer_class(self):
return PostListSerializer if self.action == "list" else PostSerializer
def get_queryset(self):
qs = super().get_queryset()
user = self.request.user
@ -181,7 +186,7 @@ class PostViewSet(viewsets.ModelViewSet):
if ids:
last_comments_map = Comment.objects.select_related("author").in_bulk(ids)
serializer = self.get_serializer(
serializer = PostListSerializer(
objects,
many=True,
context={
@ -193,14 +198,42 @@ class PostViewSet(viewsets.ModelViewSet):
return self.get_paginated_response(serializer.data)
return Response(serializer.data)
# --- ВАЖНО: возвращаем perform_create, чтобы заполнить author ---
def perform_create(self, serializer: PostSerializer) -> None:
"""Сохраняет пост, проставляя автора текущим пользователем.
# --- Файлы из request.FILES сохраняются вручную ---
# Аналогично механизму upload в communications (MessageViewSet.upload),
# где файлы берутся напрямую из request.FILES, а не через сериализатор.
Raises:
ValidationError: если сериализатор невалиден (обрабатывается DRF).
def perform_create(self, serializer: PostSerializer) -> None:
"""Сохраняет пост, проставляя автора.
Файлы (image/attachment) записываются через _attach_files.
"""
serializer.save(author=self.request.user)
instance = serializer.save(author=self.request.user)
self._attach_files(instance)
def perform_update(self, serializer) -> None:
"""Обновляет пост. Файлы записываются через _attach_files."""
instance = serializer.save()
self._attach_files(instance)
def _attach_files(self, instance: Post) -> None:
"""Сохраняет файлы из request.FILES в инстанс поста.
По аналогии с communications.views.MessageViewSet.upload:
файлы берутся напрямую из request.FILES, минуя сериализатор.
"""
files = self.request.FILES
image = files.get("image")
attachment = files.get("attachment")
update_fields = []
if image:
instance.image = image # type: ignore[assignment]
update_fields.append("image")
if attachment:
instance.attachment = attachment # type: ignore[assignment]
update_fields.append("attachment")
if update_fields:
instance.save(update_fields=update_fields)
# --- actions (pin/unpin и лайки) ---
@ -247,6 +280,32 @@ class PostViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
@action(
detail=True,
methods=["get"],
permission_classes=[IsAuthenticated],
url_path="likes-users",
)
def likes_users(self, request, pk=None):
"""Возвращает список пользователей, поставивших лайк посту."""
post = self.get_object()
likes_qs = (
PostLike.objects.filter(post=post)
.select_related("user")
.order_by("-created_at")[:50]
)
users = [like.user for like in likes_qs]
serializer = AuthorMiniSerializer(users, many=True, context={"request": request})
return Response(
{
"count": post.likes_count,
"results": serializer.data,
},
status=status.HTTP_200_OK,
)
class CommentViewSet(viewsets.ModelViewSet):
"""
@ -271,6 +330,17 @@ class CommentViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def perform_update(self, serializer):
"""Редактирование только своих комментариев.
Staff/superuser могут удалять чужие комментарии, но не редактировать.
"""
instance = serializer.instance
user_pk = getattr(self.request.user, "pk", None)
if not instance or instance.author_id != user_pk:
raise PermissionDenied("Редактировать можно только свои комментарии.")
serializer.save()
def get_queryset(self):
qs = super().get_queryset()
qp = self.request.query_params

View File

@ -1,6 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Указываем корень проекта для Turbopack, чтобы не путался с lockfile в корне монорепо
turbopack: {
root: __dirname,
},
// Отключаем автоматический редирект trailing slash
// Django требует слэш в конце URL, а Next.js по умолчанию его убирает
skipTrailingSlashRedirect: true,

View File

@ -50,10 +50,13 @@ async function proxyRequest(request: NextRequest, method: string) {
}
});
// Получаем тело запроса если есть
const body = method !== 'GET' && method !== 'HEAD'
? await request.text()
: undefined;
// Получаем тело запроса как бинарные данные.
// ВАЖНО: text() ломает бинарные файлы (png/mp4) при multipart/form-data.
let body: BodyInit | undefined = undefined;
if (method !== 'GET' && method !== 'HEAD') {
const rawBody = await request.arrayBuffer();
body = rawBody.byteLength > 0 ? rawBody : undefined;
}
// Делаем запрос к backend
const response = await fetch(fullUrl, {
@ -68,6 +71,27 @@ async function proxyRequest(request: NextRequest, method: string) {
responseHeaders.set(key, value);
});
// Для 204/205/304 тело ответа запрещено по HTTP-спецификации.
// Иначе NextResponse выбрасывает: "Invalid response status code 204".
const noBodyStatuses = new Set([204, 205, 304]);
if (noBodyStatuses.has(response.status)) {
responseHeaders.delete('content-length');
responseHeaders.delete('content-type');
return new NextResponse(null, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}
// response.text() уже декодирует gzip/br/deflate.
// Удаляем encoding-заголовки, иначе браузер попытается
// распаковать повторно → ERR_CONTENT_DECODING_FAILED.
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
responseHeaders.delete('transfer-encoding');
// Возвращаем ответ от backend
const responseBody = await response.text();

View File

@ -1,9 +1,103 @@
"use client";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useMemo, useState } from "react";
import type { Department } from "@/types/api";
import { Building2, Search, Users } from "lucide-react";
export default function DepartmentsPage() {
const [departments, setDepartments] = useState<Department[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadDepartments() {
try {
setLoading(true);
setError(null);
const response = await apiClient.getDepartments();
setDepartments(response.results || []);
} catch (err) {
console.error("Ошибка загрузки отделов:", err);
setError("Не удалось загрузить отделы");
} finally {
setLoading(false);
}
}
loadDepartments();
}, []);
const filteredDepartments = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...departments].sort((a, b) => a.name.localeCompare(b.name, "ru"));
if (!q) return sorted;
return sorted.filter((dep) => {
const name = dep.name.toLowerCase();
const desc = (dep.description || "").toLowerCase();
const head = dep.head ? `${dep.head.last_name} ${dep.head.first_name}`.toLowerCase() : "";
return name.includes(q) || desc.includes(q) || head.includes(q);
});
}, [departments, search]);
return (
<AppShell>
<p className="text-sm text-gray-500">Раздел в разработке.</p>
{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-3">
{filteredDepartments.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-8 text-center">
<Building2 size={22} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Отделы не найдены</p>
</div>
) : (
filteredDepartments.map((department) => {
const headName = department.head
? `${department.head.last_name} ${department.head.first_name}`.trim()
: "Не назначен";
return (
<article key={department.id} className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{department.name}</p>
<p className="mt-1 text-xs text-gray-500">Руководитель: {headName}</p>
<p className="mt-2 text-sm text-gray-600">{department.description || "Описание не заполнено"}</p>
</div>
<div className="inline-flex shrink-0 items-center gap-1 rounded-full bg-sky-50 px-2.5 py-1 text-xs text-sky-700 ring-1 ring-sky-100">
<Users size={12} />
{department.employees_count ?? 0}
</div>
</div>
</article>
);
})
)}
</div>
</section>
)}
</AppShell>
);
}

View File

@ -1,9 +1,147 @@
"use client";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useMemo, useState } from "react";
import type { Document } from "@/types/api";
import { Search, FileText, Download } from "lucide-react";
function formatDate(value?: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export default function DocumentsPage() {
const [documents, setDocuments] = useState<Document[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadDocuments() {
try {
setLoading(true);
setError(null);
const response = await apiClient.getDocuments();
setDocuments(response.results || []);
} catch (err) {
console.error("Ошибка загрузки документов:", err);
setError("Не удалось загрузить документы");
} finally {
setLoading(false);
}
}
loadDocuments();
}, []);
const filteredDocuments = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...documents].sort((a, b) => {
const aTime = new Date(a.created_at).getTime() || 0;
const bTime = new Date(b.created_at).getTime() || 0;
return bTime - aTime;
});
if (!q) return sorted;
return sorted.filter((doc) => {
const title = doc.title.toLowerCase();
const description = (doc.description || "").toLowerCase();
const type = (doc.document_type || "").toLowerCase();
const author = doc.created_by
? `${doc.created_by.last_name} ${doc.created_by.first_name}`.toLowerCase()
: "";
return title.includes(q) || description.includes(q) || type.includes(q) || author.includes(q);
});
}, [documents, search]);
return (
<AppShell>
<p className="text-sm text-gray-500">Раздел в разработке.</p>
{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-3">
{filteredDocuments.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-8 text-center">
<FileText size={22} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Документы не найдены</p>
</div>
) : (
filteredDocuments.map((doc) => {
const authorName = doc.created_by
? `${doc.created_by.last_name} ${doc.created_by.first_name}`.trim()
: "Неизвестно";
return (
<article key={doc.id} className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{doc.title}</p>
<p className="mt-1 text-xs text-gray-500">Тип: {doc.document_type || "—"}</p>
</div>
{doc.file ? (
<a
href={doc.file}
target="_blank"
rel="noreferrer"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-sky-50 px-2.5 py-1 text-xs text-sky-700 ring-1 ring-sky-100 hover:bg-sky-100"
>
<Download size={12} />
Открыть
</a>
) : null}
</div>
<p className="mt-3 text-sm text-gray-700">{doc.description || "Описание не заполнено"}</p>
{doc.tags && doc.tags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{doc.tags.map((tag) => (
<span key={`${doc.id}-${tag}`} className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
#{tag}
</span>
))}
</div>
) : null}
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-500 sm:grid-cols-2">
<p>Автор: {authorName}</p>
<p>Создано: {formatDate(doc.created_at)}</p>
<p>Обновлено: {formatDate(doc.updated_at)}</p>
</div>
</article>
);
})
)}
</div>
</section>
)}
</AppShell>
);
}

View File

@ -2,29 +2,78 @@
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { User } from "@/types/api";
export default function EmployeesPage() {
const [employees, setEmployees] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loaderRef = useRef<HTMLDivElement | null>(null);
const isFetchingRef = useRef(false);
const loadEmployees = async (pageToLoad: number, append: boolean) => {
if (isFetchingRef.current) return;
try {
isFetchingRef.current = true;
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
setError(null);
const response = await apiClient.getEmployees({ page: pageToLoad, limit: 20 });
const nextChunk = response.results || [];
setEmployees((prev) => (append ? [...prev, ...nextChunk] : nextChunk));
setHasMore(Boolean(response.next));
setPage(pageToLoad);
} catch (err: any) {
console.error("Ошибка загрузки сотрудников:", err);
setError("Не удалось загрузить список сотрудников");
} finally {
isFetchingRef.current = false;
setLoading(false);
setLoadingMore(false);
}
};
useEffect(() => {
async function loadEmployees() {
try {
const response = await apiClient.getEmployees();
setEmployees(response.results);
} catch (err: any) {
console.error('Ошибка загрузки сотрудников:', err);
setError('Не удалось загрузить список сотрудников');
} finally {
setLoading(false);
}
}
loadEmployees();
loadEmployees(1, false);
}, []);
useEffect(() => {
if (!hasMore || loading) return;
const target = loaderRef.current;
if (!target) return;
const observer = new IntersectionObserver(
(entries) => {
const first = entries[0];
if (!first?.isIntersecting) return;
if (isFetchingRef.current || loadingMore || !hasMore) return;
loadEmployees(page + 1, true);
},
{
root: null,
rootMargin: "300px 0px",
threshold: 0.1,
}
);
observer.observe(target);
return () => {
observer.disconnect();
};
}, [page, hasMore, loading, loadingMore]);
if (loading) {
return (
<AppShell>
@ -97,6 +146,14 @@ export default function EmployeesPage() {
</article>
);
})}
<div ref={loaderRef} className="py-2">
{loadingMore ? (
<p className="text-center text-xs text-gray-500">Подгружаем сотрудников...</p>
) : !hasMore && employees.length > 0 ? (
<p className="text-center text-xs text-gray-400">Все сотрудники загружены</p>
) : null}
</div>
</div>
</AppShell>
);

File diff suppressed because it is too large Load Diff

View File

@ -6,16 +6,103 @@ import { apiClient } from "@/lib/api";
import type { Chat } from "@/types/api";
import { Search, MessageCircle } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useUser } from "@/contexts/UserContext";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
function getChatTitle(chat: Chat): string {
return chat.name?.trim() || "Диалог";
function getUserFullName(lastName?: string, firstName?: string): string {
return `${lastName || ""} ${firstName || ""}`.trim();
}
function getChatInitials(chat: Chat): string {
const title = getChatTitle(chat);
function normalizeName(value?: string | null): string {
return (value || "").replace(/\s+/g, " ").trim().toLowerCase();
}
function isLikelyCurrentUserName(candidate: string, user?: { first_name?: string; last_name?: string; patronymic?: string; email?: string } | null): boolean {
if (!user) return false;
const c = normalizeName(candidate);
if (!c) return false;
const fn = normalizeName(user.first_name);
const ln = normalizeName(user.last_name);
const pn = normalizeName(user.patronymic);
const email = normalizeName(user.email);
if (email && c === email) return true;
const variants = new Set<string>([
normalizeName(`${ln} ${fn}`),
normalizeName(`${fn} ${ln}`),
normalizeName(`${ln} ${fn} ${pn}`),
normalizeName(`${fn} ${pn} ${ln}`),
normalizeName(`${fn} ${pn}`),
]);
if (variants.has(c)) return true;
if (fn && ln && c.includes(fn) && c.includes(ln)) return true;
return false;
}
function getInterlocutorFromParticipants(chat: Chat, currentUserId?: number) {
const participants = (chat.participants || []).filter(
(p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null
);
return participants.find((p) => p.id !== currentUserId);
}
function getInterlocutorFromParticipantDetails(chat: Chat, currentUserId?: number) {
return (chat.participant_details || []).find((p) => p.id !== currentUserId);
}
function getInterlocutorNameFromParticipantNames(chat: Chat, currentUser?: { first_name?: string; last_name?: string; patronymic?: string; email?: string } | null): string {
const names = (chat.participant_names || []).map((n) => (n || "").trim()).filter(Boolean);
if (!names.length) return "";
const other = names.find((n) => !isLikelyCurrentUserName(n, currentUser));
return other || names[0] || "";
}
function getChatTitle(chat: Chat, currentUserId?: number, currentUser?: { first_name?: string; last_name?: string; patronymic?: string; email?: string } | null): string {
const chatKind = chat.chat_type || chat.type;
const rawName = (chat.name || "").trim();
if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") {
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
if (detailsOther?.name?.trim()) {
return detailsOther.name.trim();
}
const other = getInterlocutorFromParticipants(chat, currentUserId);
if (other && typeof other === "object") {
const name = getUserFullName(other.last_name, other.first_name);
if (name) return name;
if (other.email) return other.email;
}
const namesFallback = getInterlocutorNameFromParticipantNames(chat, currentUser);
if (namesFallback) return namesFallback;
}
return rawName || "Диалог";
}
function getChatAvatar(chat: Chat, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") {
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
if (detailsOther?.avatar) return detailsOther.avatar;
const other = getInterlocutorFromParticipants(chat, currentUserId);
if (other?.avatar) return other.avatar;
}
return chat.avatar || "";
}
function getChatInitials(chat: Chat, currentUserId?: number, currentUser?: { first_name?: string; last_name?: string; patronymic?: string; email?: string } | null): string {
const title = getChatTitle(chat, currentUserId, currentUser);
return title
.split(" ")
.filter(Boolean)
@ -26,11 +113,15 @@ function getChatInitials(chat: Chat): string {
function resolveAvatarUrl(url?: string | null): string {
if (!url) return "";
if (url.startsWith("data:")) return url;
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}`);
}
if (BACKEND_URL) {
return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}/${url.replace(/^\/+/, "")}`);
}
return encodeURI(url);
}
@ -46,15 +137,49 @@ function formatTime(date?: string): string {
return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
function getChatSortTimestamp(chat: Chat): number {
const lastMessage = chat.last_message;
if (typeof lastMessage?.created_ts === "number") {
return lastMessage.created_ts;
}
const lastCreatedRaw = lastMessage?.created_at || lastMessage?.created;
if (lastCreatedRaw) {
const ts = new Date(lastCreatedRaw).getTime();
if (!Number.isNaN(ts)) return ts;
}
const chatCreatedTs = new Date(chat.created_at).getTime();
return Number.isNaN(chatCreatedTs) ? 0 : chatCreatedTs;
}
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);
const otherParticipant = (chat.participants || [])
.filter((p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null)
.find((p) => p.id !== currentUserId);
return Boolean(otherParticipant?.is_active);
}
export default function MessagesPage() {
const { user } = useUser();
const currentUserId = user?.id;
const currentUserFirstName = user?.first_name;
const currentUserLastName = user?.last_name;
const currentUserPatronymic = user?.patronymic;
const currentUserEmail = user?.email;
const currentUserForMatch = useMemo(
() => ({
first_name: currentUserFirstName,
last_name: currentUserLastName,
patronymic: currentUserPatronymic,
email: currentUserEmail,
}),
[currentUserEmail, currentUserFirstName, currentUserLastName, currentUserPatronymic]
);
const [chats, setChats] = useState<Chat[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
@ -67,8 +192,53 @@ export default function MessagesPage() {
setError(null);
const response = await apiClient.getChats();
const items = response.results || [];
setChats(items);
} catch (e: any) {
// FRONTEND-ONLY: добираем детали чатов, чтобы получить имя/аватар собеседника
// для private/direct в случае, когда list не отдает avatar/participant_details.
const needDetails = items.filter((chat) => {
const kind = chat.chat_type || chat.type;
if (kind !== "private" && kind !== "direct") return false;
const hasOtherAvatar = Boolean(getChatAvatar(chat, currentUserId));
const resolvedTitle = getChatTitle(chat, currentUserId, currentUserForMatch).trim();
const hasOtherName = Boolean(resolvedTitle && resolvedTitle.toLowerCase() !== "диалог");
return !(hasOtherAvatar && hasOtherName);
});
if (!needDetails.length) {
setChats(items);
return;
}
const details = await Promise.all(
needDetails.map(async (chat) => {
try {
return await apiClient.getChat(chat.id);
} catch {
return null;
}
})
);
const detailsMap = new Map<number, Chat>();
details.forEach((d) => {
if (d) detailsMap.set(d.id, d);
});
const merged = items.map((chat) => {
const detail = detailsMap.get(chat.id);
if (!detail) return chat;
return {
...chat,
participants: detail.participants ?? chat.participants,
participant_details: detail.participant_details ?? chat.participant_details,
avatar: chat.avatar || detail.avatar,
name: chat.name || detail.name,
};
});
setChats(merged);
} catch (e: unknown) {
console.error("Ошибка загрузки чатов:", e);
setError("Не удалось загрузить чаты");
} finally {
@ -77,18 +247,20 @@ export default function MessagesPage() {
}
loadChats();
}, []);
}, [currentUserForMatch, currentUserId]);
const filteredChats = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return chats;
const sorted = [...chats].sort((a, b) => getChatSortTimestamp(b) - getChatSortTimestamp(a));
return chats.filter((chat) => {
const title = getChatTitle(chat).toLowerCase();
if (!q) return sorted;
return sorted.filter((chat) => {
const title = getChatTitle(chat, currentUserId, currentUserForMatch).toLowerCase();
const lastMessage = chat.last_message?.content?.toLowerCase() || "";
return title.includes(q) || lastMessage.includes(q);
});
}, [chats, search]);
}, [chats, currentUserForMatch, currentUserId, search]);
return (
<AppShell>
@ -120,6 +292,10 @@ export default function MessagesPage() {
</div>
) : (
filteredChats.map((chat) => (
(() => {
const chatTitle = getChatTitle(chat, currentUserId, currentUserForMatch);
const chatAvatar = getChatAvatar(chat, currentUserId);
return (
<Link
key={chat.id}
href={`/messages/${chat.id}`}
@ -128,14 +304,17 @@ export default function MessagesPage() {
<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)}
{chatAvatar ? (
<Image
src={resolveAvatarUrl(chatAvatar)}
alt={chatTitle}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getChatInitials(chat)
getChatInitials(chat, currentUserId, currentUserForMatch)
)}
</div>
{isChatOnline(chat, user?.id) ? (
@ -144,7 +323,7 @@ export default function MessagesPage() {
</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>
<p className="truncate text-sm font-semibold text-gray-900">{chatTitle}</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">
@ -153,6 +332,8 @@ export default function MessagesPage() {
</div>
</div>
</Link>
);
})()
))
)}
</div>

View File

@ -1,27 +1,169 @@
"use client";
import { Heart, MessageSquare } from "lucide-react";
import { Heart, MessageSquare, Pencil, Plus, Send, Trash2, X } from "lucide-react";
import { AppShell } from "../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useState } from "react";
import type { Post } from "@/types/api";
import { useEffect, useRef, useState } from "react";
import type { Comment, Post } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
type LikeUser = {
id: number;
first_name?: string;
last_name?: string;
full_name?: string;
avatar?: string | null;
};
export default function Home() {
const { user } = useUser();
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [likeBusyId, setLikeBusyId] = useState<number | null>(null);
const userInitials = user
? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}`
: 'Г';
const [commentsOpen, setCommentsOpen] = useState(false);
const [activePost, setActivePost] = useState<Post | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [commentSending, setCommentSending] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editingCommentText, setEditingCommentText] = useState("");
const [commentActionId, setCommentActionId] = useState<number | null>(null);
const commentsBottomRef = useRef<HTMLDivElement | null>(null);
const [createPostOpen, setCreatePostOpen] = useState(false);
const [createType, setCreateType] = useState<"company" | "department">("company");
const [createDepartmentId, setCreateDepartmentId] = useState<string>("");
const [createTitle, setCreateTitle] = useState("");
const [createBody, setCreateBody] = useState("");
const [createImage, setCreateImage] = useState<File | null>(null);
const [createAttachment, setCreateAttachment] = useState<File | null>(null);
const [createSubmitting, setCreateSubmitting] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [editingPostId, setEditingPostId] = useState<number | null>(null);
const [postActionId, setPostActionId] = useState<number | null>(null);
const [likesPopoverPostId, setLikesPopoverPostId] = useState<number | null>(null);
const [likesLoadingPostId, setLikesLoadingPostId] = useState<number | null>(null);
const [likesUsersMap, setLikesUsersMap] = useState<Record<number, LikeUser[]>>({});
const [likesUsersEndpointUnavailable, setLikesUsersEndpointUnavailable] = useState(false);
const [imageCacheBuster, setImageCacheBuster] = useState<Record<number, number>>({});
const formatUserName = (u: LikeUser) => {
const composed = `${u.last_name || ""} ${u.first_name || ""}`.trim();
return composed || u.full_name || "Пользователь";
};
const formatInitials = (firstName?: string, lastName?: string) => {
return `${(lastName || "").trim().charAt(0)}${(firstName || "").trim().charAt(0)}` || "П";
};
const resolveMediaUrl = (url?: string | null) => {
const raw = (url || "").trim();
if (!raw) return "";
if (/^https?:\/\//i.test(raw)) {
return raw;
}
const backendBase = (process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro").replace(/\/$/, "");
if (raw.startsWith("/")) {
return `${backendBase}${raw}`;
}
return `${backendBase}/${raw}`;
};
const withImageCacheBuster = (url: string, postId: number) => {
if (!url) return "";
const marker = imageCacheBuster[postId] || 0;
if (!marker) return url;
return `${url}${url.includes("?") ? "&" : "?"}v=${marker}`;
};
const auth = user?.auth;
const authPerms = auth?.permissions || [];
const authByApp = auth?.permissions_by_app || {};
const hasPermission = (perm: string) => {
if (!perm) return false;
if (authPerms.includes(perm)) return true;
if (perm.includes(".")) {
const [app, code] = perm.split(".", 2);
return Boolean(authByApp?.[app]?.includes(code));
}
return Object.values(authByApp).some((codes) => codes.includes(perm));
};
const canDeleteAnyComments = Boolean(auth?.is_staff || auth?.is_superuser);
const canCreateCompanyPost = Boolean(
auth?.is_staff ||
auth?.is_superuser ||
hasPermission("feed.add_post") ||
hasPermission("add_post") ||
hasPermission("feed.publish_company_post") ||
hasPermission("publish_company_post")
);
const canCreateDepartmentPost = Boolean(
auth?.is_staff ||
auth?.is_superuser ||
hasPermission("feed.publish_department_post") ||
hasPermission("publish_department_post") ||
hasPermission("feed.create_post") ||
hasPermission("create_post") ||
hasPermission("employees.manage_feed")
);
const canCreatePost = canCreateCompanyPost || canCreateDepartmentPost || ((user?.departments?.length || 0) > 0);
const userDepartments = user?.departments || [];
const canManageAnyPost = Boolean(
auth?.is_staff ||
auth?.is_superuser ||
hasPermission("feed.change_post") ||
hasPermission("change_post") ||
hasPermission("feed.delete_post") ||
hasPermission("delete_post") ||
hasPermission("employees.manage_feed")
);
const canEditPost = (post: Post) => {
if (canManageAnyPost) return true;
return Boolean(post.author?.id && user?.id && post.author.id === user.id);
};
const canDeletePost = (post: Post) => {
if (canManageAnyPost) return true;
return Boolean(post.author?.id && user?.id && post.author.id === user.id);
};
const canEditComment = (comment: Comment) => {
return Boolean(comment.author?.id && user?.id && comment.author.id === user.id);
};
const canDeleteComment = (comment: Comment) => {
if (canEditComment(comment)) return true;
return canDeleteAnyComments;
};
const scrollCommentsToBottom = (smooth = true) => {
requestAnimationFrame(() => {
commentsBottomRef.current?.scrollIntoView({
behavior: smooth ? "smooth" : "auto",
block: "end",
});
});
};
const refreshPosts = async () => {
const response = await apiClient.getPosts();
setPosts(response.results);
};
useEffect(() => {
async function loadPosts() {
try {
const response = await apiClient.getPosts();
setPosts(response.results);
await refreshPosts();
} catch (err: any) {
console.error('Ошибка загрузки ленты:', err);
setError('Не удалось загрузить ленту');
@ -32,6 +174,317 @@ export default function Home() {
loadPosts();
}, []);
useEffect(() => {
if (!commentsOpen && !createPostOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setCommentsOpen(false);
}
};
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = previous;
window.removeEventListener("keydown", onKeyDown);
};
}, [commentsOpen, createPostOpen]);
useEffect(() => {
if (createType !== "department") return;
if (createDepartmentId) return;
if (!userDepartments.length) return;
setCreateDepartmentId(String(userDepartments[0].id));
}, [createType, createDepartmentId, userDepartments]);
useEffect(() => {
if (!commentsOpen || commentsLoading) return;
scrollCommentsToBottom(false);
}, [commentsOpen, commentsLoading, comments.length]);
const handleLikeToggle = async (post: Post) => {
if (likeBusyId === post.id) return;
const currentlyLiked = Boolean(post.is_liked);
const optimisticLikes = Math.max(0, (post.likes_count || 0) + (currentlyLiked ? -1 : 1));
setLikeBusyId(post.id);
setPosts((prev) =>
prev.map((p) =>
p.id === post.id
? { ...p, is_liked: !currentlyLiked, likes_count: optimisticLikes }
: p
)
);
try {
const res = currentlyLiked
? await apiClient.unlikePost(post.id)
: await apiClient.likePost(post.id);
setPosts((prev) =>
prev.map((p) =>
p.id === post.id
? { ...p, is_liked: res.liked, likes_count: res.likes_count }
: p
)
);
} catch (err) {
console.error("Ошибка лайка:", err);
// rollback
setPosts((prev) =>
prev.map((p) =>
p.id === post.id
? { ...p, is_liked: currentlyLiked, likes_count: post.likes_count }
: p
)
);
} finally {
setLikeBusyId(null);
}
};
const loadComments = async (postId: number) => {
setCommentsLoading(true);
setCommentsError(null);
try {
const response = await apiClient.getComments({ post: postId });
const items = Array.isArray(response)
? response
: Array.isArray(response?.results)
? response.results
: [];
setComments(items);
} catch (err) {
console.error("Ошибка загрузки комментариев:", err);
setCommentsError("Не удалось загрузить комментарии");
setComments([]);
} finally {
setCommentsLoading(false);
}
};
const openLikesPopover = async (postId: number, likesCount: number) => {
setLikesPopoverPostId(postId);
if (!likesCount) {
setLikesUsersMap((prev) => ({ ...prev, [postId]: [] }));
return;
}
if (likesUsersEndpointUnavailable) return;
if (likesUsersMap[postId]) return;
setLikesLoadingPostId(postId);
try {
const response = await apiClient.getPostLikers(postId);
setLikesUsersMap((prev) => ({ ...prev, [postId]: response.results || [] }));
} catch (err: any) {
const message = String(err?.message || "");
if (
message.includes("404") ||
message.includes("NetworkError") ||
message.includes("Failed to fetch")
) {
setLikesUsersEndpointUnavailable(true);
}
setLikesUsersMap((prev) => ({ ...prev, [postId]: [] }));
} finally {
setLikesLoadingPostId((prev) => (prev === postId ? null : prev));
}
};
const openComments = async (post: Post) => {
setActivePost(post);
setCommentsOpen(true);
setNewComment("");
setEditingCommentId(null);
setEditingCommentText("");
await loadComments(post.id);
};
const handleCreateComment = async () => {
if (!activePost) return;
const text = newComment.trim();
if (!text) return;
setCommentSending(true);
try {
const created = await apiClient.createComment(activePost.id, text);
setComments((prev) => [...prev, created]);
setNewComment("");
setPosts((prev) =>
prev.map((p) =>
p.id === activePost.id
? { ...p, comments_count: (p.comments_count || 0) + 1 }
: p
)
);
setActivePost((prev) =>
prev ? { ...prev, comments_count: (prev.comments_count || 0) + 1 } : prev
);
scrollCommentsToBottom();
} catch (err) {
console.error("Ошибка отправки комментария:", err);
} finally {
setCommentSending(false);
}
};
const startEditComment = (comment: Comment) => {
setEditingCommentId(comment.id);
setEditingCommentText((comment.text || comment.content || "").trim());
};
const cancelEditComment = () => {
setEditingCommentId(null);
setEditingCommentText("");
};
const saveEditComment = async (commentId: number) => {
const text = editingCommentText.trim();
if (!text) return;
setCommentActionId(commentId);
try {
const updated = await apiClient.updateComment(commentId, text);
setComments((prev) => prev.map((c) => (c.id === commentId ? { ...c, ...updated } : c)));
cancelEditComment();
} catch (err) {
console.error("Ошибка редактирования комментария:", err);
} finally {
setCommentActionId(null);
}
};
const removeComment = async (comment: Comment) => {
if (!activePost) return;
setCommentActionId(comment.id);
try {
await apiClient.deleteComment(comment.id);
setComments((prev) => prev.filter((c) => c.id !== comment.id));
setPosts((prev) =>
prev.map((p) =>
p.id === activePost.id
? { ...p, comments_count: Math.max(0, (p.comments_count || 0) - 1) }
: p
)
);
setActivePost((prev) =>
prev
? { ...prev, comments_count: Math.max(0, (prev.comments_count || 0) - 1) }
: prev
);
if (editingCommentId === comment.id) {
cancelEditComment();
}
} catch (err) {
console.error("Ошибка удаления комментария:", err);
} finally {
setCommentActionId(null);
}
};
const openCreatePostModal = () => {
setCreateError(null);
setEditingPostId(null);
setCreateTitle("");
setCreateBody("");
setCreateImage(null);
setCreateAttachment(null);
if (canCreateCompanyPost) {
setCreateType("company");
} else {
setCreateType("department");
setCreateDepartmentId(userDepartments[0] ? String(userDepartments[0].id) : "");
}
setCreatePostOpen(true);
};
const openEditPostModal = (post: Post) => {
setCreateError(null);
setEditingPostId(post.id);
setCreateType(post.type === "department" ? "department" : "company");
setCreateDepartmentId(post.department_id ? String(post.department_id) : "");
setCreateTitle((post.title || "").trim());
setCreateBody((post.body || post.content || "").trim());
setCreateImage(null);
setCreateAttachment(null);
setCreatePostOpen(true);
};
const handleCreatePost = async () => {
const title = createTitle.trim();
const body = createBody.trim();
if (!title || !body) {
setCreateError("Заполните заголовок и содержание");
return;
}
if (createType === "department" && !createDepartmentId) {
setCreateError("Выберите отдел");
return;
}
setCreateSubmitting(true);
setCreateError(null);
try {
if (editingPostId) {
await apiClient.updatePost(editingPostId, {
type: createType,
title,
body,
department: createType === "department" ? Number(createDepartmentId) : undefined,
image: createImage,
attachment: createAttachment,
});
if (createImage) {
setImageCacheBuster((prev) => ({
...prev,
[editingPostId]: Date.now(),
}));
}
} else {
await apiClient.createPost({
type: createType,
title,
body,
department: createType === "department" ? Number(createDepartmentId) : undefined,
image: createImage,
attachment: createAttachment,
});
}
// Важно: часть backend-инсталляций возвращает неполные данные в create/update,
// поэтому всегда обновляем ленту с сервера и берем актуальные URL файлов.
await refreshPosts();
setCreatePostOpen(false);
setEditingPostId(null);
} catch (err: any) {
const message = String(err?.message || "Не удалось сохранить публикацию");
setCreateError(message);
} finally {
setCreateSubmitting(false);
}
};
const removePost = async (post: Post) => {
setPostActionId(post.id);
try {
await apiClient.deletePost(post.id);
setPosts((prev) => prev.filter((p) => p.id !== post.id));
} catch (err) {
console.error("Ошибка удаления публикации:", err);
} finally {
setPostActionId(null);
}
};
if (loading) {
return (
<AppShell>
@ -56,6 +509,19 @@ export default function Home() {
}
return (
<AppShell>
{canCreatePost ? (
<div className="mb-4">
<button
type="button"
onClick={openCreatePostModal}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-sky-500 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-sky-600"
>
<Plus size={16} />
Создать публикацию
</button>
</div>
) : null}
<div className="space-y-4">
{posts.length === 0 ? (
<div className="rounded-2xl bg-gray-50 p-8 text-center">
@ -74,6 +540,8 @@ export default function Home() {
? post.author.is_active
: Boolean(post.author?.id && user?.id && post.author.id === user.id && user.is_active);
const postText = (post.content || post.body || "").trim();
// Форматируем дату
const postDate = new Date(post.created_at);
const now = new Date();
@ -92,7 +560,7 @@ export default function Home() {
<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" />
<img src={resolveMediaUrl(post.author.avatar)} alt={authorName} className="h-full w-full object-cover" />
) : (
authorInitials
)}
@ -106,18 +574,99 @@ export default function Home() {
<p className="text-xs text-gray-500">{timeAgo}</p>
</div>
</div>
{(canEditPost(post) || canDeletePost(post)) ? (
<div className="flex items-center gap-1">
{canEditPost(post) ? (
<button
type="button"
onClick={() => openEditPostModal(post)}
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
title="Редактировать публикацию"
>
<Pencil size={15} />
</button>
) : null}
{canDeletePost(post) ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => removePost(post)}
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:bg-red-100 hover:text-red-600 disabled:opacity-50"
title="Удалить публикацию"
>
<Trash2 size={15} />
</button>
) : null}
</div>
) : null}
</header>
<p className="text-sm leading-6 text-gray-800">{post.content}</p>
{postText ? (
<p className="text-sm leading-6 text-gray-800">{postText}</p>
) : null}
{post.image && (
<div className="mt-3 overflow-hidden rounded-lg">
<img src={post.image} alt="" className="w-full" />
<img src={withImageCacheBuster(resolveMediaUrl(post.image), post.id)} alt="" className="w-full" />
</div>
)}
<div className="mt-4 flex items-center gap-4 text-sm text-gray-600">
<button className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-gray-50">
<Heart size={16} className="text-gray-400" /> {post.likes_count || 0}
</button>
<button className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-gray-50">
<div
className="relative"
onMouseEnter={() => openLikesPopover(post.id, post.likes_count || 0)}
onMouseLeave={() => setLikesPopoverPostId((prev) => (prev === post.id ? null : prev))}
>
<button
type="button"
disabled={likeBusyId === post.id}
onClick={() => handleLikeToggle(post)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 transition hover:bg-gray-50 ${post.is_liked ? "text-sky-500" : "text-gray-600"}`}
>
<Heart
size={16}
className={post.is_liked ? "fill-sky-500 text-sky-500" : "text-gray-400"}
/>
{post.likes_count || 0}
</button>
{likesPopoverPostId === post.id ? (
<div className="absolute left-0 top-full z-20 mt-1 w-64 rounded-xl bg-white p-2 shadow-lg ring-1 ring-gray-200">
<p className="px-1 pb-1 text-xs font-semibold text-gray-600">Лайкнули</p>
{likesLoadingPostId === post.id ? (
<p className="px-1 py-1 text-xs text-gray-500">Загрузка...</p>
) : likesUsersEndpointUnavailable ? (
<p className="px-1 py-1 text-xs text-gray-500">Список лайкнувших временно недоступен</p>
) : (likesUsersMap[post.id] || []).length === 0 ? (
<p className="px-1 py-1 text-xs text-gray-500">Пока нет лайков</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto">
{(likesUsersMap[post.id] || []).map((u) => {
const name = formatUserName(u);
const initials = formatInitials(u.first_name, u.last_name);
return (
<div key={u.id} className="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-gray-50">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-[10px] font-semibold text-white">
{u.avatar ? (
<img src={resolveMediaUrl(u.avatar)} alt={name} className="h-full w-full object-cover" />
) : (
initials
)}
</div>
<p className="truncate text-xs text-gray-700">{name}</p>
</div>
);
})}
</div>
)}
</div>
) : null}
</div>
<button
type="button"
onClick={() => openComments(post)}
className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-gray-50"
>
<MessageSquare size={16} className="text-gray-400" /> {post.comments_count || 0}
</button>
</div>
@ -126,6 +675,277 @@ export default function Home() {
})
)}
</div>
{createPostOpen ? (
<div className="fixed inset-0 z-[95]">
<button
type="button"
aria-label="Закрыть создание публикации"
onClick={() => setCreatePostOpen(false)}
className="absolute inset-0 bg-black/45"
/>
<div className="absolute inset-x-3 top-4 bottom-4 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 sm:inset-x-6">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<p className="text-sm font-semibold text-gray-900">
{editingPostId ? "Редактировать публикацию" : "Создать публикацию"}
</p>
<button
type="button"
onClick={() => setCreatePostOpen(false)}
className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-gray-100"
>
<X size={18} className="text-gray-600" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-4">
{createError ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{createError}</div>
) : null}
{(canCreateCompanyPost || canCreateDepartmentPost) ? (
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Тип публикации</label>
<select
value={createType}
onChange={(e) => setCreateType(e.target.value as "company" | "department")}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
{canCreateCompanyPost ? <option value="company">Новость компании</option> : null}
{canCreateDepartmentPost ? <option value="department">Новость отдела</option> : null}
</select>
</div>
) : null}
{createType === "department" ? (
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Отдел</label>
<select
value={createDepartmentId}
onChange={(e) => setCreateDepartmentId(e.target.value)}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
<option value="">Выберите отдел</option>
{userDepartments.map((dept) => (
<option key={dept.id} value={String(dept.id)}>{dept.name}</option>
))}
</select>
</div>
) : null}
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Заголовок</label>
<input
type="text"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Содержание</label>
<textarea
value={createBody}
onChange={(e) => setCreateBody(e.target.value)}
className="min-h-32 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Изображение</label>
<input
type="file"
accept="image/*"
onChange={(e) => setCreateImage(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sm file:text-sky-700 hover:file:bg-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Вложение</label>
<input
type="file"
onChange={(e) => setCreateAttachment(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm file:text-gray-700 hover:file:bg-gray-200"
/>
</div>
</div>
</div>
<div className="border-t border-gray-100 px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setCreatePostOpen(false)}
className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
>
Отмена
</button>
<button
type="button"
disabled={createSubmitting}
onClick={handleCreatePost}
className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-50"
>
{createSubmitting ? (editingPostId ? "Сохраняем..." : "Публикуем...") : (editingPostId ? "Сохранить" : "Опубликовать")}
</button>
</div>
</div>
</div>
</div>
) : null}
{commentsOpen && activePost && (
<div className="fixed inset-0 z-[90]">
<button
type="button"
aria-label="Закрыть комментарии"
onClick={() => setCommentsOpen(false)}
className="absolute inset-0 bg-black/45"
/>
<div className="absolute inset-x-3 top-4 bottom-4 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 sm:inset-x-6">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<div>
<p className="text-sm font-semibold text-gray-900">Комментарии</p>
<p className="text-xs text-gray-500">Публикация #{activePost.id}</p>
</div>
<button
type="button"
onClick={() => setCommentsOpen(false)}
className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-gray-100"
>
<X size={18} className="text-gray-600" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3">
{commentsLoading ? (
<p className="text-sm text-gray-500">Загрузка комментариев...</p>
) : commentsError ? (
<p className="text-sm text-red-600">{commentsError}</p>
) : comments.length === 0 ? (
<p className="text-sm text-gray-500">Комментариев пока нет</p>
) : (
<div className="space-y-3">
{comments.map((comment) => {
const commentText = (comment.text || comment.content || "").trim();
const authorName = comment.author
? `${comment.author.last_name || ""} ${comment.author.first_name || ""}`.trim() || "Пользователь"
: "Пользователь";
const authorInitials = comment.author
? `${comment.author.last_name?.[0] || ""}${comment.author.first_name?.[0] || ""}` || "П"
: "П";
return (
<div key={comment.id} className="rounded-xl bg-gray-50 px-3 py-2.5">
<div className="flex items-start gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-[11px] font-semibold text-white">
{comment.author?.avatar ? (
<img src={resolveMediaUrl(comment.author.avatar)} alt={authorName} className="h-full w-full object-cover" />
) : (
authorInitials
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-semibold text-gray-900">{authorName}</p>
<div className="flex items-center gap-1">
{canEditComment(comment) ? (
<button
type="button"
onClick={() => startEditComment(comment)}
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-500 hover:bg-gray-200 hover:text-gray-700"
title="Редактировать"
>
<Pencil size={13} />
</button>
) : null}
{canDeleteComment(comment) ? (
<button
type="button"
disabled={commentActionId === comment.id}
onClick={() => removeComment(comment)}
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-500 hover:bg-red-100 hover:text-red-600 disabled:opacity-50"
title="Удалить"
>
<Trash2 size={13} />
</button>
) : null}
</div>
</div>
{editingCommentId === comment.id ? (
<div className="mt-1 space-y-2">
<textarea
value={editingCommentText}
onChange={(e) => setEditingCommentText(e.target.value)}
className="min-h-20 w-full rounded-lg border border-gray-300 px-2.5 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<div className="flex items-center gap-2">
<button
type="button"
disabled={commentActionId === comment.id || !editingCommentText.trim()}
onClick={() => saveEditComment(comment.id)}
className="rounded-md bg-sky-500 px-2.5 py-1.5 text-xs font-medium text-white disabled:opacity-50"
>
Сохранить
</button>
<button
type="button"
disabled={commentActionId === comment.id}
onClick={cancelEditComment}
className="rounded-md bg-gray-200 px-2.5 py-1.5 text-xs font-medium text-gray-700"
>
Отмена
</button>
</div>
</div>
) : (
<p className="mt-1 text-sm leading-6 text-gray-800">{commentText}</p>
)}
</div>
</div>
</div>
);
})}
<div ref={commentsBottomRef} />
</div>
)}
</div>
<div className="border-t border-gray-100 px-4 py-3">
<div className="flex items-center gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCreateComment();
}
}}
placeholder="Напишите комментарий..."
className="h-10 flex-1 rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<button
type="button"
disabled={commentSending || !newComment.trim()}
onClick={handleCreateComment}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-500 text-white disabled:cursor-not-allowed disabled:opacity-50"
>
<Send size={16} />
</button>
</div>
</div>
</div>
</div>
)}
</AppShell>
);
}

View File

@ -0,0 +1,429 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { Camera, Pencil, Save, X } from "lucide-react";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
function resolveMediaUrl(url?: string | null): string {
const raw = (url || "").trim();
if (!raw) return "";
if (raw.startsWith("data:")) return raw;
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.startsWith("//")) return `https:${raw}`;
const base = BACKEND_URL.replace(/\/$/, "");
if (raw.startsWith("/")) return `${base}${raw}`;
return `${base}/${raw.replace(/^\/+/, "")}`;
}
function formatDate(value?: string): string {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function ProfilePage() {
const { user, loading, refreshUser } = useUser();
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [editProfileOpen, setEditProfileOpen] = useState(false);
const [editContactsOpen, setEditContactsOpen] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [savingContacts, setSavingContacts] = useState(false);
const [profileError, setProfileError] = useState<string | null>(null);
const [contactsError, setContactsError] = useState<string | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [profileForm, setProfileForm] = useState({
last_name: "",
first_name: "",
patronymic: "",
birth_date: "",
});
const [contactsForm, setContactsForm] = useState({
email: "",
phone_number: "",
telegram: "",
whatsapp: "",
wechat: "",
});
const fullName = useMemo(() => {
if (!user) return "";
return `${user.last_name || ""} ${user.first_name || ""} ${user.patronymic || ""}`.trim() || "Пользователь";
}, [user]);
const initials = useMemo(() => {
if (!user) return "П";
const s = `${user.last_name?.[0] || ""}${user.first_name?.[0] || ""}`.trim();
return s || "П";
}, [user]);
const departments = user?.departments || [];
const skills = user?.skills || [];
const actions = user?.actions || [];
const birthDateLabel = useMemo(() => {
if (!user?.birth_date) return "—";
const d = new Date(user.birth_date);
if (Number.isNaN(d.getTime())) return user.birth_date;
return d.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}, [user?.birth_date]);
useEffect(() => {
if (!user) return;
setProfileForm({
last_name: user.last_name || "",
first_name: user.first_name || "",
patronymic: user.patronymic || "",
birth_date: user.birth_date || "",
});
setContactsForm({
email: user.email || "",
phone_number: user.phone_number || "",
telegram: user.telegram || "",
whatsapp: user.whatsapp || "",
wechat: user.wechat || "",
});
}, [user]);
const handleSaveProfile = async () => {
try {
setSavingProfile(true);
setProfileError(null);
await apiClient.updateCurrentUserProfile({
last_name: profileForm.last_name.trim(),
first_name: profileForm.first_name.trim(),
patronymic: profileForm.patronymic.trim(),
birth_date: profileForm.birth_date.trim() || null,
avatar: avatarFile || undefined,
});
await refreshUser();
setAvatarFile(null);
setEditProfileOpen(false);
} catch (e: any) {
setProfileError(String(e?.message || "Не удалось сохранить профиль"));
} finally {
setSavingProfile(false);
}
};
const handleSaveContacts = async () => {
try {
setSavingContacts(true);
setContactsError(null);
await apiClient.updateCurrentUserProfile({
email: contactsForm.email.trim(),
phone_number: contactsForm.phone_number.trim(),
telegram: contactsForm.telegram.trim(),
whatsapp: contactsForm.whatsapp.trim(),
wechat: contactsForm.wechat.trim(),
});
await refreshUser();
setEditContactsOpen(false);
} catch (e: any) {
setContactsError(String(e?.message || "Не удалось сохранить контакты"));
} finally {
setSavingContacts(false);
}
};
if (loading) {
return (
<AppShell>
<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" />
<p className="text-sm text-gray-500">Загрузка профиля...</p>
</div>
</div>
</AppShell>
);
}
if (!user) {
return null;
}
const normalizeTelegram = (value: string) => {
const v = value.trim();
if (!v) return "";
if (/^https?:\/\//i.test(v)) return v;
return `https://t.me/${v.replace(/^@/, "")}`;
};
const normalizeWhatsApp = (value: string) => {
const v = value.trim();
if (!v) return "";
if (/^https?:\/\//i.test(v)) return v;
const digits = v.replace(/[^\d]/g, "");
return digits ? `https://wa.me/${digits}` : "";
};
const contactItems = [
{ label: "Телефон", value: user.phone_number?.trim() || "", href: user.phone_number?.trim() ? `tel:${user.phone_number.trim().replace(/\s+/g, "")}` : "", external: false },
{ label: "Почта", value: user.email?.trim() || "", href: user.email?.trim() ? `mailto:${user.email.trim()}` : "", external: false },
{ label: "Telegram", value: user.telegram?.trim() || "", href: normalizeTelegram(user.telegram || ""), external: true },
{ label: "WhatsApp", value: user.whatsapp?.trim() || "", href: normalizeWhatsApp(user.whatsapp || ""), external: true },
{ label: "WeChat", value: user.wechat?.trim() || "", href: "", external: false },
].filter((item) => item.value);
const editableContactItems = [
{ label: "Телефон", key: "phone_number" as const },
{ label: "Почта", key: "email" as const },
{ label: "Telegram", key: "telegram" as const },
{ label: "WhatsApp", key: "whatsapp" as const },
{ label: "WeChat", key: "wechat" as const },
];
return (
<AppShell>
<div className="space-y-4">
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start gap-4">
<div className="shrink-0">
<div className="relative h-16 w-16">
{user.avatar ? (
<a href={resolveMediaUrl(user.avatar)} target="_blank" rel="noreferrer" title="Открыть фото" className="block h-16 w-16 overflow-hidden rounded-full bg-sky-400">
<Image
src={resolveMediaUrl(user.avatar)}
alt={fullName}
width={64}
height={64}
className="h-full w-full object-cover"
unoptimized
/>
</a>
) : (
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">{initials}</div>
)}
{user.is_active ? <span className="absolute -bottom-0.5 -right-0.5 z-20 h-3.5 w-3.5 rounded-full bg-sky-400 ring-2 ring-white" /> : null}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => {
setAvatarFile(e.target.files?.[0] || null);
setEditProfileOpen(true);
}}
className="hidden"
/>
{editProfileOpen ? (
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-gray-200 px-2 py-1 text-gray-700 hover:bg-gray-50"
title="Заменить фото"
>
<Camera size={14} />
</button>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
{editProfileOpen ? (
<div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">
<input value={profileForm.last_name} onChange={(e) => setProfileForm((p) => ({ ...p, last_name: e.target.value }))} placeholder="Фамилия" className="rounded-lg border border-gray-300 px-3 py-2 text-sm" />
<input value={profileForm.first_name} onChange={(e) => setProfileForm((p) => ({ ...p, first_name: e.target.value }))} placeholder="Имя" className="rounded-lg border border-gray-300 px-3 py-2 text-sm" />
<input value={profileForm.patronymic} onChange={(e) => setProfileForm((p) => ({ ...p, patronymic: e.target.value }))} placeholder="Отчество" className="rounded-lg border border-gray-300 px-3 py-2 text-sm" />
</div>
) : (
<p className="truncate text-xl font-semibold text-gray-900">{fullName}</p>
)}
<button
type="button"
onClick={() => {
setEditProfileOpen((v) => !v);
setProfileError(null);
}}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-gray-200 text-gray-600 hover:bg-gray-50"
title="Редактировать профиль"
>
<Pencil size={14} />
</button>
</div>
{editProfileOpen ? (
<div className="mt-2 flex items-center gap-2 text-sm text-gray-600">
<span>Дата рождения:</span>
<input type="date" value={profileForm.birth_date} onChange={(e) => setProfileForm((p) => ({ ...p, birth_date: e.target.value }))} className="rounded-lg border border-gray-300 px-2 py-1 text-sm" />
</div>
) : (
<p className="mt-1 text-sm text-gray-600">Дата рождения: {birthDateLabel}</p>
)}
<p className="mt-1 text-sm text-gray-600">Должность: {user.position?.name || "—"}</p>
<div className="mt-3 space-y-2">
{departments.length === 0 ? (
<p className="text-sm text-gray-500">Отделы не указаны</p>
) : (
departments.map((d) => (
<div key={d.id} className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800">
<div className="min-w-0">
<p className="truncate font-medium">{d.name}</p>
{d.role_name ? <p className="truncate text-xs text-gray-500">{d.role_name}</p> : null}
</div>
{d.is_head ? <span className="shrink-0 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">Руководитель</span> : null}
</div>
))
)}
</div>
</div>
</div>
{editProfileOpen ? (
<div className="mt-4 border-t border-gray-100 pt-4">
{profileError ? <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{profileError}</p> : null}
{avatarFile ? <p className="mb-3 text-xs text-gray-500">Выбран файл: {avatarFile.name}</p> : null}
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => {
setEditProfileOpen(false);
setProfileError(null);
setAvatarFile(null);
}}
className="inline-flex items-center gap-1 rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
>
<X size={14} /> Отмена
</button>
<button
type="button"
onClick={handleSaveProfile}
disabled={savingProfile}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60"
>
<Save size={14} /> {savingProfile ? "Сохраняем..." : "Сохранить"}
</button>
</div>
</div>
) : null}
</section>
<article className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Контакты</h3>
<button
type="button"
onClick={() => {
setEditContactsOpen((v) => !v);
setContactsError(null);
}}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-gray-200 text-gray-600 hover:bg-gray-50"
title="Редактировать контакты"
>
<Pencil size={14} />
</button>
</div>
{editContactsOpen ? (
<>
{contactsError ? <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{contactsError}</p> : null}
<ul className="space-y-2">
{editableContactItems.map((item) => (
<li key={item.key} className="rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800">
<label className="mb-1 block font-medium">{item.label}</label>
<input
value={contactsForm[item.key]}
onChange={(e) => setContactsForm((p) => ({ ...p, [item.key]: e.target.value }))}
placeholder={item.label}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</li>
))}
</ul>
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => {
setEditContactsOpen(false);
setContactsError(null);
}}
className="inline-flex items-center gap-1 rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
>
<X size={14} /> Отмена
</button>
<button
type="button"
onClick={handleSaveContacts}
disabled={savingContacts}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60"
>
<Save size={14} /> {savingContacts ? "Сохраняем..." : "Сохранить"}
</button>
</div>
</>
) : contactItems.length === 0 ? (
<p className="text-sm text-gray-500">Контакты не указаны</p>
) : (
<ul className="space-y-2">
{contactItems.map((item) => (
<li key={item.label} className="rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800">
<span className="font-medium">{item.label}:</span>{" "}
{item.href ? (
<a href={item.href} target={item.external ? "_blank" : undefined} rel={item.external ? "noreferrer" : undefined} className="text-sky-700 underline decoration-sky-300 underline-offset-2 hover:text-sky-800">
{item.value}
</a>
) : (
<span>{item.value}</span>
)}
</li>
))}
</ul>
)}
</article>
<article className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Навыки</h3>
{skills.length === 0 ? (
<p className="text-sm text-gray-500">Навыки не указаны</p>
) : (
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span key={s.id} className="rounded-full bg-sky-50 px-3 py-1 text-xs font-medium text-sky-700 ring-1 ring-sky-100">
{s.name}
</span>
))}
</div>
)}
</article>
<article className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Кадровые события</h3>
{actions.length === 0 ? (
<p className="text-sm text-gray-500">Событий нет</p>
) : (
<div className="space-y-2">
{actions.map((a) => (
<div key={a.id} className="rounded-lg bg-gray-50 px-3 py-2">
<p className="text-sm font-medium text-gray-900">{a.action_type}</p>
{a.description ? <p className="text-xs text-gray-600">{a.description}</p> : null}
<p className="mt-1 text-xs text-gray-500">{formatDate(a.date || a.created_at)}</p>
</div>
))}
</div>
)}
</article>
</div>
</AppShell>
);
}

View File

@ -1,9 +1,159 @@
"use client";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useMemo, useState } from "react";
import type { Request } from "@/types/api";
import { Search, FileSignature } from "lucide-react";
const statusMeta: Record<Request["status"], { label: string; className: string }> = {
pending: {
label: "На рассмотрении",
className: "bg-amber-50 text-amber-700 ring-amber-100",
},
approved: {
label: "Одобрено",
className: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
rejected: {
label: "Отклонено",
className: "bg-rose-50 text-rose-700 ring-rose-100",
},
in_progress: {
label: "В работе",
className: "bg-sky-50 text-sky-700 ring-sky-100",
},
completed: {
label: "Завершено",
className: "bg-violet-50 text-violet-700 ring-violet-100",
},
};
const defaultStatusMeta = {
label: "Неизвестный статус",
className: "bg-gray-50 text-gray-700 ring-gray-200",
};
function formatDate(value?: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export default function RequestsPage() {
const [requests, setRequests] = useState<Request[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadRequests() {
try {
setLoading(true);
setError(null);
const response = await apiClient.getRequests();
setRequests(response.results || []);
} catch (err) {
console.error("Ошибка загрузки заявлений:", err);
setError("Не удалось загрузить заявления");
} finally {
setLoading(false);
}
}
loadRequests();
}, []);
const filteredRequests = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...requests].sort((a, b) => {
const aTime = new Date(a.created_at).getTime() || 0;
const bTime = new Date(b.created_at).getTime() || 0;
return bTime - aTime;
});
if (!q) return sorted;
return sorted.filter((item) => {
const title = item.title.toLowerCase();
const description = item.description.toLowerCase();
const type = item.request_type.toLowerCase();
const author = `${item.created_by?.last_name || ""} ${item.created_by?.first_name || ""}`.toLowerCase();
return title.includes(q) || description.includes(q) || type.includes(q) || author.includes(q);
});
}, [requests, search]);
return (
<AppShell>
<p className="text-sm text-gray-500">Раздел в разработке.</p>
{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-3">
{filteredRequests.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-8 text-center">
<FileSignature size={22} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Заявления не найдены</p>
</div>
) : (
filteredRequests.map((item) => {
const authorName = `${item.created_by?.last_name || ""} ${item.created_by?.first_name || ""}`.trim() || "Неизвестно";
const assigneeName = item.assigned_to
? `${item.assigned_to.last_name} ${item.assigned_to.first_name}`.trim()
: "Не назначен";
const statusKey = String(item.status || "").toLowerCase() as Request["status"];
const status = statusMeta[statusKey] ?? defaultStatusMeta;
return (
<article key={item.id} className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{item.title}</p>
<p className="mt-1 text-xs text-gray-500">Тип: {item.request_type}</p>
</div>
<span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
{status.label}
</span>
</div>
<p className="mt-3 text-sm text-gray-700">{item.description}</p>
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-500 sm:grid-cols-2">
<p>Автор: {authorName}</p>
<p>Исполнитель: {assigneeName}</p>
<p>Создано: {formatDate(item.created_at)}</p>
<p>Обновлено: {formatDate(item.updated_at)}</p>
</div>
</article>
);
})
)}
</div>
</section>
)}
</AppShell>
);
}

View File

@ -0,0 +1,219 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { Chat, Department, Document, Post, Request, User } from "@/types/api";
import { Search } from "lucide-react";
type SearchState = {
employees: User[];
departments: Department[];
documents: Document[];
requests: Request[];
chats: Chat[];
posts: Post[];
};
const emptyState: SearchState = {
employees: [],
departments: [],
documents: [],
requests: [],
chats: [],
posts: [],
};
function contains(value: string | undefined, query: string): boolean {
return (value || "").toLowerCase().includes(query);
}
export default function SearchPage() {
const searchParams = useSearchParams();
const queryRaw = searchParams.get("q") || "";
const query = queryRaw.trim().toLowerCase();
const [results, setResults] = useState<SearchState>(emptyState);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query) {
setResults(emptyState);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
async function runSearch() {
try {
setLoading(true);
setError(null);
const [employees, departments, documents, requests, chats, posts] = await Promise.all([
apiClient.getEmployees({ search: queryRaw, limit: 20 }),
apiClient.getDepartments({ search: queryRaw, limit: 20 }),
apiClient.getDocuments({ search: queryRaw, limit: 20 }),
apiClient.getRequests({ search: queryRaw, limit: 20 }),
apiClient.getChats({ search: queryRaw, limit: 20 }),
apiClient.getPosts({ search: queryRaw, limit: 20 }),
]);
if (cancelled) return;
const filtered: SearchState = {
employees: (employees.results || []).filter((u) =>
contains(`${u.last_name} ${u.first_name} ${u.patronymic || ""}`, query) ||
contains(u.email, query)
),
departments: (departments.results || []).filter((d) =>
contains(d.name, query) || contains(d.description, query)
),
documents: (documents.results || []).filter((d) =>
contains(d.title, query) || contains(d.description, query) || contains(d.document_type, query)
),
requests: (requests.results || []).filter((r) =>
contains(r.title, query) || contains(r.description, query) || contains(r.request_type, query)
),
chats: (chats.results || []).filter((c) => contains(c.name, query) || contains(c.last_message?.content, query)),
posts: (posts.results || []).filter((p) => contains(p.content, query)),
};
setResults(filtered);
} catch (err) {
if (!cancelled) {
console.error("Ошибка поиска:", err);
setError("Не удалось выполнить поиск");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
runSearch();
return () => {
cancelled = true;
};
}, [query, queryRaw]);
const total = useMemo(
() =>
results.employees.length +
results.departments.length +
results.documents.length +
results.requests.length +
results.chats.length +
results.posts.length,
[results]
);
return (
<AppShell>
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="mb-4 flex items-center gap-2">
<Search size={16} className="text-gray-400" />
<p className="text-sm text-gray-600">
{query ? `Результаты по запросу: ${queryRaw}` : "Введите запрос в поиске сверху"}
</p>
</div>
{loading ? (
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">Идёт поиск...</div>
) : error ? (
<div className="rounded-xl bg-red-50 p-6 text-center text-sm text-red-800">{error}</div>
) : !query ? (
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">Начните вводить запрос в строке поиска</div>
) : total === 0 ? (
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">Ничего не найдено</div>
) : (
<div className="space-y-4">
{results.employees.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Сотрудники</p>
<div className="space-y-2">
{results.employees.map((u) => (
<Link key={u.id} href="/employees" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{`${u.last_name} ${u.first_name}`.trim()} {u.email ? `• ${u.email}` : ""}
</Link>
))}
</div>
</div>
) : null}
{results.departments.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Отделы</p>
<div className="space-y-2">
{results.departments.map((d) => (
<Link key={d.id} href="/departments" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{d.name}
</Link>
))}
</div>
</div>
) : null}
{results.requests.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Заявления</p>
<div className="space-y-2">
{results.requests.map((r) => (
<Link key={r.id} href="/requests" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{r.title}
</Link>
))}
</div>
</div>
) : null}
{results.documents.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Документы</p>
<div className="space-y-2">
{results.documents.map((d) => (
<Link key={d.id} href="/documents" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{d.title}
</Link>
))}
</div>
</div>
) : null}
{results.chats.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Чаты</p>
<div className="space-y-2">
{results.chats.map((c) => (
<Link key={c.id} href={`/messages/${c.id}`} className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{c.name || `Чат #${c.id}`}
</Link>
))}
</div>
</div>
) : null}
{results.posts.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Посты</p>
<div className="space-y-2">
{results.posts.map((p) => (
<Link key={p.id} href="/" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{p.content?.slice(0, 120) ?? "Без текста"}
</Link>
))}
</div>
</div>
) : null}
</div>
)}
</section>
</AppShell>
);
}

View File

@ -1,9 +1,10 @@
"use client";
import { Bell, Building2, FileSignature, FileText, Home as HomeIcon, MessageSquare, Search, Users, Wallet } from "lucide-react";
import { Bell, Building2, CalendarDays, ChevronLeft, ChevronRight, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, Users, Wallet, X } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect } from "react";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
type AppShellProps = {
@ -17,6 +18,15 @@ type PageHeaderProps = {
eyebrow?: string;
};
type HeaderProps = {
onOpenLeftNav: () => void;
onOpenCalendar: () => void;
};
type LeftNavContentProps = {
onNavigate?: () => void;
};
const navItems = [
{ href: "/", label: "Лента", icon: HomeIcon },
{ href: "/messages", label: "Сообщения", icon: MessageSquare },
@ -29,16 +39,60 @@ const navItems = [
const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const calendarWeeks = [
["", "", "", "", "1", "2", "3"],
["4", "5", "6", "7", "8", "9", "10"],
["11", "12", "13", "14", "15", "16", "17"],
["18", "19", "20", "21", "22", "23", "24"],
["25", "26", "27", "28", "", "", ""],
];
type SidebarCalendarEvent = {
id: number;
title: string;
start?: string | null;
end?: string | null;
allDay?: boolean;
color?: string | null;
};
function Header() {
type SidebarCalendarOption = {
id: string;
title: string;
color?: string | null;
/** Параметры для запроса событий */
queryParams: Record<string, string | number>;
};
const pad = (v: number) => String(v).padStart(2, "0");
const formatDateKey = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const startOfWeekMonday = (date: Date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const dayIndex = (d.getDay() + 6) % 7; // Пн = 0
d.setDate(d.getDate() - dayIndex);
return d;
};
const sameDate = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const router = useRouter();
const { user, logout } = useUser();
const [searchQuery, setSearchQuery] = useState("");
const [userMenuOpen, setUserMenuOpen] = useState(false);
// Закрытие меню при клике вне
useEffect(() => {
if (!userMenuOpen) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
const desktop = document.getElementById("user-menu-root");
const mobile = document.getElementById("user-menu-root-mobile");
const insideDesktop = desktop && desktop.contains(target);
const insideMobile = mobile && mobile.contains(target);
if (!insideDesktop && !insideMobile) {
setUserMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [userMenuOpen]);
const userInitials = user
? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}`
@ -47,76 +101,67 @@ function Header() {
? `${user.last_name} ${user.first_name}`.trim()
: 'Гость';
const submitSearch = () => {
const query = searchQuery.trim();
if (!query) return;
router.push(`/search?q=${encodeURIComponent(query)}`);
};
return (
<header className="sticky top-0 z-30 border-b border-slate-100 bg-white/90 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4 sm:px-8">
<Link href="/" className="flex items-center gap-2">
<img src="/logo.png" alt="Логотип" className="h-11 w-auto" />
</Link>
<div className="hidden flex-1 items-center justify-center md:flex">
<div className="relative w-full max-w-xl">
<Search size={16} className="pointer-events-none absolute left-4 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-11 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="ml-auto flex items-center gap-1 sm:gap-2">
<button className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100">
<Bell size={18} className="text-gray-600" />
</button>
<div
className="ml-1 relative h-10 w-10 cursor-pointer"
title={userName}
onClick={() => {
if (confirm('Выйти из системы?')) {
logout();
}
}}
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div className="flex h-14 items-center justify-between gap-3">
<button
type="button"
onClick={onOpenLeftNav}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Открыть левое меню"
>
<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
)}
<Menu size={20} className="text-gray-700" />
</button>
<Link href="/" className="flex items-center justify-center">
<img src="/logo.png" alt="Логотип" className="h-10 w-auto lg:h-11" />
</Link>
<button
type="button"
onClick={onOpenCalendar}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Открыть календарь"
>
<CalendarDays size={20} className="text-gray-700" />
</button>
<div className="hidden flex-1 items-center justify-center lg:flex">
<div className="relative w-full max-w-xl">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitSearch();
}
}}
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 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>
{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>
</header>
);
}
function LeftNav() {
const pathname = usePathname();
const { user } = useUser();
const userInitials = user
? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}`
: 'Г';
const userName = user
? `${user.last_name} ${user.first_name}`.trim()
: 'Гость';
const navLinkClass = (href: string) =>
`flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700"
}`;
const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400");
return (
<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="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">
<div className="ml-auto hidden items-center gap-1 sm:gap-2 lg:flex">
<button className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100">
<Bell size={18} className="text-gray-600" />
</button>
<div className="ml-1 relative h-10 w-10" id="user-menu-root">
<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 cursor-pointer"
title={userName}
onClick={() => setUserMenuOpen((v) => !v)}
>
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
@ -126,63 +171,390 @@ function LeftNav() {
{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}
{/* Меню пользователя */}
{userMenuOpen && (
<div className="absolute right-0 top-12 z-50 w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/settings'); }}
>Настройки</button>
<div className="my-1 border-t border-slate-100" />
<button
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
onClick={() => { setUserMenuOpen(false); logout(); }}
>Выйти</button>
</div>
)}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{userName}</p>
<p className="text-xs text-gray-500">{user?.is_active ? 'Онлайн' : 'Оффлайн'}</p>
</div>
</div>
<div className="space-y-2 text-sm text-gray-700">
{navItems.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href} className={navLinkClass(href)}>
<Icon size={18} className={navIconClass(href)} />
{label}
</Link>
))}
</div>
</div>
<div className="pb-3 lg:hidden">
<div className="flex items-center gap-2">
<button
type="button"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 hover:bg-slate-100"
aria-label="Уведомления"
>
<Bell size={18} className="text-gray-600" />
</button>
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitSearch();
}
}}
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 className="relative h-10 w-10" id="user-menu-root-mobile">
<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 cursor-pointer"
title={userName}
onClick={() => setUserMenuOpen((v) => !v)}
>
{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}
{/* Меню пользователя */}
{userMenuOpen && (
<div className="absolute right-0 top-12 z-50 w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/settings'); }}
>Настройки</button>
<div className="my-1 border-t border-slate-100" />
<button
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
onClick={() => { setUserMenuOpen(false); logout(); }}
>Выйти</button>
</div>
)}
</div>
</div>
</div>
</div>
</header>
);
}
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const pathname = usePathname();
const navLinkClass = (href: string) =>
`flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700"
}`;
const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400");
return (
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="space-y-2 text-sm text-gray-700">
{navItems.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href} className={navLinkClass(href)} onClick={onNavigate}>
<Icon size={18} className={navIconClass(href)} />
{label}
</Link>
))}
</div>
</div>
);
}
function LeftNav() {
return (
<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 lg:pb-2">
<LeftNavContent />
</div>
</aside>
);
}
function CalendarCard() {
const { user } = useUser();
const [monthDate, setMonthDate] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const [events, setEvents] = useState<SidebarCalendarEvent[]>([]);
const [selectedCalendarKey, setSelectedCalendarKey] = useState<string>("company");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Строим список вариантов из данных пользователя (legacy: department_id / employee_id)
const calendarOptions = useMemo<SidebarCalendarOption[]>(() => {
const opts: SidebarCalendarOption[] = [
{ id: "company", title: "Компания", queryParams: {} },
];
// Отделы пользователя
if (user?.departments) {
user.departments.forEach((dept) => {
opts.push({
id: `dept-${dept.id}`,
title: dept.name,
queryParams: { department_id: dept.id },
});
});
}
// Личный календарь
if (user?.id) {
opts.push({
id: "personal",
title: "Личный",
queryParams: { employee_id: user.id },
});
}
return opts;
}, [user]);
useEffect(() => {
let cancelled = false;
const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const end = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1);
const weekStart = startOfWeekMonday(new Date());
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setDate(weekEndExclusive.getDate() + 7);
const fetchStart = weekStart < start ? weekStart : start;
const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end;
async function loadCalendarEvents() {
try {
setLoading(true);
setError(null);
const selected = calendarOptions.find((c) => c.id === selectedCalendarKey);
const extraParams = selected?.queryParams ?? {};
const result = await apiClient.getCalendarEvents({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
...extraParams,
});
if (!cancelled) {
setEvents(Array.isArray(result) ? result : []);
}
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err || "");
const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message);
if (!isNetworkLike) {
console.error("Ошибка загрузки календаря:", err);
}
setError("Не удалось загрузить события");
setEvents([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadCalendarEvents();
return () => {
cancelled = true;
};
}, [monthDate, selectedCalendarKey, calendarOptions]);
const monthLabel = useMemo(() => {
const label = monthDate.toLocaleDateString("ru-RU", {
month: "long",
year: "numeric",
});
return label.charAt(0).toUpperCase() + label.slice(1);
}, [monthDate]);
const eventDays = useMemo(() => {
const days = new Set<string>();
events.forEach((ev) => {
if (!ev.start) return;
const start = new Date(ev.start);
if (Number.isNaN(start.getTime())) return;
days.add(formatDateKey(start));
});
return days;
}, [events]);
const days = useMemo(() => {
const firstOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const weekDayIndex = (firstOfMonth.getDay() + 6) % 7;
const gridStart = new Date(firstOfMonth);
gridStart.setDate(firstOfMonth.getDate() - weekDayIndex);
const today = new Date();
return Array.from({ length: 42 }, (_, i) => {
const date = new Date(gridStart);
date.setDate(gridStart.getDate() + i);
const key = formatDateKey(date);
return {
key,
day: date.getDate(),
inCurrentMonth: date.getMonth() === monthDate.getMonth(),
isToday: sameDate(date, today),
hasEvents: eventDays.has(key),
};
});
}, [monthDate, eventDays]);
const weekEvents = useMemo(() => {
const weekStart = startOfWeekMonday(new Date());
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setDate(weekEndExclusive.getDate() + 7);
return [...events]
.filter((ev) => ev.start)
.filter((ev) => {
const start = new Date(ev.start || "");
if (Number.isNaN(start.getTime())) return false;
return start >= weekStart && start < weekEndExclusive;
})
.sort((a, b) => new Date(a.start || "").getTime() - new Date(b.start || "").getTime())
.slice(0, 12);
}, [events]);
return (
<>
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500">Календарь</p>
<select
value={selectedCalendarKey}
onChange={(e) => setSelectedCalendarKey(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-2 text-xs text-gray-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
{calendarOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.title}
</option>
))}
</select>
</div>
<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>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1))}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Предыдущий месяц"
>
<ChevronLeft size={16} className="text-gray-600" />
</button>
<span className="min-w-28 text-center text-xs font-normal text-gray-500">{monthLabel}</span>
<button
type="button"
onClick={() => setMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1))}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Следующий месяц"
>
<ChevronRight size={16} className="text-gray-600" />
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
{weekdays.map((day) => (
<div key={day} className="py-1 font-medium">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{days.map((day) => (
<div
key={day.key}
className={`relative flex h-9 items-center justify-center rounded-full ${
day.isToday
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
: day.inCurrentMonth
? "hover:bg-sky-50"
: "text-gray-300"
}`}
>
{day.day}
{day.hasEvents && day.inCurrentMonth ? (
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-sky-500" />
) : null}
</div>
))}
</div>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">На этой неделе</p>
{loading ? (
<p className="text-xs text-gray-500">Загрузка...</p>
) : error ? (
<p className="text-xs text-red-600">{error}</p>
) : weekEvents.length === 0 ? (
<p className="text-xs text-gray-500">Событий нет</p>
) : (
<div className="space-y-2">
{weekEvents.map((event) => {
const start = event.start ? new Date(event.start) : null;
const dateLabel = start && !Number.isNaN(start.getTime())
? start.toLocaleString("ru-RU", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "Без даты";
return (
<div key={`${event.id}-${event.start || ""}`} className="rounded-lg bg-gray-50 px-2.5 py-2">
<p className="truncate text-xs font-medium text-gray-800">{event.title}</p>
<p className="mt-0.5 text-[11px] text-gray-500">{dateLabel}</p>
</div>
);
})}
</div>
)}
</div>
</>
);
}
function Calendar() {
return (
<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>
<span className="text-xs font-normal text-gray-500">Февраль 2026</span>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
{weekdays.map((day) => (
<div key={day} className="py-1 font-medium">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{calendarWeeks.map((week, wi) =>
week.map((day, di) => {
const isToday = day === "18";
return (
<div
key={`${wi}-${di}`}
className={`flex h-9 items-center justify-center rounded-full ${day
? isToday
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
: "hover:bg-sky-50"
: ""
}`}
>
{day}
</div>
);
})
)}
</div>
</div>
<div className="space-y-4 lg:pb-2">
<CalendarCard />
</div>
</aside>
);
@ -208,6 +580,11 @@ export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }:
export function AppShell({ children }: AppShellProps) {
const { user, loading } = useUser();
const router = useRouter();
const pathname = usePathname();
const isMessageDialogPage = pathname.startsWith('/messages/') && pathname !== '/messages';
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
useEffect(() => {
// Если загрузка завершена и пользователь не авторизован - редирект на логин
@ -216,6 +593,40 @@ export function AppShell({ children }: AppShellProps) {
}
}, [user, loading, router]);
useEffect(() => {
setIsMobileLeftNavOpen(false);
setIsMobileCalendarOpen(false);
}, [pathname]);
useEffect(() => {
const onResize = () => {
if (window.innerWidth >= 1024) {
setIsMobileLeftNavOpen(false);
setIsMobileCalendarOpen(false);
}
};
window.addEventListener("resize", onResize);
onResize();
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
useEffect(() => {
if (isMobileLeftNavOpen || isMobileCalendarOpen) {
document.body.style.overflow = "hidden";
return;
}
document.body.style.overflow = "";
return () => {
document.body.style.overflow = "";
};
}, [isMobileLeftNavOpen, isMobileCalendarOpen]);
// Показываем загрузку, пока проверяем авторизацию
if (loading) {
return (
@ -234,13 +645,67 @@ 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-[calc(100vh-3.5rem)] max-w-6xl gap-6 px-4 py-8 sm:px-8">
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} bg-gradient-to-b from-sky-50 via-white to-white text-gray-900 flex flex-col`}>
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
<div className="mx-auto flex w-full flex-1 min-h-0 max-w-6xl gap-6 px-4 py-6 sm:px-8 lg:py-8">
<LeftNav />
<main className="flex-1 min-w-0 space-y-6">{children}</main>
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>{children}</main>
<Calendar />
</div>
<div className={`fixed inset-0 z-50 lg:hidden ${isMobileLeftNavOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileLeftNavOpen ? "opacity-100" : "opacity-0"}`}
onClick={() => setIsMobileLeftNavOpen(false)}
aria-label="Закрыть левое меню"
/>
<div
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
isMobileLeftNavOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Меню</p>
<button
type="button"
onClick={() => setIsMobileLeftNavOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть меню"
>
<X size={20} className="text-gray-700" />
</button>
</div>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</div>
</div>
<div className={`fixed inset-0 z-50 lg:hidden ${isMobileCalendarOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileCalendarOpen ? "opacity-100" : "opacity-0"}`}
onClick={() => setIsMobileCalendarOpen(false)}
aria-label="Закрыть календарь"
/>
<div
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
isMobileCalendarOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Календарь</p>
<button
type="button"
onClick={() => setIsMobileCalendarOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть календарь"
>
<X size={20} className="text-gray-700" />
</button>
</div>
<CalendarCard />
</div>
</div>
</div>
);
}

View File

@ -46,7 +46,15 @@ export interface User {
updated_at?: string;
last_login?: string;
date_joined?: string;
auth?: Record<string, boolean>; // глобальные права пользователя
auth?: {
id?: number;
email?: string;
is_staff?: boolean;
is_superuser?: boolean;
groups?: string[];
permissions?: string[];
permissions_by_app?: Record<string, string[]>;
};
}
export interface Department {
@ -68,7 +76,13 @@ export interface Position {
export interface Post {
id: number;
author: User;
content: string;
author_id?: number;
type?: 'company' | 'department' | 'employee';
department?: number | null;
department_id?: number | null;
content?: string;
body?: string;
title?: string;
image?: string;
tags?: string[];
created_at: string;
@ -82,7 +96,8 @@ export interface Comment {
id: number;
post: number;
author: User;
content: string;
content?: string;
text?: string;
created_at: string;
updated_at: string;
}
@ -118,9 +133,12 @@ export interface Chat {
id: number;
name?: string;
avatar?: string | null;
interlocutor?: { id: number; name?: string; avatar?: string | null } | null;
chat_type?: 'direct' | 'group' | 'department';
type?: 'private' | 'group' | 'department' | 'announcement';
participants: User[];
participants?: Array<User | number>;
participant_names?: string[];
participant_details?: Array<{ id: number; name?: string; avatar?: string | null }>;
last_message?: Message;
unread_count?: number;
created_at: string;
@ -143,6 +161,14 @@ export interface Message {
is_deleted?: boolean;
has_attachments?: boolean;
attachments?: MessageAttachment[];
reply_to_id?: number;
reply_to?: number | Message | null;
reply_to_message?: Message | null;
reactions_summary?: Record<string, {
count: number;
users?: number[];
user_names?: string[];
}>;
}
export interface MessageAttachment {