Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-02 10:34:45 +03:00
commit 3c7582f229
62 changed files with 7626 additions and 7549 deletions

View File

@ -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({

View File

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

View 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,
},
)

View File

@ -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):

View 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": "Заявки"}

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View 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,
};
}

View 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]);
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View File

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

View 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()}`);
},
};
}

View 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();
}
}

View 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}`;
},
};
}

View 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}/`),
};
}

View 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' }),
};
}

View 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' }),
};
}

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

View 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 }) }),
};
}

View 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/'),
};
}

View 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/'),
};
}

View 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 }) }),
};
}

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

View 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() || "?"
);
}

View 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());
}

View 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;
}

View 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 })} ₽`;
}

View File

@ -0,0 +1,3 @@
export { formatDate, formatMoney } from "./formatters";
export { displayUserName, userProfileLink } from "./userUtils";
export { extractNextPage, loadAllPages } from "./paginationUtils";

View 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;
}

View 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}`;
}

View 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";
}
}

View File

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