Альфа версия
This commit is contained in:
parent
523f09074d
commit
38ab1afa7c
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
429
frontend/src/app/profile/page.tsx
Normal file
429
frontend/src/app/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
219
frontend/src/app/search/page.tsx
Normal file
219
frontend/src/app/search/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user