Merge branch 'develop'
This commit is contained in:
commit
3c7582f229
@ -60,6 +60,18 @@ logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _parse_bool_query_param(value, default=True):
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {'1', 'true', 'yes', 'on'}:
|
||||
return True
|
||||
if normalized in {'0', 'false', 'no', 'off'}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
class ChatViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet для управления чатами
|
||||
@ -563,7 +575,12 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def messages(self, request, pk=None):
|
||||
"""Загрузка сообщений чата (пагинация по времени)"""
|
||||
"""Загрузка сообщений чата (пагинация по времени).
|
||||
|
||||
Query params:
|
||||
- mark_read=false: не обновлять last_read_message при after_id/after_ts.
|
||||
Нужен для тихой синхронизации клиента после разрыва WebSocket.
|
||||
"""
|
||||
chat = self.get_object()
|
||||
|
||||
# Проверка доступа
|
||||
@ -582,6 +599,10 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
limit = min(int(request.query_params.get('limit', 50)), 100)
|
||||
except (ValueError, TypeError):
|
||||
limit = 50
|
||||
mark_read = _parse_bool_query_param(
|
||||
request.query_params.get('mark_read'),
|
||||
default=True,
|
||||
)
|
||||
|
||||
queryset = chat.messages.filter(is_deleted=False).select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
@ -633,7 +654,7 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# Автоотметка при загрузке НОВЫХ сообщений (after_id/after_ts)
|
||||
# При scroll вверх (before_id) - не отмечаем
|
||||
if (after_id or after_ts) and messages:
|
||||
if mark_read and (after_id or after_ts) and messages:
|
||||
self._auto_mark_read(chat, request.user, messages)
|
||||
|
||||
return Response({
|
||||
|
||||
@ -16,6 +16,10 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from ..models import Notification, UserChannelPreferences
|
||||
from ..realtime import (
|
||||
send_notification_read_event,
|
||||
send_notifications_read_all_event,
|
||||
)
|
||||
from .serializers import (
|
||||
ChannelPreferencesSerializer,
|
||||
CountResponseSerializer,
|
||||
@ -188,8 +192,12 @@ def mark_as_read(request, notification_id):
|
||||
id=notification_id,
|
||||
recipient=request.user
|
||||
)
|
||||
was_unread = notification.unread
|
||||
notification.mark_as_read()
|
||||
|
||||
if was_unread:
|
||||
send_notification_read_event(request.user.id, notification_id)
|
||||
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'notification_id': notification_id
|
||||
@ -252,8 +260,16 @@ def mark_all_as_read(request):
|
||||
if verb:
|
||||
queryset = queryset.filter(verb=verb)
|
||||
|
||||
notification_ids = list(queryset.values_list('id', flat=True))
|
||||
count = queryset.mark_all_as_read()
|
||||
|
||||
if notification_ids:
|
||||
send_notifications_read_all_event(
|
||||
request.user.id,
|
||||
notification_ids,
|
||||
category=verb,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'count': count
|
||||
@ -299,8 +315,16 @@ def mark_category_as_read(request):
|
||||
verb__in=verbs,
|
||||
)
|
||||
|
||||
notification_ids = list(queryset.values_list('id', flat=True))
|
||||
count = queryset.mark_all_as_read()
|
||||
|
||||
if notification_ids:
|
||||
send_notifications_read_all_event(
|
||||
request.user.id,
|
||||
notification_ids,
|
||||
category=request.data.get('category'),
|
||||
)
|
||||
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'verbs': verbs,
|
||||
|
||||
74
backend/notifications/realtime.py
Normal file
74
backend/notifications/realtime.py
Normal file
@ -0,0 +1,74 @@
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
from .models import Notification
|
||||
|
||||
|
||||
def get_unread_notifications_count(user_id):
|
||||
return Notification.objects.filter(
|
||||
recipient_id=user_id,
|
||||
unread=True,
|
||||
deleted=False,
|
||||
).count()
|
||||
|
||||
|
||||
def _send_user_event(user_id, event):
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
return
|
||||
|
||||
async_to_sync(channel_layer.group_send)(f"user_{user_id}", event)
|
||||
|
||||
|
||||
def send_notification_read_event(user_id, notification_id, unread_count=None):
|
||||
count = (
|
||||
get_unread_notifications_count(user_id)
|
||||
if unread_count is None
|
||||
else unread_count
|
||||
)
|
||||
|
||||
_send_user_event(
|
||||
user_id,
|
||||
{
|
||||
"type": "notification_read",
|
||||
"notification_id": notification_id,
|
||||
"unread_count": count,
|
||||
},
|
||||
)
|
||||
_send_user_event(
|
||||
user_id,
|
||||
{
|
||||
"type": "notification_count_update",
|
||||
"count": count,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def send_notifications_read_all_event(
|
||||
user_id,
|
||||
notification_ids,
|
||||
unread_count=None,
|
||||
category=None,
|
||||
):
|
||||
count = (
|
||||
get_unread_notifications_count(user_id)
|
||||
if unread_count is None
|
||||
else unread_count
|
||||
)
|
||||
|
||||
_send_user_event(
|
||||
user_id,
|
||||
{
|
||||
"type": "notifications_read_all",
|
||||
"notification_ids": notification_ids,
|
||||
"category": category,
|
||||
"unread_count": count,
|
||||
},
|
||||
)
|
||||
_send_user_event(
|
||||
user_id,
|
||||
{
|
||||
"type": "notification_count_update",
|
||||
"count": count,
|
||||
},
|
||||
)
|
||||
@ -186,6 +186,30 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
|
||||
elif action == "vote_poll":
|
||||
await self._handle_vote_poll(content)
|
||||
|
||||
@staticmethod
|
||||
def _extract_notification_chat_id(notification):
|
||||
"""Достаёт chat_id из payload уведомления, если он есть."""
|
||||
if not isinstance(notification, dict):
|
||||
return None
|
||||
|
||||
data = notification.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
chat_id = data.get("chat_id")
|
||||
try:
|
||||
return int(chat_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _should_suppress_active_chat_notification(self, notification):
|
||||
"""Не показываем уведомление о сообщении в уже открытом чате."""
|
||||
if self.active_chat_id is None:
|
||||
return False
|
||||
|
||||
notification_chat_id = self._extract_notification_chat_id(notification)
|
||||
return notification_chat_id == self.active_chat_id
|
||||
|
||||
# ==================== Обработчики уведомлений ====================
|
||||
|
||||
async def notification_message(self, event):
|
||||
@ -193,16 +217,36 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
|
||||
Новое уведомление от WebSocketNotificationSender.
|
||||
Вызывается из notifications/senders/websocket.py
|
||||
"""
|
||||
notification = event.get("message", {})
|
||||
|
||||
if self._should_suppress_active_chat_notification(notification):
|
||||
logger.info(
|
||||
"[UserWS] Suppressed notification for active chat %s in session %s",
|
||||
self.active_chat_id,
|
||||
self.channel_name,
|
||||
)
|
||||
return
|
||||
|
||||
await self.send_json(
|
||||
{"type": "notification", "notification": event.get("message", {})}
|
||||
{"type": "notification", "notification": notification}
|
||||
)
|
||||
|
||||
async def notification_new(self, event):
|
||||
"""Новое уведомление (вызывается из notifications/services.py)"""
|
||||
notification = event.get("notification", {})
|
||||
|
||||
if self._should_suppress_active_chat_notification(notification):
|
||||
logger.info(
|
||||
"[UserWS] Suppressed notification_new for active chat %s in session %s",
|
||||
self.active_chat_id,
|
||||
self.channel_name,
|
||||
)
|
||||
return
|
||||
|
||||
await self.send_json(
|
||||
{
|
||||
"type": "notification",
|
||||
"notification": event.get("notification", {}),
|
||||
"notification": notification,
|
||||
}
|
||||
)
|
||||
|
||||
@ -215,6 +259,27 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
|
||||
{"type": "unread_count", "count": event.get("count", 0)}
|
||||
)
|
||||
|
||||
async def notification_read(self, event):
|
||||
"""Синхронизация прочтения одного уведомления между сессиями."""
|
||||
await self.send_json(
|
||||
{
|
||||
"type": "notification_read",
|
||||
"notification_id": event.get("notification_id"),
|
||||
"unread_count": event.get("unread_count"),
|
||||
}
|
||||
)
|
||||
|
||||
async def notifications_read_all(self, event):
|
||||
"""Синхронизация массового прочтения уведомлений между сессиями."""
|
||||
await self.send_json(
|
||||
{
|
||||
"type": "notifications_read_all",
|
||||
"notification_ids": event.get("notification_ids", []),
|
||||
"category": event.get("category"),
|
||||
"unread_count": event.get("unread_count"),
|
||||
}
|
||||
)
|
||||
|
||||
# ==================== Обработка событий закупок ====================
|
||||
|
||||
async def poll_update(self, event):
|
||||
|
||||
108
backend/tests/api/v1/notifications/test_notifications_api.py
Normal file
108
backend/tests/api/v1/notifications/test_notifications_api.py
Normal file
@ -0,0 +1,108 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from notifications.models import Notification
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class TestNotificationRealtimeEvents:
|
||||
def test_mark_as_read_sends_realtime_event(self, api_client, user_factory):
|
||||
user = user_factory()
|
||||
notification = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="request_updated",
|
||||
description="Заявка обновлена",
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
with patch(
|
||||
"notifications.api.views.send_notification_read_event"
|
||||
) as send_event:
|
||||
response = api_client.post(
|
||||
f"/api/v1/notifications/{notification.id}/read/"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
notification.refresh_from_db()
|
||||
assert notification.unread is False
|
||||
send_event.assert_called_once_with(user.id, notification.id)
|
||||
|
||||
def test_mark_all_as_read_sends_realtime_event_with_ids(
|
||||
self, api_client, user_factory
|
||||
):
|
||||
user = user_factory()
|
||||
first = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="request_new",
|
||||
description="Новая заявка",
|
||||
)
|
||||
second = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="request_updated",
|
||||
description="Заявка обновлена",
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
with patch(
|
||||
"notifications.api.views.send_notifications_read_all_event"
|
||||
) as send_event:
|
||||
response = api_client.post("/api/v1/notifications/read-all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
Notification.objects.filter(
|
||||
recipient=user,
|
||||
unread=True,
|
||||
deleted=False,
|
||||
).count()
|
||||
== 0
|
||||
)
|
||||
send_event.assert_called_once()
|
||||
args, kwargs = send_event.call_args
|
||||
assert args[0] == user.id
|
||||
assert set(args[1]) == {first.id, second.id}
|
||||
assert kwargs == {"category": None}
|
||||
|
||||
def test_mark_category_as_read_sends_category_realtime_event(
|
||||
self, api_client, user_factory
|
||||
):
|
||||
user = user_factory()
|
||||
matching = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="request_updated",
|
||||
description="Заявка обновлена",
|
||||
)
|
||||
other = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="document_uploaded",
|
||||
description="Документ загружен",
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
with patch(
|
||||
"notifications.api.views.send_notifications_read_all_event"
|
||||
) as send_event:
|
||||
response = api_client.post(
|
||||
"/api/v1/notifications/category/read/",
|
||||
{
|
||||
"verbs": ["request_updated"],
|
||||
"category": "Заявки",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
matching.refresh_from_db()
|
||||
other.refresh_from_db()
|
||||
assert matching.unread is False
|
||||
assert other.unread is True
|
||||
send_event.assert_called_once()
|
||||
args, kwargs = send_event.call_args
|
||||
assert args == (user.id, [matching.id])
|
||||
assert kwargs == {"category": "Заявки"}
|
||||
@ -544,3 +544,144 @@ class TestChatMarkedReadEvent:
|
||||
import asyncio
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await comm2.receive_json_from(timeout=2)
|
||||
|
||||
# Cleanup: after TimeoutError, communicator may be in cancelled state
|
||||
try:
|
||||
await comm2.disconnect()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await comm1.disconnect()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
class TestNotificationStateEvents:
|
||||
"""Тесты синхронизации состояния уведомлений через user channel."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_chat_session_does_not_receive_own_chat_notification(
|
||||
self,
|
||||
ws_communicator,
|
||||
user,
|
||||
test_chat,
|
||||
):
|
||||
"""Уведомление о сообщении не должно приходить в сессию с открытым этим чатом."""
|
||||
import asyncio
|
||||
|
||||
comm_active = await ws_communicator(user=user)
|
||||
comm_background = await ws_communicator(user=user)
|
||||
|
||||
connected_active, _ = await comm_active.connect()
|
||||
connected_background, _ = await comm_background.connect()
|
||||
assert connected_active and connected_background
|
||||
|
||||
await comm_active.send_json_to(
|
||||
{
|
||||
"action": "open_chat",
|
||||
"chat_id": test_chat.id,
|
||||
"load_history": False,
|
||||
}
|
||||
)
|
||||
await comm_active.receive_json_from(timeout=5)
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
payload = {
|
||||
"type": "notification",
|
||||
"id": 777,
|
||||
"verb": "chat_new_message",
|
||||
"description": "New message in active chat",
|
||||
"action_url": f"/messages/{test_chat.id}",
|
||||
"timestamp": "2026-04-02T10:00:00Z",
|
||||
"unread": True,
|
||||
"data": {
|
||||
"chat_id": test_chat.id,
|
||||
"message_id": 123,
|
||||
},
|
||||
}
|
||||
|
||||
await channel_layer.group_send(
|
||||
f"user_{user.id}",
|
||||
{
|
||||
"type": "notification_message",
|
||||
"message": payload,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await comm_active.receive_json_from(timeout=2)
|
||||
|
||||
response = await comm_background.receive_json_from(timeout=5)
|
||||
assert response["type"] == "notification"
|
||||
assert response["notification"]["id"] == 777
|
||||
assert response["notification"]["data"]["chat_id"] == test_chat.id
|
||||
|
||||
try:
|
||||
await comm_background.disconnect()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await comm_active.disconnect()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_read_sync_between_sessions(self, ws_communicator, user):
|
||||
comm1 = await ws_communicator(user=user)
|
||||
comm2 = await ws_communicator(user=user)
|
||||
|
||||
connected1, _ = await comm1.connect()
|
||||
connected2, _ = await comm2.connect()
|
||||
assert connected1 and connected2
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
f'user_{user.id}',
|
||||
{
|
||||
'type': 'notification_read',
|
||||
'notification_id': 321,
|
||||
'unread_count': 4,
|
||||
}
|
||||
)
|
||||
|
||||
response1 = await comm1.receive_json_from(timeout=5)
|
||||
response2 = await comm2.receive_json_from(timeout=5)
|
||||
|
||||
assert response1['type'] == 'notification_read'
|
||||
assert response1['notification_id'] == 321
|
||||
assert response1['unread_count'] == 4
|
||||
assert response2['type'] == 'notification_read'
|
||||
|
||||
await comm1.disconnect()
|
||||
await comm2.disconnect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notifications_read_all_sync_between_sessions(self, ws_communicator, user):
|
||||
comm1 = await ws_communicator(user=user)
|
||||
comm2 = await ws_communicator(user=user)
|
||||
|
||||
connected1, _ = await comm1.connect()
|
||||
connected2, _ = await comm2.connect()
|
||||
assert connected1 and connected2
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
f'user_{user.id}',
|
||||
{
|
||||
'type': 'notifications_read_all',
|
||||
'notification_ids': [11, 12, 13],
|
||||
'category': 'Заявки',
|
||||
'unread_count': 0,
|
||||
}
|
||||
)
|
||||
|
||||
response1 = await comm1.receive_json_from(timeout=5)
|
||||
response2 = await comm2.receive_json_from(timeout=5)
|
||||
|
||||
assert response1['type'] == 'notifications_read_all'
|
||||
assert response1['notification_ids'] == [11, 12, 13]
|
||||
assert response1['category'] == 'Заявки'
|
||||
assert response1['unread_count'] == 0
|
||||
assert response2['type'] == 'notifications_read_all'
|
||||
|
||||
await comm1.disconnect()
|
||||
await comm2.disconnect()
|
||||
|
||||
@ -1,72 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { canManageEquipment } from "@/lib/permissions";
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type {
|
||||
Department,
|
||||
Equipment,
|
||||
EquipmentCategory,
|
||||
EquipmentComment,
|
||||
EquipmentCreateOptions,
|
||||
EquipmentTransferHistoryEntry,
|
||||
MaintenanceRecord,
|
||||
User,
|
||||
} from "@/types/api";
|
||||
import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react";
|
||||
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import { formatDate, formatMoney } from "@/lib/shared";
|
||||
import { useEquipmentPage } from "@/hooks/useEquipmentPage";
|
||||
|
||||
/* ──── form state ──── */
|
||||
type EquipmentFormState = {
|
||||
name: string;
|
||||
notes: string;
|
||||
category: number | null;
|
||||
department: number | null;
|
||||
responsible_person: number | null;
|
||||
quantity: number;
|
||||
serial_number: string;
|
||||
purchase_date: string;
|
||||
purchase_cost: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
const emptyForm: EquipmentFormState = {
|
||||
name: "",
|
||||
notes: "",
|
||||
category: null,
|
||||
department: null,
|
||||
responsible_person: null,
|
||||
quantity: 1,
|
||||
serial_number: "",
|
||||
purchase_date: "",
|
||||
purchase_cost: "",
|
||||
location: "",
|
||||
};
|
||||
|
||||
type EquipmentListMode = "all" | "mine" | "warranty";
|
||||
|
||||
type OperationModal = "transfer" | "writeoff" | "maintenance" | null;
|
||||
|
||||
type TransferFormState = {
|
||||
to_department: number | null;
|
||||
to_person: number | null;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type MaintenanceFormState = {
|
||||
type: string;
|
||||
description: string;
|
||||
cost: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
const listModeMeta: Array<{ value: EquipmentListMode; label: string }> = [
|
||||
const listModeMeta = [
|
||||
{ value: "all", label: "Весь реестр" },
|
||||
{ value: "mine", label: "Мое оборудование" },
|
||||
{ value: "warranty", label: "Истекает гарантия" },
|
||||
];
|
||||
] as const;
|
||||
|
||||
const orderingOptions = [
|
||||
{ value: "-created_at", label: "Сначала новые" },
|
||||
@ -117,729 +63,91 @@ const defaultStatusMeta = {
|
||||
surfaceClass: "border-gray-200",
|
||||
};
|
||||
|
||||
function formatDate(value?: string | null): 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" });
|
||||
}
|
||||
|
||||
function formatMoney(value?: string | number | null): string {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount)) return "—";
|
||||
return `${amount.toLocaleString("ru-RU")} ₽`;
|
||||
}
|
||||
|
||||
/* ──── main page ──── */
|
||||
export default function EquipmentPage() {
|
||||
const { user } = useUser();
|
||||
const [items, setItems] = useState<Equipment[]>([]);
|
||||
const [detailsMap, setDetailsMap] = useState<Record<number, Equipment>>({});
|
||||
const [transferHistoryMap, setTransferHistoryMap] = useState<Record<number, EquipmentTransferHistoryEntry[]>>({});
|
||||
const [maintenanceMap, setMaintenanceMap] = useState<Record<number, MaintenanceRecord[]>>({});
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [categories, setCategories] = useState<EquipmentCategory[]>([]);
|
||||
const [createOptions, setCreateOptions] = useState<EquipmentCreateOptions | null>(null);
|
||||
const [previewInventoryNumber, setPreviewInventoryNumber] = useState<string>("");
|
||||
const [commentsMap, setCommentsMap] = useState<Record<number, EquipmentComment[]>>({});
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
|
||||
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
|
||||
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
|
||||
const [loadingRowDetails, setLoadingRowDetails] = useState<Record<number, boolean>>({});
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<EquipmentFormState>(emptyForm);
|
||||
const [listMode, setListMode] = useState<EquipmentListMode>("all");
|
||||
const [ordering, setOrdering] = useState("-created_at");
|
||||
const [operationModal, setOperationModal] = useState<OperationModal>(null);
|
||||
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null);
|
||||
const [writeOffReason, setWriteOffReason] = useState("");
|
||||
const [transferForm, setTransferForm] = useState<TransferFormState>({ to_department: null, to_person: null, reason: "" });
|
||||
const [maintenanceForm, setMaintenanceForm] = useState<MaintenanceFormState>({
|
||||
type: "repair",
|
||||
description: "",
|
||||
cost: "",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
const [searchQuery, setSearch] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [departmentFilter, setDepartmentFilter] = useState("");
|
||||
const [responsibleFilter, setResponsibleFilter] = useState("");
|
||||
const [dateFromFilter, setDateFromFilter] = useState("");
|
||||
const [dateToFilter, setDateToFilter] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [nextPage, setNextPage] = useState<number | null>(null);
|
||||
|
||||
const auth = user?.auth;
|
||||
const canManage = canManageEquipment(user);
|
||||
const isCreateMode = createOpen && editingId === null;
|
||||
|
||||
/* ──── helpers ──── */
|
||||
const displayUserName = (person?: User | null) => {
|
||||
if (!person) return "—";
|
||||
const full = `${person.last_name || ""} ${person.first_name || ""}`.trim();
|
||||
return full || (person as any)?.full_name || (person as any)?.display_name || person.email || "Пользователь";
|
||||
};
|
||||
|
||||
const extractNextPage = (nextUrl?: string | null): number | null => {
|
||||
if (!nextUrl) return null;
|
||||
try {
|
||||
const parsed = new URL(nextUrl, window.location.origin);
|
||||
const num = Number(parsed.searchParams.get("page"));
|
||||
return Number.isFinite(num) && num > 0 ? num : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildParams = (page: number): Record<string, string | number> => {
|
||||
const p: Record<string, string | number> = { page };
|
||||
if (deferredSearchQuery.trim()) p.search = deferredSearchQuery.trim();
|
||||
if (ordering) p.ordering = ordering;
|
||||
if (statusFilter) p.status = statusFilter;
|
||||
if (categoryFilter) p.category = categoryFilter;
|
||||
if (departmentFilter) p.department = departmentFilter;
|
||||
if (responsibleFilter) p.responsible_person = responsibleFilter;
|
||||
if (dateFromFilter) p.purchase_date__gte = dateFromFilter;
|
||||
if (dateToFilter) p.purchase_date__lte = dateToFilter;
|
||||
return p;
|
||||
};
|
||||
|
||||
const getCategoryName = (eq: Equipment): string => {
|
||||
if (eq.category_name) return eq.category_name;
|
||||
const cat = categories.find((c) => c.id === Number(eq.category));
|
||||
return cat?.name || "—";
|
||||
};
|
||||
|
||||
const getDepartmentName = (eq: Equipment): string => {
|
||||
if (eq.department_name) return eq.department_name;
|
||||
const dep = departments.find((d) => d.id === Number(eq.department));
|
||||
return dep?.name || "—";
|
||||
};
|
||||
|
||||
const getResponsibleName = (eq: Equipment): string => {
|
||||
if (eq.responsible_name) return eq.responsible_name;
|
||||
if (eq.responsible_person && typeof eq.responsible_person === "number") {
|
||||
const emp = employees.find((e) => e.id === eq.responsible_person);
|
||||
return emp ? displayUserName(emp) : "—";
|
||||
}
|
||||
return "—";
|
||||
};
|
||||
|
||||
const getResponsibleLink = (eq: Equipment): string => {
|
||||
if (!eq.responsible_person || typeof eq.responsible_person !== "number") return "";
|
||||
if (eq.responsible_person === user?.id) return "/profile";
|
||||
return `/users/${eq.responsible_person}`;
|
||||
};
|
||||
|
||||
const getEquipmentMeta = (eq: Equipment) => {
|
||||
return [
|
||||
{ label: "Категория", value: getCategoryName(eq) },
|
||||
{ label: "Отдел", value: getDepartmentName(eq) },
|
||||
{ label: "Ответственный", value: getResponsibleName(eq) },
|
||||
{ label: "Серийный номер", value: eq.serial_number || "—" },
|
||||
{ label: "Расположение", value: eq.location || "—" },
|
||||
{ label: "Гарантия до", value: formatDate(eq.warranty_until) || "—" },
|
||||
{ label: "Обслуживаний", value: String(eq.maintenance_count ?? "—") },
|
||||
{ label: "Стоимость", value: formatMoney(eq.purchase_cost) },
|
||||
{ label: "Добавлено", value: formatDate(eq.created_at) || "—" },
|
||||
];
|
||||
};
|
||||
|
||||
const filteredDepartmentsForForm = useMemo(() => {
|
||||
if (!createOptions || !isCreateMode || createOptions.allowed_departments.length === 0) {
|
||||
return departments;
|
||||
}
|
||||
const allowedIds = new Set(createOptions.allowed_departments.map((dept) => dept.id));
|
||||
return departments.filter((dept) => allowedIds.has(dept.id));
|
||||
}, [createOptions, departments, isCreateMode]);
|
||||
|
||||
const filteredEmployeesForForm = useMemo(() => {
|
||||
if (!isCreateMode || !createOptions || createOptions.can_choose_responsible) {
|
||||
return employees;
|
||||
}
|
||||
if (!createOptions.default_responsible) {
|
||||
return [];
|
||||
}
|
||||
return employees.filter((employee) => employee.id === createOptions.default_responsible?.id);
|
||||
}, [createOptions, employees, isCreateMode]);
|
||||
|
||||
const applyCreateDefaults = (options: EquipmentCreateOptions | null) => {
|
||||
if (!options) return;
|
||||
|
||||
setForm((prev) => {
|
||||
const nextDepartment = options.can_choose_department
|
||||
? prev.department
|
||||
: options.allowed_departments[0]?.id ?? prev.department;
|
||||
const nextResponsible = options.can_choose_responsible
|
||||
? prev.responsible_person
|
||||
: options.default_responsible?.id ?? prev.responsible_person;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
department: nextDepartment,
|
||||
responsible_person: nextResponsible,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const loadRowDetails = async (equipmentId: number) => {
|
||||
if (loadingRowDetails[equipmentId]) return;
|
||||
|
||||
try {
|
||||
setLoadingRowDetails((prev) => ({ ...prev, [equipmentId]: true }));
|
||||
const results = await Promise.allSettled([
|
||||
detailsMap[equipmentId] ? Promise.resolve(detailsMap[equipmentId]) : apiClient.getEquipmentDetail(equipmentId),
|
||||
transferHistoryMap[equipmentId] ? Promise.resolve(transferHistoryMap[equipmentId]) : apiClient.getEquipmentTransferHistory(equipmentId),
|
||||
maintenanceMap[equipmentId] ? Promise.resolve(maintenanceMap[equipmentId]) : apiClient.getMaintenanceRecords({ equipment: equipmentId }),
|
||||
]);
|
||||
|
||||
const [detailResult, transferResult, maintenanceResult] = results;
|
||||
|
||||
if (detailResult.status === "fulfilled") {
|
||||
setDetailsMap((prev) => ({ ...prev, [equipmentId]: detailResult.value }));
|
||||
}
|
||||
|
||||
if (transferResult.status === "fulfilled") {
|
||||
setTransferHistoryMap((prev) => ({ ...prev, [equipmentId]: Array.isArray(transferResult.value) ? transferResult.value : transferResult.value.results || [] }));
|
||||
}
|
||||
|
||||
if (maintenanceResult.status === "fulfilled") {
|
||||
setMaintenanceMap((prev) => ({
|
||||
...prev,
|
||||
[equipmentId]: Array.isArray(maintenanceResult.value) ? maintenanceResult.value : maintenanceResult.value.results || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки деталей оборудования:", error);
|
||||
} finally {
|
||||
setLoadingRowDetails((prev) => ({ ...prev, [equipmentId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = async () => {
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
setCreateOpen(true);
|
||||
|
||||
try {
|
||||
const [options, inventoryPreview] = await Promise.all([
|
||||
apiClient.getEquipmentCreateOptions(),
|
||||
apiClient.generateEquipmentInventoryNumber(),
|
||||
]);
|
||||
setCreateOptions(options);
|
||||
setPreviewInventoryNumber(inventoryPreview.inventory_number || "");
|
||||
applyCreateDefaults(options);
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки опций создания оборудования:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const openOperationModal = (type: Exclude<OperationModal, null>, equipment: Equipment) => {
|
||||
setSelectedEquipment(equipment);
|
||||
setOperationModal(type);
|
||||
setActionError(null);
|
||||
|
||||
if (type === "transfer") {
|
||||
setTransferForm({
|
||||
to_department: equipment.department ?? null,
|
||||
to_person: typeof equipment.responsible_person === "number" ? equipment.responsible_person : null,
|
||||
reason: "",
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "writeoff") {
|
||||
setWriteOffReason("");
|
||||
}
|
||||
|
||||
if (type === "maintenance") {
|
||||
setMaintenanceForm({
|
||||
type: "repair",
|
||||
description: "",
|
||||
cost: "",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const closeOperationModal = () => {
|
||||
setOperationModal(null);
|
||||
setSelectedEquipment(null);
|
||||
setWriteOffReason("");
|
||||
setTransferForm({ to_department: null, to_person: null, reason: "" });
|
||||
setMaintenanceForm({ type: "repair", description: "", cost: "", date: new Date().toISOString().slice(0, 10) });
|
||||
};
|
||||
|
||||
/* ──── load data ──── */
|
||||
const fetchEquipmentPage = async (page: number) => {
|
||||
if (listMode === "mine") {
|
||||
return apiClient.getMyEquipment({ page });
|
||||
}
|
||||
|
||||
if (listMode === "warranty") {
|
||||
if (page > 1) {
|
||||
return { results: [], next: null };
|
||||
}
|
||||
return apiClient.getWarrantyExpiringEquipment();
|
||||
}
|
||||
|
||||
return apiClient.getEquipment(buildParams(page));
|
||||
};
|
||||
|
||||
const reloadEquipmentList = async () => {
|
||||
const res = await fetchEquipmentPage(1);
|
||||
const results = Array.isArray(res) ? res : (res.results || []);
|
||||
setItems(results);
|
||||
setNextPage(extractNextPage((res as { next?: string | null }).next));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetchEquipmentPage(1);
|
||||
const results = Array.isArray(res) ? res : (res.results || []);
|
||||
setItems(results);
|
||||
setNextPage(extractNextPage(res.next));
|
||||
} catch (e: any) {
|
||||
console.error("Ошибка загрузки оборудования:", e);
|
||||
setError("Не удалось загрузить оборудование");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
listMode,
|
||||
deferredSearchQuery,
|
||||
ordering,
|
||||
statusFilter,
|
||||
categoryFilter,
|
||||
departmentFilter,
|
||||
responsibleFilter,
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAllPages<T extends { id: number }>(fetcher: (params: any) => Promise<any>): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const res = await fetcher({ page, limit: 200 });
|
||||
const results = Array.isArray(res) ? res : (res.results || []);
|
||||
all.push(...results);
|
||||
if (Array.isArray(res) || !res.next) break;
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [allEmployees, allDepartments, allCategories] = await Promise.all([
|
||||
loadAllPages<User>((p) => apiClient.getEmployees(p)),
|
||||
loadAllPages<Department>((p) => apiClient.getDepartments(p)),
|
||||
loadAllPages<EquipmentCategory>((p) => apiClient.getEquipmentCategories(p)),
|
||||
]);
|
||||
setEmployees(allEmployees);
|
||||
setDepartments(allDepartments);
|
||||
setCategories(allCategories);
|
||||
} catch (e) {
|
||||
console.error("Ошибка загрузки справочников:", e);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const options = await apiClient.getEquipmentCreateOptions();
|
||||
setCreateOptions(options);
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки create options:", error);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/* ──── filtered ──── */
|
||||
const sortItemsLocally = (source: Equipment[]) => {
|
||||
const sorted = [...source];
|
||||
|
||||
sorted.sort((left, right) => {
|
||||
switch (ordering) {
|
||||
case "name":
|
||||
return (left.name || "").localeCompare(right.name || "", "ru");
|
||||
case "purchase_date":
|
||||
return new Date(left.purchase_date || 0).getTime() - new Date(right.purchase_date || 0).getTime();
|
||||
case "-purchase_date":
|
||||
return new Date(right.purchase_date || 0).getTime() - new Date(left.purchase_date || 0).getTime();
|
||||
case "created_at":
|
||||
return new Date(left.created_at || 0).getTime() - new Date(right.created_at || 0).getTime();
|
||||
case "-created_at":
|
||||
default:
|
||||
return new Date(right.created_at || 0).getTime() - new Date(left.created_at || 0).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
let scopedItems = [...items];
|
||||
|
||||
if (listMode !== "all") {
|
||||
scopedItems = scopedItems.filter((item) => {
|
||||
const itemDate = item.purchase_date || "";
|
||||
if (statusFilter && item.status !== statusFilter) return false;
|
||||
if (categoryFilter && Number(item.category) !== Number(categoryFilter)) return false;
|
||||
if (departmentFilter && Number(item.department) !== Number(departmentFilter)) return false;
|
||||
if (responsibleFilter && Number(item.responsible_person) !== Number(responsibleFilter)) return false;
|
||||
if (dateFromFilter && itemDate && itemDate < dateFromFilter) return false;
|
||||
if (dateToFilter && itemDate && itemDate > dateToFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = listMode === "all" ? items : sortItemsLocally(scopedItems);
|
||||
if (!q) return sorted;
|
||||
return sorted.filter((item) => {
|
||||
const name = (item.name || "").toLowerCase();
|
||||
const notes = (item.notes || "").toLowerCase();
|
||||
const sn = (item.serial_number || "").toLowerCase();
|
||||
const inv = (item.inventory_number || "").toLowerCase();
|
||||
const responsible = getResponsibleName(item).toLowerCase();
|
||||
const cat = getCategoryName(item).toLowerCase();
|
||||
return name.includes(q) || notes.includes(q) || sn.includes(q) || inv.includes(q) || responsible.includes(q) || cat.includes(q);
|
||||
});
|
||||
}, [
|
||||
items,
|
||||
listMode,
|
||||
ordering,
|
||||
searchQuery,
|
||||
statusFilter,
|
||||
categoryFilter,
|
||||
departmentFilter,
|
||||
responsibleFilter,
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
employees,
|
||||
const {
|
||||
auth,
|
||||
actionError,
|
||||
actionSuccess,
|
||||
activeFilterCount,
|
||||
busyKey,
|
||||
canManage,
|
||||
categories,
|
||||
categoryFilter,
|
||||
closeModal,
|
||||
closeOperationModal,
|
||||
commentDrafts,
|
||||
commentsMap,
|
||||
createOptions,
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
departmentFilter,
|
||||
departments,
|
||||
]);
|
||||
|
||||
/* ──── form ──── */
|
||||
const resetForm = () => setForm(emptyForm);
|
||||
|
||||
const openEdit = (eq: Equipment) => {
|
||||
setEditingId(eq.id);
|
||||
setCreateOpen(false);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
setPreviewInventoryNumber(eq.inventory_number || "");
|
||||
setForm({
|
||||
name: eq.name || "",
|
||||
notes: eq.notes || "",
|
||||
category: eq.category ?? null,
|
||||
department: eq.department ?? null,
|
||||
responsible_person: typeof eq.responsible_person === "number" ? eq.responsible_person : null,
|
||||
quantity: 1,
|
||||
serial_number: eq.serial_number || "",
|
||||
purchase_date: eq.purchase_date || "",
|
||||
purchase_cost: String(eq.purchase_cost || ""),
|
||||
location: eq.location || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (mode: "create" | "edit") => {
|
||||
try {
|
||||
setBusyKey(`${mode}-save`);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
|
||||
if (!form.name.trim()) { setActionError("Укажите название оборудования."); return; }
|
||||
if (!form.category) { setActionError("Выберите категорию."); return; }
|
||||
if (!form.department) { setActionError("Выберите отдел."); return; }
|
||||
if (!form.purchase_date) { setActionError("Укажите дату покупки."); return; }
|
||||
if (!form.purchase_cost) { setActionError("Укажите стоимость."); return; }
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: form.name,
|
||||
notes: form.notes,
|
||||
category: form.category,
|
||||
department: form.department,
|
||||
purchase_date: form.purchase_date,
|
||||
purchase_cost: form.purchase_cost,
|
||||
};
|
||||
|
||||
if (form.responsible_person) payload.responsible_person = form.responsible_person;
|
||||
if (mode === "create" && form.quantity > 1) payload.quantity = form.quantity;
|
||||
if (form.serial_number) payload.serial_number = form.serial_number;
|
||||
if (form.location) payload.location = form.location;
|
||||
|
||||
if (mode === "create") {
|
||||
await apiClient.createEquipment(payload);
|
||||
setActionSuccess("Оборудование добавлено.");
|
||||
setCreateOpen(false);
|
||||
} else if (editingId) {
|
||||
await apiClient.updateEquipment(editingId, payload);
|
||||
setActionSuccess("Оборудование обновлено.");
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
await reloadEquipmentList();
|
||||
} catch (e: any) {
|
||||
const raw = String(e?.message || "Не удалось сохранить");
|
||||
let readable = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
readable = Object.entries(parsed).map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(", ") : val}`).join(". ");
|
||||
} catch {}
|
||||
setActionError(readable);
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!nextPage || loadingMore) return;
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const res = await fetchEquipmentPage(nextPage);
|
||||
const chunk = Array.isArray(res) ? res : (res.results || []);
|
||||
setItems((prev) => {
|
||||
const known = new Set(prev.map((r) => r.id));
|
||||
return [...prev, ...chunk.filter((r: Equipment) => !known.has(r.id))];
|
||||
});
|
||||
setNextPage(extractNextPage(res.next));
|
||||
} catch {
|
||||
setError("Не удалось загрузить ещё");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ──── actions ──── */
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("Удалить это оборудование?")) return;
|
||||
try { setBusyKey(`delete-${id}`); await apiClient.deleteEquipment(id); setItems((p) => p.filter((r) => r.id !== id)); } catch { setActionError("Не удалось удалить"); } finally { setBusyKey(null); } };
|
||||
|
||||
const toggleRow = (equipmentId: number) => {
|
||||
setExpandedRows((prev) => {
|
||||
const nextOpen = !prev[equipmentId];
|
||||
if (nextOpen) {
|
||||
void loadRowDetails(equipmentId);
|
||||
}
|
||||
return { ...prev, [equipmentId]: nextOpen };
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!selectedEquipment) return;
|
||||
try {
|
||||
setBusyKey(`transfer-${selectedEquipment.id}`);
|
||||
await apiClient.transferEquipment(selectedEquipment.id, {
|
||||
to_department: transferForm.to_department,
|
||||
to_person: transferForm.to_person,
|
||||
reason: transferForm.reason,
|
||||
});
|
||||
setActionSuccess("Оборудование переведено.");
|
||||
closeOperationModal();
|
||||
await reloadEquipmentList();
|
||||
await loadRowDetails(selectedEquipment.id);
|
||||
} catch (error: any) {
|
||||
setActionError(String(error?.message || "Не удалось выполнить перевод"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWriteOff = async () => {
|
||||
if (!selectedEquipment) return;
|
||||
try {
|
||||
setBusyKey(`writeoff-${selectedEquipment.id}`);
|
||||
await apiClient.writeOffEquipment(selectedEquipment.id, writeOffReason);
|
||||
setActionSuccess("Оборудование списано.");
|
||||
closeOperationModal();
|
||||
await reloadEquipmentList();
|
||||
await loadRowDetails(selectedEquipment.id);
|
||||
} catch (error: any) {
|
||||
setActionError(String(error?.message || "Не удалось списать оборудование"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaintenance = async () => {
|
||||
if (!selectedEquipment) return;
|
||||
try {
|
||||
setBusyKey(`maintenance-${selectedEquipment.id}`);
|
||||
await apiClient.addEquipmentMaintenance(selectedEquipment.id, {
|
||||
type: maintenanceForm.type,
|
||||
description: maintenanceForm.description,
|
||||
cost: maintenanceForm.cost || undefined,
|
||||
date: maintenanceForm.date,
|
||||
});
|
||||
setActionSuccess("Запись обслуживания добавлена.");
|
||||
closeOperationModal();
|
||||
await loadRowDetails(selectedEquipment.id);
|
||||
} catch (error: any) {
|
||||
setActionError(String(error?.message || "Не удалось добавить обслуживание"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenQr = async (equipmentId: number) => {
|
||||
try {
|
||||
setBusyKey(`qr-${equipmentId}`);
|
||||
const blobUrl = await apiClient.getEquipmentQrCodeBlobUrl(equipmentId);
|
||||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
} catch (error: any) {
|
||||
setActionError(String(error?.message || "Не удалось получить QR-код"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComments = async (eqId: number) => {
|
||||
const isOpen = Boolean(expandedComments[eqId]);
|
||||
setExpandedComments((p) => ({ ...p, [eqId]: !isOpen }));
|
||||
if (!isOpen && !commentsMap[eqId]) {
|
||||
try {
|
||||
const c = await apiClient.getEquipmentComments(eqId);
|
||||
setCommentsMap((p) => ({ ...p, [eqId]: Array.isArray(c) ? c : c.results || [] }));
|
||||
} catch {
|
||||
setCommentsMap((p) => ({ ...p, [eqId]: [] }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddComment = async (eqId: number) => {
|
||||
const text = (commentDrafts[eqId] || "").trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
setBusyKey(`comment-${eqId}`);
|
||||
const saved = await apiClient.addEquipmentComment(eqId, text);
|
||||
setCommentsMap((p) => ({ ...p, [eqId]: [...(p[eqId] || []), saved] }));
|
||||
setCommentDrafts((p) => ({ ...p, [eqId]: "" }));
|
||||
} catch {
|
||||
setActionError("Не удалось добавить комментарий");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (eqId: number, commentId: number) => {
|
||||
try {
|
||||
setBusyKey(`comment-delete-${commentId}`);
|
||||
await apiClient.deleteEquipmentComment(eqId, commentId);
|
||||
setCommentsMap((p) => ({ ...p, [eqId]: (p[eqId] || []).filter((c) => c.id !== commentId) }));
|
||||
} catch {
|
||||
setActionError("Не удалось удалить комментарий");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
/* ──── SearchableSelect (single) ──── */
|
||||
const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder, disabled = false }: {
|
||||
label: string;
|
||||
items: { id: number; name: string }[];
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedName = selectItems.find((i) => i.id === selectedId)?.name;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedName ? selectedName : <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
<div className="border-b border-gray-100 p-2">
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus />
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto p-1">
|
||||
{selectedId && (
|
||||
<button type="button" onClick={() => { onSelect(null); setOpen(false); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-400 hover:bg-gray-50">
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => { onSelect(item.id); setOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 ${selectedId === item.id ? "bg-sky-50 text-sky-700 font-medium" : ""}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const modalMode: "create" | "edit" = editingId ? "edit" : "create";
|
||||
const isModalOpen = createOpen || editingId !== null;
|
||||
|
||||
const closeModal = () => {
|
||||
setCreateOpen(false);
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
};
|
||||
|
||||
const activeFilterCount = [statusFilter, categoryFilter, departmentFilter, responsibleFilter, dateFromFilter, dateToFilter].filter(Boolean).length;
|
||||
detailsMap,
|
||||
displayUserName,
|
||||
employees,
|
||||
error,
|
||||
expandedComments,
|
||||
expandedRows,
|
||||
filteredDepartmentsForForm,
|
||||
filteredEmployeesForForm,
|
||||
filteredItems,
|
||||
filtersOpen,
|
||||
form,
|
||||
getEquipmentMeta,
|
||||
getResponsibleLink,
|
||||
getResponsibleName,
|
||||
handleAddComment,
|
||||
handleDelete,
|
||||
handleDeleteComment,
|
||||
handleLoadMore,
|
||||
handleMaintenance,
|
||||
handleOpenQr,
|
||||
handleSave,
|
||||
handleTransfer,
|
||||
handleWriteOff,
|
||||
isCreateMode,
|
||||
isModalOpen,
|
||||
listMode,
|
||||
loading,
|
||||
loadingMore,
|
||||
loadingRowDetails,
|
||||
maintenanceForm,
|
||||
maintenanceMap,
|
||||
modalMode,
|
||||
nextPage,
|
||||
openCreateModal,
|
||||
openEdit,
|
||||
openOperationModal,
|
||||
operationModal,
|
||||
ordering,
|
||||
previewInventoryNumber,
|
||||
responsibleFilter,
|
||||
searchQuery,
|
||||
selectedEquipment,
|
||||
setCategoryFilter,
|
||||
setCommentDrafts,
|
||||
setDateFromFilter,
|
||||
setDateToFilter,
|
||||
setDepartmentFilter,
|
||||
setFiltersOpen,
|
||||
setForm,
|
||||
setListMode,
|
||||
setMaintenanceForm,
|
||||
setOrdering,
|
||||
setResponsibleFilter,
|
||||
setSearchQuery,
|
||||
setStatusFilter,
|
||||
setTransferForm,
|
||||
setWriteOffReason,
|
||||
statusFilter,
|
||||
transferForm,
|
||||
transferHistoryMap,
|
||||
toggleComments,
|
||||
toggleRow,
|
||||
writeOffReason,
|
||||
} = useEquipmentPage(user);
|
||||
/* ──── render ──── */
|
||||
return (
|
||||
<AppShell>
|
||||
@ -875,7 +183,7 @@ export default function EquipmentPage() {
|
||||
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => setSearchQuery(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"
|
||||
/>
|
||||
|
||||
@ -22,6 +22,7 @@ import { apiClient } from '@/lib/api';
|
||||
import { useUser } from '@/contexts/UserContext';
|
||||
import { resolveMediaUrl, getWebSocketUrl } from '@/lib/url';
|
||||
import NewChatModal from '@/components/NewChatModal';
|
||||
import wsManager from '@/lib/websocketManager';
|
||||
import type { Chat as ChatType, Message as MessageType } from '@/types/api';
|
||||
|
||||
function getChatTitle(chat: ChatType, currentUserId?: number): string {
|
||||
@ -144,6 +145,16 @@ export default function ChatscapeMessenger() {
|
||||
}, [selectedChatId]);
|
||||
|
||||
// WebSocket для real-time обновлений
|
||||
useEffect(() => {
|
||||
if (!selectedChatId) return;
|
||||
|
||||
wsManager.setActiveChat(selectedChatId);
|
||||
|
||||
return () => {
|
||||
wsManager.clearActiveChat(selectedChatId);
|
||||
};
|
||||
}, [selectedChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChatId) return;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,107 +10,20 @@ import {
|
||||
BellOff,
|
||||
Pin,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
Users,
|
||||
Pencil,
|
||||
ChevronDown,
|
||||
Shield,
|
||||
ShieldCheck
|
||||
Pencil
|
||||
} from "lucide-react";
|
||||
import { AppShell } from "../../../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
|
||||
import ChatParticipantsSection from "@/components/messages/ChatParticipantsSection";
|
||||
import EditChatModal from "@/components/messages/EditChatModal";
|
||||
import AddChatMemberModal from "@/components/messages/AddChatMemberModal";
|
||||
import { getChatTypeLabel, type MemberSearchResult } from "@/lib/messages/chatSettingsUtils";
|
||||
import type { Chat } from "@/types/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
|
||||
function getUserFullName(lastName?: string, firstName?: string): string {
|
||||
return `${lastName || ""} ${firstName || ""}`.trim();
|
||||
}
|
||||
|
||||
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 getChatTitle(chat: Chat, currentUserId?: number): string {
|
||||
const chatKind = chat.chat_type || chat.type;
|
||||
const rawName = (chat.name || "").trim();
|
||||
|
||||
if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") {
|
||||
if (chat.interlocutor?.name?.trim()) {
|
||||
return chat.interlocutor.name.trim();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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() === "диалог") {
|
||||
if (chat.interlocutor?.avatar) return chat.interlocutor.avatar;
|
||||
|
||||
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): string {
|
||||
const title = getChatTitle(chat, currentUserId);
|
||||
return (
|
||||
title
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() || "")
|
||||
.join("") || "Ч"
|
||||
);
|
||||
}
|
||||
|
||||
function getChatTypeLabel(chat: Chat): string {
|
||||
const type = chat.chat_type || chat.type;
|
||||
switch (type) {
|
||||
case "global":
|
||||
return "Глобальный чат";
|
||||
case "channel":
|
||||
return "Канал";
|
||||
case "private":
|
||||
case "direct":
|
||||
return "Личный чат";
|
||||
case "group":
|
||||
return "Групповой чат";
|
||||
case "announcement":
|
||||
return "Канал объявлений";
|
||||
case "comments":
|
||||
return "Комментарии";
|
||||
default:
|
||||
return "Диалог";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChatSettingsPage() {
|
||||
const params = useParams<{ chatId: string }>();
|
||||
const router = useRouter();
|
||||
@ -137,7 +50,7 @@ export default function ChatSettingsPage() {
|
||||
// Состояние модала добавления участников
|
||||
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
||||
const [memberSearchQuery, setMemberSearchQuery] = useState("");
|
||||
const [memberSearchResults, setMemberSearchResults] = useState<any[]>([]);
|
||||
const [memberSearchResults, setMemberSearchResults] = useState<MemberSearchResult[]>([]);
|
||||
const [memberSearchLoading, setMemberSearchLoading] = useState(false);
|
||||
|
||||
// Состояние для отображения полного списка участников
|
||||
@ -314,7 +227,7 @@ export default function ChatSettingsPage() {
|
||||
setActionLoading("edit");
|
||||
try {
|
||||
// Обновляем название и описание
|
||||
const updatedChat = await apiClient.updateChat(chatId, {
|
||||
await apiClient.updateChat(chatId, {
|
||||
name: editName.trim() || undefined,
|
||||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
@ -325,7 +238,7 @@ export default function ChatSettingsPage() {
|
||||
}
|
||||
|
||||
// Перезагружаем данные чата
|
||||
const refreshedChat = await apiClient.getChat(chatId);
|
||||
const refreshedChat: Chat = await apiClient.getChat(chatId);
|
||||
setChat(refreshedChat);
|
||||
|
||||
handleCloseEditModal();
|
||||
@ -366,7 +279,7 @@ export default function ChatSettingsPage() {
|
||||
const currentMemberIds = new Set(
|
||||
chat?.participant_details?.map(p => p.id) || []
|
||||
);
|
||||
const filtered = results.filter((r: any) => !currentMemberIds.has(r.id));
|
||||
const filtered = (results as MemberSearchResult[]).filter((result) => !currentMemberIds.has(result.id));
|
||||
|
||||
setMemberSearchResults(filtered);
|
||||
} catch (e) {
|
||||
@ -384,7 +297,7 @@ export default function ChatSettingsPage() {
|
||||
await apiClient.addChatMember(chatId, userId);
|
||||
|
||||
// Перезагружаем данные чата
|
||||
const refreshedChat = await apiClient.getChat(chatId);
|
||||
const refreshedChat: Chat = await apiClient.getChat(chatId);
|
||||
setChat(refreshedChat);
|
||||
|
||||
// Обновляем результаты поиска с учетом новых данных
|
||||
@ -397,9 +310,9 @@ export default function ChatSettingsPage() {
|
||||
|
||||
// Используем свежие данные чата для фильтрации
|
||||
const currentMemberIds = new Set(
|
||||
refreshedChat?.participant_details?.map((p: any) => p.id) || []
|
||||
refreshedChat?.participant_details?.map((participant) => participant.id) || []
|
||||
);
|
||||
const filtered = results.filter((r: any) => !currentMemberIds.has(r.id));
|
||||
const filtered = (results as MemberSearchResult[]).filter((result) => !currentMemberIds.has(result.id));
|
||||
|
||||
setMemberSearchResults(filtered);
|
||||
}
|
||||
@ -451,7 +364,7 @@ export default function ChatSettingsPage() {
|
||||
});
|
||||
|
||||
// Перезагружаем данные чата с сервера для синхронизации
|
||||
const refreshedChat = await apiClient.getChat(chatId);
|
||||
const refreshedChat: Chat = await apiClient.getChat(chatId);
|
||||
console.log('[handleChangeRole] Refreshed chat from server:', refreshedChat);
|
||||
console.log('[handleChangeRole] Refreshed memberships:', refreshedChat.memberships);
|
||||
setChat(refreshedChat);
|
||||
@ -509,11 +422,8 @@ export default function ChatSettingsPage() {
|
||||
}
|
||||
|
||||
const chatType = chat.chat_type || chat.type;
|
||||
const isPrivateChat = chatType === "private" || chatType === "direct";
|
||||
const isGroupChat = chatType === "group";
|
||||
// Чаты с управлением участниками (группы, каналы, объявления)
|
||||
const hasMembers = chatType === "group" || chatType === "channel" || chatType === "announcement";
|
||||
const canManageChat = chat.can_manage ?? false;
|
||||
|
||||
// Проверка прав: владелец или админ
|
||||
const isOwner = chat.created_by === currentUserId;
|
||||
@ -522,37 +432,6 @@ export default function ChatSettingsPage() {
|
||||
) ?? false;
|
||||
const canEdit = isOwner || isAdmin;
|
||||
|
||||
// Получить роль участника
|
||||
const getMemberRole = (userId: number): 'admin' | 'moderator' | 'member' | 'guest' | null => {
|
||||
if (chat.created_by === userId) return null; // Владелец не имеет роли в membership
|
||||
const membership = chat.memberships?.find(m => m.user === userId);
|
||||
return membership?.role || 'member';
|
||||
};
|
||||
|
||||
// Получить отображаемое имя роли
|
||||
const getRoleLabel = (role: 'admin' | 'moderator' | 'member' | 'guest' | null): string => {
|
||||
if (role === null) return 'Владелец';
|
||||
switch (role) {
|
||||
case 'admin': return 'Админ';
|
||||
case 'moderator': return 'Модератор';
|
||||
case 'member': return 'Участник';
|
||||
case 'guest': return 'Гость';
|
||||
default: return 'Участник';
|
||||
}
|
||||
};
|
||||
|
||||
// Цвет бейджа роли
|
||||
const getRoleBadgeColor = (role: 'admin' | 'moderator' | 'member' | 'guest' | null): string => {
|
||||
if (role === null) return 'bg-purple-100 text-purple-700 ring-purple-200'; // Владелец
|
||||
switch (role) {
|
||||
case 'admin': return 'bg-red-100 text-red-700 ring-red-200';
|
||||
case 'moderator': return 'bg-blue-100 text-blue-700 ring-blue-200';
|
||||
case 'member': return 'bg-gray-100 text-gray-700 ring-gray-200';
|
||||
case 'guest': return 'bg-amber-100 text-amber-700 ring-amber-200';
|
||||
default: return 'bg-gray-100 text-gray-700 ring-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
@ -670,219 +549,21 @@ export default function ChatSettingsPage() {
|
||||
|
||||
{/* Участники (для групповых чатов, каналов и объявлений) */}
|
||||
{hasMembers && (
|
||||
<section className="mb-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Участники</h3>
|
||||
{isOwner && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Вы можете изменять роли участников
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAddMemberModal}
|
||||
className="flex items-center gap-1 rounded-lg border border-sky-200 bg-sky-50 px-3 py-1.5 text-xs font-medium text-sky-700 transition hover:bg-sky-100"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>Добавить</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(chat.participant_details || [])
|
||||
.slice(0, showAllParticipants ? undefined : 5)
|
||||
.map((participant) => {
|
||||
const memberRole = getMemberRole(participant.id);
|
||||
const roleLabel = getRoleLabel(memberRole);
|
||||
const roleBadgeColor = getRoleBadgeColor(memberRole);
|
||||
const isCurrentUserOwner = chat.created_by === participant.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xs font-semibold text-white">
|
||||
{participant.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(participant.avatar)}
|
||||
alt={participant.name || "User"}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>
|
||||
{(participant.name || "?")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0])
|
||||
.join("")
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{participant.name || "Без имени"}
|
||||
{participant.id === currentUserId && (
|
||||
<span className="ml-1.5 text-xs text-gray-500">(вы)</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ${roleBadgeColor}`}>
|
||||
{isCurrentUserOwner && <ShieldCheck size={10} />}
|
||||
{!isCurrentUserOwner && memberRole === 'admin' && <Shield size={10} />}
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Управление ролью (только для владельца) */}
|
||||
{isOwner && !isCurrentUserOwner && participant.id !== currentUserId && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRoleDropdownOpen(roleDropdownOpen === participant.id ? null : participant.id)}
|
||||
disabled={actionLoading?.startsWith(`change-role-${participant.id}`) || actionLoading === `remove-member-${participant.id}`}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
|
||||
title="Изменить роль"
|
||||
>
|
||||
<span>Роль</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
|
||||
{roleDropdownOpen === participant.id && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRoleDropdownOpen(null)}
|
||||
className="fixed inset-0 z-40"
|
||||
aria-label="Закрыть меню"
|
||||
/>
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeRole(participant.id, 'admin')}
|
||||
disabled={memberRole === 'admin'}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Shield size={12} className="text-red-600" />
|
||||
<span>Админ</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeRole(participant.id, 'moderator')}
|
||||
disabled={memberRole === 'moderator'}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Shield size={12} className="text-blue-600" />
|
||||
<span>Модератор</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeRole(participant.id, 'member')}
|
||||
disabled={memberRole === 'member'}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Users size={12} className="text-gray-600" />
|
||||
<span>Участник</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeRole(participant.id, 'guest')}
|
||||
disabled={memberRole === 'guest'}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Users size={12} className="text-amber-600" />
|
||||
<span>Гость</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка удаления */}
|
||||
{canEdit && !isCurrentUserOwner && participant.id !== currentUserId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveMember(participant.id)}
|
||||
disabled={actionLoading === `remove-member-${participant.id}` || actionLoading?.startsWith(`change-role-${participant.id}`)}
|
||||
className="text-gray-400 hover:text-red-600 disabled:opacity-50"
|
||||
title="Удалить из чата"
|
||||
>
|
||||
<UserMinus size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Кнопка показать всех участников */}
|
||||
{(chat.participant_details || []).length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllParticipants(!showAllParticipants)}
|
||||
className="mt-3 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
{showAllParticipants
|
||||
? "Свернуть"
|
||||
: `Посмотреть всех (${chat.participant_details?.length || 0})`
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Легенда ролей */}
|
||||
{isOwner && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-700">Роли участников:</p>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-purple-100 text-purple-700 ring-1 ring-purple-200">
|
||||
<ShieldCheck size={10} />
|
||||
Владелец
|
||||
</span>
|
||||
<span>— полный контроль над чатом</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-red-100 text-red-700 ring-1 ring-red-200">
|
||||
<Shield size={10} />
|
||||
Админ
|
||||
</span>
|
||||
<span>— управление участниками, редактирование</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-blue-100 text-blue-700 ring-1 ring-blue-200">
|
||||
<Shield size={10} />
|
||||
Модератор
|
||||
</span>
|
||||
<span>— закрепление и удаление сообщений</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-gray-100 text-gray-700 ring-1 ring-gray-200">
|
||||
Участник
|
||||
</span>
|
||||
<span>— отправка сообщений</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-amber-100 text-amber-700 ring-1 ring-amber-200">
|
||||
Гость
|
||||
</span>
|
||||
<span>— только чтение</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<ChatParticipantsSection
|
||||
chat={chat}
|
||||
currentUserId={currentUserId}
|
||||
isOwner={isOwner}
|
||||
canEdit={canEdit}
|
||||
showAllParticipants={showAllParticipants}
|
||||
roleDropdownOpen={roleDropdownOpen}
|
||||
actionLoading={actionLoading}
|
||||
onOpenAddMemberModal={handleOpenAddMemberModal}
|
||||
onToggleShowAllParticipants={() => setShowAllParticipants((prev) => !prev)}
|
||||
onToggleRoleDropdown={(participantId) => setRoleDropdownOpen((prev) => prev === participantId ? null : participantId)}
|
||||
onCloseRoleDropdown={() => setRoleDropdownOpen(null)}
|
||||
onChangeRole={handleChangeRole}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Опасная зона */}
|
||||
@ -933,207 +614,31 @@ export default function ChatSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Модал редактирования чата */}
|
||||
{isEditModalOpen && chat && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-900">Редактировать чат</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Аватар */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Аватар
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">
|
||||
{editAvatarPreview ? (
|
||||
<img
|
||||
src={editAvatarPreview || ""}
|
||||
alt="Preview"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : getChatAvatar(chat, currentUserId) ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(getChatAvatar(chat, currentUserId))}
|
||||
alt="Avatar"
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
getChatInitials(chat, currentUserId)
|
||||
)}
|
||||
</div>
|
||||
<label className="cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
Загрузить
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Максимальный размер: 5MB. Форматы: JPG, PNG, GIF
|
||||
</p>
|
||||
</div>
|
||||
<EditChatModal
|
||||
chat={chat}
|
||||
open={isEditModalOpen}
|
||||
currentUserId={currentUserId}
|
||||
editName={editName}
|
||||
editDescription={editDescription}
|
||||
editAvatarPreview={editAvatarPreview}
|
||||
actionLoading={actionLoading}
|
||||
onClose={handleCloseEditModal}
|
||||
onNameChange={setEditName}
|
||||
onDescriptionChange={setEditDescription}
|
||||
onAvatarChange={handleAvatarChange}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
|
||||
{/* Название */}
|
||||
<div>
|
||||
<label htmlFor="edit-name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Название чата
|
||||
</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Введите название чата"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Описание */}
|
||||
<div>
|
||||
<label htmlFor="edit-description" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Введите описание чата (необязательно)"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseEditModal}
|
||||
disabled={actionLoading === "edit"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={actionLoading === "edit" || !editName.trim()}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модал добавления участника */}
|
||||
{isAddMemberModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Добавить участника</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseAddMemberModal}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={memberSearchQuery}
|
||||
onChange={(e) => handleMemberSearch(e.target.value)}
|
||||
placeholder="Введите имя или email..."
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Минимум 2 символа для поиска
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Результаты поиска */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{memberSearchLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Поиск...</p>
|
||||
</div>
|
||||
) : memberSearchQuery.trim().length < 2 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Users className="mx-auto mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">
|
||||
Начните вводить имя или email для поиска
|
||||
</p>
|
||||
</div>
|
||||
) : memberSearchResults.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-gray-500">Пользователи не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memberSearchResults.map((result) => (
|
||||
<div
|
||||
key={result.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{result.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(result.avatar)}
|
||||
alt={result.name || ""}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>
|
||||
{result.name ? result.name.split(" ").map((n: string) => n[0]).join("") : "?"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{result.name || getUserFullName(result.last_name, result.first_name)}
|
||||
</p>
|
||||
{result.email && (
|
||||
<p className="text-xs text-gray-500">{result.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddMember(result.id)}
|
||||
disabled={actionLoading === `add-member-${result.id}`}
|
||||
className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AddChatMemberModal
|
||||
open={isAddMemberModalOpen}
|
||||
memberSearchQuery={memberSearchQuery}
|
||||
memberSearchResults={memberSearchResults}
|
||||
memberSearchLoading={memberSearchLoading}
|
||||
actionLoading={actionLoading}
|
||||
onClose={handleCloseAddMemberModal}
|
||||
onSearchChange={handleMemberSearch}
|
||||
onAddMember={handleAddMember}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,112 +4,22 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import { ChatIdentity, getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
|
||||
import type { Chat } from "@/types/api";
|
||||
import { Search, MessageCircle, Pin, BellOff, Filter, Plus, X, Users, Globe, Radio, Upload, Image as ImageIcon, Megaphone, MessageSquare } from "lucide-react";
|
||||
import { Search, MessageCircle, Pin, BellOff, Filter, Plus, X, Users, Globe, Radio, Image as ImageIcon, Megaphone, MessageSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function getUserFullName(lastName?: string, firstName?: string): string {
|
||||
return `${lastName || ""} ${firstName || ""}`.trim();
|
||||
}
|
||||
|
||||
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)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() || "")
|
||||
.join("") || "Ч";
|
||||
}
|
||||
type UnreadFilter = "all" | "unread" | "read";
|
||||
type PinnedFilter = "all" | "pinned" | "unpinned";
|
||||
type CreateChatPayload = {
|
||||
type: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar?: File;
|
||||
};
|
||||
|
||||
function getChatTypeIcon(chat: Chat) {
|
||||
const chatType = chat.chat_type || chat.type;
|
||||
@ -170,7 +80,7 @@ export default function MessagesPage() {
|
||||
const currentUserPatronymic = user?.patronymic;
|
||||
const currentUserEmail = user?.email;
|
||||
|
||||
const currentUserForMatch = useMemo(
|
||||
const currentUserForMatch = useMemo<ChatIdentity>(
|
||||
() => ({
|
||||
first_name: currentUserFirstName,
|
||||
last_name: currentUserLastName,
|
||||
@ -182,8 +92,8 @@ export default function MessagesPage() {
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [chatTypeFilter, setChatTypeFilter] = useState<string>("all");
|
||||
const [filterUnread, setFilterUnread] = useState<'all' | 'unread' | 'read'>('all');
|
||||
const [filterPinned, setFilterPinned] = useState<'all' | 'pinned' | 'unpinned'>('all');
|
||||
const [filterUnread, setFilterUnread] = useState<UnreadFilter>('all');
|
||||
const [filterPinned, setFilterPinned] = useState<PinnedFilter>('all');
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -343,7 +253,7 @@ export default function MessagesPage() {
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const chatData: any = {
|
||||
const chatData: CreateChatPayload = {
|
||||
type: newChatType,
|
||||
name: newChatName.trim(),
|
||||
};
|
||||
@ -462,7 +372,7 @@ export default function MessagesPage() {
|
||||
<div className="mb-4 flex flex-col gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3">
|
||||
<select
|
||||
value={filterUnread}
|
||||
onChange={(e) => setFilterUnread(e.target.value as any)}
|
||||
onChange={(e) => setFilterUnread(e.target.value as UnreadFilter)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800"
|
||||
>
|
||||
<option value="all">Все чаты</option>
|
||||
@ -472,7 +382,7 @@ export default function MessagesPage() {
|
||||
|
||||
<select
|
||||
value={filterPinned}
|
||||
onChange={(e) => setFilterPinned(e.target.value as any)}
|
||||
onChange={(e) => setFilterPinned(e.target.value as PinnedFilter)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800"
|
||||
>
|
||||
<option value="all">Все чаты</option>
|
||||
|
||||
@ -1,19 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { canManageRequests, canManageSupplier } from "@/lib/permissions";
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
|
||||
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
|
||||
import type {
|
||||
ProcurementRequest,
|
||||
User,
|
||||
Department,
|
||||
UrgencyLevel,
|
||||
} from "@/types/api";
|
||||
import type { UrgencyLevel } from "@/types/api";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
@ -35,6 +27,9 @@ import {
|
||||
X,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import { formatDate, formatMoney } from "@/lib/shared";
|
||||
import { useProcurementPage } from "@/hooks/useProcurementPage";
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
Constants & helpers
|
||||
@ -84,53 +79,8 @@ const periodOptions = [
|
||||
{ value: "quarter", label: "90 дней" },
|
||||
];
|
||||
|
||||
function fmt(d?: string | null) {
|
||||
if (!d) return "";
|
||||
const dt = new Date(d);
|
||||
return Number.isNaN(dt.getTime()) ? "" : dt.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
function money(v?: string | number | null) {
|
||||
if (v === null || v === undefined || v === "") return "—";
|
||||
return Number(v).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " ₽";
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
Item row (позиция заявки) — for inline editing
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
type ItemDraft = {
|
||||
name: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
estimated_unit_price: string;
|
||||
supplier_info: string;
|
||||
};
|
||||
|
||||
const emptyItem: ItemDraft = { name: "", description: "", quantity: "1", unit: "шт", estimated_unit_price: "", supplier_info: "" };
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
Form state
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
department: number | null;
|
||||
urgency: UrgencyLevel;
|
||||
items: ItemDraft[];
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
title: "",
|
||||
description: "",
|
||||
department: null,
|
||||
urgency: "medium",
|
||||
items: [{ ...emptyItem }],
|
||||
};
|
||||
|
||||
type ProcurementSection = "requests" | "stats" | "suppliers";
|
||||
const fmt = formatDate;
|
||||
const money = formatMoney;
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
Main page component
|
||||
@ -138,435 +88,67 @@ type ProcurementSection = "requests" | "stats" | "suppliers";
|
||||
|
||||
export default function ProcurementPage() {
|
||||
const { user } = useUser();
|
||||
const canManage = canManageRequests(user);
|
||||
const canSupplierManage = canManageSupplier(user);
|
||||
|
||||
/* ── data ── */
|
||||
const [requests, setRequests] = useState<ProcurementRequest[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
|
||||
/* ── UI flags ── */
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [nextPage, setNextPage] = useState<number | null>(null);
|
||||
|
||||
/* ── scope / filters ── */
|
||||
const [scope, setScope] = useState<ScopeTab>("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [urgencyFilter, setUrgencyFilter] = useState("");
|
||||
const [departmentFilter, setDepartmentFilter] = useState("");
|
||||
const [periodFilter, setPeriodFilter] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [ordering, setOrdering] = useState("-created_at");
|
||||
const [activeSection, setActiveSection] = useState<ProcurementSection>("requests");
|
||||
|
||||
/* ── form modal ── */
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
/* ── expanded details ── */
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
|
||||
|
||||
/* ══════════ helpers ══════════ */
|
||||
|
||||
const resolveUserId = (person?: User | number | null) => {
|
||||
if (!person) return null;
|
||||
return typeof person === "number" ? person : person.id;
|
||||
};
|
||||
|
||||
const displayUserName = (person?: User | number | null, fallbackName?: string | null, fallbackEmail?: string | null) => {
|
||||
if (fallbackName) return fallbackName;
|
||||
if (!person) return fallbackEmail || "—";
|
||||
if (typeof person === "number") return fallbackEmail || `Пользователь #${person}`;
|
||||
const full = `${person.last_name || ""} ${person.first_name || ""}`.trim();
|
||||
return full || (person as any)?.full_name || person.email || fallbackEmail || "Пользователь";
|
||||
};
|
||||
|
||||
const userLink = (person?: User | number | null) => {
|
||||
const personId = resolveUserId(person);
|
||||
if (!personId) return "";
|
||||
return user?.id && personId === user.id ? "/profile" : `/users/${personId}`;
|
||||
};
|
||||
|
||||
const extractNextPage = (nextUrl?: string | null): number | null => {
|
||||
if (!nextUrl) return null;
|
||||
try {
|
||||
const u = new URL(nextUrl, window.location.origin);
|
||||
const n = Number(u.searchParams.get("page"));
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const getDeptName = (req: ProcurementRequest) => {
|
||||
if (req.department_name) return req.department_name;
|
||||
if (req.department_details?.name) return req.department_details.name;
|
||||
const d = departments.find((dep) => dep.id === Number(req.department));
|
||||
return d?.name || "—";
|
||||
};
|
||||
|
||||
const getRequestAmount = (req: ProcurementRequest) => req.total_cost ?? req.total_estimated_cost;
|
||||
|
||||
/* ══════════ data loading ══════════ */
|
||||
|
||||
const buildParams = useCallback((page: number): Record<string, string | number> => {
|
||||
const p: Record<string, string | number> = { page };
|
||||
if (scope === "mine") p.scope = "mine";
|
||||
else if (scope === "department") p.scope = "department";
|
||||
else if (scope === "my_work") p.scope = "my_work";
|
||||
else if (scope === "available") p.scope = "available";
|
||||
if (statusFilter) p.status = statusFilter;
|
||||
if (urgencyFilter) p.urgency = urgencyFilter;
|
||||
if (departmentFilter) p.department = departmentFilter;
|
||||
if (periodFilter) p.period = periodFilter;
|
||||
if (searchQuery.trim()) p.search = searchQuery.trim();
|
||||
return p;
|
||||
}, [scope, statusFilter, urgencyFilter, departmentFilter, periodFilter, searchQuery]);
|
||||
|
||||
const loadPage1 = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
let res: any;
|
||||
if (scope === "pending_approvals") {
|
||||
res = await apiClient.getPendingApprovals(buildParams(1));
|
||||
} else {
|
||||
res = await apiClient.getProcurementRequests(buildParams(1));
|
||||
}
|
||||
const results = Array.isArray(res) ? res : (res.results || []);
|
||||
setRequests(results);
|
||||
setNextPage(extractNextPage(res.next));
|
||||
} catch (e: any) {
|
||||
console.error("Load procurement error:", e);
|
||||
setError("Не удалось загрузить заявки на закупку");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [buildParams, scope]);
|
||||
|
||||
useEffect(() => { loadPage1(); }, [loadPage1]);
|
||||
|
||||
// Load departments once
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const all: Department[] = [];
|
||||
let pg = 1;
|
||||
while (true) {
|
||||
const res = await apiClient.getDepartments({ page: pg, limit: 200 });
|
||||
const chunk = Array.isArray(res) ? res : (res.results || []);
|
||||
all.push(...chunk);
|
||||
if (Array.isArray(res) || !res.next) break;
|
||||
pg++;
|
||||
}
|
||||
setDepartments(all);
|
||||
} catch { /* silent */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/* ══════════ load more ══════════ */
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!nextPage || loadingMore) return;
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
let res: any;
|
||||
if (scope === "pending_approvals") {
|
||||
res = await apiClient.getPendingApprovals(buildParams(nextPage));
|
||||
} else {
|
||||
res = await apiClient.getProcurementRequests(buildParams(nextPage));
|
||||
}
|
||||
const chunk = Array.isArray(res) ? res : (res.results || []);
|
||||
setRequests((prev) => {
|
||||
const known = new Set(prev.map((r) => r.id));
|
||||
return [...prev, ...chunk.filter((r: ProcurementRequest) => !known.has(r.id))];
|
||||
});
|
||||
setNextPage(extractNextPage(res.next));
|
||||
} catch {
|
||||
setError("Не удалось загрузить ещё");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ══════════ expand detail ══════════ */
|
||||
|
||||
const toggleExpand = async (id: number) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) { next.delete(id); } else { next.add(id); }
|
||||
return next;
|
||||
});
|
||||
if (!detailsCache[id]) {
|
||||
try {
|
||||
const detail = await apiClient.getProcurementRequest(id);
|
||||
setDetailsCache((p) => ({ ...p, [id]: detail }));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
};
|
||||
|
||||
/* ══════════ client-side filter ══════════ */
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
const urgencyRank: Record<string, number> = {
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
critical: 4,
|
||||
};
|
||||
|
||||
return [...requests].sort((a, b) => {
|
||||
switch (ordering) {
|
||||
case "created_at":
|
||||
return (new Date(a.created_at).getTime() || 0) - (new Date(b.created_at).getTime() || 0);
|
||||
case "title":
|
||||
return String(a.title || "").localeCompare(String(b.title || ""), "ru", { sensitivity: "base" });
|
||||
case "urgency":
|
||||
return (urgencyRank[a.urgency || "medium"] || 0) - (urgencyRank[b.urgency || "medium"] || 0);
|
||||
case "-urgency":
|
||||
return (urgencyRank[b.urgency || "medium"] || 0) - (urgencyRank[a.urgency || "medium"] || 0);
|
||||
case "-created_at":
|
||||
default:
|
||||
return (new Date(b.created_at).getTime() || 0) - (new Date(a.created_at).getTime() || 0);
|
||||
}
|
||||
});
|
||||
}, [ordering, requests]);
|
||||
|
||||
/* ══════════ form ══════════ */
|
||||
|
||||
const resetForm = () => setForm({ ...emptyForm, items: [{ ...emptyItem }] });
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (req: ProcurementRequest) => {
|
||||
setCreateOpen(false);
|
||||
setEditingId(req.id);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
const detail = detailsCache[req.id] || req;
|
||||
setForm({
|
||||
title: detail.title || "",
|
||||
description: detail.description || "",
|
||||
department: detail.department ?? null,
|
||||
urgency: detail.urgency || "medium",
|
||||
items: (detail.items && detail.items.length > 0)
|
||||
? detail.items.map((it) => ({
|
||||
name: it.name || "",
|
||||
description: it.description || "",
|
||||
quantity: String(it.quantity || "1"),
|
||||
unit: it.unit || "шт",
|
||||
estimated_unit_price: String(it.estimated_unit_price || ""),
|
||||
supplier_info: it.supplier_info || "",
|
||||
}))
|
||||
: [{ ...emptyItem }],
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setCreateOpen(false);
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
};
|
||||
|
||||
const modalMode: "create" | "edit" = editingId ? "edit" : "create";
|
||||
const isModalOpen = createOpen || editingId !== null;
|
||||
|
||||
/* ── save ── */
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setBusyKey("save");
|
||||
setActionError(null);
|
||||
|
||||
if (!form.title.trim()) { setActionError("Укажите название заявки."); return; }
|
||||
if (!form.description.trim()) { setActionError("Укажите описание и обоснование."); return; }
|
||||
if (!form.department) { setActionError("Выберите отдел."); return; }
|
||||
|
||||
// Filter out empty item rows
|
||||
const validItems = form.items.filter((it) => it.name.trim());
|
||||
if (validItems.length === 0) { setActionError("Добавьте хотя бы одну позицию."); return; }
|
||||
for (const it of validItems) {
|
||||
if (!it.quantity || Number(it.quantity) <= 0) { setActionError(`Позиция «${it.name}»: укажите количество.`); return; }
|
||||
if (!it.estimated_unit_price || Number(it.estimated_unit_price) <= 0) { setActionError(`Позиция «${it.name}»: укажите цену за единицу.`); return; }
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
department: form.department,
|
||||
urgency: form.urgency,
|
||||
items: validItems.map((it) => ({
|
||||
name: it.name,
|
||||
description: it.description || undefined,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit || "шт",
|
||||
estimated_unit_price: it.estimated_unit_price,
|
||||
supplier_info: it.supplier_info || undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
if (modalMode === "create") {
|
||||
await apiClient.createProcurementRequest(payload);
|
||||
setActionSuccess("Заявка создана (черновик).");
|
||||
setCreateOpen(false);
|
||||
} else if (editingId) {
|
||||
await apiClient.updateProcurementRequest(editingId, {
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
urgency: payload.urgency,
|
||||
});
|
||||
setActionSuccess("Заявка обновлена.");
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
await loadPage1();
|
||||
} catch (e: any) {
|
||||
const raw = String(e?.message || "Ошибка сохранения");
|
||||
let readable = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
readable = Object.entries(parsed).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join(". ");
|
||||
} catch { /* keep raw */ }
|
||||
setActionError(readable);
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
/* ══════════ workflow actions ══════════ */
|
||||
|
||||
const refreshOne = async (id: number) => {
|
||||
try {
|
||||
const updated = await apiClient.getProcurementRequest(id);
|
||||
setRequests((prev) => prev.map((r) => (r.id === id ? updated : r)));
|
||||
setDetailsCache((p) => ({ ...p, [id]: updated }));
|
||||
} catch { /* reload all */ await loadPage1(); }
|
||||
};
|
||||
|
||||
const doAction = async (key: string, fn: () => Promise<any>, id: number, successMsg: string) => {
|
||||
try {
|
||||
setBusyKey(key);
|
||||
setActionError(null);
|
||||
await fn();
|
||||
setActionSuccess(successMsg);
|
||||
await refreshOne(id);
|
||||
} catch (e: any) {
|
||||
const raw = String(e?.message || "Ошибка");
|
||||
let readable = raw;
|
||||
try { const p = JSON.parse(raw); readable = typeof p === "object" ? Object.entries(p).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join(". ") : raw; } catch {}
|
||||
setActionError(readable);
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (id: number) => doAction(`submit-${id}`, () => apiClient.submitProcurementRequest(id), id, "Заявка отправлена на согласование.");
|
||||
const handleApprove = (id: number) => {
|
||||
const comment = window.prompt("Комментарий к одобрению (необязательно)", "");
|
||||
if (comment === null) return;
|
||||
return doAction(`approve-${id}`, () => apiClient.approveProcurementRequest(id, comment), id, "Заявка одобрена.");
|
||||
};
|
||||
const handleReject = (id: number) => {
|
||||
const comment = window.prompt("Комментарий к отклонению", "");
|
||||
if (comment === null) return;
|
||||
return doAction(`reject-${id}`, () => apiClient.rejectProcurementRequest(id, comment), id, "Заявка отклонена.");
|
||||
};
|
||||
const handleStart = (id: number) => doAction(`start-${id}`, () => apiClient.startWorkProcurementRequest(id), id, "Вы взяли заявку в работу.");
|
||||
const handleComplete= (id: number) => doAction(`complete-${id}`,() => apiClient.completeProcurementRequest(id), id, "Заявка завершена.");
|
||||
const handleCancel = (id: number) => {
|
||||
const reason = window.prompt("Причина отмены", "");
|
||||
if (reason === null) return;
|
||||
return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена.");
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("Удалить эту заявку? Доступно только для черновиков.")) return;
|
||||
try {
|
||||
setBusyKey(`delete-${id}`);
|
||||
await apiClient.deleteProcurementRequest(id);
|
||||
setRequests((p) => p.filter((r) => r.id !== id));
|
||||
} catch (e: any) {
|
||||
setActionError(String(e?.message || "Не удалось удалить"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
/* ══════════ form helpers ══════════ */
|
||||
|
||||
const addItemRow = () => setForm((f) => ({ ...f, items: [...f.items, { ...emptyItem }] }));
|
||||
const removeItemRow = (idx: number) => setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
|
||||
const updateItemRow = (idx: number, patch: Partial<ItemDraft>) =>
|
||||
setForm((f) => ({ ...f, items: f.items.map((it, i) => (i === idx ? { ...it, ...patch } : it)) }));
|
||||
|
||||
/* ══════════ SearchableSelect ══════════ */
|
||||
|
||||
const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder }: {
|
||||
label: string;
|
||||
items: { id: number; name: string }[];
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number | null) => void;
|
||||
placeholder?: string;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedName = selectItems.find((i) => i.id === selectedId)?.name;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
|
||||
<button type="button" onClick={() => setOpen((v) => !v)} className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800">
|
||||
<span className="truncate">{selectedName || <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}</span>
|
||||
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
<div className="border-b border-gray-100 p-2">
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus />
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto p-1">
|
||||
{selectedId && (
|
||||
<button type="button" onClick={() => { onSelect(null); setOpen(false); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-400 hover:bg-gray-50">Сбросить</button>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
|
||||
) : filtered.map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => { onSelect(item.id); setOpen(false); }} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 ${selectedId === item.id ? "bg-sky-50 text-sky-700 font-medium" : ""}`}>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const activeFilterCount = [statusFilter, urgencyFilter, departmentFilter, periodFilter].filter(Boolean).length;
|
||||
const isFinal = (s?: string) => ["completed", "rejected", "cancelled"].includes(String(s || "").toLowerCase());
|
||||
const {
|
||||
activeFilterCount,
|
||||
activeSection,
|
||||
actionError,
|
||||
actionSuccess,
|
||||
addItemRow,
|
||||
busyKey,
|
||||
canManage,
|
||||
canSupplierManage,
|
||||
closeModal,
|
||||
departmentFilter,
|
||||
departments,
|
||||
detailsCache,
|
||||
displayUserName,
|
||||
error,
|
||||
expandedIds,
|
||||
filteredRequests,
|
||||
filtersOpen,
|
||||
form,
|
||||
getDeptName,
|
||||
getRequestAmount,
|
||||
handleApprove,
|
||||
handleCancel,
|
||||
handleComplete,
|
||||
handleDelete,
|
||||
handleLoadMore,
|
||||
handleReject,
|
||||
handleSave,
|
||||
handleStart,
|
||||
handleSubmit,
|
||||
isFinal,
|
||||
isModalOpen,
|
||||
loadPage1,
|
||||
loading,
|
||||
loadingMore,
|
||||
modalMode,
|
||||
nextPage,
|
||||
openCreate,
|
||||
openEdit,
|
||||
ordering,
|
||||
periodFilter,
|
||||
removeItemRow,
|
||||
resolveUserId,
|
||||
searchQuery,
|
||||
setActiveSection,
|
||||
setDepartmentFilter,
|
||||
setFiltersOpen,
|
||||
setForm,
|
||||
setOrdering,
|
||||
setPeriodFilter,
|
||||
setScope,
|
||||
setSearchQuery,
|
||||
setStatusFilter,
|
||||
setUrgencyFilter,
|
||||
statusFilter,
|
||||
toggleExpand,
|
||||
updateItemRow,
|
||||
urgencyFilter,
|
||||
userLink,
|
||||
scope,
|
||||
} = useProcurementPage(user);
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
RENDER
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Building2, CalendarDays, ChevronDown, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users, Wallet, X, Sparkles } from "lucide-react";
|
||||
import { Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users, Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ReactNode, useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useNotifications } from "@/hooks/useApi";
|
||||
import { getVerbCategory } from "@/lib/verbTranslations";
|
||||
import { CalendarModal } from "@/components/CalendarModal";
|
||||
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
|
||||
import { NotificationCenter, NotificationPanel } from "@/components/NotificationCenter";
|
||||
import { EventModal } from "@/components/EventModal";
|
||||
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
|
||||
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
|
||||
import { CalendarSidebar } from "@/components/calendar/CalendarSidebar";
|
||||
import { CalendarCard } from "@/components/calendar/CalendarCard";
|
||||
import type { CalendarEvent } from "@/services/calendarService";
|
||||
import { useCalendarModals } from "@/hooks/useCalendarModals";
|
||||
import { CalendarModals } from "@/components/layout/CalendarModals";
|
||||
import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/MobileDrawers";
|
||||
|
||||
type AppShellProps = {
|
||||
children: ReactNode;
|
||||
@ -259,13 +254,13 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
|
||||
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
|
||||
const pathname = usePathname();
|
||||
const { notifications: notificationsData, markCategoryAsRead } = useNotifications();
|
||||
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
|
||||
const notifications = useMemo(() => Array.isArray(notificationsData) ? notificationsData : [], [notificationsData]);
|
||||
|
||||
// Подсчет уведомлений по категориям
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
notifications.forEach((n: any) => {
|
||||
notifications.forEach((n: { verb?: string; is_read?: boolean }) => {
|
||||
// Пропускаем прочитанные
|
||||
if (!n.verb || n.is_read) return;
|
||||
const category = getVerbCategory(n.verb);
|
||||
@ -349,145 +344,19 @@ export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }:
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const { user, loading, logout } = useUser();
|
||||
const { user, loading } = useUser();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isMessageDialogPage = pathname.startsWith('/messages/') &&
|
||||
pathname !== '/messages' &&
|
||||
!pathname.includes('/settings');
|
||||
|
||||
// Вычисляем данные пользователя один раз на уровне AppShell
|
||||
const userInitials = user
|
||||
? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}`
|
||||
: 'Г';
|
||||
const userName = user
|
||||
? `${user.last_name} ${user.first_name}`.trim()
|
||||
: 'Гость';
|
||||
|
||||
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
|
||||
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
|
||||
const [isProfileExpanded, setIsProfileExpanded] = useState(false);
|
||||
|
||||
// Состояния модалов календаря
|
||||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showParticipantsModal, setShowParticipantsModal] = useState(false);
|
||||
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<any>(null);
|
||||
const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null);
|
||||
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
|
||||
|
||||
// Новые модалы для просмотра
|
||||
const [showDayEventsModal, setShowDayEventsModal] = useState(false);
|
||||
const [selectedDateForModal, setSelectedDateForModal] = useState<Date | null>(null);
|
||||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||||
const [viewingEvent, setViewingEvent] = useState<any>(null);
|
||||
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
|
||||
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(null);
|
||||
const prevPathnameRef = useRef(pathname);
|
||||
|
||||
// Мемоизированные callback'и для предотвращения ререндеров CalendarCard
|
||||
const handleSetSidebarEvents = useCallback((events: CalendarEvent[]) => {
|
||||
setSidebarEvents(events);
|
||||
}, []);
|
||||
|
||||
const handleCalendarChange = useCallback((calendarId: number | null) => {
|
||||
setCurrentSelectedCalendarId(calendarId);
|
||||
}, []);
|
||||
|
||||
const handleSetEventsRefreshTrigger = useCallback((value: number | ((prev: number) => number)) => {
|
||||
setEventsRefreshTrigger(value);
|
||||
}, []);
|
||||
|
||||
// Обработчики модалов (мемоизированные для предотвращения ререндеров)
|
||||
const handleOpenCalendarModal = useCallback((calendar?: { id?: number; name: string }) => {
|
||||
setEditingCalendar(calendar || { name: "" });
|
||||
setShowCalendarModal(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenEventModal = useCallback((event: any, date?: Date) => {
|
||||
// Если передана дата (клик на дату), открываем модал просмотра дня
|
||||
if (date && !event.id) {
|
||||
setSelectedDateForModal(date);
|
||||
setShowDayEventsModal(true);
|
||||
}
|
||||
// Если передано событие с id (клик на событие), открываем детали
|
||||
else if (event.id) {
|
||||
setViewingEvent(event);
|
||||
setShowEventDetailsModal(true);
|
||||
}
|
||||
// Fallback - создание нового события
|
||||
else {
|
||||
setEditingEvent(event);
|
||||
setShowEventModal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Создание события из модала просмотра дня
|
||||
const handleCreateEventFromDay = useCallback(() => {
|
||||
if (!currentSelectedCalendarId) {
|
||||
alert("Сначала выберите календарь");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDateForModal) return;
|
||||
|
||||
const startDate = new Date(selectedDateForModal);
|
||||
startDate.setHours(10, 0, 0, 0);
|
||||
const endDate = new Date(selectedDateForModal);
|
||||
endDate.setHours(11, 0, 0, 0);
|
||||
|
||||
setEditingEvent({
|
||||
title: "",
|
||||
description: "",
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
calendar: currentSelectedCalendarId,
|
||||
color_event: "#3498db",
|
||||
});
|
||||
|
||||
setShowDayEventsModal(false);
|
||||
setShowEventModal(true);
|
||||
}, [currentSelectedCalendarId, selectedDateForModal]);
|
||||
|
||||
// Переход к редактированию из модала просмотра
|
||||
const handleEditFromDetails = useCallback(() => {
|
||||
setEditingEvent(viewingEvent);
|
||||
setShowEventDetailsModal(false);
|
||||
setShowEventModal(true);
|
||||
}, [viewingEvent]);
|
||||
|
||||
// Клик на событие из модала просмотра дня
|
||||
const handleEventClickFromDay = useCallback(async (event: any) => {
|
||||
setShowDayEventsModal(false);
|
||||
|
||||
// Если это occurrence, загружаем базовое событие
|
||||
if (event.is_recurring && event.event_id) {
|
||||
try {
|
||||
const fullEvent = await apiClient.getEvent(event.event_id);
|
||||
setViewingEvent({
|
||||
...fullEvent,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
});
|
||||
setShowEventDetailsModal(true);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки события:", err);
|
||||
}
|
||||
} else {
|
||||
setViewingEvent(event);
|
||||
setShowEventDetailsModal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEventSaved = useCallback(() => {
|
||||
// Обновляем список событий в CalendarCard
|
||||
setEventsRefreshTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handleOpenParticipantsModal = useCallback((calendar: { id: number; name: string; user_role?: string }) => {
|
||||
setParticipantsCalendar(calendar);
|
||||
setShowParticipantsModal(true);
|
||||
}, []);
|
||||
const cal = useCalendarModals();
|
||||
|
||||
useEffect(() => {
|
||||
// Если загрузка завершена и пользователь не авторизован - редирект на логин
|
||||
@ -496,9 +365,15 @@ export function AppShell({ children }: AppShellProps) {
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
// Close mobile drawers on route change
|
||||
useEffect(() => {
|
||||
setIsMobileLeftNavOpen(false);
|
||||
setIsMobileCalendarOpen(false);
|
||||
if (prevPathnameRef.current !== pathname) {
|
||||
prevPathnameRef.current = pathname;
|
||||
startTransition(() => {
|
||||
setIsMobileLeftNavOpen(false);
|
||||
setIsMobileCalendarOpen(false);
|
||||
});
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -554,192 +429,36 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<LeftNav />
|
||||
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>{children}</main>
|
||||
<CalendarSidebar
|
||||
onOpenCalendarModal={handleOpenCalendarModal}
|
||||
onOpenEventModal={handleOpenEventModal}
|
||||
onOpenParticipantsModal={handleOpenParticipantsModal}
|
||||
eventsRefreshTrigger={eventsRefreshTrigger}
|
||||
setEventsRefreshTrigger={handleSetEventsRefreshTrigger}
|
||||
setSidebarEvents={handleSetSidebarEvents}
|
||||
onCalendarChange={handleCalendarChange}
|
||||
onOpenCalendarModal={cal.handleOpenCalendarModal}
|
||||
onOpenEventModal={cal.handleOpenEventModal}
|
||||
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
|
||||
eventsRefreshTrigger={cal.eventsRefreshTrigger}
|
||||
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
|
||||
setSidebarEvents={cal.handleSetSidebarEvents}
|
||||
onCalendarChange={cal.handleCalendarChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`fixed inset-0 z-[100] 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>
|
||||
<MobileLeftDrawer
|
||||
isOpen={isMobileLeftNavOpen}
|
||||
onClose={() => setIsMobileLeftNavOpen(false)}
|
||||
>
|
||||
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
|
||||
</MobileLeftDrawer>
|
||||
|
||||
{/* Профиль пользователя */}
|
||||
<div className="mb-6 rounded-xl bg-gradient-to-br from-sky-50 to-sky-100 ring-1 ring-sky-100 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsProfileExpanded(!isProfileExpanded)}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-sky-200/50 transition-colors"
|
||||
>
|
||||
<div className="h-12 w-12 overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white flex items-center justify-center flex-shrink-0">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
userInitials
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-gray-900">{userName}</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={`text-gray-700 transition-transform flex-shrink-0 ${isProfileExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isProfileExpanded && (
|
||||
<div className="border-t border-sky-200 p-2 space-y-2">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
|
||||
onClick={() => { setIsMobileLeftNavOpen(false); router.push('/profile'); }}
|
||||
>Мой профиль</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
|
||||
onClick={() => { setIsMobileLeftNavOpen(false); router.push('/settings'); }}
|
||||
>Настройки</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-red-50 text-red-600"
|
||||
onClick={() => { setIsMobileLeftNavOpen(false); logout(); }}
|
||||
>Выйти</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`fixed inset-0 z-[100] 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>
|
||||
{isMobileCalendarOpen && (
|
||||
<CalendarCard
|
||||
onOpenCalendarModal={handleOpenCalendarModal}
|
||||
onOpenEventModal={handleOpenEventModal}
|
||||
onOpenParticipantsModal={handleOpenParticipantsModal}
|
||||
eventsRefreshTrigger={eventsRefreshTrigger}
|
||||
setEventsRefreshTrigger={handleSetEventsRefreshTrigger}
|
||||
setSidebarEvents={handleSetSidebarEvents}
|
||||
onCalendarChange={handleCalendarChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалы календаря - рендерятся на верхнем уровне */}
|
||||
<CalendarModal
|
||||
isOpen={showCalendarModal}
|
||||
onClose={() => {
|
||||
setShowCalendarModal(false);
|
||||
setEditingCalendar(null);
|
||||
}}
|
||||
calendar={editingCalendar}
|
||||
<MobileCalendarDrawer
|
||||
isOpen={isMobileCalendarOpen}
|
||||
onClose={() => setIsMobileCalendarOpen(false)}
|
||||
onOpenCalendarModal={cal.handleOpenCalendarModal}
|
||||
onOpenEventModal={cal.handleOpenEventModal}
|
||||
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
|
||||
eventsRefreshTrigger={cal.eventsRefreshTrigger}
|
||||
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
|
||||
setSidebarEvents={cal.handleSetSidebarEvents}
|
||||
onCalendarChange={cal.handleCalendarChange}
|
||||
/>
|
||||
|
||||
{/* Модальное окно участников календаря */}
|
||||
{participantsCalendar && (
|
||||
<CalendarParticipantsModal
|
||||
isOpen={showParticipantsModal}
|
||||
onClose={() => {
|
||||
setShowParticipantsModal(false);
|
||||
setParticipantsCalendar(null);
|
||||
}}
|
||||
calendarId={participantsCalendar.id}
|
||||
calendarName={participantsCalendar.name}
|
||||
userRole={participantsCalendar.user_role}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модальное окно события */}
|
||||
<EventModal
|
||||
isOpen={showEventModal}
|
||||
onClose={() => {
|
||||
setShowEventModal(false);
|
||||
setEditingEvent(null);
|
||||
}}
|
||||
event={editingEvent}
|
||||
onSave={handleEventSaved}
|
||||
showParticipants={true}
|
||||
/>
|
||||
|
||||
{/* Модальное окно просмотра событий дня */}
|
||||
<ViewDayEventsModal
|
||||
isOpen={showDayEventsModal}
|
||||
onClose={() => {
|
||||
setShowDayEventsModal(false);
|
||||
setSelectedDateForModal(null);
|
||||
}}
|
||||
date={selectedDateForModal}
|
||||
events={sidebarEvents}
|
||||
onEventClick={handleEventClickFromDay}
|
||||
onCreateEvent={handleCreateEventFromDay}
|
||||
/>
|
||||
|
||||
{/* Модальное окно просмотра деталей события */}
|
||||
<ViewEventDetailsModal
|
||||
isOpen={showEventDetailsModal}
|
||||
onClose={() => {
|
||||
setShowEventDetailsModal(false);
|
||||
setViewingEvent(null);
|
||||
}}
|
||||
event={viewingEvent}
|
||||
onEdit={handleEditFromDetails}
|
||||
onDelete={async () => {
|
||||
if (!viewingEvent?.id) return;
|
||||
if (!confirm("Удалить это событие?")) return;
|
||||
|
||||
try {
|
||||
await apiClient.deleteEvent(viewingEvent.id);
|
||||
setShowEventDetailsModal(false);
|
||||
setViewingEvent(null);
|
||||
handleEventSaved(); // Обновляем список событий
|
||||
} catch (err) {
|
||||
console.error("Ошибка удаления события:", err);
|
||||
alert("Не удалось удалить событие");
|
||||
}
|
||||
}}
|
||||
showParticipants={true}
|
||||
/>
|
||||
<CalendarModals {...cal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { Bell, Settings, X, Trash2 } from 'lucide-react';
|
||||
import { useNotifications } from '@/hooks/useApi';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale/ru';
|
||||
import { getVerbName } from '@/lib/verbTranslations';
|
||||
import Link from 'next/link';
|
||||
import type { NotificationItem } from '@/contexts/NotificationsContext';
|
||||
|
||||
interface NotificationCenterProps {
|
||||
variant?: 'default' | 'mobile';
|
||||
@ -14,6 +15,18 @@ interface NotificationCenterProps {
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
function getNotificationTitle(notification: NotificationItem): string {
|
||||
return notification.title || getVerbName(notification.verb || '');
|
||||
}
|
||||
|
||||
function getNotificationMessage(notification: NotificationItem): string {
|
||||
return notification.description || notification.short_message || notification.message || '';
|
||||
}
|
||||
|
||||
function getNotificationTimestamp(notification: NotificationItem): string | null {
|
||||
return notification.timestamp || notification.created_at || null;
|
||||
}
|
||||
|
||||
export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen, onToggle }: NotificationCenterProps) {
|
||||
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
||||
const isControlled = externalIsOpen !== undefined;
|
||||
@ -32,13 +45,13 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
const close = useCallback(() => {
|
||||
if (onToggle && isOpen) {
|
||||
onToggle();
|
||||
} else {
|
||||
setInternalIsOpen(false);
|
||||
}
|
||||
};
|
||||
}, [isOpen, onToggle]);
|
||||
|
||||
// Закрытие при клике вне компонента (только для desktop варианта)
|
||||
useEffect(() => {
|
||||
@ -54,16 +67,16 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, variant]);
|
||||
}, [close, isOpen, variant]);
|
||||
|
||||
const handleNotificationClick = async (notification: any) => {
|
||||
const handleNotificationClick = async (notification: NotificationItem) => {
|
||||
if (!notification.is_read) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
|
||||
// Навигация если есть action_url
|
||||
if (notification.action_url) {
|
||||
window.location.href = notification.action_url;
|
||||
window.location.assign(notification.action_url);
|
||||
}
|
||||
};
|
||||
|
||||
@ -144,11 +157,9 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
<ul>
|
||||
{notifications.slice(0, 20).map((notification) => {
|
||||
const isUnread = !notification.is_read;
|
||||
// v2 API: timestamp вместо created_at
|
||||
const timestamp = notification.timestamp || notification.created_at;
|
||||
// v2 API: verb + description вместо title + message
|
||||
const title = notification.title || getVerbName(notification.verb);
|
||||
const message = notification.description || notification.short_message || notification.message;
|
||||
const timestamp = getNotificationTimestamp(notification);
|
||||
const title = getNotificationTitle(notification);
|
||||
const message = getNotificationMessage(notification);
|
||||
|
||||
return (
|
||||
<li
|
||||
@ -172,10 +183,10 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
{message}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{formatDistanceToNow(new Date(timestamp), {
|
||||
{timestamp ? formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
locale: ru,
|
||||
})}
|
||||
}) : 'Только что'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@ -214,15 +225,15 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
|
||||
/** Встраиваемая панель уведомлений (для выдвижных блоков) */
|
||||
export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||||
const { notifications: notificationsData, unreadCount, markAsRead, markAllAsRead, deleteNotification, loading } = useNotifications();
|
||||
const { notifications: notificationsData, unreadCount, markAsRead, markAllAsRead, loading } = useNotifications();
|
||||
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
|
||||
|
||||
const handleNotificationClick = async (notification: any) => {
|
||||
const handleNotificationClick = async (notification: NotificationItem) => {
|
||||
if (!notification.is_read) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
if (notification.action_url) {
|
||||
window.location.href = notification.action_url;
|
||||
window.location.assign(notification.action_url);
|
||||
}
|
||||
};
|
||||
|
||||
@ -273,11 +284,9 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||||
<ul>
|
||||
{notifications.slice(0, 20).map((notification) => {
|
||||
const isUnread = !notification.is_read;
|
||||
// v2 API: timestamp вместо created_at
|
||||
const timestamp = notification.timestamp || notification.created_at;
|
||||
// v2 API: verb + description вместо title + message
|
||||
const title = notification.title || getVerbName(notification.verb);
|
||||
const message = notification.description || notification.short_message || notification.message;
|
||||
const timestamp = getNotificationTimestamp(notification);
|
||||
const title = getNotificationTitle(notification);
|
||||
const message = getNotificationMessage(notification);
|
||||
|
||||
return (
|
||||
<li
|
||||
@ -295,7 +304,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||||
<h4 className="font-medium text-sm mb-0.5 truncate text-gray-800">{title}</h4>
|
||||
<p className="text-xs text-gray-600 line-clamp-2 mb-1">{message}</p>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: ru })}
|
||||
{timestamp ? formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: ru }) : 'Только что'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
58
frontend/src/components/layout/CalendarModals.tsx
Normal file
58
frontend/src/components/layout/CalendarModals.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarModal } from "@/components/CalendarModal";
|
||||
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
|
||||
import { EventModal } from "@/components/EventModal";
|
||||
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
|
||||
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
|
||||
import type { useCalendarModals } from "@/hooks/useCalendarModals";
|
||||
|
||||
type CalendarModalsProps = ReturnType<typeof useCalendarModals>;
|
||||
|
||||
export function CalendarModals(props: CalendarModalsProps) {
|
||||
return (
|
||||
<>
|
||||
<CalendarModal
|
||||
isOpen={props.showCalendarModal}
|
||||
onClose={props.closeCalendarModal}
|
||||
calendar={props.editingCalendar}
|
||||
/>
|
||||
|
||||
{props.participantsCalendar && (
|
||||
<CalendarParticipantsModal
|
||||
isOpen={props.showParticipantsModal}
|
||||
onClose={props.closeParticipantsModal}
|
||||
calendarId={props.participantsCalendar.id}
|
||||
calendarName={props.participantsCalendar.name}
|
||||
userRole={props.participantsCalendar.user_role}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EventModal
|
||||
isOpen={props.showEventModal}
|
||||
onClose={props.closeEventModal}
|
||||
event={props.editingEvent}
|
||||
onSave={props.handleEventSaved}
|
||||
showParticipants={true}
|
||||
/>
|
||||
|
||||
<ViewDayEventsModal
|
||||
isOpen={props.showDayEventsModal}
|
||||
onClose={props.closeDayEventsModal}
|
||||
date={props.selectedDateForModal}
|
||||
events={props.sidebarEvents}
|
||||
onEventClick={props.handleEventClickFromDay}
|
||||
onCreateEvent={props.handleCreateEventFromDay}
|
||||
/>
|
||||
|
||||
<ViewEventDetailsModal
|
||||
isOpen={props.showEventDetailsModal}
|
||||
onClose={props.closeEventDetailsModal}
|
||||
event={props.viewingEvent}
|
||||
onEdit={props.handleEditFromDetails}
|
||||
onDelete={props.handleDeleteEvent}
|
||||
showParticipants={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/layout/MobileDrawers.tsx
Normal file
163
frontend/src/components/layout/MobileDrawers.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { X, ChevronDown } from "lucide-react";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { CalendarCard } from "@/components/calendar/CalendarCard";
|
||||
import type { CalendarEvent } from "@/services/calendarService";
|
||||
|
||||
/* ────────────── Left Navigation Drawer (mobile) ────────────── */
|
||||
|
||||
interface LeftNavDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode; // LeftNavContent rendered by parent
|
||||
}
|
||||
|
||||
export function MobileLeftDrawer({ isOpen, onClose, children }: LeftNavDrawerProps) {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useUser();
|
||||
const [isProfileExpanded, setIsProfileExpanded] = useState(false);
|
||||
|
||||
const userInitials = user
|
||||
? `${user.last_name?.[0] || ""}${user.first_name?.[0] || ""}`
|
||||
: "Г";
|
||||
const userName = user
|
||||
? `${user.last_name} ${user.first_name}`.trim()
|
||||
: "Гость";
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-[100] lg:hidden ${isOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity ${isOpen ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть левое меню"
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
|
||||
isOpen ? "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={onClose}
|
||||
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>
|
||||
|
||||
{/* Профиль пользователя */}
|
||||
<div className="mb-6 rounded-xl bg-gradient-to-br from-sky-50 to-sky-100 ring-1 ring-sky-100 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsProfileExpanded(!isProfileExpanded)}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-sky-200/50 transition-colors"
|
||||
>
|
||||
<div className="h-12 w-12 overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white flex items-center justify-center flex-shrink-0">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
userInitials
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-gray-900">{userName}</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={`text-gray-700 transition-transform flex-shrink-0 ${isProfileExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isProfileExpanded && (
|
||||
<div className="border-t border-sky-200 p-2 space-y-2">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
|
||||
onClick={() => { onClose(); router.push("/profile"); }}
|
||||
>Мой профиль</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
|
||||
onClick={() => { onClose(); router.push("/settings"); }}
|
||||
>Настройки</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-red-50 text-red-600"
|
||||
onClick={() => { onClose(); logout(); }}
|
||||
>Выйти</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────── Calendar Drawer (mobile) ────────────── */
|
||||
|
||||
interface CalendarDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
|
||||
onOpenEventModal: (event: Partial<CalendarEvent> & { id?: number }, date?: Date) => void;
|
||||
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
|
||||
eventsRefreshTrigger: number;
|
||||
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
|
||||
setSidebarEvents: (events: CalendarEvent[]) => void;
|
||||
onCalendarChange: (calendarId: number | null) => void;
|
||||
}
|
||||
|
||||
export function MobileCalendarDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenCalendarModal,
|
||||
onOpenEventModal,
|
||||
onOpenParticipantsModal,
|
||||
eventsRefreshTrigger,
|
||||
setEventsRefreshTrigger,
|
||||
setSidebarEvents,
|
||||
onCalendarChange,
|
||||
}: CalendarDrawerProps) {
|
||||
return (
|
||||
<div className={`fixed inset-0 z-[100] lg:hidden ${isOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity ${isOpen ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть календарь"
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
|
||||
isOpen ? "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={onClose}
|
||||
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>
|
||||
{isOpen && (
|
||||
<CalendarCard
|
||||
onOpenCalendarModal={onOpenCalendarModal}
|
||||
onOpenEventModal={onOpenEventModal}
|
||||
onOpenParticipantsModal={onOpenParticipantsModal}
|
||||
eventsRefreshTrigger={eventsRefreshTrigger}
|
||||
setEventsRefreshTrigger={setEventsRefreshTrigger}
|
||||
setSidebarEvents={setSidebarEvents}
|
||||
onCalendarChange={onCalendarChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend/src/components/messages/AddChatMemberModal.tsx
Normal file
110
frontend/src/components/messages/AddChatMemberModal.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
import { getParticipantInitials, type MemberSearchResult } from "@/lib/messages/chatSettingsUtils";
|
||||
import { getUserFullName } from "@/lib/messages/chatUtils";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
|
||||
type AddChatMemberModalProps = {
|
||||
open: boolean;
|
||||
memberSearchQuery: string;
|
||||
memberSearchResults: MemberSearchResult[];
|
||||
memberSearchLoading: boolean;
|
||||
actionLoading: string | null;
|
||||
onClose: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onAddMember: (userId: number) => void;
|
||||
};
|
||||
|
||||
export default function AddChatMemberModal({
|
||||
open,
|
||||
memberSearchQuery,
|
||||
memberSearchResults,
|
||||
memberSearchLoading,
|
||||
actionLoading,
|
||||
onClose,
|
||||
onSearchChange,
|
||||
onAddMember,
|
||||
}: AddChatMemberModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Добавить участника</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={memberSearchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Введите имя или email..."
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 2 символа для поиска</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{memberSearchLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Поиск...</p>
|
||||
</div>
|
||||
) : memberSearchQuery.trim().length < 2 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Users className="mx-auto mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">Начните вводить имя или email для поиска</p>
|
||||
</div>
|
||||
) : memberSearchResults.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-gray-500">Пользователи не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memberSearchResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center gap-3 rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{result.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(result.avatar)}
|
||||
alt={result.name || getUserFullName(result.last_name, result.first_name)}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{getParticipantInitials(result.name || getUserFullName(result.last_name, result.first_name))}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{result.name || getUserFullName(result.last_name, result.first_name)}</p>
|
||||
{result.email ? <p className="text-xs text-gray-500">{result.email}</p> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddMember(result.id)}
|
||||
disabled={actionLoading === `add-member-${result.id}`}
|
||||
className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
frontend/src/components/messages/ChatDialogHeader.tsx
Normal file
112
frontend/src/components/messages/ChatDialogHeader.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Bell, BellOff, Pin } from "lucide-react";
|
||||
|
||||
import type { Chat } from "@/types/api";
|
||||
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
|
||||
type ChatDialogHeaderProps = {
|
||||
chat: Chat;
|
||||
chatId: number;
|
||||
currentUserId?: number;
|
||||
isPinned: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
onTogglePin: () => void;
|
||||
onToggleNotifications: () => void;
|
||||
};
|
||||
|
||||
function getChatSubtitle(chat: Chat): string {
|
||||
const type = chat.chat_type || chat.type;
|
||||
|
||||
switch (type) {
|
||||
case "group":
|
||||
return "Групповой чат";
|
||||
case "channel":
|
||||
return "Канал";
|
||||
case "announcement":
|
||||
return "Канал объявлений";
|
||||
case "global":
|
||||
return "Глобальный чат";
|
||||
case "comments":
|
||||
return "Комментарии";
|
||||
default:
|
||||
return "Диалог";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChatDialogHeader({
|
||||
chat,
|
||||
chatId,
|
||||
currentUserId,
|
||||
isPinned,
|
||||
notificationsEnabled,
|
||||
onTogglePin,
|
||||
onToggleNotifications,
|
||||
}: ChatDialogHeaderProps) {
|
||||
const avatar = getChatAvatar(chat, currentUserId);
|
||||
const title = getChatTitle(chat, currentUserId);
|
||||
|
||||
return (
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{avatar ? (
|
||||
<div className="relative h-full w-full">
|
||||
<Image src={resolveMediaUrl(avatar)} alt={title} fill unoptimized className="object-cover" sizes="44px" />
|
||||
</div>
|
||||
) : (
|
||||
getChatInitials(chat, currentUserId)
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/messages/${chatId}/settings`} className="hover:opacity-80 transition">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{getChatSubtitle(chat)}</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePin}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
|
||||
isPinned
|
||||
? "border-sky-500 bg-sky-50 text-sky-700 hover:bg-sky-100"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
|
||||
}`}
|
||||
title={isPinned ? "Открепить чат" : "Закрепить чат"}
|
||||
aria-label={isPinned ? "Открепить чат" : "Закрепить чат"}
|
||||
>
|
||||
<Pin size={16} className={isPinned ? "fill-current" : ""} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleNotifications}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
|
||||
notificationsEnabled
|
||||
? "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
|
||||
: "border-amber-500 bg-amber-50 text-amber-700 hover:bg-amber-100"
|
||||
}`}
|
||||
title={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
|
||||
aria-label={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
|
||||
>
|
||||
{notificationsEnabled ? <Bell size={16} /> : <BellOff size={16} />}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/messages"
|
||||
aria-label="К списку чатов"
|
||||
title="К списку чатов"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/messages/ChatMediaPreviewModal.tsx
Normal file
41
frontend/src/components/messages/ChatMediaPreviewModal.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
|
||||
|
||||
type ChatMediaPreviewModalProps = {
|
||||
preview: MediaPreview;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPreviewModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
aria-label="Закрыть предпросмотр"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="max-h-full max-w-[92vw]" onClick={(event) => event.stopPropagation()}>
|
||||
{preview.type === "image" ? (
|
||||
<Image
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
width={1600}
|
||||
height={1200}
|
||||
unoptimized
|
||||
className="max-h-[88vh] max-w-[92vw] rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<video controls autoPlay className="max-h-[88vh] max-w-[92vw] rounded-lg bg-black" src={preview.src} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
frontend/src/components/messages/ChatMessageItem.tsx
Normal file
298
frontend/src/components/messages/ChatMessageItem.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ChevronRight, FileText } from "lucide-react";
|
||||
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import {
|
||||
formatFileSize,
|
||||
getMessageDate,
|
||||
getMessageInitials,
|
||||
getMessagePreviewText,
|
||||
isAudioAttachment,
|
||||
isImageAttachment,
|
||||
isVideoAttachment,
|
||||
messageTime,
|
||||
} from "@/lib/messages/messageUtils";
|
||||
import type { Message } from "@/types/api";
|
||||
|
||||
export type MediaPreview = {
|
||||
type: "image" | "video";
|
||||
src: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ChatMessageItemProps = {
|
||||
message: Message;
|
||||
currentUserId?: number;
|
||||
repliedMessage?: Message | null;
|
||||
isActionsOpen: boolean;
|
||||
canManage: boolean;
|
||||
canReply: boolean;
|
||||
brokenMedia: Record<number, boolean>;
|
||||
useOriginalImage: Record<number, boolean>;
|
||||
onToggleActions: (messageId: number, anchor: { x: number; y: number }) => void;
|
||||
onOpenMediaPreview: (preview: MediaPreview) => void;
|
||||
onAttachmentLoad: (attachmentId: number) => void;
|
||||
onAttachmentError: (attachmentId: number) => void;
|
||||
onUseOriginalImage: (attachmentId: number) => void;
|
||||
onReact: (message: Message, emoji: string) => void;
|
||||
hasMyReaction: (message: Message, emoji: string) => boolean;
|
||||
};
|
||||
|
||||
function getMessageAuthorLabel(message: Message): string {
|
||||
return message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник";
|
||||
}
|
||||
|
||||
export default function ChatMessageItem({
|
||||
message,
|
||||
currentUserId,
|
||||
repliedMessage,
|
||||
isActionsOpen,
|
||||
canManage,
|
||||
canReply,
|
||||
brokenMedia,
|
||||
useOriginalImage,
|
||||
onToggleActions,
|
||||
onOpenMediaPreview,
|
||||
onAttachmentLoad,
|
||||
onAttachmentError,
|
||||
onUseOriginalImage,
|
||||
onReact,
|
||||
hasMyReaction,
|
||||
}: ChatMessageItemProps) {
|
||||
const currentDate = getMessageDate(message);
|
||||
const replyToId = repliedMessage?.id ?? message.reply_to_id ?? (typeof message.reply_to === "number" ? message.reply_to : null);
|
||||
const isMine = Boolean(
|
||||
currentUserId &&
|
||||
(message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId)
|
||||
);
|
||||
const hasActions = canReply || canManage;
|
||||
|
||||
return (
|
||||
<div data-message-id={message.id} data-message-date={currentDate?.toISOString() || ""} className="mb-3 last:mb-0">
|
||||
<div className={`flex ${isMine ? "justify-end" : "justify-start"}`}>
|
||||
{!isMine ? (
|
||||
<div className="relative mr-2 mt-1 h-8 w-8 shrink-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-[10px] font-semibold text-white">
|
||||
{message.avatar || message.author?.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(message.avatar || message.author?.avatar || "")}
|
||||
alt={message.author_name || "Автор"}
|
||||
width={32}
|
||||
height={32}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
getMessageInitials(message)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`flex min-w-0 items-start gap-1 ${isMine ? "max-w-[88%] flex-row-reverse" : "max-w-[calc(100%-2.5rem)]"}`}>
|
||||
<div className={`relative min-w-0 rounded-2xl px-3 py-2 pr-9 ${isMine ? "bg-sky-500 text-white" : "bg-white text-gray-900 ring-1 ring-gray-100"}`}>
|
||||
{hasActions ? (
|
||||
<div className="absolute right-1 top-1 z-20">
|
||||
<button
|
||||
type="button"
|
||||
data-actions-trigger="true"
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
onToggleActions(message.id, { x: rect.right, y: rect.top });
|
||||
}}
|
||||
className={`inline-flex h-6 w-6 items-center justify-center rounded-full border border-transparent bg-transparent text-gray-500 transition hover:text-sky-600 ${
|
||||
isActionsOpen ? "rotate-90" : ""
|
||||
}`}
|
||||
title="Действия"
|
||||
aria-label="Действия сообщения"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isMine ? <p className="mb-1 text-[11px] font-medium text-gray-500">{getMessageAuthorLabel(message)}</p> : null}
|
||||
|
||||
{replyToId ? (
|
||||
<div className={`mb-2 rounded-lg border-l-2 px-2 py-1 text-xs ${isMine ? "border-sky-200 bg-sky-400/30 text-sky-50" : "border-gray-300 bg-gray-100 text-gray-600"}`}>
|
||||
<p className="font-medium">{repliedMessage?.author_name || "Ответ на сообщение"}</p>
|
||||
<p className="truncate">{repliedMessage ? getMessagePreviewText(repliedMessage) : `Сообщение #${replyToId}`}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{message.is_deleted ? (
|
||||
<p className={`italic text-sm ${isMine ? "text-sky-100" : "text-gray-500"}`}>Сообщение удалено</p>
|
||||
) : message.content ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-5">{message.content}</p>
|
||||
) : null}
|
||||
|
||||
{message.attachments && message.attachments.length > 0 ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{message.attachments.map((attachment) => {
|
||||
const fileUrl = resolveMediaUrl(attachment.file_url);
|
||||
|
||||
if (isImageAttachment(attachment)) {
|
||||
if (brokenMedia[attachment.id]) {
|
||||
return (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">Предпросмотр недоступен — открыть файл</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const hasThumbnail = Boolean(attachment.thumbnail);
|
||||
const imageSrc = useOriginalImage[attachment.id]
|
||||
? fileUrl
|
||||
: resolveMediaUrl(attachment.thumbnail || attachment.file_url);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => onOpenMediaPreview({ type: "image", src: fileUrl, name: attachment.file_name })}
|
||||
className="block w-full overflow-hidden rounded-lg"
|
||||
>
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={attachment.file_name}
|
||||
width={attachment.width || 1280}
|
||||
height={attachment.height || 720}
|
||||
unoptimized
|
||||
className="max-h-64 w-full rounded-lg object-cover"
|
||||
onError={() => {
|
||||
if (hasThumbnail && !useOriginalImage[attachment.id]) {
|
||||
onUseOriginalImage(attachment.id);
|
||||
return;
|
||||
}
|
||||
onAttachmentError(attachment.id);
|
||||
}}
|
||||
onLoad={() => onAttachmentLoad(attachment.id)}
|
||||
sizes="(max-width: 768px) 100vw, 512px"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideoAttachment(attachment)) {
|
||||
if (brokenMedia[attachment.id]) {
|
||||
return (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">Видео не поддерживается в браузере — открыть файл</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => onOpenMediaPreview({ type: "video", src: fileUrl, name: attachment.file_name })}
|
||||
className="block w-full overflow-hidden rounded-lg"
|
||||
>
|
||||
<video
|
||||
preload="metadata"
|
||||
playsInline
|
||||
muted
|
||||
src={fileUrl}
|
||||
width={attachment.width || undefined}
|
||||
height={attachment.height || undefined}
|
||||
className="max-h-64 w-full rounded-lg bg-black"
|
||||
onError={() => onAttachmentError(attachment.id)}
|
||||
onLoadedData={() => onAttachmentLoad(attachment.id)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAudioAttachment(attachment)) {
|
||||
if (brokenMedia[attachment.id]) {
|
||||
return (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">Аудио не поддерживается — открыть файл</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<audio
|
||||
key={attachment.id}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="w-full"
|
||||
onError={() => onAttachmentError(attachment.id)}
|
||||
onCanPlay={() => onAttachmentLoad(attachment.id)}
|
||||
>
|
||||
<source src={fileUrl} type={attachment.mime_type || "audio/mpeg"} />
|
||||
</audio>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 text-sm text-sky-700 hover:bg-white"
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{attachment.file_name}</span>
|
||||
<span className="shrink-0 text-xs text-gray-500">{formatFileSize(attachment.file_size)}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className={`mt-1 text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
|
||||
|
||||
{Object.keys(message.reactions_summary || {}).length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{Object.entries(message.reactions_summary || {}).map(([emoji, meta]) => {
|
||||
const mine = hasMyReaction(message, emoji);
|
||||
return (
|
||||
<button
|
||||
key={`${message.id}-${emoji}`}
|
||||
type="button"
|
||||
onClick={() => onReact(message, emoji)}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs transition ${
|
||||
mine ? "bg-sky-100 text-sky-700 ring-1 ring-sky-300" : "bg-white/80 text-gray-700 ring-1 ring-gray-200 hover:bg-white"
|
||||
}`}
|
||||
title="Реакция"
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
<span>{meta.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
frontend/src/components/messages/ChatParticipantsSection.tsx
Normal file
219
frontend/src/components/messages/ChatParticipantsSection.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ChevronDown, Shield, ShieldCheck, UserMinus, UserPlus, Users } from "lucide-react";
|
||||
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import {
|
||||
getMemberRole,
|
||||
getParticipantInitials,
|
||||
getRoleBadgeColor,
|
||||
getRoleLabel,
|
||||
type ChatMemberRole,
|
||||
} from "@/lib/messages/chatSettingsUtils";
|
||||
import type { Chat } from "@/types/api";
|
||||
|
||||
type ChatParticipantsSectionProps = {
|
||||
chat: Chat;
|
||||
currentUserId?: number;
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
showAllParticipants: boolean;
|
||||
roleDropdownOpen: number | null;
|
||||
actionLoading: string | null;
|
||||
onOpenAddMemberModal: () => void;
|
||||
onToggleShowAllParticipants: () => void;
|
||||
onToggleRoleDropdown: (participantId: number) => void;
|
||||
onCloseRoleDropdown: () => void;
|
||||
onChangeRole: (userId: number, newRole: Exclude<ChatMemberRole, null>) => void;
|
||||
onRemoveMember: (userId: number) => void;
|
||||
};
|
||||
|
||||
const assignableRoles: Array<Exclude<ChatMemberRole, null>> = ["admin", "moderator", "member", "guest"];
|
||||
|
||||
function getRoleIcon(role: ChatMemberRole) {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return <Shield size={12} className="text-red-600" />;
|
||||
case "moderator":
|
||||
return <Shield size={12} className="text-blue-600" />;
|
||||
case "guest":
|
||||
return <Users size={12} className="text-amber-600" />;
|
||||
default:
|
||||
return <Users size={12} className="text-gray-600" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChatParticipantsSection({
|
||||
chat,
|
||||
currentUserId,
|
||||
isOwner,
|
||||
canEdit,
|
||||
showAllParticipants,
|
||||
roleDropdownOpen,
|
||||
actionLoading,
|
||||
onOpenAddMemberModal,
|
||||
onToggleShowAllParticipants,
|
||||
onToggleRoleDropdown,
|
||||
onCloseRoleDropdown,
|
||||
onChangeRole,
|
||||
onRemoveMember,
|
||||
}: ChatParticipantsSectionProps) {
|
||||
const visibleParticipants = (chat.participant_details || []).slice(0, showAllParticipants ? undefined : 5);
|
||||
|
||||
return (
|
||||
<section className="mb-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Участники</h3>
|
||||
{isOwner ? <p className="mt-0.5 text-xs text-gray-500">Вы можете изменять роли участников</p> : null}
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAddMemberModal}
|
||||
className="flex items-center gap-1 rounded-lg border border-sky-200 bg-sky-50 px-3 py-1.5 text-xs font-medium text-sky-700 transition hover:bg-sky-100"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>Добавить</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{visibleParticipants.map((participant) => {
|
||||
const memberRole = getMemberRole(chat, participant.id);
|
||||
const roleLabel = getRoleLabel(memberRole);
|
||||
const roleBadgeColor = getRoleBadgeColor(memberRole);
|
||||
const isParticipantOwner = chat.created_by === participant.id;
|
||||
|
||||
return (
|
||||
<div key={participant.id} className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xs font-semibold text-white">
|
||||
{participant.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(participant.avatar)}
|
||||
alt={participant.name || "User"}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{getParticipantInitials(participant.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{participant.name || "Без имени"}
|
||||
{participant.id === currentUserId ? <span className="ml-1.5 text-xs text-gray-500">(вы)</span> : null}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ${roleBadgeColor}`}>
|
||||
{isParticipantOwner ? <ShieldCheck size={10} /> : memberRole === "admin" ? <Shield size={10} /> : null}
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwner && !isParticipantOwner && participant.id !== currentUserId ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleRoleDropdown(participant.id)}
|
||||
disabled={actionLoading?.startsWith(`change-role-${participant.id}`) || actionLoading === `remove-member-${participant.id}`}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
|
||||
title="Изменить роль"
|
||||
>
|
||||
<span>Роль</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
|
||||
{roleDropdownOpen === participant.id ? (
|
||||
<>
|
||||
<button type="button" onClick={onCloseRoleDropdown} className="fixed inset-0 z-40" aria-label="Закрыть меню" />
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
|
||||
{assignableRoles.map((role) => (
|
||||
<button
|
||||
key={`${participant.id}-${role}`}
|
||||
type="button"
|
||||
onClick={() => onChangeRole(participant.id, role)}
|
||||
disabled={memberRole === role}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{getRoleIcon(role)}
|
||||
<span>{getRoleLabel(role)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && !isParticipantOwner && participant.id !== currentUserId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveMember(participant.id)}
|
||||
disabled={actionLoading === `remove-member-${participant.id}` || actionLoading?.startsWith(`change-role-${participant.id}`)}
|
||||
className="text-gray-400 hover:text-red-600 disabled:opacity-50"
|
||||
title="Удалить из чата"
|
||||
>
|
||||
<UserMinus size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(chat.participant_details || []).length > 5 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleShowAllParticipants}
|
||||
className="mt-3 w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
{showAllParticipants ? "Свернуть" : `Посмотреть всех (${chat.participant_details?.length || 0})`}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{isOwner ? (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-700">Роли участников:</p>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-purple-100 px-2 py-0.5 text-[10px] font-medium text-purple-700 ring-1 ring-purple-200">
|
||||
<ShieldCheck size={10} />
|
||||
Владелец
|
||||
</span>
|
||||
<span>— полный контроль над чатом</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-medium text-red-700 ring-1 ring-red-200">
|
||||
<Shield size={10} />
|
||||
Админ
|
||||
</span>
|
||||
<span>— управление участниками, редактирование</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-blue-200">
|
||||
<Shield size={10} />
|
||||
Модератор
|
||||
</span>
|
||||
<span>— закрепление и удаление сообщений</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-700 ring-1 ring-gray-200">Участник</span>
|
||||
<span>— отправка сообщений</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700 ring-1 ring-amber-200">Гость</span>
|
||||
<span>— только чтение</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/messages/EditChatModal.tsx
Normal file
121
frontend/src/components/messages/EditChatModal.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { getChatAvatar, getChatInitials } from "@/lib/messages/chatUtils";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import type { Chat } from "@/types/api";
|
||||
|
||||
type EditChatModalProps = {
|
||||
chat: Chat | null;
|
||||
open: boolean;
|
||||
currentUserId?: number;
|
||||
editName: string;
|
||||
editDescription: string;
|
||||
editAvatarPreview: string | null;
|
||||
actionLoading: string | null;
|
||||
onClose: () => void;
|
||||
onNameChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onAvatarChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export default function EditChatModal({
|
||||
chat,
|
||||
open,
|
||||
currentUserId,
|
||||
editName,
|
||||
editDescription,
|
||||
editAvatarPreview,
|
||||
actionLoading,
|
||||
onClose,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
onAvatarChange,
|
||||
onSave,
|
||||
}: EditChatModalProps) {
|
||||
if (!open || !chat) return null;
|
||||
|
||||
const avatar = getChatAvatar(chat, currentUserId);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-900">Редактировать чат</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Аватар</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">
|
||||
{editAvatarPreview ? (
|
||||
<Image src={editAvatarPreview} alt="Preview" width={64} height={64} unoptimized className="h-full w-full object-cover" />
|
||||
) : avatar ? (
|
||||
<Image src={resolveMediaUrl(avatar)} alt="Avatar" width={64} height={64} unoptimized className="h-full w-full object-cover" />
|
||||
) : (
|
||||
getChatInitials(chat, currentUserId)
|
||||
)}
|
||||
</div>
|
||||
<label className="cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50">
|
||||
<input type="file" accept="image/*" onChange={onAvatarChange} className="hidden" />
|
||||
Загрузить
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Максимальный размер: 5MB. Форматы: JPG, PNG, GIF</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Название чата
|
||||
</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="Введите название чата"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-description" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
value={editDescription}
|
||||
onChange={(event) => onDescriptionChange(event.target.value)}
|
||||
placeholder="Введите описание чата (необязательно)"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "edit"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "edit" || !editName.trim()}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/messages/MessageActionsMenu.tsx
Normal file
100
frontend/src/components/messages/MessageActionsMenu.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Reply, Smile, Trash2 } from "lucide-react";
|
||||
|
||||
import type { Message } from "@/types/api";
|
||||
|
||||
type MessageActionsMenuProps = {
|
||||
anchor: { x: number; y: number };
|
||||
message: Message;
|
||||
canReply: boolean;
|
||||
canManage: boolean;
|
||||
recentReactions: string[];
|
||||
onQuickReact: (emoji: string) => void;
|
||||
onOpenReactionPicker: () => void;
|
||||
onReply: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export default function MessageActionsMenu({
|
||||
anchor,
|
||||
message,
|
||||
canReply,
|
||||
canManage,
|
||||
recentReactions,
|
||||
onQuickReact,
|
||||
onOpenReactionPicker,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: MessageActionsMenuProps) {
|
||||
return (
|
||||
<div
|
||||
data-actions-menu="true"
|
||||
className="fixed z-[60] flex min-w-[176px] flex-col gap-1 rounded-lg border border-gray-200 bg-white p-1 shadow-xl"
|
||||
style={{
|
||||
left: anchor.x,
|
||||
top: anchor.y - 6,
|
||||
transform: "translate(-100%, -100%)",
|
||||
}}
|
||||
>
|
||||
{canReply ? (
|
||||
<div className="mb-1 flex items-center gap-1 rounded-md bg-gray-50 p-1">
|
||||
{recentReactions.map((emoji) => (
|
||||
<button
|
||||
key={`recent-${message.id}-${emoji}`}
|
||||
type="button"
|
||||
onClick={() => onQuickReact(emoji)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-base hover:bg-sky-50"
|
||||
title="Быстрая реакция"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenReactionPicker}
|
||||
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-gray-600 hover:bg-sky-50 hover:text-sky-700"
|
||||
title="Все смайлы"
|
||||
>
|
||||
<Smile size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canReply ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReply}
|
||||
className="inline-flex w-full items-center gap-1 rounded-md border border-sky-200 bg-sky-50 px-2 py-1 text-xs font-medium text-sky-700 hover:bg-sky-100"
|
||||
>
|
||||
<Reply size={12} />
|
||||
Ответить
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{canManage ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="inline-flex w-full items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
Редактировать
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="inline-flex w-full items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
frontend/src/components/messages/MessageComposer.tsx
Normal file
206
frontend/src/components/messages/MessageComposer.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { Paperclip, Send, Smile, X } from "lucide-react";
|
||||
|
||||
type ReplyTarget = {
|
||||
id: number;
|
||||
author: string;
|
||||
preview: string;
|
||||
};
|
||||
|
||||
type MessageComposerProps = {
|
||||
canSendMessages: boolean;
|
||||
membershipRole?: string | null;
|
||||
editingMessageId: number | null;
|
||||
replyTo: ReplyTarget | null;
|
||||
attachedFiles: File[];
|
||||
messageText: string;
|
||||
sending: boolean;
|
||||
showEmojiPicker: boolean;
|
||||
allReactions: string[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
messageInputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
onPickFiles: () => void;
|
||||
onFilesChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onRemoveFile: (index: number) => void;
|
||||
onToggleEmojiPicker: () => void;
|
||||
onSelectEmoji: (emoji: string) => void;
|
||||
onInputClick: () => void;
|
||||
onChangeMessage: (value: string) => void;
|
||||
onTyping: () => void;
|
||||
onSend: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onCancelReply: () => void;
|
||||
};
|
||||
|
||||
export default function MessageComposer({
|
||||
canSendMessages,
|
||||
membershipRole,
|
||||
editingMessageId,
|
||||
replyTo,
|
||||
attachedFiles,
|
||||
messageText,
|
||||
sending,
|
||||
showEmojiPicker,
|
||||
allReactions,
|
||||
fileInputRef,
|
||||
messageInputRef,
|
||||
onPickFiles,
|
||||
onFilesChange,
|
||||
onRemoveFile,
|
||||
onToggleEmojiPicker,
|
||||
onSelectEmoji,
|
||||
onInputClick,
|
||||
onChangeMessage,
|
||||
onTyping,
|
||||
onSend,
|
||||
onCancelEdit,
|
||||
onCancelReply,
|
||||
}: MessageComposerProps) {
|
||||
return (
|
||||
<div className="shrink-0 border-t border-gray-100 bg-white pt-3">
|
||||
{editingMessageId ? (
|
||||
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold">Режим редактирования</p>
|
||||
<p className="truncate">Измените текст сообщения и отправьте</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelEdit}
|
||||
className="rounded-full p-0.5 text-amber-700 hover:bg-amber-100"
|
||||
aria-label="Отменить редактирование"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{replyTo ? (
|
||||
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg border border-sky-100 bg-sky-50 px-3 py-2 text-xs text-sky-800">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold">Ответ: {replyTo.author}</p>
|
||||
<p className="truncate">{replyTo.preview}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelReply}
|
||||
className="rounded-full p-0.5 text-sky-700 hover:bg-sky-100"
|
||||
aria-label="Отменить ответ"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachedFiles.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-700 ring-1 ring-sky-100"
|
||||
>
|
||||
<span className="truncate max-w-[180px]">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFile(index)}
|
||||
className="rounded-full p-0.5 hover:bg-sky-100"
|
||||
aria-label="Удалить файл"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canSendMessages ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onFilesChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickFiles}
|
||||
disabled={Boolean(editingMessageId)}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
|
||||
>
|
||||
<Paperclip size={15} />
|
||||
</button>
|
||||
|
||||
<div className="relative w-full" data-composer-emoji="true">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleEmojiPicker}
|
||||
className="absolute right-2 top-1/2 z-10 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-sky-600"
|
||||
title="Смайлы"
|
||||
>
|
||||
<Smile size={14} />
|
||||
</button>
|
||||
|
||||
{showEmojiPicker ? (
|
||||
<div className="absolute bottom-full right-0 z-20 mb-2 w-[260px] rounded-lg border border-gray-200 bg-white p-2 shadow-xl">
|
||||
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-y-auto">
|
||||
{allReactions.map((emoji) => (
|
||||
<button
|
||||
key={`composer-${emoji}`}
|
||||
type="button"
|
||||
onClick={() => onSelectEmoji(emoji)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-base hover:bg-sky-50"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<textarea
|
||||
ref={messageInputRef}
|
||||
value={messageText}
|
||||
onChange={(event) => {
|
||||
onChangeMessage(event.target.value);
|
||||
onTyping();
|
||||
}}
|
||||
onClick={onInputClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={editingMessageId ? "Редактируйте сообщение..." : "Введите сообщение..."}
|
||||
className="h-9 w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 pr-10 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
disabled={sending || (editingMessageId ? !messageText.trim() : (!messageText.trim() && attachedFiles.length === 0))}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={editingMessageId ? "Сохранить" : "Отправить"}
|
||||
>
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-center text-sm text-amber-800">
|
||||
<p className="font-medium">У вас нет прав на отправку сообщений</p>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
{membershipRole === "guest"
|
||||
? "Гости могут только просматривать сообщения и отправлять реакции"
|
||||
: "Обратитесь к администратору чата для получения прав"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/messages/ReactionPickerModal.tsx
Normal file
36
frontend/src/components/messages/ReactionPickerModal.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type ReactionPickerModalProps = {
|
||||
allReactions: string[];
|
||||
onClose: () => void;
|
||||
onSelect: (emoji: string) => void;
|
||||
};
|
||||
|
||||
export default function ReactionPickerModal({ allReactions, onClose, onSelect }: ReactionPickerModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-2 sm:p-4" data-reaction-picker="true">
|
||||
<div className="w-full max-w-[95vw] rounded-xl bg-white p-4 shadow-xl sm:max-w-md sm:rounded-2xl sm:p-6">
|
||||
<div className="mb-3 flex items-center justify-between sm:mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 sm:text-lg">Выберите реакцию</h3>
|
||||
<button type="button" onClick={onClose} className="rounded-full p-1 hover:bg-gray-100" aria-label="Закрыть">
|
||||
<X size={18} className="text-gray-600 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid max-h-[60vh] grid-cols-6 gap-1.5 overflow-y-auto sm:max-h-[55vh] sm:grid-cols-8 sm:gap-2">
|
||||
{allReactions.map((emoji) => (
|
||||
<button
|
||||
key={`picker-${emoji}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(emoji)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-lg hover:bg-sky-50"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
frontend/src/components/shared/SearchableSelect.tsx
Normal file
167
frontend/src/components/shared/SearchableSelect.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/* ── Single-select dropdown with search ── */
|
||||
export function SearchableSelectSingle({
|
||||
label,
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
items: { id: number; name: string }[];
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedName = items.find((i) => i.id === selectedId)?.name;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedName || <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
<div className="border-b border-gray-100 p-2">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Поиск..."
|
||||
className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto p-1">
|
||||
{selectedId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSelect(null); setOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-400 hover:bg-gray-50"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => { onSelect(item.id); setOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 ${selectedId === item.id ? "bg-sky-50 font-medium text-sky-700" : ""}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Multi-select dropdown with search ── */
|
||||
export function SearchableSelectMulti({
|
||||
label,
|
||||
items,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
items: { id: number; name: string }[];
|
||||
selectedIds: number[];
|
||||
onToggle: (id: number) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedNames = items.filter((i) => selectedIds.includes(i.id)).map((i) => i.name);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedNames.length > 0 ? selectedNames.join(", ") : <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
<div className="border-b border-gray-100 p-2">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Поиск..."
|
||||
className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<label key={item.id} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onChange={() => onToggle(item.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
frontend/src/components/users/EditUserProfileModal.tsx
Normal file
237
frontend/src/components/users/EditUserProfileModal.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import type { User } from "@/types/api";
|
||||
import type { UserProfileEditForm, UserProfileTextField } from "@/lib/users/userDetailUtils";
|
||||
|
||||
type EditUserProfileModalProps = {
|
||||
actionLoading: string | null;
|
||||
avatarFailed: boolean;
|
||||
avatarUrl: string | null;
|
||||
form: UserProfileEditForm;
|
||||
initials: string;
|
||||
isOpen: boolean;
|
||||
onAvatarChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onTextFieldChange: (field: UserProfileTextField, value: string) => void;
|
||||
person: User | null;
|
||||
};
|
||||
|
||||
export default function EditUserProfileModal({
|
||||
actionLoading,
|
||||
avatarFailed,
|
||||
avatarUrl,
|
||||
form,
|
||||
initials,
|
||||
isOpen,
|
||||
onAvatarChange,
|
||||
onClose,
|
||||
onSave,
|
||||
onTextFieldChange,
|
||||
person,
|
||||
}: EditUserProfileModalProps) {
|
||||
if (!isOpen || !person) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Редактировать профиль</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
disabled={actionLoading === "edit"}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Аватар
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xl font-semibold text-white">
|
||||
{form.avatarPreview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={form.avatarPreview}
|
||||
alt="Preview"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : avatarUrl && !avatarFailed ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={onAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
Загрузить
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Максимальный размер: 5MB. Форматы: JPG, PNG, GIF
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="edit-last-name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Фамилия *
|
||||
</label>
|
||||
<input
|
||||
id="edit-last-name"
|
||||
type="text"
|
||||
value={form.lastName}
|
||||
onChange={(event) => onTextFieldChange("lastName", event.target.value)}
|
||||
placeholder="Иванов"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
maxLength={150}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-first-name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Имя *
|
||||
</label>
|
||||
<input
|
||||
id="edit-first-name"
|
||||
type="text"
|
||||
value={form.firstName}
|
||||
onChange={(event) => onTextFieldChange("firstName", event.target.value)}
|
||||
placeholder="Иван"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
maxLength={150}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-patronymic" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Отчество
|
||||
</label>
|
||||
<input
|
||||
id="edit-patronymic"
|
||||
type="text"
|
||||
value={form.patronymic}
|
||||
onChange={(event) => onTextFieldChange("patronymic", event.target.value)}
|
||||
placeholder="Иванович"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
maxLength={150}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="edit-email" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
id="edit-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(event) => onTextFieldChange("email", event.target.value)}
|
||||
placeholder="ivan@example.com"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-phone" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Телефон
|
||||
</label>
|
||||
<input
|
||||
id="edit-phone"
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={(event) => onTextFieldChange("phone", event.target.value)}
|
||||
placeholder="+7 999 123-45-67"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="edit-telegram" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Telegram
|
||||
</label>
|
||||
<input
|
||||
id="edit-telegram"
|
||||
type="text"
|
||||
value={form.telegram}
|
||||
onChange={(event) => onTextFieldChange("telegram", event.target.value)}
|
||||
placeholder="@username"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-whatsapp" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
WhatsApp
|
||||
</label>
|
||||
<input
|
||||
id="edit-whatsapp"
|
||||
type="tel"
|
||||
value={form.whatsapp}
|
||||
onChange={(event) => onTextFieldChange("whatsapp", event.target.value)}
|
||||
placeholder="+7 999 123-45-67"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-wechat" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
WeChat
|
||||
</label>
|
||||
<input
|
||||
id="edit-wechat"
|
||||
type="text"
|
||||
value={form.wechat}
|
||||
onChange={(event) => onTextFieldChange("wechat", event.target.value)}
|
||||
placeholder="username"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "edit"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "edit" || !form.firstName.trim() || !form.lastName.trim() || !form.email.trim()}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/components/users/EmployeeActionModal.tsx
Normal file
120
frontend/src/components/users/EmployeeActionModal.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import type { EmployeeActionField, EmployeeActionForm } from "@/lib/users/userDetailUtils";
|
||||
|
||||
type EmployeeActionOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type EmployeeActionModalProps = {
|
||||
actionLoading: string | null;
|
||||
actionTypes: readonly EmployeeActionOption[];
|
||||
form: EmployeeActionForm;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onFieldChange: (field: EmployeeActionField, value: string) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export default function EmployeeActionModal({
|
||||
actionLoading,
|
||||
actionTypes,
|
||||
form,
|
||||
isOpen,
|
||||
onClose,
|
||||
onFieldChange,
|
||||
onSave,
|
||||
}: EmployeeActionModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
{form.editingId ? "Редактировать событие" : "Кадровое событие"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
disabled={actionLoading === "action"}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="action-type" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Тип события *
|
||||
</label>
|
||||
<select
|
||||
id="action-type"
|
||||
value={form.type}
|
||||
onChange={(event) => onFieldChange("type", event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
>
|
||||
<option value="">Выберите тип события</option>
|
||||
{actionTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="action-date" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Дата *
|
||||
</label>
|
||||
<input
|
||||
id="action-date"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(event) => onFieldChange("date", event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="action-comment" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Комментарий
|
||||
</label>
|
||||
<textarea
|
||||
id="action-comment"
|
||||
value={form.comment}
|
||||
onChange={(event) => onFieldChange("comment", event.target.value)}
|
||||
placeholder="Дополнительная информация..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "action"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "action" || !form.type || !form.date}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "action" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/components/users/EmployeeActionsTimeline.tsx
Normal file
120
frontend/src/components/users/EmployeeActionsTimeline.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { History, Pencil, Trash2 } from "lucide-react";
|
||||
import { getEmployeeActionBadgeClass, getEmployeeActionBorderColor } from "@/lib/users/userDetailUtils";
|
||||
import type { EmployeeAction } from "@/types/api";
|
||||
|
||||
type EmployeeActionsTimelineProps = {
|
||||
actionLoading: string | null;
|
||||
canManageActions: boolean;
|
||||
canViewActions: boolean;
|
||||
latestActionId: number | null;
|
||||
onAddAction: () => void;
|
||||
onDeleteAction: (actionId: number) => void;
|
||||
onEditAction: (action: EmployeeAction) => void;
|
||||
sortedActions: EmployeeAction[];
|
||||
};
|
||||
|
||||
export default function EmployeeActionsTimeline({
|
||||
actionLoading,
|
||||
canManageActions,
|
||||
canViewActions,
|
||||
latestActionId,
|
||||
onAddAction,
|
||||
onDeleteAction,
|
||||
onEditAction,
|
||||
sortedActions,
|
||||
}: EmployeeActionsTimelineProps) {
|
||||
if (!canViewActions || sortedActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
<History size={16} />
|
||||
История кадровых событий
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{sortedActions.length} {sortedActions.length === 1 ? "событие" : sortedActions.length < 5 ? "события" : "событий"}
|
||||
</span>
|
||||
{canManageActions && (
|
||||
<button
|
||||
onClick={onAddAction}
|
||||
className="rounded-lg bg-sky-100 px-2 py-1 text-xs font-medium text-sky-700 hover:bg-sky-200 transition"
|
||||
>
|
||||
+ Добавить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{sortedActions.map((action) => {
|
||||
const isCurrent = latestActionId === action.id;
|
||||
const deleteKey = `delete-${action.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="flex gap-3 border-l-2 py-1 pl-3"
|
||||
style={{ borderColor: getEmployeeActionBorderColor(action.action) }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${getEmployeeActionBadgeClass(action.action)}`}>
|
||||
{action.action_display || action.action}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-xs text-gray-500">текущий</span>
|
||||
)}
|
||||
</div>
|
||||
{action.comment && (
|
||||
<p className="mt-1 text-sm text-gray-600">{action.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<time className="flex-shrink-0 text-xs text-gray-500">
|
||||
{new Date(action.date).toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</time>
|
||||
{canManageActions && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onEditAction(action)}
|
||||
className="rounded p-1 text-gray-400 transition hover:bg-gray-100 hover:text-sky-600"
|
||||
title="Редактировать"
|
||||
disabled={actionLoading === deleteKey}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteAction(action.id)}
|
||||
className="rounded p-1 text-gray-400 transition hover:bg-red-50 hover:text-red-600"
|
||||
title="Удалить"
|
||||
disabled={actionLoading === deleteKey}
|
||||
>
|
||||
{actionLoading === deleteKey ? (
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-red-600 border-t-transparent" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
|
||||
import apiClient from '@/lib/api';
|
||||
import wsManager from '@/lib/websocketManager';
|
||||
|
||||
export type NotificationItem = {
|
||||
id: number;
|
||||
is_read?: boolean;
|
||||
unread?: boolean;
|
||||
category?: string;
|
||||
title?: string;
|
||||
verb?: string;
|
||||
description?: string;
|
||||
short_message?: string;
|
||||
message?: string;
|
||||
timestamp?: string;
|
||||
created_at?: string;
|
||||
action_url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type NotificationSocketEvent = {
|
||||
type: string;
|
||||
notification?: NotificationItem;
|
||||
notification_id?: number;
|
||||
notification_ids?: number[];
|
||||
unread_count?: number;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
interface NotificationsContextType {
|
||||
notifications: any[];
|
||||
notifications: NotificationItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
@ -19,34 +44,57 @@ interface NotificationsContextType {
|
||||
const NotificationsContext = createContext<NotificationsContextType | undefined>(undefined);
|
||||
|
||||
export function NotificationsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isWsConnected, setIsWsConnected] = useState(
|
||||
typeof window !== 'undefined' && wsManager.getState() === WebSocket.OPEN
|
||||
);
|
||||
const fetchInFlightRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const syncUnreadNotifications = useCallback(async (showLoader = false) => {
|
||||
if (fetchInFlightRef.current) {
|
||||
return fetchInFlightRef.current;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
if (showLoader) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
// Загрузка начальных данных
|
||||
useEffect(() => {
|
||||
async function fetchNotifications() {
|
||||
try {
|
||||
const data = await apiClient.getNotifications({
|
||||
page_size: 50,
|
||||
unread_only: true
|
||||
const data = await apiClient.getNotifications({
|
||||
page_size: 50,
|
||||
unread_only: true,
|
||||
});
|
||||
const notifs = data.notifications || data.results || data;
|
||||
const notificationsArray = Array.isArray(notifs) ? notifs : [];
|
||||
setNotifications(notificationsArray);
|
||||
setUnreadCount(data.unread_count ?? notificationsArray.length);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
setNotifications([]);
|
||||
if (showLoader) {
|
||||
setNotifications([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoader) {
|
||||
setLoading(false);
|
||||
}
|
||||
fetchInFlightRef.current = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
fetchNotifications();
|
||||
fetchInFlightRef.current = request;
|
||||
return request;
|
||||
}, []);
|
||||
|
||||
// Загрузка начальных данных
|
||||
useEffect(() => {
|
||||
void syncUnreadNotifications(true);
|
||||
}, [syncUnreadNotifications]);
|
||||
|
||||
// ЕДИНСТВЕННЫЙ WebSocket через singleton manager
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
@ -54,107 +102,134 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
return;
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
const unsubscribeMessages = wsManager.subscribe((event) => {
|
||||
const data = event as NotificationSocketEvent;
|
||||
|
||||
const setupWebSocket = () => {
|
||||
// Подписываемся на сообщения через singleton
|
||||
unsubscribe = wsManager.subscribe((data) => {
|
||||
// Игнорируем служебные события
|
||||
if (['ping', 'list_update', 'new_message', 'marked_read', 'message_edited', 'reaction_added', 'reaction_removed', 'poll_update'].includes(data.type)) {
|
||||
return;
|
||||
}
|
||||
if (['ping', 'list_update', 'new_message', 'marked_read', 'message_edited', 'reaction_added', 'reaction_removed', 'poll_update'].includes(data.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Новое уведомление
|
||||
if (data.type === 'notification' && data.notification) {
|
||||
console.log('[NotificationsContext] New notification:', data.notification);
|
||||
|
||||
setNotifications(prev => {
|
||||
if (prev.some(n => n.id === data.notification.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [data.notification, ...prev];
|
||||
});
|
||||
const incomingNotification = data.notification;
|
||||
|
||||
if (!data.notification.is_read) {
|
||||
setUnreadCount(prev => prev + 1);
|
||||
if (data.type === 'notification' && incomingNotification) {
|
||||
setNotifications(prev => {
|
||||
if (prev.some(n => n.id === incomingNotification.id)) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
return [incomingNotification, ...prev];
|
||||
});
|
||||
|
||||
// Обновление счетчика
|
||||
if (data.type === 'unread_count' && typeof data.count === 'number') {
|
||||
console.log('[NotificationsContext] Unread count update:', data.count);
|
||||
setUnreadCount(data.count);
|
||||
const isRead = incomingNotification.is_read ?? !incomingNotification.unread;
|
||||
if (!isRead) {
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
setupWebSocket();
|
||||
if (data.type === 'notification_read' && typeof data.notification_id === 'number') {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === data.notification_id ? { ...n, is_read: true, unread: false } : n))
|
||||
);
|
||||
if (typeof data.unread_count === 'number') {
|
||||
setUnreadCount(data.unread_count);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'notifications_read_all') {
|
||||
const ids = Array.isArray(data.notification_ids) ? new Set<number>(data.notification_ids) : null;
|
||||
setNotifications(prev =>
|
||||
prev.map((notification) => {
|
||||
if (!ids || ids.has(notification.id)) {
|
||||
return { ...notification, is_read: true, unread: false };
|
||||
}
|
||||
return notification;
|
||||
})
|
||||
);
|
||||
if (typeof data.unread_count === 'number') {
|
||||
setUnreadCount(data.unread_count);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'unread_count' && typeof data.count === 'number') {
|
||||
setUnreadCount(data.count);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeStatus = wsManager.subscribeStatus((status) => {
|
||||
setIsWsConnected(status.isConnected);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribeMessages();
|
||||
unsubscribeStatus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// API fallback на время разрыва WebSocket и при возврате фокуса
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || isWsConnected || !localStorage.getItem('access_token')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
void syncUnreadNotifications(false);
|
||||
};
|
||||
|
||||
sync();
|
||||
|
||||
const intervalId = window.setInterval(sync, 15000);
|
||||
const handleFocus = () => sync();
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
sync();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Синхронизация между вкладками через CustomEvent
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleMarkedRead = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const { id } = customEvent.detail;
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const handleAllMarkedRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
window.addEventListener('notification-marked-read', handleMarkedRead);
|
||||
window.addEventListener('all-notifications-marked-read', handleAllMarkedRead);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('notification-marked-read', handleMarkedRead);
|
||||
window.removeEventListener('all-notifications-marked-read', handleAllMarkedRead);
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}, [isWsConnected, syncUnreadNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWsConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
void syncUnreadNotifications(false);
|
||||
}, [isWsConnected, syncUnreadNotifications]);
|
||||
|
||||
const markAsRead = useCallback(async (id: number) => {
|
||||
await apiClient.markNotificationAsRead(id);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
|
||||
prev.map(n => (n.id === id ? { ...n, is_read: true, unread: false } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('notification-marked-read', { detail: { id } }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
await apiClient.markAllNotificationsAsRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true, unread: false })));
|
||||
setUnreadCount(0);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('all-notifications-marked-read'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markCategoryAsRead = useCallback(async (category: string) => {
|
||||
await apiClient.markCategoryAsRead(category);
|
||||
const updatedNotifications = notifications.map(n =>
|
||||
n.category === category ? { ...n, is_read: true } : n
|
||||
);
|
||||
setNotifications(updatedNotifications);
|
||||
const newUnreadCount = updatedNotifications.filter(n => !n.is_read).length;
|
||||
setUnreadCount(newUnreadCount);
|
||||
}, [notifications]);
|
||||
setNotifications(prev => {
|
||||
const updatedNotifications = prev.map(n =>
|
||||
n.category === category ? { ...n, is_read: true, unread: false } : n
|
||||
);
|
||||
const newUnreadCount = updatedNotifications.filter(n => !n.is_read && n.unread !== false).length;
|
||||
setUnreadCount(newUnreadCount);
|
||||
return updatedNotifications;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteNotification = useCallback(async (id: number) => {
|
||||
await apiClient.deleteNotification(id);
|
||||
|
||||
209
frontend/src/hooks/useCalendarModals.ts
Normal file
209
frontend/src/hooks/useCalendarModals.ts
Normal file
@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { CalendarEvent } from "@/services/calendarService";
|
||||
|
||||
export interface CalendarModalsState {
|
||||
showCalendarModal: boolean;
|
||||
showEventModal: boolean;
|
||||
showParticipantsModal: boolean;
|
||||
showDayEventsModal: boolean;
|
||||
showEventDetailsModal: boolean;
|
||||
editingCalendar: { id?: number; name: string } | null;
|
||||
editingEvent: CalendarEvent | null;
|
||||
participantsCalendar: { id: number; name: string; user_role?: string } | null;
|
||||
selectedDateForModal: Date | null;
|
||||
viewingEvent: CalendarEvent | null;
|
||||
sidebarEvents: CalendarEvent[];
|
||||
currentSelectedCalendarId: number | null;
|
||||
eventsRefreshTrigger: number;
|
||||
}
|
||||
|
||||
export function useCalendarModals() {
|
||||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showParticipantsModal, setShowParticipantsModal] = useState(false);
|
||||
const [showDayEventsModal, setShowDayEventsModal] = useState(false);
|
||||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||||
|
||||
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
|
||||
const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null);
|
||||
const [selectedDateForModal, setSelectedDateForModal] = useState<Date | null>(null);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||||
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
|
||||
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(null);
|
||||
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
|
||||
|
||||
const handleSetSidebarEvents = useCallback((events: CalendarEvent[]) => {
|
||||
setSidebarEvents(events);
|
||||
}, []);
|
||||
|
||||
const handleCalendarChange = useCallback((calendarId: number | null) => {
|
||||
setCurrentSelectedCalendarId(calendarId);
|
||||
}, []);
|
||||
|
||||
const handleSetEventsRefreshTrigger = useCallback((value: number | ((prev: number) => number)) => {
|
||||
setEventsRefreshTrigger(value);
|
||||
}, []);
|
||||
|
||||
const handleOpenCalendarModal = useCallback((calendar?: { id?: number; name: string }) => {
|
||||
setEditingCalendar(calendar || { name: "" });
|
||||
setShowCalendarModal(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenEventModal = useCallback((event: Partial<CalendarEvent> & { id?: number }, date?: Date) => {
|
||||
if (date && !event.id) {
|
||||
setSelectedDateForModal(date);
|
||||
setShowDayEventsModal(true);
|
||||
} else if (event.id) {
|
||||
setViewingEvent(event as CalendarEvent);
|
||||
setShowEventDetailsModal(true);
|
||||
} else {
|
||||
setEditingEvent(event as CalendarEvent);
|
||||
setShowEventModal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreateEventFromDay = useCallback(() => {
|
||||
if (!currentSelectedCalendarId) {
|
||||
alert("Сначала выберите календарь");
|
||||
return;
|
||||
}
|
||||
if (!selectedDateForModal) return;
|
||||
|
||||
const startDate = new Date(selectedDateForModal);
|
||||
startDate.setHours(10, 0, 0, 0);
|
||||
const endDate = new Date(selectedDateForModal);
|
||||
endDate.setHours(11, 0, 0, 0);
|
||||
|
||||
setEditingEvent({
|
||||
title: "",
|
||||
description: "",
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
calendar: currentSelectedCalendarId,
|
||||
color_event: "#3498db",
|
||||
} as CalendarEvent);
|
||||
|
||||
setShowDayEventsModal(false);
|
||||
setShowEventModal(true);
|
||||
}, [currentSelectedCalendarId, selectedDateForModal]);
|
||||
|
||||
const handleEditFromDetails = useCallback(() => {
|
||||
setEditingEvent(viewingEvent);
|
||||
setShowEventDetailsModal(false);
|
||||
setShowEventModal(true);
|
||||
}, [viewingEvent]);
|
||||
|
||||
const handleEventClickFromDay = useCallback(async (event: CalendarEvent & { is_recurring?: boolean; event_id?: number }) => {
|
||||
setShowDayEventsModal(false);
|
||||
|
||||
if (event.is_recurring && event.event_id) {
|
||||
try {
|
||||
const fullEvent = await apiClient.getEvent(event.event_id);
|
||||
setViewingEvent({
|
||||
...fullEvent,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
});
|
||||
setShowEventDetailsModal(true);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки события:", err);
|
||||
}
|
||||
} else {
|
||||
setViewingEvent(event);
|
||||
setShowEventDetailsModal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEventSaved = useCallback(() => {
|
||||
setEventsRefreshTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handleOpenParticipantsModal = useCallback((calendar: { id: number; name: string; user_role?: string }) => {
|
||||
setParticipantsCalendar(calendar);
|
||||
setShowParticipantsModal(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteEvent = useCallback(async () => {
|
||||
if (!viewingEvent?.id) return;
|
||||
if (!confirm("Удалить это событие?")) return;
|
||||
|
||||
try {
|
||||
await apiClient.deleteEvent(viewingEvent.id);
|
||||
setShowEventDetailsModal(false);
|
||||
setViewingEvent(null);
|
||||
setEventsRefreshTrigger(prev => prev + 1);
|
||||
} catch (err) {
|
||||
console.error("Ошибка удаления события:", err);
|
||||
alert("Не удалось удалить событие");
|
||||
}
|
||||
}, [viewingEvent]);
|
||||
|
||||
const closeCalendarModal = useCallback(() => {
|
||||
setShowCalendarModal(false);
|
||||
setEditingCalendar(null);
|
||||
}, []);
|
||||
|
||||
const closeParticipantsModal = useCallback(() => {
|
||||
setShowParticipantsModal(false);
|
||||
setParticipantsCalendar(null);
|
||||
}, []);
|
||||
|
||||
const closeEventModal = useCallback(() => {
|
||||
setShowEventModal(false);
|
||||
setEditingEvent(null);
|
||||
}, []);
|
||||
|
||||
const closeDayEventsModal = useCallback(() => {
|
||||
setShowDayEventsModal(false);
|
||||
setSelectedDateForModal(null);
|
||||
}, []);
|
||||
|
||||
const closeEventDetailsModal = useCallback(() => {
|
||||
setShowEventDetailsModal(false);
|
||||
setViewingEvent(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State for CalendarSidebar/CalendarCard props
|
||||
eventsRefreshTrigger,
|
||||
handleSetEventsRefreshTrigger,
|
||||
handleSetSidebarEvents,
|
||||
handleCalendarChange,
|
||||
|
||||
// Handlers for opening modals
|
||||
handleOpenCalendarModal,
|
||||
handleOpenEventModal,
|
||||
handleOpenParticipantsModal,
|
||||
|
||||
// Modal state and close handlers
|
||||
showCalendarModal,
|
||||
editingCalendar,
|
||||
closeCalendarModal,
|
||||
|
||||
showParticipantsModal,
|
||||
participantsCalendar,
|
||||
closeParticipantsModal,
|
||||
|
||||
showEventModal,
|
||||
editingEvent,
|
||||
closeEventModal,
|
||||
handleEventSaved,
|
||||
|
||||
showDayEventsModal,
|
||||
selectedDateForModal,
|
||||
closeDayEventsModal,
|
||||
sidebarEvents,
|
||||
handleEventClickFromDay,
|
||||
handleCreateEventFromDay,
|
||||
|
||||
showEventDetailsModal,
|
||||
viewingEvent,
|
||||
closeEventDetailsModal,
|
||||
handleEditFromDetails,
|
||||
handleDeleteEvent,
|
||||
};
|
||||
}
|
||||
71
frontend/src/hooks/useChatFallbackSync.ts
Normal file
71
frontend/src/hooks/useChatFallbackSync.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
type SyncLatestMessagesOptions = {
|
||||
limit?: number;
|
||||
scrollOnSync?: boolean;
|
||||
};
|
||||
|
||||
type UseChatFallbackSyncOptions = {
|
||||
chatId: number | null;
|
||||
isConnected: boolean;
|
||||
userLoading: boolean;
|
||||
hasUser: boolean;
|
||||
messagesLoading: boolean;
|
||||
hasMessages: boolean;
|
||||
isNearBottom: () => boolean;
|
||||
syncLatestMessages: (options?: SyncLatestMessagesOptions) => Promise<void>;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
export function useChatFallbackSync({
|
||||
chatId,
|
||||
isConnected,
|
||||
userLoading,
|
||||
hasUser,
|
||||
messagesLoading,
|
||||
hasMessages,
|
||||
isNearBottom,
|
||||
syncLatestMessages,
|
||||
intervalMs = 10000,
|
||||
}: UseChatFallbackSyncOptions) {
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
isConnected ||
|
||||
!chatId ||
|
||||
Number.isNaN(chatId) ||
|
||||
userLoading ||
|
||||
!hasUser ||
|
||||
messagesLoading ||
|
||||
!hasMessages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
void syncLatestMessages({
|
||||
limit: 20,
|
||||
scrollOnSync: isNearBottom(),
|
||||
});
|
||||
};
|
||||
|
||||
sync();
|
||||
|
||||
const intervalId = window.setInterval(sync, intervalMs);
|
||||
const handleFocus = () => sync();
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
sync();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [chatId, hasMessages, hasUser, intervalMs, isConnected, isNearBottom, messagesLoading, syncLatestMessages, userLoading]);
|
||||
}
|
||||
868
frontend/src/hooks/useEquipmentPage.ts
Normal file
868
frontend/src/hooks/useEquipmentPage.ts
Normal file
@ -0,0 +1,868 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { canManageEquipment } from "@/lib/permissions";
|
||||
import { displayUserName as sharedDisplayUserName, extractNextPage, formatDate, formatMoney, loadAllPages } from "@/lib/shared";
|
||||
import type {
|
||||
Department,
|
||||
Equipment,
|
||||
EquipmentCategory,
|
||||
EquipmentComment,
|
||||
EquipmentCreateOptions,
|
||||
EquipmentTransferHistoryEntry,
|
||||
MaintenanceRecord,
|
||||
User,
|
||||
} from "@/types/api";
|
||||
|
||||
type EquipmentFormState = {
|
||||
name: string;
|
||||
notes: string;
|
||||
category: number | null;
|
||||
department: number | null;
|
||||
responsible_person: number | null;
|
||||
quantity: number;
|
||||
serial_number: string;
|
||||
purchase_date: string;
|
||||
purchase_cost: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
type EquipmentListMode = "all" | "mine" | "warranty";
|
||||
|
||||
type OperationModal = "transfer" | "writeoff" | "maintenance" | null;
|
||||
|
||||
type TransferFormState = {
|
||||
to_department: number | null;
|
||||
to_person: number | null;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type MaintenanceFormState = {
|
||||
type: string;
|
||||
description: string;
|
||||
cost: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
type PaginatedLike<T> = {
|
||||
results?: T[];
|
||||
next?: string | null;
|
||||
};
|
||||
|
||||
const emptyForm: EquipmentFormState = {
|
||||
name: "",
|
||||
notes: "",
|
||||
category: null,
|
||||
department: null,
|
||||
responsible_person: null,
|
||||
quantity: 1,
|
||||
serial_number: "",
|
||||
purchase_date: "",
|
||||
purchase_cost: "",
|
||||
location: "",
|
||||
};
|
||||
|
||||
const createInitialMaintenanceForm = (): MaintenanceFormState => ({
|
||||
type: "repair",
|
||||
description: "",
|
||||
cost: "",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
const getPaginatedResults = <T,>(response: unknown): T[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response as T[];
|
||||
}
|
||||
|
||||
if (response && typeof response === "object") {
|
||||
const results = (response as PaginatedLike<T>).results;
|
||||
if (Array.isArray(results)) {
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const getPaginatedNext = (response: unknown): number | null => {
|
||||
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return extractNextPage((response as PaginatedLike<unknown>).next ?? null);
|
||||
};
|
||||
|
||||
const getReadableError = (error: unknown, fallback: string): string => {
|
||||
const raw = String((error as Error)?.message || fallback);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return Object.entries(parsed)
|
||||
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`)
|
||||
.join(". ");
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
|
||||
export function useEquipmentPage(user: User | null) {
|
||||
const canManage = canManageEquipment(user);
|
||||
const auth = user?.auth;
|
||||
|
||||
const [items, setItems] = useState<Equipment[]>([]);
|
||||
const [detailsMap, setDetailsMap] = useState<Record<number, Equipment>>({});
|
||||
const [transferHistoryMap, setTransferHistoryMap] = useState<Record<number, EquipmentTransferHistoryEntry[]>>({});
|
||||
const [maintenanceMap, setMaintenanceMap] = useState<Record<number, MaintenanceRecord[]>>({});
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [categories, setCategories] = useState<EquipmentCategory[]>([]);
|
||||
const [createOptions, setCreateOptions] = useState<EquipmentCreateOptions | null>(null);
|
||||
const [previewInventoryNumber, setPreviewInventoryNumber] = useState("");
|
||||
const [commentsMap, setCommentsMap] = useState<Record<number, EquipmentComment[]>>({});
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
|
||||
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
|
||||
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
|
||||
const [loadingRowDetails, setLoadingRowDetails] = useState<Record<number, boolean>>({});
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<EquipmentFormState>(emptyForm);
|
||||
const [listMode, setListMode] = useState<EquipmentListMode>("all");
|
||||
const [ordering, setOrdering] = useState("-created_at");
|
||||
const [operationModal, setOperationModal] = useState<OperationModal>(null);
|
||||
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null);
|
||||
const [writeOffReason, setWriteOffReason] = useState("");
|
||||
const [transferForm, setTransferForm] = useState<TransferFormState>({ to_department: null, to_person: null, reason: "" });
|
||||
const [maintenanceForm, setMaintenanceForm] = useState<MaintenanceFormState>(createInitialMaintenanceForm);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [departmentFilter, setDepartmentFilter] = useState("");
|
||||
const [responsibleFilter, setResponsibleFilter] = useState("");
|
||||
const [dateFromFilter, setDateFromFilter] = useState("");
|
||||
const [dateToFilter, setDateToFilter] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [nextPage, setNextPage] = useState<number | null>(null);
|
||||
|
||||
const isCreateMode = createOpen && editingId === null;
|
||||
|
||||
const displayUserName = useCallback((person?: User | null) => sharedDisplayUserName(person), []);
|
||||
|
||||
const buildParams = useCallback(
|
||||
(page: number): Record<string, string | number> => {
|
||||
const params: Record<string, string | number> = { page };
|
||||
if (deferredSearchQuery.trim()) params.search = deferredSearchQuery.trim();
|
||||
if (ordering) params.ordering = ordering;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (categoryFilter) params.category = categoryFilter;
|
||||
if (departmentFilter) params.department = departmentFilter;
|
||||
if (responsibleFilter) params.responsible_person = responsibleFilter;
|
||||
if (dateFromFilter) params.purchase_date__gte = dateFromFilter;
|
||||
if (dateToFilter) params.purchase_date__lte = dateToFilter;
|
||||
return params;
|
||||
},
|
||||
[categoryFilter, dateFromFilter, dateToFilter, deferredSearchQuery, departmentFilter, ordering, responsibleFilter, statusFilter],
|
||||
);
|
||||
|
||||
const fetchEquipmentPage = useCallback(
|
||||
async (page: number): Promise<unknown> => {
|
||||
if (listMode === "mine") {
|
||||
return apiClient.getMyEquipment({ page });
|
||||
}
|
||||
|
||||
if (listMode === "warranty") {
|
||||
if (page > 1) {
|
||||
return { results: [], next: null };
|
||||
}
|
||||
return apiClient.getWarrantyExpiringEquipment();
|
||||
}
|
||||
|
||||
return apiClient.getEquipment(buildParams(page));
|
||||
},
|
||||
[buildParams, listMode],
|
||||
);
|
||||
|
||||
const reloadEquipmentList = useCallback(async () => {
|
||||
const response = await fetchEquipmentPage(1);
|
||||
setItems(getPaginatedResults<Equipment>(response));
|
||||
setNextPage(getPaginatedNext(response));
|
||||
}, [fetchEquipmentPage]);
|
||||
|
||||
const getCategoryName = useCallback(
|
||||
(equipment: Equipment): string => {
|
||||
if (equipment.category_name) return equipment.category_name;
|
||||
const category = categories.find((item) => item.id === Number(equipment.category));
|
||||
return category?.name || "—";
|
||||
},
|
||||
[categories],
|
||||
);
|
||||
|
||||
const getDepartmentName = useCallback(
|
||||
(equipment: Equipment): string => {
|
||||
if (equipment.department_name) return equipment.department_name;
|
||||
const department = departments.find((item) => item.id === Number(equipment.department));
|
||||
return department?.name || "—";
|
||||
},
|
||||
[departments],
|
||||
);
|
||||
|
||||
const getResponsibleName = useCallback(
|
||||
(equipment: Equipment): string => {
|
||||
if (equipment.responsible_name) return equipment.responsible_name;
|
||||
if (equipment.responsible_person && typeof equipment.responsible_person === "number") {
|
||||
const employee = employees.find((item) => item.id === equipment.responsible_person);
|
||||
return employee ? displayUserName(employee) : "—";
|
||||
}
|
||||
return "—";
|
||||
},
|
||||
[displayUserName, employees],
|
||||
);
|
||||
|
||||
const getResponsibleLink = useCallback(
|
||||
(equipment: Equipment): string => {
|
||||
if (!equipment.responsible_person || typeof equipment.responsible_person !== "number") return "";
|
||||
if (equipment.responsible_person === user?.id) return "/profile";
|
||||
return `/users/${equipment.responsible_person}`;
|
||||
},
|
||||
[user?.id],
|
||||
);
|
||||
|
||||
const getEquipmentMeta = useCallback(
|
||||
(equipment: Equipment) => ([
|
||||
{ label: "Категория", value: getCategoryName(equipment) },
|
||||
{ label: "Отдел", value: getDepartmentName(equipment) },
|
||||
{ label: "Ответственный", value: getResponsibleName(equipment) },
|
||||
{ label: "Серийный номер", value: equipment.serial_number || "—" },
|
||||
{ label: "Расположение", value: equipment.location || "—" },
|
||||
{ label: "Гарантия до", value: formatDate(equipment.warranty_until) || "—" },
|
||||
{ label: "Обслуживаний", value: String(equipment.maintenance_count ?? "—") },
|
||||
{ label: "Стоимость", value: formatMoney(equipment.purchase_cost) },
|
||||
{ label: "Добавлено", value: formatDate(equipment.created_at) || "—" },
|
||||
]),
|
||||
[getCategoryName, getDepartmentName, getResponsibleName],
|
||||
);
|
||||
|
||||
const filteredDepartmentsForForm = useMemo(() => {
|
||||
if (!createOptions || !isCreateMode || createOptions.allowed_departments.length === 0) {
|
||||
return departments;
|
||||
}
|
||||
|
||||
const allowedIds = new Set(createOptions.allowed_departments.map((department) => department.id));
|
||||
return departments.filter((department) => allowedIds.has(department.id));
|
||||
}, [createOptions, departments, isCreateMode]);
|
||||
|
||||
const filteredEmployeesForForm = useMemo(() => {
|
||||
if (!isCreateMode || !createOptions || createOptions.can_choose_responsible) {
|
||||
return employees;
|
||||
}
|
||||
if (!createOptions.default_responsible) {
|
||||
return [];
|
||||
}
|
||||
return employees.filter((employee) => employee.id === createOptions.default_responsible?.id);
|
||||
}, [createOptions, employees, isCreateMode]);
|
||||
|
||||
const applyCreateDefaults = useCallback((options: EquipmentCreateOptions | null) => {
|
||||
if (!options) return;
|
||||
|
||||
setForm((previous) => {
|
||||
const nextDepartment = options.can_choose_department
|
||||
? previous.department
|
||||
: options.allowed_departments[0]?.id ?? previous.department;
|
||||
const nextResponsible = options.can_choose_responsible
|
||||
? previous.responsible_person
|
||||
: options.default_responsible?.id ?? previous.responsible_person;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
department: nextDepartment,
|
||||
responsible_person: nextResponsible,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadRowDetails = useCallback(async (equipmentId: number) => {
|
||||
if (loadingRowDetails[equipmentId]) return;
|
||||
|
||||
try {
|
||||
setLoadingRowDetails((previous) => ({ ...previous, [equipmentId]: true }));
|
||||
const results = await Promise.allSettled([
|
||||
detailsMap[equipmentId] ? Promise.resolve(detailsMap[equipmentId]) : apiClient.getEquipmentDetail(equipmentId),
|
||||
transferHistoryMap[equipmentId] ? Promise.resolve(transferHistoryMap[equipmentId]) : apiClient.getEquipmentTransferHistory(equipmentId),
|
||||
maintenanceMap[equipmentId] ? Promise.resolve(maintenanceMap[equipmentId]) : apiClient.getMaintenanceRecords({ equipment: equipmentId }),
|
||||
]);
|
||||
|
||||
const [detailResult, transferResult, maintenanceResult] = results;
|
||||
|
||||
if (detailResult.status === "fulfilled") {
|
||||
setDetailsMap((previous) => ({ ...previous, [equipmentId]: detailResult.value as Equipment }));
|
||||
}
|
||||
|
||||
if (transferResult.status === "fulfilled") {
|
||||
setTransferHistoryMap((previous) => ({
|
||||
...previous,
|
||||
[equipmentId]: getPaginatedResults<EquipmentTransferHistoryEntry>(transferResult.value),
|
||||
}));
|
||||
}
|
||||
|
||||
if (maintenanceResult.status === "fulfilled") {
|
||||
setMaintenanceMap((previous) => ({
|
||||
...previous,
|
||||
[equipmentId]: getPaginatedResults<MaintenanceRecord>(maintenanceResult.value),
|
||||
}));
|
||||
}
|
||||
} catch (loadError) {
|
||||
console.error("Ошибка загрузки деталей оборудования:", loadError);
|
||||
} finally {
|
||||
setLoadingRowDetails((previous) => ({ ...previous, [equipmentId]: false }));
|
||||
}
|
||||
}, [detailsMap, loadingRowDetails, maintenanceMap, transferHistoryMap]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setForm(emptyForm);
|
||||
}, []);
|
||||
|
||||
const openCreateModal = useCallback(async () => {
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
setCreateOpen(true);
|
||||
|
||||
try {
|
||||
const [options, inventoryPreview] = await Promise.all([
|
||||
apiClient.getEquipmentCreateOptions(),
|
||||
apiClient.generateEquipmentInventoryNumber(),
|
||||
]);
|
||||
setCreateOptions(options);
|
||||
setPreviewInventoryNumber(inventoryPreview.inventory_number || "");
|
||||
applyCreateDefaults(options);
|
||||
} catch (loadError) {
|
||||
console.error("Ошибка загрузки опций создания оборудования:", loadError);
|
||||
}
|
||||
}, [applyCreateDefaults, resetForm]);
|
||||
|
||||
const openEdit = useCallback((equipment: Equipment) => {
|
||||
setEditingId(equipment.id);
|
||||
setCreateOpen(false);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
setPreviewInventoryNumber(equipment.inventory_number || "");
|
||||
setForm({
|
||||
name: equipment.name || "",
|
||||
notes: equipment.notes || "",
|
||||
category: equipment.category ?? null,
|
||||
department: equipment.department ?? null,
|
||||
responsible_person: typeof equipment.responsible_person === "number" ? equipment.responsible_person : null,
|
||||
quantity: 1,
|
||||
serial_number: equipment.serial_number || "",
|
||||
purchase_date: equipment.purchase_date || "",
|
||||
purchase_cost: String(equipment.purchase_cost || ""),
|
||||
location: equipment.location || "",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openOperationModal = useCallback((type: Exclude<OperationModal, null>, equipment: Equipment) => {
|
||||
setSelectedEquipment(equipment);
|
||||
setOperationModal(type);
|
||||
setActionError(null);
|
||||
|
||||
if (type === "transfer") {
|
||||
setTransferForm({
|
||||
to_department: equipment.department ?? null,
|
||||
to_person: typeof equipment.responsible_person === "number" ? equipment.responsible_person : null,
|
||||
reason: "",
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "writeoff") {
|
||||
setWriteOffReason("");
|
||||
}
|
||||
|
||||
if (type === "maintenance") {
|
||||
setMaintenanceForm(createInitialMaintenanceForm());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeOperationModal = useCallback(() => {
|
||||
setOperationModal(null);
|
||||
setSelectedEquipment(null);
|
||||
setWriteOffReason("");
|
||||
setTransferForm({ to_department: null, to_person: null, reason: "" });
|
||||
setMaintenanceForm(createInitialMaintenanceForm());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetchEquipmentPage(1);
|
||||
if (cancelled) return;
|
||||
setItems(getPaginatedResults<Equipment>(response));
|
||||
setNextPage(getPaginatedNext(response));
|
||||
} catch (loadError) {
|
||||
if (cancelled) return;
|
||||
console.error("Ошибка загрузки оборудования:", loadError);
|
||||
setError("Не удалось загрузить оборудование");
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchEquipmentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [allEmployees, allDepartments, allCategories] = await Promise.all([
|
||||
loadAllPages<User>((page) => apiClient.getEmployees(page)),
|
||||
loadAllPages<Department>((page) => apiClient.getDepartments(page)),
|
||||
loadAllPages<EquipmentCategory>((page) => apiClient.getEquipmentCategories(page)),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
setEmployees(allEmployees);
|
||||
setDepartments(allDepartments);
|
||||
setCategories(allCategories);
|
||||
} catch (loadError) {
|
||||
if (!cancelled) {
|
||||
console.error("Ошибка загрузки справочников:", loadError);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const options = await apiClient.getEquipmentCreateOptions();
|
||||
if (!cancelled) {
|
||||
setCreateOptions(options);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (!cancelled) {
|
||||
console.error("Ошибка загрузки create options:", loadError);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sortItemsLocally = useCallback((source: Equipment[]) => {
|
||||
const sorted = [...source];
|
||||
|
||||
sorted.sort((left, right) => {
|
||||
switch (ordering) {
|
||||
case "name":
|
||||
return (left.name || "").localeCompare(right.name || "", "ru");
|
||||
case "purchase_date":
|
||||
return new Date(left.purchase_date || 0).getTime() - new Date(right.purchase_date || 0).getTime();
|
||||
case "-purchase_date":
|
||||
return new Date(right.purchase_date || 0).getTime() - new Date(left.purchase_date || 0).getTime();
|
||||
case "created_at":
|
||||
return new Date(left.created_at || 0).getTime() - new Date(right.created_at || 0).getTime();
|
||||
case "-created_at":
|
||||
default:
|
||||
return new Date(right.created_at || 0).getTime() - new Date(left.created_at || 0).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [ordering]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
let scopedItems = [...items];
|
||||
|
||||
if (listMode !== "all") {
|
||||
scopedItems = scopedItems.filter((item) => {
|
||||
const itemDate = item.purchase_date || "";
|
||||
if (statusFilter && item.status !== statusFilter) return false;
|
||||
if (categoryFilter && Number(item.category) !== Number(categoryFilter)) return false;
|
||||
if (departmentFilter && Number(item.department) !== Number(departmentFilter)) return false;
|
||||
if (responsibleFilter && Number(item.responsible_person) !== Number(responsibleFilter)) return false;
|
||||
if (dateFromFilter && itemDate && itemDate < dateFromFilter) return false;
|
||||
if (dateToFilter && itemDate && itemDate > dateToFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = listMode === "all" ? items : sortItemsLocally(scopedItems);
|
||||
if (!query) return sorted;
|
||||
|
||||
return sorted.filter((item) => {
|
||||
const name = (item.name || "").toLowerCase();
|
||||
const notes = (item.notes || "").toLowerCase();
|
||||
const serialNumber = (item.serial_number || "").toLowerCase();
|
||||
const inventoryNumber = (item.inventory_number || "").toLowerCase();
|
||||
const responsible = getResponsibleName(item).toLowerCase();
|
||||
const category = getCategoryName(item).toLowerCase();
|
||||
|
||||
return (
|
||||
name.includes(query)
|
||||
|| notes.includes(query)
|
||||
|| serialNumber.includes(query)
|
||||
|| inventoryNumber.includes(query)
|
||||
|| responsible.includes(query)
|
||||
|| category.includes(query)
|
||||
);
|
||||
});
|
||||
}, [
|
||||
categoryFilter,
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
departmentFilter,
|
||||
getCategoryName,
|
||||
getResponsibleName,
|
||||
items,
|
||||
listMode,
|
||||
responsibleFilter,
|
||||
searchQuery,
|
||||
sortItemsLocally,
|
||||
statusFilter,
|
||||
]);
|
||||
|
||||
const handleSave = useCallback(async (mode: "create" | "edit") => {
|
||||
try {
|
||||
setBusyKey(`${mode}-save`);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
|
||||
if (!form.name.trim()) {
|
||||
setActionError("Укажите название оборудования.");
|
||||
return;
|
||||
}
|
||||
if (!form.category) {
|
||||
setActionError("Выберите категорию.");
|
||||
return;
|
||||
}
|
||||
if (!form.department) {
|
||||
setActionError("Выберите отдел.");
|
||||
return;
|
||||
}
|
||||
if (!form.purchase_date) {
|
||||
setActionError("Укажите дату покупки.");
|
||||
return;
|
||||
}
|
||||
if (!form.purchase_cost) {
|
||||
setActionError("Укажите стоимость.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
notes: form.notes,
|
||||
category: form.category,
|
||||
department: form.department,
|
||||
purchase_date: form.purchase_date,
|
||||
purchase_cost: form.purchase_cost,
|
||||
};
|
||||
|
||||
if (form.responsible_person) payload.responsible_person = form.responsible_person;
|
||||
if (mode === "create" && form.quantity > 1) payload.quantity = form.quantity;
|
||||
if (form.serial_number) payload.serial_number = form.serial_number;
|
||||
if (form.location) payload.location = form.location;
|
||||
|
||||
if (mode === "create") {
|
||||
await apiClient.createEquipment(payload);
|
||||
setActionSuccess("Оборудование добавлено.");
|
||||
setCreateOpen(false);
|
||||
} else if (editingId) {
|
||||
await apiClient.updateEquipment(editingId, payload);
|
||||
setActionSuccess("Оборудование обновлено.");
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
await reloadEquipmentList();
|
||||
} catch (saveError) {
|
||||
setActionError(getReadableError(saveError, "Не удалось сохранить"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [editingId, form, reloadEquipmentList, resetForm]);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!nextPage || loadingMore) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const response = await fetchEquipmentPage(nextPage);
|
||||
const chunk = getPaginatedResults<Equipment>(response);
|
||||
setItems((previous) => {
|
||||
const known = new Set(previous.map((item) => item.id));
|
||||
return [...previous, ...chunk.filter((item) => !known.has(item.id))];
|
||||
});
|
||||
setNextPage(getPaginatedNext(response));
|
||||
} catch {
|
||||
setError("Не удалось загрузить ещё");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [fetchEquipmentPage, loadingMore, nextPage]);
|
||||
|
||||
const handleDelete = useCallback(async (id: number) => {
|
||||
if (!window.confirm("Удалить это оборудование?")) return;
|
||||
|
||||
try {
|
||||
setBusyKey(`delete-${id}`);
|
||||
await apiClient.deleteEquipment(id);
|
||||
setItems((previous) => previous.filter((item) => item.id !== id));
|
||||
} catch {
|
||||
setActionError("Не удалось удалить");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleRow = useCallback((equipmentId: number) => {
|
||||
const nextOpen = !expandedRows[equipmentId];
|
||||
setExpandedRows((previous) => ({ ...previous, [equipmentId]: nextOpen }));
|
||||
if (nextOpen) {
|
||||
void loadRowDetails(equipmentId);
|
||||
}
|
||||
}, [expandedRows, loadRowDetails]);
|
||||
|
||||
const handleTransfer = useCallback(async () => {
|
||||
if (!selectedEquipment) return;
|
||||
|
||||
try {
|
||||
setBusyKey(`transfer-${selectedEquipment.id}`);
|
||||
await apiClient.transferEquipment(selectedEquipment.id, {
|
||||
to_department: transferForm.to_department,
|
||||
to_person: transferForm.to_person,
|
||||
reason: transferForm.reason,
|
||||
});
|
||||
setActionSuccess("Оборудование переведено.");
|
||||
closeOperationModal();
|
||||
await reloadEquipmentList();
|
||||
await loadRowDetails(selectedEquipment.id);
|
||||
} catch (transferError) {
|
||||
setActionError(String((transferError as Error)?.message || "Не удалось выполнить перевод"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [closeOperationModal, loadRowDetails, reloadEquipmentList, selectedEquipment, transferForm]);
|
||||
|
||||
const handleWriteOff = useCallback(async () => {
|
||||
if (!selectedEquipment) return;
|
||||
|
||||
try {
|
||||
setBusyKey(`writeoff-${selectedEquipment.id}`);
|
||||
await apiClient.writeOffEquipment(selectedEquipment.id, writeOffReason);
|
||||
setActionSuccess("Оборудование списано.");
|
||||
closeOperationModal();
|
||||
await reloadEquipmentList();
|
||||
await loadRowDetails(selectedEquipment.id);
|
||||
} catch (writeOffError) {
|
||||
setActionError(String((writeOffError as Error)?.message || "Не удалось списать оборудование"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [closeOperationModal, loadRowDetails, reloadEquipmentList, selectedEquipment, writeOffReason]);
|
||||
|
||||
const handleMaintenance = useCallback(async () => {
|
||||
if (!selectedEquipment) return;
|
||||
|
||||
try {
|
||||
setBusyKey(`maintenance-${selectedEquipment.id}`);
|
||||
await apiClient.addEquipmentMaintenance(selectedEquipment.id, {
|
||||
type: maintenanceForm.type,
|
||||
description: maintenanceForm.description,
|
||||
cost: maintenanceForm.cost || undefined,
|
||||
date: maintenanceForm.date,
|
||||
});
|
||||
setActionSuccess("Запись обслуживания добавлена.");
|
||||
closeOperationModal();
|
||||
await loadRowDetails(selectedEquipment.id);
|
||||
} catch (maintenanceError) {
|
||||
setActionError(String((maintenanceError as Error)?.message || "Не удалось добавить обслуживание"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [closeOperationModal, loadRowDetails, maintenanceForm, selectedEquipment]);
|
||||
|
||||
const handleOpenQr = useCallback(async (equipmentId: number) => {
|
||||
try {
|
||||
setBusyKey(`qr-${equipmentId}`);
|
||||
const blobUrl = await apiClient.getEquipmentQrCodeBlobUrl(equipmentId);
|
||||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
} catch (qrError) {
|
||||
setActionError(String((qrError as Error)?.message || "Не удалось получить QR-код"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleComments = useCallback(async (equipmentId: number) => {
|
||||
const isOpen = Boolean(expandedComments[equipmentId]);
|
||||
setExpandedComments((previous) => ({ ...previous, [equipmentId]: !isOpen }));
|
||||
|
||||
if (!isOpen && !commentsMap[equipmentId]) {
|
||||
try {
|
||||
const response = await apiClient.getEquipmentComments(equipmentId);
|
||||
setCommentsMap((previous) => ({
|
||||
...previous,
|
||||
[equipmentId]: getPaginatedResults<EquipmentComment>(response),
|
||||
}));
|
||||
} catch {
|
||||
setCommentsMap((previous) => ({ ...previous, [equipmentId]: [] }));
|
||||
}
|
||||
}
|
||||
}, [commentsMap, expandedComments]);
|
||||
|
||||
const handleAddComment = useCallback(async (equipmentId: number) => {
|
||||
const text = (commentDrafts[equipmentId] || "").trim();
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
setBusyKey(`comment-${equipmentId}`);
|
||||
const saved = await apiClient.addEquipmentComment(equipmentId, text);
|
||||
setCommentsMap((previous) => ({ ...previous, [equipmentId]: [...(previous[equipmentId] || []), saved as EquipmentComment] }));
|
||||
setCommentDrafts((previous) => ({ ...previous, [equipmentId]: "" }));
|
||||
} catch {
|
||||
setActionError("Не удалось добавить комментарий");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [commentDrafts]);
|
||||
|
||||
const handleDeleteComment = useCallback(async (equipmentId: number, commentId: number) => {
|
||||
try {
|
||||
setBusyKey(`comment-delete-${commentId}`);
|
||||
await apiClient.deleteEquipmentComment(equipmentId, commentId);
|
||||
setCommentsMap((previous) => ({
|
||||
...previous,
|
||||
[equipmentId]: (previous[equipmentId] || []).filter((comment) => comment.id !== commentId),
|
||||
}));
|
||||
} catch {
|
||||
setActionError("Не удалось удалить комментарий");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modalMode: "create" | "edit" = editingId ? "edit" : "create";
|
||||
const isModalOpen = createOpen || editingId !== null;
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setCreateOpen(false);
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
}, [resetForm]);
|
||||
|
||||
const activeFilterCount = [statusFilter, categoryFilter, departmentFilter, responsibleFilter, dateFromFilter, dateToFilter].filter(Boolean).length;
|
||||
|
||||
return {
|
||||
auth,
|
||||
actionError,
|
||||
actionSuccess,
|
||||
activeFilterCount,
|
||||
busyKey,
|
||||
canManage,
|
||||
categories,
|
||||
categoryFilter,
|
||||
closeModal,
|
||||
closeOperationModal,
|
||||
commentDrafts,
|
||||
commentsMap,
|
||||
createOptions,
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
departmentFilter,
|
||||
departments,
|
||||
detailsMap,
|
||||
displayUserName,
|
||||
editingId,
|
||||
employees,
|
||||
error,
|
||||
expandedComments,
|
||||
expandedRows,
|
||||
filteredDepartmentsForForm,
|
||||
filteredEmployeesForForm,
|
||||
filteredItems,
|
||||
filtersOpen,
|
||||
form,
|
||||
getEquipmentMeta,
|
||||
getResponsibleLink,
|
||||
getResponsibleName,
|
||||
handleAddComment,
|
||||
handleDelete,
|
||||
handleDeleteComment,
|
||||
handleLoadMore,
|
||||
handleMaintenance,
|
||||
handleOpenQr,
|
||||
handleSave,
|
||||
handleTransfer,
|
||||
handleWriteOff,
|
||||
isCreateMode,
|
||||
isModalOpen,
|
||||
listMode,
|
||||
loading,
|
||||
loadingMore,
|
||||
loadingRowDetails,
|
||||
maintenanceForm,
|
||||
maintenanceMap,
|
||||
modalMode,
|
||||
nextPage,
|
||||
openCreateModal,
|
||||
openEdit,
|
||||
openOperationModal,
|
||||
operationModal,
|
||||
ordering,
|
||||
previewInventoryNumber,
|
||||
responsibleFilter,
|
||||
searchQuery,
|
||||
selectedEquipment,
|
||||
setCategoryFilter,
|
||||
setCommentDrafts,
|
||||
setDateFromFilter,
|
||||
setDateToFilter,
|
||||
setDepartmentFilter,
|
||||
setFiltersOpen,
|
||||
setForm,
|
||||
setListMode,
|
||||
setMaintenanceForm,
|
||||
setOrdering,
|
||||
setResponsibleFilter,
|
||||
setSearchQuery,
|
||||
setStatusFilter,
|
||||
setTransferForm,
|
||||
setWriteOffReason,
|
||||
statusFilter,
|
||||
transferForm,
|
||||
transferHistoryMap,
|
||||
toggleComments,
|
||||
toggleRow,
|
||||
writeOffReason,
|
||||
};
|
||||
}
|
||||
575
frontend/src/hooks/useProcurementPage.ts
Normal file
575
frontend/src/hooks/useProcurementPage.ts
Normal file
@ -0,0 +1,575 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { canManageRequests, canManageSupplier } from "@/lib/permissions";
|
||||
import { displayUserName, extractNextPage, loadAllPages } from "@/lib/shared";
|
||||
import type {
|
||||
Department,
|
||||
ProcurementRequest,
|
||||
UrgencyLevel,
|
||||
User,
|
||||
} from "@/types/api";
|
||||
|
||||
type ItemDraft = {
|
||||
name: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
estimated_unit_price: string;
|
||||
supplier_info: string;
|
||||
};
|
||||
|
||||
const emptyItem: ItemDraft = {
|
||||
name: "",
|
||||
description: "",
|
||||
quantity: "1",
|
||||
unit: "шт",
|
||||
estimated_unit_price: "",
|
||||
supplier_info: "",
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
department: number | null;
|
||||
urgency: UrgencyLevel;
|
||||
items: ItemDraft[];
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
title: "",
|
||||
description: "",
|
||||
department: null,
|
||||
urgency: "medium",
|
||||
items: [{ ...emptyItem }],
|
||||
};
|
||||
|
||||
type ScopeTab = "all" | "mine" | "department" | "pending_approvals" | "my_work" | "available";
|
||||
type ProcurementSection = "requests" | "stats" | "suppliers";
|
||||
|
||||
type PaginatedLike<T> = {
|
||||
results?: T[];
|
||||
next?: string | null;
|
||||
};
|
||||
|
||||
const getPaginatedResults = <T,>(response: unknown): T[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response as T[];
|
||||
}
|
||||
|
||||
if (response && typeof response === "object") {
|
||||
const results = (response as PaginatedLike<T>).results;
|
||||
if (Array.isArray(results)) {
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const getPaginatedNext = (response: unknown): number | null => {
|
||||
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return extractNextPage((response as PaginatedLike<unknown>).next ?? null);
|
||||
};
|
||||
|
||||
const getReadableError = (error: unknown, fallback: string): string => {
|
||||
const raw = String((error as Error)?.message || fallback);
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return Object.entries(parsed)
|
||||
.map(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return `${key}: ${value.join(", ")}`;
|
||||
}
|
||||
return `${key}: ${String(value)}`;
|
||||
})
|
||||
.join(". ");
|
||||
}
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return raw;
|
||||
};
|
||||
|
||||
export function useProcurementPage(user: User | null) {
|
||||
const canManage = canManageRequests(user);
|
||||
const canSupplierManage = canManageSupplier(user);
|
||||
|
||||
const [requests, setRequests] = useState<ProcurementRequest[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [nextPage, setNextPage] = useState<number | null>(null);
|
||||
|
||||
const [scope, setScope] = useState<ScopeTab>("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [urgencyFilter, setUrgencyFilter] = useState("");
|
||||
const [departmentFilter, setDepartmentFilter] = useState("");
|
||||
const [periodFilter, setPeriodFilter] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [ordering, setOrdering] = useState("-created_at");
|
||||
const [activeSection, setActiveSection] = useState<ProcurementSection>("requests");
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
|
||||
|
||||
const resolveUserId = useCallback((person?: User | number | null) => {
|
||||
if (!person) return null;
|
||||
return typeof person === "number" ? person : person.id;
|
||||
}, []);
|
||||
|
||||
const displayProcurementUserName = useCallback(
|
||||
(person?: User | number | null, fallbackName?: string | null, fallbackEmail?: string | null) => (
|
||||
displayUserName(person, fallbackName, fallbackEmail)
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const userLink = useCallback(
|
||||
(person?: User | number | null) => {
|
||||
const personId = resolveUserId(person);
|
||||
if (!personId) return "";
|
||||
return user?.id && personId === user.id ? "/profile" : `/users/${personId}`;
|
||||
},
|
||||
[resolveUserId, user?.id],
|
||||
);
|
||||
|
||||
const getDeptName = useCallback(
|
||||
(request: ProcurementRequest) => {
|
||||
if (request.department_name) return request.department_name;
|
||||
if (request.department_details?.name) return request.department_details.name;
|
||||
const department = departments.find((item) => item.id === Number(request.department));
|
||||
return department?.name || "—";
|
||||
},
|
||||
[departments],
|
||||
);
|
||||
|
||||
const getRequestAmount = useCallback(
|
||||
(request: ProcurementRequest) => request.total_cost ?? request.total_estimated_cost,
|
||||
[],
|
||||
);
|
||||
|
||||
const buildParams = useCallback(
|
||||
(page: number): Record<string, string | number> => {
|
||||
const params: Record<string, string | number> = { page };
|
||||
if (scope === "mine") params.scope = "mine";
|
||||
else if (scope === "department") params.scope = "department";
|
||||
else if (scope === "my_work") params.scope = "my_work";
|
||||
else if (scope === "available") params.scope = "available";
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (urgencyFilter) params.urgency = urgencyFilter;
|
||||
if (departmentFilter) params.department = departmentFilter;
|
||||
if (periodFilter) params.period = periodFilter;
|
||||
if (searchQuery.trim()) params.search = searchQuery.trim();
|
||||
return params;
|
||||
},
|
||||
[departmentFilter, periodFilter, scope, searchQuery, statusFilter, urgencyFilter],
|
||||
);
|
||||
|
||||
const loadPage1 = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response: unknown = scope === "pending_approvals"
|
||||
? await apiClient.getPendingApprovals(buildParams(1))
|
||||
: await apiClient.getProcurementRequests(buildParams(1));
|
||||
|
||||
setRequests(getPaginatedResults<ProcurementRequest>(response));
|
||||
setNextPage(getPaginatedNext(response));
|
||||
} catch (loadError) {
|
||||
console.error("Load procurement error:", loadError);
|
||||
setError("Не удалось загрузить заявки на закупку");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [buildParams, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage1();
|
||||
}, [loadPage1]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const allDepartments = await loadAllPages<Department>((page) => apiClient.getDepartments(page));
|
||||
if (!cancelled) {
|
||||
setDepartments(allDepartments);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setDepartments([]);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
const urgencyRank: Record<string, number> = {
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
critical: 4,
|
||||
};
|
||||
|
||||
return [...requests].sort((left, right) => {
|
||||
switch (ordering) {
|
||||
case "created_at":
|
||||
return (new Date(left.created_at).getTime() || 0) - (new Date(right.created_at).getTime() || 0);
|
||||
case "title":
|
||||
return String(left.title || "").localeCompare(String(right.title || ""), "ru", { sensitivity: "base" });
|
||||
case "urgency":
|
||||
return (urgencyRank[left.urgency || "medium"] || 0) - (urgencyRank[right.urgency || "medium"] || 0);
|
||||
case "-urgency":
|
||||
return (urgencyRank[right.urgency || "medium"] || 0) - (urgencyRank[left.urgency || "medium"] || 0);
|
||||
case "-created_at":
|
||||
default:
|
||||
return (new Date(right.created_at).getTime() || 0) - (new Date(left.created_at).getTime() || 0);
|
||||
}
|
||||
});
|
||||
}, [ordering, requests]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setForm({ ...emptyForm, items: [{ ...emptyItem }] });
|
||||
}, []);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
setCreateOpen(true);
|
||||
}, [resetForm]);
|
||||
|
||||
const openEdit = useCallback((request: ProcurementRequest) => {
|
||||
setCreateOpen(false);
|
||||
setEditingId(request.id);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
|
||||
const detail = detailsCache[request.id] || request;
|
||||
setForm({
|
||||
title: detail.title || "",
|
||||
description: detail.description || "",
|
||||
department: detail.department ?? null,
|
||||
urgency: detail.urgency || "medium",
|
||||
items: detail.items && detail.items.length > 0
|
||||
? detail.items.map((item) => ({
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
quantity: String(item.quantity || "1"),
|
||||
unit: item.unit || "шт",
|
||||
estimated_unit_price: String(item.estimated_unit_price || ""),
|
||||
supplier_info: item.supplier_info || "",
|
||||
}))
|
||||
: [{ ...emptyItem }],
|
||||
});
|
||||
}, [detailsCache]);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setCreateOpen(false);
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
}, [resetForm]);
|
||||
|
||||
const modalMode: "create" | "edit" = editingId ? "edit" : "create";
|
||||
const isModalOpen = createOpen || editingId !== null;
|
||||
|
||||
const refreshOne = useCallback(async (id: number) => {
|
||||
try {
|
||||
const updated = await apiClient.getProcurementRequest(id);
|
||||
setRequests((previous) => previous.map((request) => (request.id === id ? updated : request)));
|
||||
setDetailsCache((previous) => ({ ...previous, [id]: updated }));
|
||||
} catch {
|
||||
await loadPage1();
|
||||
}
|
||||
}, [loadPage1]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
setBusyKey("save");
|
||||
setActionError(null);
|
||||
|
||||
if (!form.title.trim()) {
|
||||
setActionError("Укажите название заявки.");
|
||||
return;
|
||||
}
|
||||
if (!form.description.trim()) {
|
||||
setActionError("Укажите описание и обоснование.");
|
||||
return;
|
||||
}
|
||||
if (!form.department) {
|
||||
setActionError("Выберите отдел.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = form.items.filter((item) => item.name.trim());
|
||||
if (validItems.length === 0) {
|
||||
setActionError("Добавьте хотя бы одну позицию.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of validItems) {
|
||||
if (!item.quantity || Number(item.quantity) <= 0) {
|
||||
setActionError(`Позиция «${item.name}»: укажите количество.`);
|
||||
return;
|
||||
}
|
||||
if (!item.estimated_unit_price || Number(item.estimated_unit_price) <= 0) {
|
||||
setActionError(`Позиция «${item.name}»: укажите цену за единицу.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
department: form.department,
|
||||
urgency: form.urgency,
|
||||
items: validItems.map((item) => ({
|
||||
name: item.name,
|
||||
description: item.description || undefined,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || "шт",
|
||||
estimated_unit_price: item.estimated_unit_price,
|
||||
supplier_info: item.supplier_info || undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
if (modalMode === "create") {
|
||||
await apiClient.createProcurementRequest(payload);
|
||||
setActionSuccess("Заявка создана (черновик).");
|
||||
setCreateOpen(false);
|
||||
} else if (editingId) {
|
||||
await apiClient.updateProcurementRequest(editingId, {
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
urgency: payload.urgency,
|
||||
});
|
||||
setActionSuccess("Заявка обновлена.");
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
await loadPage1();
|
||||
} catch (saveError) {
|
||||
setActionError(getReadableError(saveError, "Ошибка сохранения"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [editingId, form, loadPage1, modalMode, resetForm]);
|
||||
|
||||
const doAction = useCallback(async (key: string, action: () => Promise<unknown>, id: number, successMessage: string) => {
|
||||
try {
|
||||
setBusyKey(key);
|
||||
setActionError(null);
|
||||
await action();
|
||||
setActionSuccess(successMessage);
|
||||
await refreshOne(id);
|
||||
} catch (actionErrorValue) {
|
||||
setActionError(getReadableError(actionErrorValue, "Ошибка"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [refreshOne]);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!nextPage || loadingMore) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const response: unknown = scope === "pending_approvals"
|
||||
? await apiClient.getPendingApprovals(buildParams(nextPage))
|
||||
: await apiClient.getProcurementRequests(buildParams(nextPage));
|
||||
|
||||
const chunk = getPaginatedResults<ProcurementRequest>(response);
|
||||
setRequests((previous) => {
|
||||
const known = new Set(previous.map((request) => request.id));
|
||||
return [...previous, ...chunk.filter((request) => !known.has(request.id))];
|
||||
});
|
||||
setNextPage(getPaginatedNext(response));
|
||||
} catch {
|
||||
setError("Не удалось загрузить ещё");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [buildParams, loadingMore, nextPage, scope]);
|
||||
|
||||
const toggleExpand = useCallback(async (id: number) => {
|
||||
setExpandedIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!detailsCache[id]) {
|
||||
try {
|
||||
const detail = await apiClient.getProcurementRequest(id);
|
||||
setDetailsCache((previous) => ({ ...previous, [id]: detail }));
|
||||
} catch {
|
||||
// ignore detail loading failures for collapsed rows
|
||||
}
|
||||
}
|
||||
}, [detailsCache]);
|
||||
|
||||
const handleSubmit = useCallback((id: number) => doAction(
|
||||
`submit-${id}`,
|
||||
() => apiClient.submitProcurementRequest(id),
|
||||
id,
|
||||
"Заявка отправлена на согласование.",
|
||||
), [doAction]);
|
||||
|
||||
const handleApprove = useCallback((id: number) => {
|
||||
const comment = window.prompt("Комментарий к одобрению (необязательно)", "");
|
||||
if (comment === null) return;
|
||||
return doAction(`approve-${id}`, () => apiClient.approveProcurementRequest(id, comment), id, "Заявка одобрена.");
|
||||
}, [doAction]);
|
||||
|
||||
const handleReject = useCallback((id: number) => {
|
||||
const comment = window.prompt("Комментарий к отклонению", "");
|
||||
if (comment === null) return;
|
||||
return doAction(`reject-${id}`, () => apiClient.rejectProcurementRequest(id, comment), id, "Заявка отклонена.");
|
||||
}, [doAction]);
|
||||
|
||||
const handleStart = useCallback((id: number) => doAction(
|
||||
`start-${id}`,
|
||||
() => apiClient.startWorkProcurementRequest(id),
|
||||
id,
|
||||
"Вы взяли заявку в работу.",
|
||||
), [doAction]);
|
||||
|
||||
const handleComplete = useCallback((id: number) => doAction(
|
||||
`complete-${id}`,
|
||||
() => apiClient.completeProcurementRequest(id),
|
||||
id,
|
||||
"Заявка завершена.",
|
||||
), [doAction]);
|
||||
|
||||
const handleCancel = useCallback((id: number) => {
|
||||
const reason = window.prompt("Причина отмены", "");
|
||||
if (reason === null) return;
|
||||
return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена.");
|
||||
}, [doAction]);
|
||||
|
||||
const handleDelete = useCallback(async (id: number) => {
|
||||
if (!window.confirm("Удалить эту заявку? Доступно только для черновиков.")) return;
|
||||
|
||||
try {
|
||||
setBusyKey(`delete-${id}`);
|
||||
await apiClient.deleteProcurementRequest(id);
|
||||
setRequests((previous) => previous.filter((request) => request.id !== id));
|
||||
} catch (deleteError) {
|
||||
setActionError(getReadableError(deleteError, "Не удалось удалить"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addItemRow = useCallback(() => {
|
||||
setForm((previous) => ({ ...previous, items: [...previous.items, { ...emptyItem }] }));
|
||||
}, []);
|
||||
|
||||
const removeItemRow = useCallback((index: number) => {
|
||||
setForm((previous) => ({ ...previous, items: previous.items.filter((_, itemIndex) => itemIndex !== index) }));
|
||||
}, []);
|
||||
|
||||
const updateItemRow = useCallback((index: number, patch: Partial<ItemDraft>) => {
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const activeFilterCount = [statusFilter, urgencyFilter, departmentFilter, periodFilter].filter(Boolean).length;
|
||||
const isFinal = useCallback((status?: string) => ["completed", "rejected", "cancelled"].includes(String(status || "").toLowerCase()), []);
|
||||
|
||||
return {
|
||||
canManage,
|
||||
canSupplierManage,
|
||||
requests,
|
||||
departments,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
actionError,
|
||||
actionSuccess,
|
||||
busyKey,
|
||||
nextPage,
|
||||
scope,
|
||||
setScope,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
urgencyFilter,
|
||||
setUrgencyFilter,
|
||||
departmentFilter,
|
||||
setDepartmentFilter,
|
||||
periodFilter,
|
||||
setPeriodFilter,
|
||||
filtersOpen,
|
||||
setFiltersOpen,
|
||||
ordering,
|
||||
setOrdering,
|
||||
activeSection,
|
||||
setActiveSection,
|
||||
createOpen,
|
||||
editingId,
|
||||
form,
|
||||
setForm,
|
||||
expandedIds,
|
||||
detailsCache,
|
||||
filteredRequests,
|
||||
openCreate,
|
||||
openEdit,
|
||||
closeModal,
|
||||
modalMode,
|
||||
isModalOpen,
|
||||
handleSave,
|
||||
handleLoadMore,
|
||||
toggleExpand,
|
||||
handleSubmit,
|
||||
handleApprove,
|
||||
handleReject,
|
||||
handleStart,
|
||||
handleComplete,
|
||||
handleCancel,
|
||||
handleDelete,
|
||||
addItemRow,
|
||||
removeItemRow,
|
||||
updateItemRow,
|
||||
activeFilterCount,
|
||||
isFinal,
|
||||
resolveUserId,
|
||||
displayUserName: displayProcurementUserName,
|
||||
userLink,
|
||||
getDeptName,
|
||||
getRequestAmount,
|
||||
loadPage1,
|
||||
};
|
||||
}
|
||||
426
frontend/src/hooks/useRequestsPage.ts
Normal file
426
frontend/src/hooks/useRequestsPage.ts
Normal file
@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { Request, RequestComment, User, Department } from "@/types/api";
|
||||
import { extractNextPage, loadAllPages, displayUserName } from "@/lib/shared";
|
||||
|
||||
/* ── constants ── */
|
||||
export type RequestFormState = {
|
||||
type: string;
|
||||
title: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
comment: string;
|
||||
department_ids: number[];
|
||||
recipient_ids: number[];
|
||||
cc_user_ids: number[];
|
||||
sent_to_all_department: boolean;
|
||||
attachment: File | null;
|
||||
};
|
||||
|
||||
export const emptyForm: RequestFormState = {
|
||||
type: "",
|
||||
title: "",
|
||||
date_from: "",
|
||||
date_to: "",
|
||||
comment: "",
|
||||
department_ids: [],
|
||||
recipient_ids: [],
|
||||
cc_user_ids: [],
|
||||
sent_to_all_department: false,
|
||||
attachment: null,
|
||||
};
|
||||
|
||||
export const statusMeta: Record<string, { label: string; className: string }> = {
|
||||
draft: { label: "Черновик", className: "bg-slate-100 text-slate-700 ring-slate-200" },
|
||||
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" },
|
||||
cancelled: { label: "Отменено", className: "bg-gray-100 text-gray-700 ring-gray-200" },
|
||||
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" },
|
||||
};
|
||||
|
||||
export const defaultStatusMeta = { label: "Неизвестный статус", className: "bg-gray-50 text-gray-700 ring-gray-200" };
|
||||
|
||||
export const requestTypeLabels: Record<string, string> = {
|
||||
vacation: "Отпуск",
|
||||
sick_leave: "Больничный",
|
||||
day_off: "Отгул",
|
||||
transfer: "Перевод",
|
||||
dismissal: "Увольнение",
|
||||
other: "Другое",
|
||||
};
|
||||
|
||||
export const orderingOptions = [
|
||||
{ value: "-created_at", label: "Сначала новые" },
|
||||
{ value: "created_at", label: "Сначала старые" },
|
||||
{ value: "title", label: "По названию" },
|
||||
{ value: "date_from", label: "По периоду ↑" },
|
||||
{ value: "-date_from", label: "По периоду ↓" },
|
||||
];
|
||||
|
||||
/* ── hook ── */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function useRequestsPage(_userId: number | null | undefined) {
|
||||
/* data */
|
||||
const [requests, setRequests] = useState<Request[]>([]);
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [commentsMap, setCommentsMap] = useState<Record<number, RequestComment[]>>({});
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
|
||||
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
|
||||
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
|
||||
|
||||
/* form */
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingRequestId, setEditingRequestId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<RequestFormState>(emptyForm);
|
||||
|
||||
/* filters */
|
||||
const [search, setSearch] = useState("");
|
||||
const [view, setView] = useState<"" | "mine" | "addressed">("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [employeeFilter, setEmployeeFilter] = useState("");
|
||||
const [createdFromFilter, setCreatedFromFilter] = useState("");
|
||||
const [createdToFilter, setCreatedToFilter] = useState("");
|
||||
const [periodFromFilter, setPeriodFromFilter] = useState("");
|
||||
const [periodToFilter, setPeriodToFilter] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [ordering, setOrdering] = useState("-created_at");
|
||||
const [swipeMode, setSwipeMode] = useState(false);
|
||||
|
||||
/* UI */
|
||||
const [attachmentPreview, setAttachmentPreview] = useState<{ url: string; name: string } | null>(null);
|
||||
const [detailsRequest, setDetailsRequest] = useState<Request | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [nextPage, setNextPage] = useState<number | null>(null);
|
||||
|
||||
const departmentNameMap = useMemo(
|
||||
() => new Map((departments || []).map((d) => [d.id, d.name])),
|
||||
[departments],
|
||||
);
|
||||
|
||||
/* ── params ── */
|
||||
const buildRequestParams = useCallback(
|
||||
(page: number): Record<string, string | number> => {
|
||||
const params: Record<string, string | number> = { page, limit: 20 };
|
||||
if (view === "mine") params.view = "mine";
|
||||
if (view === "addressed") params.addressed_to_me = "true";
|
||||
if (typeFilter) params.type = typeFilter;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (employeeFilter) params.employee_id = employeeFilter;
|
||||
if (createdFromFilter) params.created_from = createdFromFilter;
|
||||
if (createdToFilter) params.created_to = createdToFilter;
|
||||
if (periodFromFilter) params.date_from = periodFromFilter;
|
||||
if (periodToFilter) params.date_to = periodToFilter;
|
||||
return params;
|
||||
},
|
||||
[view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter],
|
||||
);
|
||||
|
||||
/* ── load data ── */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiClient.getRequests(buildRequestParams(1));
|
||||
if (cancelled) return;
|
||||
setRequests(response.results || []);
|
||||
setNextPage(extractNextPage(response.next));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("Ошибка загрузки заявлений:", err);
|
||||
setError("Не удалось загрузить заявления");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [buildRequestParams]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [allEmployees, allDepartments] = await Promise.all([
|
||||
loadAllPages<User>((p) => apiClient.getEmployees(p)),
|
||||
loadAllPages<Department>((p) => apiClient.getDepartments(p)),
|
||||
]);
|
||||
setEmployees(allEmployees);
|
||||
setDepartments(allDepartments);
|
||||
} catch {
|
||||
setEmployees([]);
|
||||
setDepartments([]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/* ── filtered + sorted ── */
|
||||
const filteredRequests = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const sorted = [...requests].sort((a, b) => {
|
||||
switch (ordering) {
|
||||
case "created_at":
|
||||
return (new Date(a.created_at).getTime() || 0) - (new Date(b.created_at).getTime() || 0);
|
||||
case "title": {
|
||||
const l = String(a.display_title || a.title || "").trim();
|
||||
const r = String(b.display_title || b.title || "").trim();
|
||||
return l.localeCompare(r, "ru", { sensitivity: "base" });
|
||||
}
|
||||
case "date_from":
|
||||
return (new Date(a.date_from || 0).getTime() || 0) - (new Date(b.date_from || 0).getTime() || 0);
|
||||
case "-date_from":
|
||||
return (new Date(b.date_from || 0).getTime() || 0) - (new Date(a.date_from || 0).getTime() || 0);
|
||||
default:
|
||||
return (new Date(b.created_at).getTime() || 0) - (new Date(a.created_at).getTime() || 0);
|
||||
}
|
||||
});
|
||||
if (!q) return sorted;
|
||||
return sorted.filter((item) => {
|
||||
const title = (item.display_title || item.title || "").toLowerCase();
|
||||
const description = (item.comment || item.description || "").toLowerCase();
|
||||
const type = String(item.type || item.request_type || "").toLowerCase();
|
||||
const author = displayUserName(item.employee || item.created_by).toLowerCase();
|
||||
return title.includes(q) || description.includes(q) || type.includes(q) || author.includes(q);
|
||||
});
|
||||
}, [ordering, requests, search]);
|
||||
|
||||
/* ── form ── */
|
||||
const resetForm = () => setForm(emptyForm);
|
||||
|
||||
const openEdit = (req: Request) => {
|
||||
setEditingRequestId(req.id);
|
||||
setCreateOpen(false);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
setForm({
|
||||
type: req.type || req.request_type || "",
|
||||
title: req.title || "",
|
||||
date_from: req.date_from || "",
|
||||
date_to: req.date_to || "",
|
||||
comment: req.comment || req.description || "",
|
||||
department_ids: (req.departments || []).map(Number).filter((n) => Number.isFinite(n)),
|
||||
recipient_ids: (req.recipients || []).map((u: User) => u.id).filter(Boolean),
|
||||
cc_user_ids: (req.cc_users || []).map((u: User) => u.id).filter(Boolean),
|
||||
sent_to_all_department: Boolean(req.sent_to_all_department),
|
||||
attachment: null,
|
||||
});
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingRequestId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setCreateOpen(false);
|
||||
setEditingRequestId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
};
|
||||
|
||||
/* ── CRUD ── */
|
||||
const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submitted") => {
|
||||
try {
|
||||
setBusyKey(`${mode}-${saveAs}`);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
|
||||
if (saveAs === "submitted" && !form.sent_to_all_department && form.department_ids.length === 0 && form.recipient_ids.length === 0) {
|
||||
setActionError("Укажите получателей или отделы.");
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const payload: Record<string, any> = {
|
||||
type: form.type,
|
||||
title: form.title,
|
||||
date_from: form.date_from || null,
|
||||
date_to: form.date_to || null,
|
||||
comment: form.comment,
|
||||
sent_to_all_department: form.sent_to_all_department,
|
||||
};
|
||||
if (form.department_ids.length > 0) payload.department_ids = form.department_ids;
|
||||
if (form.recipient_ids.length > 0) payload.recipient_ids = form.recipient_ids;
|
||||
if (form.cc_user_ids.length > 0) payload.cc_user_ids = form.cc_user_ids;
|
||||
if (form.attachment) payload.attachment = form.attachment;
|
||||
|
||||
if (mode === "create") {
|
||||
await apiClient.createRequest(payload, saveAs);
|
||||
setActionSuccess(saveAs === "draft" ? "Черновик сохранён." : "Заявление создано.");
|
||||
setCreateOpen(false);
|
||||
} else if (editingRequestId) {
|
||||
await apiClient.updateRequest(editingRequestId, payload, saveAs);
|
||||
setActionSuccess("Заявление обновлено.");
|
||||
setEditingRequestId(null);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
const response = await apiClient.getRequests(buildRequestParams(1));
|
||||
setRequests(response.results || []);
|
||||
setNextPage(extractNextPage(response.next));
|
||||
} catch (e: unknown) {
|
||||
const raw = String((e as Error)?.message || "Не удалось сохранить заявление");
|
||||
let readable = raw;
|
||||
try { const parsed = JSON.parse(raw); readable = Object.values(parsed).flat().join(". "); } catch { /* keep raw */ }
|
||||
setActionError(readable);
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!nextPage || loadingMore) return;
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
setError(null);
|
||||
const response = await apiClient.getRequests(buildRequestParams(nextPage));
|
||||
const chunk = response.results || [];
|
||||
setRequests((prev) => {
|
||||
const known = new Set(prev.map((r: Request) => r.id));
|
||||
return [...prev, ...chunk.filter((r: Request) => !known.has(r.id))];
|
||||
});
|
||||
setNextPage(extractNextPage(response.next));
|
||||
} catch (e: unknown) {
|
||||
setError(String((e as Error)?.message || "Не удалось загрузить ещё заявления"));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [nextPage, loadingMore, buildRequestParams]);
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
try { setBusyKey(`approve-${id}`); setActionError(null); await apiClient.approveRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "approved" } : r))); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleReject = async (id: number) => {
|
||||
try { setBusyKey(`reject-${id}`); setActionError(null); await apiClient.rejectRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "rejected" } : r))); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleCancel = async (id: number) => {
|
||||
try { setBusyKey(`cancel-${id}`); setActionError(null); await apiClient.cancelRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "cancelled" } : r))); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отменить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try { setBusyKey(`delete-${id}`); setActionError(null); await apiClient.deleteRequest(id); setRequests((p) => p.filter((r) => r.id !== id)); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
/* ── comments ── */
|
||||
const toggleComments = async (requestId: number) => {
|
||||
const isOpen = Boolean(expandedComments[requestId]);
|
||||
setExpandedComments((p) => ({ ...p, [requestId]: !isOpen }));
|
||||
if (!isOpen && !commentsMap[requestId]) {
|
||||
try { const c = await apiClient.getRequestComments(requestId); setCommentsMap((p) => ({ ...p, [requestId]: c })); }
|
||||
catch { setCommentsMap((p) => ({ ...p, [requestId]: [] })); }
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (requestId: number) => setExpandedRows((p) => ({ ...p, [requestId]: !p[requestId] }));
|
||||
|
||||
const handleAddComment = async (requestId: number) => {
|
||||
const text = (commentDrafts[requestId] || "").trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
setBusyKey(`comment-${requestId}`);
|
||||
const saved = await apiClient.addRequestComment(requestId, text);
|
||||
setCommentsMap((p) => ({ ...p, [requestId]: [...(p[requestId] || []), saved] }));
|
||||
setCommentDrafts((p) => ({ ...p, [requestId]: "" }));
|
||||
} catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось добавить комментарий")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (requestId: number, commentId: number) => {
|
||||
try {
|
||||
setBusyKey(`comment-delete-${commentId}`);
|
||||
await apiClient.deleteRequestComment(requestId, commentId);
|
||||
setCommentsMap((p) => ({ ...p, [requestId]: (p[requestId] || []).filter((c) => c.id !== commentId) }));
|
||||
} catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить комментарий")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase());
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => { if (entries[0].isIntersecting && nextPage && !loadingMore && !loading) handleLoadMore(); },
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
const el = loadMoreRef.current;
|
||||
if (el) observer.observe(el);
|
||||
return () => { if (el) observer.unobserve(el); };
|
||||
}, [nextPage, loadingMore, loading, handleLoadMore]);
|
||||
|
||||
return {
|
||||
/* data */
|
||||
requests: filteredRequests,
|
||||
employees,
|
||||
departments,
|
||||
departmentNameMap,
|
||||
commentsMap,
|
||||
expandedRows,
|
||||
expandedComments,
|
||||
commentDrafts,
|
||||
setCommentDrafts,
|
||||
|
||||
/* form */
|
||||
form,
|
||||
setForm,
|
||||
createOpen,
|
||||
editingRequestId,
|
||||
modalMode: (editingRequestId ? "edit" : "create") as "create" | "edit",
|
||||
isModalOpen: createOpen || editingRequestId !== null,
|
||||
openCreate,
|
||||
openEdit,
|
||||
closeModal,
|
||||
handleCreateOrUpdate,
|
||||
|
||||
/* filters */
|
||||
search, setSearch,
|
||||
view, setView,
|
||||
typeFilter, setTypeFilter,
|
||||
statusFilter, setStatusFilter,
|
||||
employeeFilter, setEmployeeFilter,
|
||||
createdFromFilter, setCreatedFromFilter,
|
||||
createdToFilter, setCreatedToFilter,
|
||||
periodFromFilter, setPeriodFromFilter,
|
||||
periodToFilter, setPeriodToFilter,
|
||||
filtersOpen, setFiltersOpen,
|
||||
ordering, setOrdering,
|
||||
swipeMode, setSwipeMode,
|
||||
|
||||
/* UI */
|
||||
attachmentPreview, setAttachmentPreview,
|
||||
detailsRequest, setDetailsRequest,
|
||||
loading, loadingMore, error,
|
||||
actionError, actionSuccess,
|
||||
busyKey, nextPage,
|
||||
loadMoreRef,
|
||||
|
||||
/* actions */
|
||||
handleApprove, handleReject, handleCancel, handleDelete,
|
||||
toggleRow, toggleComments, handleAddComment, handleDeleteComment,
|
||||
isFinal,
|
||||
};
|
||||
}
|
||||
50
frontend/src/hooks/useSilentChatReloadGuard.ts
Normal file
50
frontend/src/hooks/useSilentChatReloadGuard.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
const WS_SILENT_RELOAD_COOLDOWN_MS = 60_000;
|
||||
|
||||
function getWsReloadGuardKey(chatId: number): string {
|
||||
return `messages-ws-silent-reload:${chatId}`;
|
||||
}
|
||||
|
||||
type ReconnectExhaustedDetails = {
|
||||
chatId: number | null;
|
||||
reconnectAttempts: number;
|
||||
};
|
||||
|
||||
export function useSilentChatReloadGuard(chatId: number | null) {
|
||||
const handleReconnectExhausted = useCallback((details: ReconnectExhaustedDetails) => {
|
||||
if (typeof window === "undefined" || !chatId || Number.isNaN(chatId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guardKey = getWsReloadGuardKey(chatId);
|
||||
const now = Date.now();
|
||||
const lastReloadAt = Number(window.sessionStorage.getItem(guardKey) || 0);
|
||||
|
||||
if (Number.isFinite(lastReloadAt) && now - lastReloadAt < WS_SILENT_RELOAD_COOLDOWN_MS) {
|
||||
console.warn(
|
||||
`Пропускаем тихую перезагрузку чата ${details.chatId}: cooldown еще не истек после ${details.reconnectAttempts} попыток.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(guardKey, String(now));
|
||||
console.warn(
|
||||
`WebSocket чата ${details.chatId} не восстановлен после ${details.reconnectAttempts} попыток, выполняем тихую перезагрузку.`
|
||||
);
|
||||
window.location.reload();
|
||||
}, [chatId]);
|
||||
|
||||
const resetReloadGuard = useCallback(() => {
|
||||
if (typeof window === "undefined" || !chatId || Number.isNaN(chatId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem(getWsReloadGuardKey(chatId));
|
||||
}, [chatId]);
|
||||
|
||||
return {
|
||||
handleReconnectExhausted,
|
||||
resetReloadGuard,
|
||||
};
|
||||
}
|
||||
344
frontend/src/hooks/useUserDetailPage.ts
Normal file
344
frontend/src/hooks/useUserDetailPage.ts
Normal file
@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import {
|
||||
createEmptyActionForm,
|
||||
createEmptyEditForm,
|
||||
employeeActionTypes,
|
||||
getLatestEmployeeAction,
|
||||
getUserFullName,
|
||||
getUserInitials,
|
||||
type EmployeeActionField,
|
||||
type EmployeeActionForm,
|
||||
type UserProfileEditForm,
|
||||
type UserProfileTextField,
|
||||
sortEmployeeActions,
|
||||
} from "@/lib/users/userDetailUtils";
|
||||
import type { Chat, EmployeeAction, User } from "@/types/api";
|
||||
|
||||
type ChatListResponse = Chat[] | { results?: Chat[] };
|
||||
type ChatWithMemberIds = Chat & { member_ids?: number[] };
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string): string => (
|
||||
String((error as Error)?.message || fallback)
|
||||
);
|
||||
|
||||
const getChatResults = (response: ChatListResponse): ChatWithMemberIds[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response as ChatWithMemberIds[];
|
||||
}
|
||||
|
||||
return Array.isArray(response.results)
|
||||
? (response.results as ChatWithMemberIds[])
|
||||
: [];
|
||||
};
|
||||
|
||||
export function useUserDetailPage(userId: number, currentUser: User | null) {
|
||||
const router = useRouter();
|
||||
|
||||
const [person, setPerson] = useState<User | null>(null);
|
||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [creatingChat, setCreatingChat] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState<string | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isActionModalOpen, setIsActionModalOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<UserProfileEditForm>(createEmptyEditForm);
|
||||
const [actionForm, setActionForm] = useState<EmployeeActionForm>(createEmptyActionForm);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const refreshPerson = useCallback(async () => {
|
||||
const data = await apiClient.getEmployee(userId) as User;
|
||||
setPerson(data);
|
||||
return data;
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadUser() {
|
||||
if (!userId || Number.isNaN(userId)) {
|
||||
setError("Некорректный идентификатор пользователя");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await refreshPerson();
|
||||
} catch (loadError) {
|
||||
setError(getErrorMessage(loadError, "Не удалось загрузить пользователя"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadUser();
|
||||
}, [refreshPerson, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarFailed(false);
|
||||
}, [person?.avatar, person?.id]);
|
||||
|
||||
const fullName = useMemo(() => getUserFullName(person), [person]);
|
||||
|
||||
const latestAction = useMemo(() => getLatestEmployeeAction(person?.actions), [person?.actions]);
|
||||
const sortedActions = useMemo(() => sortEmployeeActions(person?.actions), [person?.actions]);
|
||||
|
||||
const avatarUrl = resolveMediaUrl(person?.avatar);
|
||||
const initials = getUserInitials(person);
|
||||
|
||||
const copyToClipboard = useCallback(async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopySuccess(label);
|
||||
window.setTimeout(() => setCopySuccess(null), 2000);
|
||||
} catch (copyError) {
|
||||
console.error("Ошибка копирования:", copyError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStartChat = useCallback(async () => {
|
||||
if (!person || !currentUser || creatingChat) return;
|
||||
|
||||
try {
|
||||
setCreatingChat(true);
|
||||
|
||||
const chatsResponse = await apiClient.getChats() as ChatListResponse;
|
||||
const allChats = getChatResults(chatsResponse);
|
||||
const existingChat = allChats.find((chat) => {
|
||||
if (chat.type !== "private") return false;
|
||||
|
||||
const memberIds = Array.isArray(chat.member_ids) ? chat.member_ids : [];
|
||||
return memberIds.length === 2 && memberIds.includes(currentUser.id) && memberIds.includes(person.id);
|
||||
});
|
||||
|
||||
if (existingChat) {
|
||||
router.push(`/messages/${existingChat.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = await apiClient.createChat({
|
||||
type: "private",
|
||||
name: "Диалог",
|
||||
participants: [person.id],
|
||||
}) as Chat;
|
||||
router.push(`/messages/${chat.id}`);
|
||||
} catch (chatError) {
|
||||
console.error("Ошибка создания чата:", chatError);
|
||||
alert("Не удалось открыть чат");
|
||||
} finally {
|
||||
setCreatingChat(false);
|
||||
}
|
||||
}, [creatingChat, currentUser, person, router]);
|
||||
|
||||
const handleOpenActionModal = useCallback(() => {
|
||||
setActionForm(createEmptyActionForm());
|
||||
setIsActionModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseActionModal = useCallback(() => {
|
||||
setIsActionModalOpen(false);
|
||||
setActionForm(createEmptyActionForm());
|
||||
}, []);
|
||||
|
||||
const handleEditAction = useCallback((action: EmployeeAction) => {
|
||||
setActionForm({
|
||||
editingId: action.id,
|
||||
type: action.action,
|
||||
date: action.date,
|
||||
comment: action.comment || "",
|
||||
});
|
||||
setIsActionModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteAction = useCallback(async (actionId: number) => {
|
||||
if (!confirm("Вы уверены, что хотите удалить это событие?")) return;
|
||||
|
||||
setActionLoading(`delete-${actionId}`);
|
||||
try {
|
||||
await apiClient.deleteEmployeeAction(actionId);
|
||||
await refreshPerson();
|
||||
} catch (deleteError) {
|
||||
console.error("Ошибка при удалении события:", deleteError);
|
||||
alert("Не удалось удалить событие");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}, [refreshPerson]);
|
||||
|
||||
const handleSaveAction = useCallback(async () => {
|
||||
if (!person || !userId || !actionForm.type) return;
|
||||
|
||||
setActionLoading("action");
|
||||
try {
|
||||
if (actionForm.editingId) {
|
||||
await apiClient.updateEmployeeAction(actionForm.editingId, {
|
||||
action: actionForm.type,
|
||||
date: actionForm.date,
|
||||
comment: actionForm.comment.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
await apiClient.createEmployeeAction({
|
||||
employee: userId,
|
||||
action: actionForm.type,
|
||||
date: actionForm.date,
|
||||
comment: actionForm.comment.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
await refreshPerson();
|
||||
handleCloseActionModal();
|
||||
} catch (saveError) {
|
||||
console.error("Ошибка при создании события:", saveError);
|
||||
alert("Не удалось сохранить событие");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}, [actionForm, handleCloseActionModal, person, refreshPerson, userId]);
|
||||
|
||||
const handleOpenEditModal = useCallback(() => {
|
||||
if (!person) return;
|
||||
|
||||
setEditForm({
|
||||
firstName: person.first_name || "",
|
||||
lastName: person.last_name || "",
|
||||
patronymic: person.patronymic || "",
|
||||
email: person.email || "",
|
||||
phone: person.phone_number || "",
|
||||
telegram: person.telegram || "",
|
||||
whatsapp: person.whatsapp || "",
|
||||
wechat: person.wechat || "",
|
||||
avatarFile: null,
|
||||
avatarPreview: null,
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
}, [person]);
|
||||
|
||||
const handleCloseEditModal = useCallback(() => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditForm((current) => ({ ...current, avatarFile: null, avatarPreview: null }));
|
||||
}, []);
|
||||
|
||||
const handleAvatarChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("Файл слишком большой. Максимальный размер: 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
alert("Можно загружать только изображения");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditForm((current) => ({ ...current, avatarFile: file }));
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setEditForm((current) => ({
|
||||
...current,
|
||||
avatarFile: file,
|
||||
avatarPreview: typeof reader.result === "string" ? reader.result : null,
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const setEditField = useCallback(<K extends UserProfileTextField>(field: K, value: UserProfileEditForm[K]) => {
|
||||
setEditForm((current) => ({ ...current, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const setActionField = useCallback(<K extends EmployeeActionField>(field: K, value: EmployeeActionForm[K]) => {
|
||||
setActionForm((current) => ({ ...current, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSaveEdit = useCallback(async () => {
|
||||
if (!person || !userId) return;
|
||||
|
||||
setActionLoading("edit");
|
||||
try {
|
||||
await apiClient.updateEmployee(userId, {
|
||||
first_name: editForm.firstName.trim(),
|
||||
last_name: editForm.lastName.trim(),
|
||||
patronymic: editForm.patronymic.trim() || undefined,
|
||||
email: editForm.email.trim(),
|
||||
phone_number: editForm.phone.trim() || undefined,
|
||||
telegram: editForm.telegram.trim() || undefined,
|
||||
whatsapp: editForm.whatsapp.trim() || undefined,
|
||||
wechat: editForm.wechat.trim() || undefined,
|
||||
});
|
||||
|
||||
if (editForm.avatarFile) {
|
||||
await apiClient.uploadEmployeeAvatar(userId, editForm.avatarFile);
|
||||
}
|
||||
|
||||
await refreshPerson();
|
||||
handleCloseEditModal();
|
||||
} catch (saveError) {
|
||||
console.error("Ошибка при обновлении сотрудника:", saveError);
|
||||
alert("Не удалось обновить данные");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}, [editForm, handleCloseEditModal, person, refreshPerson, userId]);
|
||||
|
||||
const canEdit = Boolean(currentUser && (
|
||||
currentUser.auth?.is_staff ||
|
||||
currentUser.auth?.is_superuser ||
|
||||
currentUser.id === userId
|
||||
));
|
||||
|
||||
const canManageActions = Boolean(currentUser && (
|
||||
currentUser.auth?.is_staff ||
|
||||
currentUser.auth?.is_superuser
|
||||
));
|
||||
|
||||
const canViewActions = Boolean(currentUser && (
|
||||
currentUser.auth?.is_staff ||
|
||||
currentUser.auth?.is_superuser ||
|
||||
currentUser.id === userId
|
||||
));
|
||||
|
||||
return {
|
||||
actionForm,
|
||||
actionLoading,
|
||||
actionTypes: employeeActionTypes,
|
||||
avatarFailed,
|
||||
avatarUrl,
|
||||
canEdit,
|
||||
canManageActions,
|
||||
canViewActions,
|
||||
copySuccess,
|
||||
creatingChat,
|
||||
editForm,
|
||||
error,
|
||||
fullName,
|
||||
handleAvatarChange,
|
||||
handleCloseActionModal,
|
||||
handleCloseEditModal,
|
||||
handleCopyToClipboard: copyToClipboard,
|
||||
handleDeleteAction,
|
||||
handleEditAction,
|
||||
handleOpenActionModal,
|
||||
handleOpenEditModal,
|
||||
handleSaveAction,
|
||||
handleSaveEdit,
|
||||
handleStartChat,
|
||||
initials,
|
||||
isActionModalOpen,
|
||||
isEditModalOpen,
|
||||
latestAction,
|
||||
loading,
|
||||
person,
|
||||
setActionField,
|
||||
setAvatarFailed,
|
||||
setEditField,
|
||||
sortedActions,
|
||||
};
|
||||
}
|
||||
@ -1,12 +1,19 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getWebSocketUrl } from '@/lib/url';
|
||||
import type { Message } from '@/types/api';
|
||||
|
||||
type ReactionsSummary = Record<string, { count: number; users?: number[]; user_names?: string[] }>;
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
payload?: any;
|
||||
payload?: Record<string, unknown>;
|
||||
message?: Message;
|
||||
message_id?: number;
|
||||
user_id?: number;
|
||||
[key: string]: any;
|
||||
chat_id?: number;
|
||||
last_read_message_id?: number;
|
||||
reactions_summary?: ReactionsSummary;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface WebSocketHandlers {
|
||||
@ -22,19 +29,27 @@ interface UseWebSocketOptions {
|
||||
autoConnect?: boolean;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
onReconnectExhausted?: (details: {
|
||||
chatId: number | null;
|
||||
reconnectAttempts: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function useWebSocket({
|
||||
chatId,
|
||||
autoConnect = true,
|
||||
reconnectInterval = 3000,
|
||||
maxReconnectAttempts = 5
|
||||
maxReconnectAttempts = 5,
|
||||
onReconnectExhausted,
|
||||
}: UseWebSocketOptions) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isConnectingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const shouldReconnectRef = useRef(autoConnect);
|
||||
const connectRef = useRef<() => void>(() => {});
|
||||
|
||||
const handlers = useRef<WebSocketHandlers>({
|
||||
onMessage: () => {},
|
||||
@ -49,6 +64,13 @@ export function useWebSocket({
|
||||
return;
|
||||
}
|
||||
|
||||
shouldReconnectRef.current = autoConnect;
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
try {
|
||||
@ -73,6 +95,7 @@ export function useWebSocket({
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setReconnectAttempts(0);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
@ -105,8 +128,6 @@ export function useWebSocket({
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
console.warn('⚠️ WebSocket не может подключиться. Убедитесь что backend запущен через Daphne:');
|
||||
console.warn(' cd backend && c:/Users/igor_/Dev/EUSRR/.venv/Scripts/python -m daphne -p 9000 eusrr_backend.asgi:application');
|
||||
handlers.current.onError(error);
|
||||
isConnectingRef.current = false;
|
||||
};
|
||||
@ -114,29 +135,39 @@ export function useWebSocket({
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false;
|
||||
wsRef.current = null;
|
||||
handlers.current.onDisconnect();
|
||||
|
||||
// Автоматическое переподключение
|
||||
if (autoConnect && reconnectAttempts < maxReconnectAttempts) {
|
||||
console.log(`🔄 Reconnecting... (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`);
|
||||
if (shouldReconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
const nextAttempt = reconnectAttemptsRef.current + 1;
|
||||
reconnectAttemptsRef.current = nextAttempt;
|
||||
setReconnectAttempts(nextAttempt);
|
||||
console.log(`🔄 Reconnecting... (attempt ${nextAttempt}/${maxReconnectAttempts})`);
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
setReconnectAttempts(prev => prev + 1);
|
||||
connect();
|
||||
connectRef.current();
|
||||
}, reconnectInterval);
|
||||
} else if (reconnectAttempts >= maxReconnectAttempts) {
|
||||
} else if (shouldReconnectRef.current && reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||
console.error('❌ Max reconnection attempts reached. Backend may not support WebSocket.');
|
||||
console.warn('⚠️ Запустите backend через Daphne для WebSocket:');
|
||||
console.warn(' cd C:/Users/igor_/Dev/EUSRR/backend');
|
||||
console.warn(' c:/Users/igor_/Dev/EUSRR/.venv/Scripts/python -m daphne -p 9000 eusrr_backend.asgi:application');
|
||||
onReconnectExhausted?.({
|
||||
chatId,
|
||||
reconnectAttempts: reconnectAttemptsRef.current,
|
||||
});
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
isConnectingRef.current = false;
|
||||
}
|
||||
}, [chatId, autoConnect, reconnectInterval, maxReconnectAttempts, reconnectAttempts]);
|
||||
}, [chatId, autoConnect, reconnectInterval, maxReconnectAttempts, onReconnectExhausted]);
|
||||
|
||||
useEffect(() => {
|
||||
connectRef.current = connect;
|
||||
}, [connect]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
shouldReconnectRef.current = false;
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
@ -157,6 +188,7 @@ export function useWebSocket({
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setReconnectAttempts(0);
|
||||
isConnectingRef.current = false;
|
||||
}, [chatId]);
|
||||
@ -169,7 +201,7 @@ export function useWebSocket({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback((message: any) => {
|
||||
const sendMessage = useCallback((message: Record<string, unknown>) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
} else {
|
||||
@ -179,6 +211,8 @@ export function useWebSocket({
|
||||
|
||||
// Подключение при монтировании или изменении chatId
|
||||
useEffect(() => {
|
||||
shouldReconnectRef.current = autoConnect;
|
||||
|
||||
if (autoConnect && chatId) {
|
||||
connect();
|
||||
}
|
||||
@ -186,7 +220,7 @@ export function useWebSocket({
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [chatId, autoConnect]);
|
||||
}, [chatId, autoConnect, connect, disconnect]);
|
||||
|
||||
// Очистка при размонтировании
|
||||
useEffect(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
74
frontend/src/lib/api/calendar.ts
Normal file
74
frontend/src/lib/api/calendar.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { RequestFn, GetTokenFn } from './utils';
|
||||
|
||||
export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
return {
|
||||
getCalendarEvents: (params?: { start?: string; end?: string; calendar?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.start) qp.append('start', params.start);
|
||||
if (params?.end) qp.append('end', params.end);
|
||||
if (params?.calendar) qp.append('calendar', params.calendar.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/events/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getMyEvents: (params?: { start?: string; end?: string }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.start) qp.append('start', params.start);
|
||||
if (params?.end) qp.append('end', params.end);
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/events/my-events/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getCalendars: () => request('/api/v1/schedule/calendars/'),
|
||||
getCalendar: (id: number) => request(`/api/v1/schedule/calendars/${id}/`),
|
||||
createCalendar: (data: { name: string; slug?: string }) => request('/api/v1/schedule/calendars/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateCalendar: (id: number, data: { name?: string; slug?: string }) => request(`/api/v1/schedule/calendars/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteCalendar: (id: number): Promise<void> => request(`/api/v1/schedule/calendars/${id}/`, { method: 'DELETE' }),
|
||||
// Subscriptions
|
||||
getCalendarSubscriptions: () => request('/api/v1/schedule/subscriptions/'),
|
||||
subscribeToCalendar: (calendarId: number) => request('/api/v1/schedule/subscriptions/', { method: 'POST', body: JSON.stringify({ calendar: calendarId }) }),
|
||||
unsubscribeFromCalendar: (subscriptionId: number) => request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { method: 'DELETE' }),
|
||||
updateSubscription: (subscriptionId: number, data: Record<string, any>) => request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
// Events
|
||||
getEvent: (id: number) => request(`/api/v1/schedule/events/${id}/`),
|
||||
createEvent: (data: { title: string; description?: string; start: string; end: string; calendar: number; color_event?: string; rule?: number | null }) =>
|
||||
request('/api/v1/schedule/events/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateEvent: (id: number, data: Record<string, any>) => request(`/api/v1/schedule/events/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteEvent: (id: number): Promise<void> => request(`/api/v1/schedule/events/${id}/`, { method: 'DELETE' }),
|
||||
// Participants
|
||||
getCalendarParticipants: (calendarId: number) => request(`/api/v1/schedule/calendars/${calendarId}/participants/`),
|
||||
addCalendarParticipant: (calendarId: number, userId: number, distinction: string = 'viewer') =>
|
||||
request(`/api/v1/schedule/calendars/${calendarId}/add-participant/`, { method: 'POST', body: JSON.stringify({ user_id: userId, distinction }) }),
|
||||
removeCalendarParticipant: (calendarId: number, userId: number): Promise<void> =>
|
||||
request(`/api/v1/schedule/calendars/${calendarId}/remove-participant/${userId}/`, { method: 'DELETE' }),
|
||||
getEventParticipants: (eventId: number) => request(`/api/v1/schedule/relations/?event=${eventId}`),
|
||||
addEventParticipant: (eventId: number, userId: number, distinction: string = 'attendee') =>
|
||||
request('/api/v1/schedule/relations/', { method: 'POST', body: JSON.stringify({ event: eventId, object_id: userId, distinction }) }),
|
||||
removeEventParticipant: (relationId: number) => request(`/api/v1/schedule/relations/${relationId}/`, { method: 'DELETE' }),
|
||||
// Export/Import
|
||||
exportCalendarToICS: async (calendarId: number): Promise<Blob> => {
|
||||
const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/export-ical/`, { method: 'GET', headers: { 'Authorization': `Bearer ${getToken()}` } });
|
||||
if (!response.ok) throw new Error('Failed to export calendar');
|
||||
return response.blob();
|
||||
},
|
||||
importCalendarFromICS: async (calendarId: number, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/import-ical/`, { method: 'POST', headers: { 'Authorization': `Bearer ${getToken()}` }, body: fd });
|
||||
if (!response.ok) { const e = await response.json(); throw new Error(e.error || 'Failed to import calendar'); }
|
||||
return response.json();
|
||||
},
|
||||
// Rules
|
||||
createRule: (data: { name: string; description?: string; frequency: string; params?: any }) =>
|
||||
request('/api/v1/schedule/rules/', { method: 'POST', body: JSON.stringify({ ...data, params: data.params ? JSON.stringify(data.params) : '{}' }) }),
|
||||
updateRule: (ruleId: number, data: { name: string; description?: string; frequency: string; params?: any }) =>
|
||||
request(`/api/v1/schedule/rules/${ruleId}/`, { method: 'PUT', body: JSON.stringify({ ...data, params: data.params ? JSON.stringify(data.params) : '{}' }) }),
|
||||
getRules: () => request('/api/v1/schedule/rules/'),
|
||||
getRule: (ruleId: number) => request(`/api/v1/schedule/rules/${ruleId}/`),
|
||||
getOccurrences: (params: { start: string; end: string; calendar?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('start', params.start);
|
||||
qp.append('end', params.end);
|
||||
if (params.calendar) qp.append('calendar', params.calendar.toString());
|
||||
return request(`/api/v1/schedule/events/occurrences/?${qp.toString()}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
197
frontend/src/lib/api/client.ts
Normal file
197
frontend/src/lib/api/client.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Base API Client — token management, auth, HTTP request logic.
|
||||
* Domain-specific methods live in separate modules.
|
||||
*/
|
||||
|
||||
interface LoginCredentials {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
export class ApiClientBase {
|
||||
private tokenKey = 'access_token';
|
||||
private refreshTokenKey = 'refresh_token';
|
||||
private isRefreshing = false;
|
||||
private refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(this.tokenKey);
|
||||
}
|
||||
|
||||
private getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(this.refreshTokenKey);
|
||||
}
|
||||
|
||||
private setToken(token: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
}
|
||||
}
|
||||
|
||||
private setRefreshToken(token: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(this.refreshTokenKey, token);
|
||||
}
|
||||
}
|
||||
|
||||
clearToken(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(this.tokenKey);
|
||||
localStorage.removeItem(this.refreshTokenKey);
|
||||
}
|
||||
}
|
||||
|
||||
private onRefreshed(token: string): void {
|
||||
this.refreshSubscribers.forEach((callback) => callback(token));
|
||||
this.refreshSubscribers = [];
|
||||
}
|
||||
|
||||
private addRefreshSubscriber(callback: (token: string) => void): void {
|
||||
this.refreshSubscribers.push(callback);
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/token/refresh/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.clearToken();
|
||||
throw new Error('Refresh token expired');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
this.setToken(data.access);
|
||||
if (data.refresh) {
|
||||
this.setRefreshToken(data.refresh);
|
||||
}
|
||||
return data.access;
|
||||
}
|
||||
|
||||
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (!(options.body instanceof FormData) && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, { ...options, headers });
|
||||
|
||||
if (response.status === 401 && this.getRefreshToken()) {
|
||||
if (!this.isRefreshing) {
|
||||
this.isRefreshing = true;
|
||||
try {
|
||||
const newToken = await this.refreshAccessToken();
|
||||
this.isRefreshing = false;
|
||||
this.onRefreshed(newToken);
|
||||
|
||||
headers['Authorization'] = `Bearer ${newToken}`;
|
||||
const retryResponse = await fetch(endpoint, { ...options, headers });
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
let errorDetails = '';
|
||||
try { const d = await retryResponse.json(); errorDetails = d.detail || JSON.stringify(d); } catch { errorDetails = retryResponse.statusText; }
|
||||
throw new Error(`API Error: ${retryResponse.status} ${errorDetails}`);
|
||||
}
|
||||
if (options.method === 'DELETE' && retryResponse.status === 204) return undefined as T;
|
||||
const cl = retryResponse.headers.get('content-length');
|
||||
if (cl === '0' || retryResponse.status === 204) return undefined as T;
|
||||
return retryResponse.json();
|
||||
} catch (refreshError) {
|
||||
this.isRefreshing = false;
|
||||
this.refreshSubscribers = [];
|
||||
throw refreshError;
|
||||
}
|
||||
} else {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.addRefreshSubscriber((newToken: string) => {
|
||||
headers['Authorization'] = `Bearer ${newToken}`;
|
||||
fetch(endpoint, { ...options, headers })
|
||||
.then((res) => {
|
||||
if (!res.ok) { reject(new Error(`API Error: ${res.status}`)); return; }
|
||||
if (options.method === 'DELETE' && res.status === 204) { resolve(undefined as T); return; }
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => resolve(data))
|
||||
.catch(reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetails = '';
|
||||
try { const d = await response.json(); errorDetails = d.detail || JSON.stringify(d); } catch { errorDetails = response.statusText; }
|
||||
throw new Error(`API Error: ${response.status} ${errorDetails}`);
|
||||
}
|
||||
|
||||
if (options.method === 'DELETE' && response.status === 204) return undefined as T;
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength === '0' || response.status === 204) return undefined as T;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────
|
||||
|
||||
async login(credentials: LoginCredentials): Promise<void> {
|
||||
const response = await fetch('/api/auth/token/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
|
||||
const data: LoginResponse = await response.json();
|
||||
this.setToken(data.access);
|
||||
this.setRefreshToken(data.refresh);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async getCurrentUser(): Promise<any> {
|
||||
return this.request('/api/v1/employees/me/');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async search(query: string, limit?: number): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('q', query);
|
||||
if (limit) queryParams.append('limit', limit.toString());
|
||||
return this.request(`/api/v1/search/?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async updateCurrentUserProfile(data: Record<string, any>): Promise<any> {
|
||||
const formData = new FormData();
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (data[key] instanceof File) { formData.append(key, data[key]); }
|
||||
else if (data[key] !== null && data[key] !== undefined) { formData.append(key, data[key]); }
|
||||
});
|
||||
const token = this.getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch('/api/v1/employees/me/', { method: 'PATCH', headers, body: formData });
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
86
frontend/src/lib/api/documents.ts
Normal file
86
frontend/src/lib/api/documents.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import type { RequestFn } from './utils';
|
||||
|
||||
export function createDocumentsApi(request: RequestFn) {
|
||||
return {
|
||||
getDocuments: (params?: { search?: string; type?: string; status?: string; page?: number; limit?: number; folder_id?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.type) qp.append('type', params.type);
|
||||
if (params?.status) qp.append('status', params.status);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
if (params?.folder_id !== undefined) qp.append('folder_id', params.folder_id.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/documents/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getDocument: (id: number) => request(`/api/v1/documents/${id}/`),
|
||||
createDocument: (data: { title: string; description?: string; file: File | Blob; extracted_text?: string; sent_to_all?: boolean; recipient_ids?: number[]; department_ids?: number[]; folder_id?: number | null; acknowledgement_required?: boolean; tag_ids?: number[] }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('title', data.title);
|
||||
if (data.description) fd.append('description', data.description);
|
||||
if (data.extracted_text) fd.append('extracted_text', data.extracted_text);
|
||||
if (data.folder_id) fd.append('folder', String(data.folder_id));
|
||||
fd.append('sent_to_all', String(data.sent_to_all ?? true));
|
||||
if (data.acknowledgement_required !== undefined) fd.append('acknowledgement_required', String(data.acknowledgement_required));
|
||||
data.recipient_ids?.forEach(id => fd.append('recipient_ids', String(id)));
|
||||
data.department_ids?.forEach(id => fd.append('department_ids', String(id)));
|
||||
data.tag_ids?.forEach(id => fd.append('tag_ids', String(id)));
|
||||
fd.append('file', data.file);
|
||||
return request('/api/v1/documents/', { method: 'POST', body: fd });
|
||||
},
|
||||
updateDocument: (id: number, data: { title?: string; description?: string; file?: File; tag_ids?: number[]; folder?: number | null }) => {
|
||||
const fd = new FormData();
|
||||
if (data.title !== undefined) fd.append('title', data.title);
|
||||
if (data.description !== undefined) fd.append('description', data.description);
|
||||
if (data.file) fd.append('file', data.file);
|
||||
if (data.folder !== undefined) fd.append('folder', data.folder === null ? '' : String(data.folder));
|
||||
data.tag_ids?.forEach(tagId => fd.append('tag_ids', String(tagId)));
|
||||
return request(`/api/v1/documents/${id}/`, { method: 'PATCH', body: fd });
|
||||
},
|
||||
deleteDocument: (id: number): Promise<void> => request(`/api/v1/documents/${id}/`, { method: 'DELETE' }),
|
||||
acknowledgeDocument: (id: number) => request(`/api/v1/documents/${id}/acknowledge/`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
getDocumentAcknowledgements: (id: number, search?: string) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (search) qp.append('search', search);
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/documents/${id}/acknowledgements/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
// Folders
|
||||
getFolders: (params?: { parent_id?: number; root?: boolean }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.parent_id !== undefined) qp.append('parent_id', params.parent_id.toString());
|
||||
if (params?.root) qp.append('root', 'true');
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/folders/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getFolder: (id: number) => request(`/api/v1/folders/${id}/`),
|
||||
createFolder: (data: { name: string; parent?: number | null }) => request('/api/v1/folders/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateFolder: (id: number, data: { name?: string; parent?: number | null }) => request(`/api/v1/folders/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteFolder: (id: number): Promise<void> => request(`/api/v1/folders/${id}/`, { method: 'DELETE' }),
|
||||
getFolderChildren: (id: number) => request(`/api/v1/folders/${id}/children/`),
|
||||
getFolderDocuments: (id: number) => request(`/api/v1/folders/${id}/documents/`),
|
||||
// Comments
|
||||
getDocumentComments: (documentId: number) => request(`/api/v1/document-comments/?document=${documentId}`),
|
||||
createDocumentComment: (data: { document: number; text: string; parent?: number }) =>
|
||||
request('/api/v1/document-comments/', { method: 'POST', body: JSON.stringify({ document_id: data.document, text: data.text, parent_id: data.parent }) }),
|
||||
updateDocumentComment: (id: number, text: string) => request(`/api/v1/document-comments/${id}/`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
||||
deleteDocumentComment: (id: number): Promise<void> => request(`/api/v1/document-comments/${id}/`, { method: 'DELETE' }),
|
||||
getCommentReplies: (commentId: number) => request(`/api/v1/document-comments/${commentId}/replies/`),
|
||||
// Tags
|
||||
getDocumentTags: () => request('/api/v1/document-tags/'),
|
||||
getDocumentTag: (id: number) => request(`/api/v1/document-tags/${id}/`),
|
||||
createDocumentTag: (data: { name: string; color?: string }) => request('/api/v1/document-tags/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateDocumentTag: (id: number, data: { name?: string; color?: string }) => request(`/api/v1/document-tags/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteDocumentTag: (id: number): Promise<void> => request(`/api/v1/document-tags/${id}/`, { method: 'DELETE' }),
|
||||
getDocumentsByTag: (tagId: number) => request(`/api/v1/document-tags/${tagId}/documents/`),
|
||||
// Related
|
||||
getRelatedDocuments: (id: number) => request(`/api/v1/documents/${id}/related/`),
|
||||
addRelatedDocument: (id: number, relatedId: number) => request(`/api/v1/documents/${id}/add_related/`, { method: 'POST', body: JSON.stringify({ related_document_id: relatedId }) }),
|
||||
removeRelatedDocument: (id: number, relatedId: number) => request(`/api/v1/documents/${id}/remove_related/`, { method: 'DELETE', body: JSON.stringify({ related_document_id: relatedId }) }),
|
||||
// Thumbnails
|
||||
getDocumentThumbnail: (id: number, size?: 'small' | 'medium' | 'large' | 'original'): string => {
|
||||
const sizeParam = size ? `?size=${size}` : '';
|
||||
return `/api/v1/documents/${id}/thumbnail/${sizeParam}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
39
frontend/src/lib/api/employees.ts
Normal file
39
frontend/src/lib/api/employees.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { type RequestFn } from './utils';
|
||||
|
||||
export function createEmployeesApi(request: RequestFn) {
|
||||
return {
|
||||
getEmployees: (params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.department) qp.append('department', params.department);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
if (params?.is_active !== undefined) qp.append('active', params.is_active.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/employees/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getEmployee: (id: number | string) => request(`/api/v1/employees/${id}/`),
|
||||
updateEmployee: (id: number | string, data: Record<string, any>) =>
|
||||
request(`/api/v1/employees/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
uploadEmployeeAvatar: (id: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('avatar', file);
|
||||
return request(`/api/v1/employees/${id}/`, { method: 'PATCH', body: fd });
|
||||
},
|
||||
createEmployeeAction: (data: { employee: number; action: string; date: string; comment?: string }) =>
|
||||
request('/api/v1/employee-actions/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateEmployeeAction: (actionId: number, data: { action?: string; date?: string; comment?: string }) =>
|
||||
request(`/api/v1/employee-actions/${actionId}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteEmployeeAction: (actionId: number) =>
|
||||
request(`/api/v1/employee-actions/${actionId}/`, { method: 'DELETE' }),
|
||||
getDepartments: (params?: { search?: string; page?: number; limit?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/departments/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getDepartment: (id: number | string) => request(`/api/v1/departments/${id}/`),
|
||||
};
|
||||
}
|
||||
39
frontend/src/lib/api/equipment.ts
Normal file
39
frontend/src/lib/api/equipment.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { buildQuery, type RequestFn, type GetTokenFn } from './utils';
|
||||
|
||||
export function createEquipmentApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
return {
|
||||
getEquipmentCategories: (params?: Record<string, string | number>) => request(`/api/v1/procurement/equipment-categories/${buildQuery(params)}`),
|
||||
getEquipment: (params?: Record<string, string | number>) => request(`/api/v1/procurement/equipment/${buildQuery(params)}`),
|
||||
getEquipmentDetail: (id: number) => request(`/api/v1/procurement/equipment/${id}/`),
|
||||
getMyEquipment: (params?: Record<string, string | number>) => request(`/api/v1/procurement/equipment/my_equipment/${buildQuery(params)}`),
|
||||
getWarrantyExpiringEquipment: () => request('/api/v1/procurement/equipment/warranty_expiring/'),
|
||||
generateEquipmentInventoryNumber: (): Promise<{ inventory_number: string }> => request('/api/v1/procurement/equipment/generate-inventory-number/'),
|
||||
getEquipmentCreateOptions: () => request('/api/v1/procurement/equipment/create-options/'),
|
||||
createEquipment: (data: Record<string, any>) => request('/api/v1/procurement/equipment/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateEquipment: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/equipment/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
deleteEquipment: (id: number): Promise<void> => request(`/api/v1/procurement/equipment/${id}/`, { method: 'DELETE' }),
|
||||
transferEquipment: (id: number, data: { to_department?: number | null; to_person?: number | null; reason?: string }) =>
|
||||
request(`/api/v1/procurement/equipment/${id}/transfer/`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
writeOffEquipment: (id: number, reason: string) =>
|
||||
request(`/api/v1/procurement/equipment/${id}/write_off/`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
addEquipmentMaintenance: (id: number, data: { type: string; description?: string; cost?: string | number; date?: string }) =>
|
||||
request(`/api/v1/procurement/equipment/${id}/add_maintenance/`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
getEquipmentTransferHistory: (id: number) => request(`/api/v1/procurement/equipment/${id}/transfer_history/`),
|
||||
getMaintenanceRecords: (params?: Record<string, string | number>) => request(`/api/v1/procurement/maintenance/${buildQuery(params)}`),
|
||||
getEquipmentCategoryTree: () => request('/api/v1/procurement/equipment-categories/tree/'),
|
||||
getEquipmentCategoryChildren: (categoryId: number) => request(`/api/v1/procurement/equipment-categories/${categoryId}/children/`),
|
||||
getEquipmentQrCodeBlobUrl: async (equipmentId: number): Promise<string> => {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(`/api/v1/procurement/equipment/${equipmentId}/qr_code/`, { method: 'GET', headers });
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
},
|
||||
getEquipmentComments: (equipmentId: number) => request(`/api/v1/procurement/equipment/${equipmentId}/comments/`),
|
||||
addEquipmentComment: (equipmentId: number, text: string) => request(`/api/v1/procurement/equipment/${equipmentId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
deleteEquipmentComment: (equipmentId: number, commentId: number): Promise<void> => request(`/api/v1/procurement/equipment/${equipmentId}/comments/${commentId}/`, { method: 'DELETE' }),
|
||||
};
|
||||
}
|
||||
64
frontend/src/lib/api/feed.ts
Normal file
64
frontend/src/lib/api/feed.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { RequestFn, GetTokenFn } from './utils';
|
||||
|
||||
export function createFeedApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
async function formDataPost(url: string, method: string, data: Record<string, any>) {
|
||||
const fd = new FormData();
|
||||
if (data.title) fd.append('title', data.title);
|
||||
if (data.body) fd.append('body', data.body);
|
||||
if (data.content) fd.append('content', data.content);
|
||||
if (data.type) fd.append('type', data.type);
|
||||
if (data.department !== undefined) fd.append('department', String(data.department));
|
||||
if (data.image) fd.append('image', data.image);
|
||||
if (data.attachment) fd.append('attachment', data.attachment);
|
||||
if (data.attachments) data.attachments.forEach((f: File) => fd.append('attachments', f));
|
||||
const token = getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(url, { method, headers, body: fd });
|
||||
if (!response.ok) { const t = await response.text(); console.error('API Error Response:', t); throw new Error(`API Error: ${response.status} ${response.statusText}`); }
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return {
|
||||
getPosts: (params?: { page?: number; search?: string; limit?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/posts/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
createPost: (data: Record<string, any>) => formDataPost('/api/v1/posts/', 'POST', data),
|
||||
updatePost: (postId: number, data: Record<string, any>) => formDataPost(`/api/v1/posts/${postId}/`, 'PATCH', data),
|
||||
deletePost: (postId: number): Promise<void> => request(`/api/v1/posts/${postId}/`, { method: 'DELETE' }),
|
||||
likePost: (postId: number) => request(`/api/v1/posts/${postId}/like/`, { method: 'POST' }),
|
||||
unlikePost: (postId: number) => request(`/api/v1/posts/${postId}/unlike/`, { method: 'POST' }),
|
||||
getPostLikers: (postId: number) => request(`/api/v1/posts/${postId}/likers/`),
|
||||
getComments: (params: { post: number; page?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params.page) qp.append('page', params.page.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/posts/${params.post}/comments/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
createComment: async (postId: number, text: string, image?: File, attachment?: File) => {
|
||||
if (image || attachment) {
|
||||
const fd = new FormData(); fd.append('text', text);
|
||||
if (image) fd.append('image', image);
|
||||
if (attachment) fd.append('attachment', attachment);
|
||||
const token = getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(`/api/v1/posts/${postId}/comments/`, { method: 'POST', headers, body: fd });
|
||||
if (!response.ok) { const t = await response.text(); console.error('API Error Response:', t); throw new Error(`API Error: ${response.status} ${response.statusText}`); }
|
||||
return response.json();
|
||||
}
|
||||
return request(`/api/v1/posts/${postId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) });
|
||||
},
|
||||
updateComment: async (commentId: number, text: string) => {
|
||||
const response: any = await request(`/api/v1/communications/messages/${commentId}/`, { method: 'PATCH', body: JSON.stringify({ content: text }) });
|
||||
return { ...response, text: response.content || text };
|
||||
},
|
||||
deleteComment: (commentId: number): Promise<void> => request(`/api/v1/communications/messages/${commentId}/`, { method: 'DELETE' }),
|
||||
};
|
||||
}
|
||||
32
frontend/src/lib/api/index.ts
Normal file
32
frontend/src/lib/api/index.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* API Client — assembled from domain modules.
|
||||
* Import: import { apiClient } from "@/lib/api"
|
||||
*/
|
||||
import { ApiClientBase } from './client';
|
||||
import { createEmployeesApi } from './employees';
|
||||
import { createDocumentsApi } from './documents';
|
||||
import { createMessagesApi } from './messages';
|
||||
import { createNotificationsApi } from './notifications';
|
||||
import { createCalendarApi } from './calendar';
|
||||
import { createFeedApi } from './feed';
|
||||
import { createRequestsApi } from './requests';
|
||||
import { createEquipmentApi } from './equipment';
|
||||
import { createProcurementApi } from './procurement';
|
||||
|
||||
const base = new ApiClientBase();
|
||||
const req = base.request.bind(base);
|
||||
const tok = base.getToken.bind(base);
|
||||
|
||||
export const apiClient = Object.assign(base, {
|
||||
...createEmployeesApi(req),
|
||||
...createDocumentsApi(req),
|
||||
...createMessagesApi(req, tok),
|
||||
...createNotificationsApi(req),
|
||||
...createCalendarApi(req, tok),
|
||||
...createFeedApi(req, tok),
|
||||
...createRequestsApi(req, tok),
|
||||
...createEquipmentApi(req, tok),
|
||||
...createProcurementApi(req),
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
82
frontend/src/lib/api/messages.ts
Normal file
82
frontend/src/lib/api/messages.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { RequestFn, GetTokenFn } from './utils';
|
||||
|
||||
export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
async function uploadMessage(chatId: number | string, text: string, files: File[], replyTo?: number) {
|
||||
const fd = new FormData();
|
||||
fd.append('content', text);
|
||||
fd.append('chat_id', chatId.toString());
|
||||
if (replyTo) fd.append('reply_to', replyTo.toString());
|
||||
files.forEach((file, i) => fd.append(`file_${i}`, file));
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch('/api/v1/communications/messages/upload/', { method: 'POST', headers, body: fd });
|
||||
if (!response.ok) {
|
||||
let d = ''; try { const e = await response.json(); d = e.detail || JSON.stringify(e); } catch { d = response.statusText; }
|
||||
throw new Error(`API Error: ${response.status} ${d}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
return result.message || result;
|
||||
}
|
||||
|
||||
return {
|
||||
getChats: (params?: { search?: string; page?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/communications/chats/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/`),
|
||||
createChat: (data: { type: string; participants?: number[]; name?: string; description?: string; department?: number; include_all_employees?: boolean; avatar?: File }) => {
|
||||
if (data.avatar) {
|
||||
const fd = new FormData();
|
||||
fd.append('type', data.type);
|
||||
if (data.name) fd.append('name', data.name);
|
||||
if (data.description) fd.append('description', data.description);
|
||||
if (data.avatar) fd.append('avatar', data.avatar);
|
||||
data.participants?.forEach(id => fd.append('participants', id.toString()));
|
||||
if (data.department) fd.append('department', data.department.toString());
|
||||
if (data.include_all_employees !== undefined) fd.append('include_all_employees', data.include_all_employees.toString());
|
||||
return request('/api/v1/communications/chats/', { method: 'POST', body: fd });
|
||||
}
|
||||
return request('/api/v1/communications/chats/', { method: 'POST', body: JSON.stringify(data) });
|
||||
},
|
||||
getChatMessages: (chatId: number, params?: { limit?: number; before_id?: number; after_id?: number; mark_read?: boolean; before?: string; after?: string }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
if (params?.before_id) qp.append('before_id', params.before_id.toString());
|
||||
if (params?.after_id) qp.append('after_id', params.after_id.toString());
|
||||
if (typeof params?.mark_read === 'boolean') qp.append('mark_read', String(params.mark_read));
|
||||
if (params?.before) qp.append('before', params.before);
|
||||
if (params?.after) qp.append('after', params.after);
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/communications/chats/${chatId}/messages/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getChatMessagesAround: (chatId: number | string, params?: { limit?: number; around_id?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
if (params?.around_id) qp.append('around_id', params.around_id.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/communications/chats/${chatId}/messages-around/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
sendMessage: (chatId: number | string, text: string, replyTo?: number) => uploadMessage(chatId, text, [], replyTo),
|
||||
sendMessageWithFiles: (chatId: number | string, text: string, files: File[], replyTo?: number) => uploadMessage(chatId, text, files, replyTo),
|
||||
updateMessage: (messageId: number, text: string) => request(`/api/v1/communications/messages/${messageId}/`, { method: 'PATCH', body: JSON.stringify({ content: text }) }),
|
||||
deleteMessage: (messageId: number): Promise<void> => request(`/api/v1/communications/messages/${messageId}/`, { method: 'DELETE' }),
|
||||
markChatAsRead: (chatId: number, lastReadMessageId?: number) => request(`/api/v1/communications/chats/${chatId}/mark-read/`, { method: 'POST', body: JSON.stringify({ message_id: lastReadMessageId }) }),
|
||||
togglePinChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/pin/`, { method: 'POST' }),
|
||||
toggleChatNotifications: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/notifications/`, { method: 'POST' }),
|
||||
leaveChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/leave/`, { method: 'POST' }),
|
||||
deleteChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/`, { method: 'DELETE' }),
|
||||
updateChat: (chatId: number, data: Record<string, any>) => request(`/api/v1/communications/chats/${chatId}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
uploadChatAvatar: (chatId: number, file: File) => { const fd = new FormData(); fd.append('avatar', file); return request(`/api/v1/communications/chats/${chatId}/`, { method: 'PATCH', body: fd }); },
|
||||
addChatMember: (chatId: number, userId: number) => request(`/api/v1/communications/chats/${chatId}/add-member/`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }),
|
||||
removeChatMember: (chatId: number, userId: number) => request(`/api/v1/communications/chats/${chatId}/remove-member/`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }),
|
||||
changeChatMemberRole: (chatId: number, userId: number, role: 'admin' | 'moderator' | 'member' | 'guest') => request(`/api/v1/communications/chats/${chatId}/change-role/`, { method: 'POST', body: JSON.stringify({ user_id: userId, role }) }),
|
||||
updateChatUserSettings: (chatId: number, data: Record<string, any>) => request(`/api/v1/communications/chats/${chatId}/user-settings/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
reactToMessage: (messageId: number, emoji: string) => request(`/api/v1/communications/messages/${messageId}/react/`, { method: 'POST', body: JSON.stringify({ emoji }) }),
|
||||
unreactToMessage: (messageId: number, emoji: string) => request(`/api/v1/communications/messages/${messageId}/unreact/`, { method: 'POST', body: JSON.stringify({ emoji }) }),
|
||||
};
|
||||
}
|
||||
37
frontend/src/lib/api/notifications.ts
Normal file
37
frontend/src/lib/api/notifications.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { RequestFn } from './utils';
|
||||
|
||||
export function createNotificationsApi(request: RequestFn) {
|
||||
return {
|
||||
getNotifications: (params?: { page?: number; page_size?: number; unread_only?: boolean }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.page) qp.append('page', String(params.page));
|
||||
if (params?.page_size) qp.append('page_size', String(params.page_size));
|
||||
if (params?.unread_only) qp.append('unread_only', 'true');
|
||||
const qs = qp.toString();
|
||||
return request(qs ? `/api/v1/notifications/?${qs}` : '/api/v1/notifications/');
|
||||
},
|
||||
getUnreadNotificationsCount: (): Promise<{ count: number }> => request('/api/v1/notifications/count/'),
|
||||
markNotificationAsRead: (id: number): Promise<void> => request(`/api/v1/notifications/${id}/read/`, { method: 'POST' }),
|
||||
markAllNotificationsAsRead: (): Promise<void> => request('/api/v1/notifications/read-all/', { method: 'POST' }),
|
||||
markCategoryAsRead: async (category: string): Promise<{ status: string; count: number }> => {
|
||||
const { getVerbsByCategory } = await import('../verbTranslations');
|
||||
const verbs = getVerbsByCategory(category);
|
||||
return request('/api/v1/notifications/category/read/', { method: 'POST', body: JSON.stringify({ verbs, category }) });
|
||||
},
|
||||
// Push
|
||||
getVapidPublicKey: (): Promise<{ vapid_public_key: string }> => request('/api/v1/notifications/push/vapid-key/'),
|
||||
subscribePush: (data: { endpoint: string; keys: { p256dh: string; auth: string }; device_name?: string }): Promise<{ status: string; message: string; created: boolean }> =>
|
||||
request('/api/v1/notifications/push/subscribe/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
unsubscribePush: (endpoint?: string): Promise<{ status: string; message: string }> =>
|
||||
request('/api/v1/notifications/push/unsubscribe/', { method: 'DELETE', body: JSON.stringify({ endpoint }) }),
|
||||
getPushSubscriptions: (): Promise<{ subscriptions: any[] }> => request('/api/v1/notifications/push/subscriptions/'),
|
||||
// CRUD
|
||||
deleteNotification: (id: number): Promise<void> => request(`/api/v1/notifications/${id}/`, { method: 'DELETE' }),
|
||||
deleteAllReadNotifications: (): Promise<{ status: string; count: number }> => request('/api/v1/notifications/delete-all-read/', { method: 'DELETE' }),
|
||||
// Preferences
|
||||
getNotificationPreferences: () => request('/api/v1/notifications/preferences/'),
|
||||
updateNotificationPreferences: (data: Record<string, any>) => request('/api/v1/notifications/preferences/', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
getVerbTypes: (): Promise<{ verb_types: any[] }> => request('/api/v1/notifications/verb-types/'),
|
||||
};
|
||||
}
|
||||
37
frontend/src/lib/api/procurement.ts
Normal file
37
frontend/src/lib/api/procurement.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { buildQuery, type RequestFn } from './utils';
|
||||
|
||||
export function createProcurementApi(request: RequestFn) {
|
||||
return {
|
||||
getProcurementRequests: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/${buildQuery(params)}`),
|
||||
getProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/`),
|
||||
createProcurementRequest: (data: Record<string, any>) => request('/api/v1/procurement/requests/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateProcurementRequest: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/requests/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
deleteProcurementRequest: (id: number): Promise<void> => request(`/api/v1/procurement/requests/${id}/`, { method: 'DELETE' }),
|
||||
submitProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/submit/`, { method: 'POST' }),
|
||||
approveProcurementRequest: (id: number, comment?: string) =>
|
||||
request(`/api/v1/procurement/requests/${id}/approve/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: comment || '' }) }),
|
||||
rejectProcurementRequest: (id: number, comment?: string) =>
|
||||
request(`/api/v1/procurement/requests/${id}/reject/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: comment || '' }) }),
|
||||
startWorkProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/start_work/`, { method: 'POST' }),
|
||||
completeProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/complete/`, { method: 'POST' }),
|
||||
cancelProcurementRequest: (id: number, reason?: string) =>
|
||||
request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }),
|
||||
getMyProcurementRequests: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/my_requests/${buildQuery(params)}`),
|
||||
getPendingApprovals: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/pending_approvals/${buildQuery(params)}`),
|
||||
// Items
|
||||
getProcurementItems: (params?: Record<string, string | number>) => request(`/api/v1/procurement/items/${buildQuery(params)}`),
|
||||
createProcurementItem: (data: Record<string, any>) => request('/api/v1/procurement/items/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateProcurementItem: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/items/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
|
||||
// Suppliers
|
||||
getProcurementSuppliers: (params?: Record<string, string | number>) => request(`/api/v1/procurement/suppliers/${buildQuery(params)}`),
|
||||
createProcurementSupplier: (data: Record<string, any>) => request('/api/v1/procurement/suppliers/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateProcurementSupplier: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/suppliers/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
deleteProcurementSupplier: (id: number): Promise<void> => request(`/api/v1/procurement/suppliers/${id}/`, { method: 'DELETE' }),
|
||||
getTopRatedProcurementSuppliers: () => request('/api/v1/procurement/suppliers/top_rated/'),
|
||||
// Stats
|
||||
getProcurementOverviewStats: () => request('/api/v1/procurement/stats/overview/'),
|
||||
getProcurementDepartmentStats: () => request('/api/v1/procurement/stats/by-department/'),
|
||||
};
|
||||
}
|
||||
42
frontend/src/lib/api/requests.ts
Normal file
42
frontend/src/lib/api/requests.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { RequestFn, GetTokenFn } from './utils';
|
||||
|
||||
export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
async function formDataRequest(url: string, method: string, data: Record<string, any>, saveAs?: 'draft' | 'submitted') {
|
||||
const fd = new FormData();
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key === 'attachments' && Array.isArray(data[key])) { data[key].forEach((f: File) => fd.append('attachments', f)); }
|
||||
else if (key === 'attachment' && data[key] instanceof File) { fd.append('attachment', data[key]); }
|
||||
else if (Array.isArray(data[key])) { data[key].forEach((v: any) => fd.append(key, String(v))); }
|
||||
else if (data[key] !== null && data[key] !== undefined) { fd.append(key, String(data[key])); }
|
||||
});
|
||||
if (saveAs) fd.append('save_as', saveAs);
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(url, { method, headers, body: fd });
|
||||
if (!response.ok) { let d = `${response.status} ${response.statusText}`; try { d = JSON.stringify(await response.json()); } catch {} throw new Error(d); }
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return {
|
||||
getRequests: (params?: Record<string, any>) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params) {
|
||||
const keys = ['status','type','search','page','limit','view','addressed_to_me','employee_id','created_from','created_to','date_from','date_to'];
|
||||
keys.forEach(k => { if (params[k]) qp.append(k, String(params[k])); });
|
||||
}
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/requests/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
createRequest: (data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
|
||||
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
|
||||
deleteRequest: (requestId: number): Promise<void> => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),
|
||||
getRequestComments: (requestId: number) => request(`/api/v1/requests/${requestId}/comments/`),
|
||||
deleteRequestComment: (requestId: number, commentId: number): Promise<void> => request(`/api/v1/requests/${requestId}/comments/${commentId}/`, { method: 'DELETE' }),
|
||||
approveRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/approve/`, { method: 'POST' }),
|
||||
rejectRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/reject/`, { method: 'POST' }),
|
||||
cancelRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/cancel/`, { method: 'POST' }),
|
||||
addRequestComment: (requestId: number, text: string) => request(`/api/v1/requests/${requestId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
};
|
||||
}
|
||||
16
frontend/src/lib/api/utils.ts
Normal file
16
frontend/src/lib/api/utils.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/** Helper to build URL query string from params object */
|
||||
export function buildQuery(params?: Record<string, string | number | boolean | undefined | null>): string {
|
||||
if (!params) return '';
|
||||
const qp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
qp.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const qs = qp.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RequestFn = (endpoint: string, options?: RequestInit) => Promise<any>;
|
||||
export type GetTokenFn = () => string | null;
|
||||
81
frontend/src/lib/messages/chatSettingsUtils.ts
Normal file
81
frontend/src/lib/messages/chatSettingsUtils.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { Chat, ChatMembership, User } from "@/types/api";
|
||||
|
||||
export type MemberSearchResult = Pick<User, "id" | "first_name" | "last_name" | "email" | "avatar"> & {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type ChatMemberRole = ChatMembership["role"] | null;
|
||||
|
||||
export function getChatTypeLabel(chat: Chat): string {
|
||||
const type = chat.chat_type || chat.type;
|
||||
switch (type) {
|
||||
case "global":
|
||||
return "Глобальный чат";
|
||||
case "channel":
|
||||
return "Канал";
|
||||
case "private":
|
||||
case "direct":
|
||||
return "Личный чат";
|
||||
case "group":
|
||||
return "Групповой чат";
|
||||
case "announcement":
|
||||
return "Канал объявлений";
|
||||
case "comments":
|
||||
return "Комментарии";
|
||||
default:
|
||||
return "Диалог";
|
||||
}
|
||||
}
|
||||
|
||||
export function getMemberRole(chat: Chat, userId: number): ChatMemberRole {
|
||||
if (chat.created_by === userId) return null;
|
||||
|
||||
const membership = chat.memberships?.find((item) => item.user === userId);
|
||||
return membership?.role || "member";
|
||||
}
|
||||
|
||||
export function getRoleLabel(role: ChatMemberRole): string {
|
||||
if (role === null) return "Владелец";
|
||||
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "Админ";
|
||||
case "moderator":
|
||||
return "Модератор";
|
||||
case "member":
|
||||
return "Участник";
|
||||
case "guest":
|
||||
return "Гость";
|
||||
default:
|
||||
return "Участник";
|
||||
}
|
||||
}
|
||||
|
||||
export function getRoleBadgeColor(role: ChatMemberRole): string {
|
||||
if (role === null) return "bg-purple-100 text-purple-700 ring-purple-200";
|
||||
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "bg-red-100 text-red-700 ring-red-200";
|
||||
case "moderator":
|
||||
return "bg-blue-100 text-blue-700 ring-blue-200";
|
||||
case "member":
|
||||
return "bg-gray-100 text-gray-700 ring-gray-200";
|
||||
case "guest":
|
||||
return "bg-amber-100 text-amber-700 ring-amber-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 ring-gray-200";
|
||||
}
|
||||
}
|
||||
|
||||
export function getParticipantInitials(name?: string): string {
|
||||
return (
|
||||
(name || "?")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0] || "")
|
||||
.join("")
|
||||
.toUpperCase() || "?"
|
||||
);
|
||||
}
|
||||
124
frontend/src/lib/messages/chatUtils.ts
Normal file
124
frontend/src/lib/messages/chatUtils.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import type { Chat, Message } from "@/types/api";
|
||||
|
||||
export type ChatIdentity = {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
patronymic?: string;
|
||||
email?: string;
|
||||
} | null | undefined;
|
||||
|
||||
export function getUserFullName(lastName?: string, firstName?: string): string {
|
||||
return `${lastName || ""} ${firstName || ""}`.trim();
|
||||
}
|
||||
|
||||
function normalizeName(value?: string | null): string {
|
||||
return (value || "").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isLikelyCurrentUserName(candidate: string, user?: ChatIdentity): boolean {
|
||||
if (!user) return false;
|
||||
|
||||
const normalizedCandidate = normalizeName(candidate);
|
||||
if (!normalizedCandidate) return false;
|
||||
|
||||
const firstName = normalizeName(user.first_name);
|
||||
const lastName = normalizeName(user.last_name);
|
||||
const patronymic = normalizeName(user.patronymic);
|
||||
const email = normalizeName(user.email);
|
||||
|
||||
if (email && normalizedCandidate === email) return true;
|
||||
|
||||
const variants = new Set<string>([
|
||||
normalizeName(`${lastName} ${firstName}`),
|
||||
normalizeName(`${firstName} ${lastName}`),
|
||||
normalizeName(`${lastName} ${firstName} ${patronymic}`),
|
||||
normalizeName(`${firstName} ${patronymic} ${lastName}`),
|
||||
normalizeName(`${firstName} ${patronymic}`),
|
||||
]);
|
||||
|
||||
if (variants.has(normalizedCandidate)) return true;
|
||||
|
||||
return Boolean(firstName && lastName && normalizedCandidate.includes(firstName) && normalizedCandidate.includes(lastName));
|
||||
}
|
||||
|
||||
export function getInterlocutorFromParticipants(chat: Chat, currentUserId?: number) {
|
||||
const participants = (chat.participants || []).filter(
|
||||
(participant): participant is Exclude<typeof participant, number> =>
|
||||
typeof participant === "object" && participant !== null
|
||||
);
|
||||
return participants.find((participant) => participant.id !== currentUserId);
|
||||
}
|
||||
|
||||
export function getInterlocutorFromParticipantDetails(chat: Chat, currentUserId?: number) {
|
||||
return (chat.participant_details || []).find((participant) => participant.id !== currentUserId);
|
||||
}
|
||||
|
||||
export function getInterlocutorNameFromParticipantNames(chat: Chat, currentUser?: ChatIdentity): string {
|
||||
const names = (chat.participant_names || []).map((name) => (name || "").trim()).filter(Boolean);
|
||||
if (!names.length) return "";
|
||||
|
||||
const otherParticipant = names.find((name) => !isLikelyCurrentUserName(name, currentUser));
|
||||
return otherParticipant || names[0] || "";
|
||||
}
|
||||
|
||||
export function getChatTitle(chat: Chat, currentUserId?: number, currentUser?: ChatIdentity): string {
|
||||
const chatKind = chat.chat_type || chat.type;
|
||||
const rawName = (chat.name || "").trim();
|
||||
|
||||
if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") {
|
||||
if (chat.interlocutor?.name?.trim()) {
|
||||
return chat.interlocutor.name.trim();
|
||||
}
|
||||
|
||||
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
|
||||
if (detailsOther?.name?.trim()) {
|
||||
return detailsOther.name.trim();
|
||||
}
|
||||
|
||||
const otherParticipant = getInterlocutorFromParticipants(chat, currentUserId);
|
||||
if (otherParticipant) {
|
||||
const participantName = getUserFullName(otherParticipant.last_name, otherParticipant.first_name);
|
||||
if (participantName) return participantName;
|
||||
if (otherParticipant.email) return otherParticipant.email;
|
||||
}
|
||||
|
||||
const namesFallback = getInterlocutorNameFromParticipantNames(chat, currentUser);
|
||||
if (namesFallback) return namesFallback;
|
||||
}
|
||||
|
||||
return rawName || "Диалог";
|
||||
}
|
||||
|
||||
export function getChatAvatar(chat: Chat, currentUserId?: number): string {
|
||||
const chatKind = chat.chat_type || chat.type;
|
||||
if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") {
|
||||
if (chat.interlocutor?.avatar) return chat.interlocutor.avatar;
|
||||
|
||||
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
|
||||
if (detailsOther?.avatar) return detailsOther.avatar;
|
||||
|
||||
const otherParticipant = getInterlocutorFromParticipants(chat, currentUserId);
|
||||
if (otherParticipant?.avatar) return otherParticipant.avatar;
|
||||
}
|
||||
|
||||
return chat.avatar || "";
|
||||
}
|
||||
|
||||
export function getChatInitials(chat: Chat, currentUserId?: number, currentUser?: ChatIdentity): string {
|
||||
return (
|
||||
getChatTitle(chat, currentUserId, currentUser)
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || "")
|
||||
.join("") || "Ч"
|
||||
);
|
||||
}
|
||||
|
||||
export function uniqueMessagesById(items: Message[]): Message[] {
|
||||
const map = new Map<number, Message>();
|
||||
items.forEach((message) => {
|
||||
map.set(message.id, message);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
102
frontend/src/lib/messages/messageUtils.ts
Normal file
102
frontend/src/lib/messages/messageUtils.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { Message, MessageAttachment } from "@/types/api";
|
||||
|
||||
export function getMessageInitials(message: Message): string {
|
||||
const name = (message.author_name || "").trim();
|
||||
if (!name) return "С";
|
||||
|
||||
const parts = name.split(" ").filter(Boolean);
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function getMessageDate(message: Message): Date | null {
|
||||
if (message.created_at) {
|
||||
const date = new Date(message.created_at);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
if (message.created_ts) {
|
||||
const date = new Date(message.created_ts);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDayDivider(date: Date): string {
|
||||
return date.toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function messageTime(message: Message): string {
|
||||
const date = getMessageDate(message);
|
||||
if (!date) return "";
|
||||
|
||||
const now = new Date();
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
return date.toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFileSize(size?: number): string {
|
||||
if (!size || size <= 0) return "";
|
||||
|
||||
const kilobytes = size / 1024;
|
||||
if (kilobytes < 1024) return `${Math.round(kilobytes)} КБ`;
|
||||
|
||||
const megabytes = kilobytes / 1024;
|
||||
return `${megabytes.toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
export function isImageAttachment(attachment: MessageAttachment): boolean {
|
||||
const mime = (attachment.mime_type || "").toLowerCase();
|
||||
const type = (attachment.file_type || "").toLowerCase();
|
||||
const name = (attachment.file_name || "").toLowerCase();
|
||||
const byExt = /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(name);
|
||||
return mime.startsWith("image/") || type === "image" || byExt;
|
||||
}
|
||||
|
||||
export function isVideoAttachment(attachment: MessageAttachment): boolean {
|
||||
const mime = (attachment.mime_type || "").toLowerCase();
|
||||
const type = (attachment.file_type || "").toLowerCase();
|
||||
const name = (attachment.file_name || "").toLowerCase();
|
||||
const byExt = /\.(mp4|webm|mov|m4v|avi|mkv|3gp|mpeg|mpg)$/i.test(name);
|
||||
return mime.startsWith("video/") || type === "video" || byExt;
|
||||
}
|
||||
|
||||
export function isAudioAttachment(attachment: MessageAttachment): boolean {
|
||||
const mime = (attachment.mime_type || "").toLowerCase();
|
||||
const type = (attachment.file_type || "").toLowerCase();
|
||||
return mime.startsWith("audio/") || type === "audio";
|
||||
}
|
||||
|
||||
export function getMessagePreviewText(message: Message): string {
|
||||
if (message.content?.trim()) return message.content.trim();
|
||||
if (message.attachments?.length) return "[Вложение]";
|
||||
return "[Сообщение]";
|
||||
}
|
||||
|
||||
export function getReplyToId(message: Message): number | null {
|
||||
if (typeof message.reply_to_id === "number") return message.reply_to_id;
|
||||
if (typeof message.reply_to === "number") return message.reply_to;
|
||||
if (message.reply_to && typeof message.reply_to === "object" && typeof message.reply_to.id === "number") {
|
||||
return message.reply_to.id;
|
||||
}
|
||||
if (message.reply_to_message && typeof message.reply_to_message.id === "number") {
|
||||
return message.reply_to_message.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
21
frontend/src/lib/shared/formatters.ts
Normal file
21
frontend/src/lib/shared/formatters.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shared formatters used across list pages (requests, equipment, procurement).
|
||||
*/
|
||||
|
||||
export function formatDate(value?: string | null): 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 function formatMoney(value?: string | number | null): string {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount)) return "—";
|
||||
return `${amount.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽`;
|
||||
}
|
||||
3
frontend/src/lib/shared/index.ts
Normal file
3
frontend/src/lib/shared/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { formatDate, formatMoney } from "./formatters";
|
||||
export { displayUserName, userProfileLink } from "./userUtils";
|
||||
export { extractNextPage, loadAllPages } from "./paginationUtils";
|
||||
29
frontend/src/lib/shared/paginationUtils.ts
Normal file
29
frontend/src/lib/shared/paginationUtils.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared pagination helpers.
|
||||
*/
|
||||
|
||||
export function extractNextPage(nextUrl?: string | null): number | null {
|
||||
if (!nextUrl) return null;
|
||||
try {
|
||||
const parsed = new URL(nextUrl, typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
||||
const num = Number(parsed.searchParams.get("page"));
|
||||
return Number.isFinite(num) && num > 0 ? num : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PageFetcher<T> = (params: Record<string, number>) => Promise<{ results?: T[]; next?: string | null } | T[]>;
|
||||
|
||||
export async function loadAllPages<T extends { id: number }>(fetcher: PageFetcher<T>): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const res = await fetcher({ page, limit: 200 });
|
||||
const results = Array.isArray(res) ? res : (res.results || []);
|
||||
all.push(...results);
|
||||
if (Array.isArray(res) || !(res as { next?: string | null }).next) break;
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
32
frontend/src/lib/shared/userUtils.ts
Normal file
32
frontend/src/lib/shared/userUtils.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared user display helpers used across list pages.
|
||||
*/
|
||||
import type { User } from "@/types/api";
|
||||
|
||||
export function displayUserName(
|
||||
person?: User | number | null,
|
||||
fallbackName?: string | null,
|
||||
fallbackEmail?: string | null,
|
||||
): string {
|
||||
if (fallbackName) return fallbackName;
|
||||
if (!person) return fallbackEmail || "—";
|
||||
if (typeof person === "number") return fallbackEmail || `Пользователь #${person}`;
|
||||
const full = `${person.last_name || ""} ${person.first_name || ""}`.trim();
|
||||
return (
|
||||
full ||
|
||||
(person as unknown as Record<string, string>)?.full_name ||
|
||||
(person as unknown as Record<string, string>)?.display_name ||
|
||||
person.email ||
|
||||
fallbackEmail ||
|
||||
"Пользователь"
|
||||
);
|
||||
}
|
||||
|
||||
export function userProfileLink(
|
||||
person: User | null | undefined,
|
||||
currentUserId: number | null | undefined,
|
||||
): string {
|
||||
if (!person?.id) return "";
|
||||
if (currentUserId && person.id === currentUserId) return "/profile";
|
||||
return `/users/${person.id}`;
|
||||
}
|
||||
153
frontend/src/lib/users/userDetailUtils.ts
Normal file
153
frontend/src/lib/users/userDetailUtils.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import type { EmployeeAction, User } from "@/types/api";
|
||||
|
||||
export type UserProfileTextField =
|
||||
| "firstName"
|
||||
| "lastName"
|
||||
| "patronymic"
|
||||
| "email"
|
||||
| "phone"
|
||||
| "telegram"
|
||||
| "whatsapp"
|
||||
| "wechat";
|
||||
|
||||
export type UserProfileEditForm = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
patronymic: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
telegram: string;
|
||||
whatsapp: string;
|
||||
wechat: string;
|
||||
avatarFile: File | null;
|
||||
avatarPreview: string | null;
|
||||
};
|
||||
|
||||
export type EmployeeActionField = "type" | "date" | "comment";
|
||||
|
||||
export type EmployeeActionForm = {
|
||||
type: string;
|
||||
date: string;
|
||||
comment: string;
|
||||
editingId: number | null;
|
||||
};
|
||||
|
||||
export const employeeActionTypes = [
|
||||
{ value: "hired", label: "Принят" },
|
||||
{ value: "dismissed", label: "Уволен" },
|
||||
{ value: "on_leave", label: "В отпуске" },
|
||||
{ value: "returned_from_leave", label: "Вернулся из отпуска" },
|
||||
{ value: "on_maternity", label: "В декрете" },
|
||||
{ value: "returned_from_maternity", label: "Вернулся из декрета" },
|
||||
{ value: "transferred", label: "Переведен" },
|
||||
{ value: "rehired", label: "Восстановлен" },
|
||||
] as const;
|
||||
|
||||
export const createEmptyActionForm = (): EmployeeActionForm => ({
|
||||
type: "",
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
comment: "",
|
||||
editingId: null,
|
||||
});
|
||||
|
||||
export const createEmptyEditForm = (): UserProfileEditForm => ({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
patronymic: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
telegram: "",
|
||||
whatsapp: "",
|
||||
wechat: "",
|
||||
avatarFile: null,
|
||||
avatarPreview: null,
|
||||
});
|
||||
|
||||
export function getUserFullName(person: User | null): string {
|
||||
if (!person) return "";
|
||||
return `${person.last_name || ""} ${person.first_name || ""} ${person.patronymic || ""}`.trim() || "Пользователь";
|
||||
}
|
||||
|
||||
export function getUserInitials(person: User | null): string {
|
||||
return `${person?.last_name?.[0] || ""}${person?.first_name?.[0] || ""}` || "П";
|
||||
}
|
||||
|
||||
export function getLatestEmployeeAction(actions?: EmployeeAction[] | null): EmployeeAction | null {
|
||||
if (!actions || actions.length === 0) return null;
|
||||
|
||||
const now = new Date();
|
||||
const pastActions = actions.filter((action) => new Date(action.date) <= now);
|
||||
|
||||
if (pastActions.length === 0) return null;
|
||||
|
||||
return pastActions.reduce((latest, action) => (
|
||||
new Date(action.date) > new Date(latest.date) ? action : latest
|
||||
));
|
||||
}
|
||||
|
||||
export function sortEmployeeActions(actions?: EmployeeAction[] | null): EmployeeAction[] {
|
||||
if (!actions || actions.length === 0) return [];
|
||||
return [...actions].sort((left, right) => (
|
||||
new Date(right.date).getTime() - new Date(left.date).getTime()
|
||||
));
|
||||
}
|
||||
|
||||
export function formatPhoneForLink(phone: string): string {
|
||||
return phone.replace(/[^0-9+]/g, "");
|
||||
}
|
||||
|
||||
export function getWorkDuration(dateJoined?: string): string | null {
|
||||
if (!dateJoined) return null;
|
||||
|
||||
const start = new Date(dateJoined);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - start.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const years = Math.floor(diffDays / 365);
|
||||
const months = Math.floor((diffDays % 365) / 30);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (years > 0) {
|
||||
parts.push(`${years} ${years === 1 ? "год" : years < 5 ? "года" : "лет"}`);
|
||||
}
|
||||
if (months > 0) {
|
||||
parts.push(`${months} ${months === 1 ? "месяц" : months < 5 ? "месяца" : "месяцев"}`);
|
||||
}
|
||||
|
||||
return parts.join(" ") || "меньше месяца";
|
||||
}
|
||||
|
||||
export function formatBirthday(birthDate?: string): string | null {
|
||||
if (!birthDate) return null;
|
||||
return new Date(birthDate).toLocaleDateString("ru-RU", { day: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
export function getEmployeeActionBadgeClass(actionType?: string): string {
|
||||
switch (actionType) {
|
||||
case "on_leave":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "on_maternity":
|
||||
return "bg-purple-100 text-purple-700";
|
||||
case "transferred":
|
||||
return "bg-blue-100 text-blue-700";
|
||||
case "dismissed":
|
||||
return "bg-red-100 text-red-700";
|
||||
default:
|
||||
return "bg-green-100 text-green-700";
|
||||
}
|
||||
}
|
||||
|
||||
export function getEmployeeActionBorderColor(actionType?: string): string {
|
||||
switch (actionType) {
|
||||
case "on_leave":
|
||||
return "#eab308";
|
||||
case "on_maternity":
|
||||
return "#a855f7";
|
||||
case "transferred":
|
||||
return "#3b82f6";
|
||||
case "dismissed":
|
||||
return "#ef4444";
|
||||
default:
|
||||
return "#22c55e";
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,17 @@
|
||||
* Управляет единственным WebSocket соединением для всего приложения
|
||||
*/
|
||||
|
||||
type MessageHandler = (data: any) => void;
|
||||
type RealtimeEvent = {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type MessageHandler = (data: RealtimeEvent) => void;
|
||||
type ConnectionStatus = {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
reconnectAttempts: number;
|
||||
};
|
||||
type StatusHandler = (status: ConnectionStatus) => void;
|
||||
|
||||
class WebSocketManager {
|
||||
private static instance: WebSocketManager | null = null;
|
||||
@ -11,8 +21,11 @@ class WebSocketManager {
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private activeChatId: number | null = null;
|
||||
private messageHandlers: Set<MessageHandler> = new Set();
|
||||
private statusHandlers: Set<StatusHandler> = new Set();
|
||||
private isConnecting = false;
|
||||
private shouldReconnect = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@ -23,6 +36,50 @@ class WebSocketManager {
|
||||
return WebSocketManager.instance;
|
||||
}
|
||||
|
||||
private hasSubscribers(): boolean {
|
||||
return this.messageHandlers.size > 0 || this.statusHandlers.size > 0;
|
||||
}
|
||||
|
||||
private getConnectionStatus(): ConnectionStatus {
|
||||
return {
|
||||
isConnected: this.ws?.readyState === WebSocket.OPEN,
|
||||
isConnecting: this.isConnecting,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
private notifyStatus() {
|
||||
const status = this.getConnectionStatus();
|
||||
this.statusHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler(status);
|
||||
} catch (error) {
|
||||
console.error('[WS Manager] Status handler error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sendJson(payload: Record<string, unknown>) {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify(payload));
|
||||
return true;
|
||||
}
|
||||
|
||||
private syncActiveChatOnSocket() {
|
||||
if (this.activeChatId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendJson({
|
||||
action: 'open_chat',
|
||||
chat_id: this.activeChatId,
|
||||
load_history: false,
|
||||
});
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Если уже подключены или подключаемся, не создаем новое соединение
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
|
||||
@ -30,13 +87,26 @@ class WebSocketManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем что мы в браузере и пользователь авторизован
|
||||
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
console.log('[WS Manager] Not in browser or not authenticated');
|
||||
if (!this.hasSubscribers()) {
|
||||
this.shouldReconnect = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем что мы в браузере и пользователь авторизован
|
||||
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
console.log('[WS Manager] Not in browser or not authenticated');
|
||||
this.notifyStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.isConnecting = true;
|
||||
this.notifyStatus();
|
||||
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:9000';
|
||||
@ -61,6 +131,8 @@ class WebSocketManager {
|
||||
console.log('[WS Manager] Connected');
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.syncActiveChatOnSocket();
|
||||
this.notifyStatus();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
@ -85,6 +157,7 @@ class WebSocketManager {
|
||||
console.error('[WS Manager] WebSocket state:', this.ws?.readyState);
|
||||
console.error('[WS Manager] WebSocket URL was:', this.ws?.url);
|
||||
this.isConnecting = false;
|
||||
this.notifyStatus();
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
@ -94,10 +167,12 @@ class WebSocketManager {
|
||||
console.log('[WS Manager] Close event wasClean:', event.wasClean);
|
||||
this.isConnecting = false;
|
||||
this.ws = null;
|
||||
this.notifyStatus();
|
||||
|
||||
// Автоматический reconnect
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
if (this.shouldReconnect && this.hasSubscribers() && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
this.notifyStatus();
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
console.log(`[WS Manager] Reconnecting in ${delay}ms... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
this.reconnectTimeout = setTimeout(() => this.connect(), delay);
|
||||
@ -106,10 +181,13 @@ class WebSocketManager {
|
||||
} catch (error) {
|
||||
console.error('[WS Manager] Connection failed:', error);
|
||||
this.isConnecting = false;
|
||||
this.notifyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
@ -121,13 +199,56 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.notifyStatus();
|
||||
}
|
||||
|
||||
setActiveChat(chatId: number) {
|
||||
if (!Number.isFinite(chatId) || chatId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeChatId === chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousChatId = this.activeChatId;
|
||||
this.activeChatId = chatId;
|
||||
|
||||
if (previousChatId !== null) {
|
||||
this.sendJson({
|
||||
action: 'close_chat',
|
||||
chat_id: previousChatId,
|
||||
});
|
||||
}
|
||||
|
||||
this.syncActiveChatOnSocket();
|
||||
}
|
||||
|
||||
clearActiveChat(chatId?: number) {
|
||||
if (this.activeChatId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof chatId === 'number' && this.activeChatId !== chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatToClose = this.activeChatId;
|
||||
this.activeChatId = null;
|
||||
|
||||
this.sendJson({
|
||||
action: 'close_chat',
|
||||
chat_id: chatToClose,
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(handler: MessageHandler) {
|
||||
this.messageHandlers.add(handler);
|
||||
this.shouldReconnect = true;
|
||||
|
||||
// Если есть подписчик и нет соединения, подключаемся
|
||||
if (this.messageHandlers.size > 0 && !this.ws && !this.isConnecting) {
|
||||
if (this.hasSubscribers() && !this.ws && !this.isConnecting) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
@ -136,7 +257,26 @@ class WebSocketManager {
|
||||
this.messageHandlers.delete(handler);
|
||||
|
||||
// Если больше нет подписчиков, отключаемся
|
||||
if (this.messageHandlers.size === 0) {
|
||||
if (!this.hasSubscribers()) {
|
||||
console.log('[WS Manager] No more subscribers, disconnecting');
|
||||
this.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
subscribeStatus(handler: StatusHandler) {
|
||||
this.statusHandlers.add(handler);
|
||||
handler(this.getConnectionStatus());
|
||||
this.shouldReconnect = true;
|
||||
|
||||
if (this.hasSubscribers() && !this.ws && !this.isConnecting) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.statusHandlers.delete(handler);
|
||||
|
||||
if (!this.hasSubscribers()) {
|
||||
console.log('[WS Manager] No more subscribers, disconnecting');
|
||||
this.disconnect();
|
||||
}
|
||||
@ -144,7 +284,7 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
getState(): number {
|
||||
return this.ws?.readyState ?? WebSocket.CLOSED;
|
||||
return this.ws?.readyState ?? 3;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user