Merge pull request #2 from IgorNadein/develop

Develop
This commit is contained in:
IgorNadein 2026-08-19 17:09:42 +03:00 committed by GitHub
commit c7d698a8f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1137 additions and 265 deletions

View File

@ -17,13 +17,21 @@ class SearchResultSerializer(serializers.Serializer):
"chat, message, event"
)
)
object_id = serializers.IntegerField(help_text="ID объекта")
object_id = serializers.JSONField(
help_text="ID объекта (число или UUID)"
)
title = serializers.CharField(
help_text="Заголовок/имя объекта", allow_blank=True
)
description = serializers.CharField(
help_text="Описание/превью контента", allow_blank=True, required=False
)
image = serializers.CharField(
help_text="Изображение или аватар результата",
allow_blank=True,
required=False,
allow_null=True,
)
meta = serializers.DictField(
help_text="Дополнительные метаданные", required=False, allow_null=True
)
@ -38,4 +46,11 @@ class SearchResponseSerializer(serializers.Serializer):
help_text="Количество результатов по типам",
child=serializers.IntegerField(),
)
hidden_counts = serializers.DictField(
help_text="Количество недоступных пользователю результатов по типам",
child=serializers.IntegerField(),
)
total = serializers.IntegerField(help_text="Общее количество результатов")
hidden_total = serializers.IntegerField(
help_text="Общее количество недоступных пользователю результатов"
)

View File

@ -7,23 +7,38 @@ from __future__ import annotations
from typing import Any, Dict
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request as DRFRequest
from rest_framework.response import Response
from django.utils.html import escape
from watson.search import search as watson_search
from api.v1.documents.permissions import user_can_view_document
from api.v1.procurement.permissions import user_can_view_procurement_request
from communications.utils import user_can_access_chat
from scheduling.services import user_can_view_event
from tasks.access import user_can_access_task_board
from .serializers import SearchResponseSerializer
def _file_url(value: Any) -> str:
"""Безопасно возвращает относительный URL файла, если он существует."""
if not value:
return ""
try:
return value.url
except (AttributeError, ValueError):
return ""
def _user_avatar(value: Any) -> str:
return _file_url(getattr(value, "avatar", None)) if value else ""
def _get_model_name(obj: Any) -> str:
"""Определяет тип модели для результата поиска."""
model = type(obj)
@ -42,11 +57,63 @@ def _get_model_name(obj: Any) -> str:
"equipment": "equipment",
"document": "document",
"notification": "notification",
"taskboard": "task_board",
"task": "task",
"product": "product",
"guest": "guest",
}
return mapping.get(model_name, model_name)
def _search_queryset_for_model(model: Any):
"""Подгружает связи, нужные форматтерам и проверкам доступа."""
queryset = model._default_manager.all()
model_name = model.__name__.lower()
select_related = {
"employee": ("position",),
"post": ("author",),
"request": ("employee",),
"message": ("author", "chat"),
"procurementrequest": ("requestor", "department"),
"document": ("uploaded_by",),
"task": ("board", "assignee"),
}.get(model_name, ())
if select_related:
queryset = queryset.select_related(*select_related)
if model_name == "product":
queryset = queryset.prefetch_related("images")
return queryset
def _materialize_search_objects(search_results: Any) -> list[Any]:
"""Загружает полиморфные результаты пакетами, сохраняя порядок Watson."""
entries = list(search_results.values_list("content_type_id", "object_id"))
content_types = ContentType.objects.in_bulk({entry[0] for entry in entries})
object_maps: Dict[int, Dict[str, Any]] = {}
for content_type_id, content_type in content_types.items():
model = content_type.model_class()
if model is None:
object_maps[content_type_id] = {}
continue
ids = [
object_id
for entry_type_id, object_id in entries
if entry_type_id == content_type_id
]
objects = _search_queryset_for_model(model).in_bulk(ids)
object_maps[content_type_id] = {
str(object_id): objects.get(model._meta.pk.to_python(object_id))
for object_id in ids
}
return [
object_maps.get(content_type_id, {}).get(str(object_id))
for content_type_id, object_id in entries
]
def _format_employee(obj: Any) -> Dict[str, Any]:
"""Форматирует сотрудника для API."""
return {
@ -58,6 +125,7 @@ def _format_employee(obj: Any) -> Dict[str, Any]:
"description": getattr(obj.position, "name", "")
if hasattr(obj, "position")
else "",
"image": _user_avatar(obj),
"meta": {
"email": obj.email,
"phone": str(obj.phone_number) if obj.phone_number else "",
@ -87,6 +155,7 @@ def _format_post(obj: Any) -> Dict[str, Any]:
"description": obj.body[:200] + "..."
if len(obj.body) > 200
else obj.body,
"image": _user_avatar(getattr(obj, "author", None)),
"meta": {
"author": str(obj.author) if hasattr(obj, "author") else None,
"created_at": obj.created_at.isoformat()
@ -105,6 +174,7 @@ def _format_request(obj: Any) -> Dict[str, Any]:
"description": obj.comment[:200] + "..."
if obj.comment and len(obj.comment) > 200
else (obj.comment or ""),
"image": _user_avatar(getattr(obj, "employee", None)),
"meta": {
"status": obj.status,
"type": obj.type,
@ -120,6 +190,7 @@ def _format_chat(obj: Any) -> Dict[str, Any]:
"object_id": obj.pk,
"title": obj.name or f"Чат #{obj.pk}",
"description": obj.description or "",
"image": _file_url(getattr(obj, "avatar", None)),
"meta": {
"type": obj.type if hasattr(obj, "type") else None,
},
@ -137,6 +208,7 @@ def _format_message(obj: Any) -> Dict[str, Any]:
"description": obj.content[:200] + "..."
if len(obj.content) > 200
else obj.content,
"image": _user_avatar(getattr(obj, "author", None)),
"meta": {
"author": str(obj.author) if hasattr(obj, "author") else None,
"chat": str(obj.chat) if hasattr(obj, "chat") else None,
@ -185,6 +257,7 @@ def _format_procurement_request(obj: Any) -> Dict[str, Any]:
"description": obj.description[:200] + "..."
if len(obj.description) > 200
else obj.description,
"image": _user_avatar(getattr(obj, "requestor", None)),
"meta": {
"status": obj.status if hasattr(obj, "status") else None,
"requestor": str(obj.requestor)
@ -227,6 +300,7 @@ def _format_document(obj: Any) -> Dict[str, Any]:
if obj.description and len(obj.description) > 200
else (obj.description or "")
),
"image": _user_avatar(getattr(obj, "uploaded_by", None)),
"meta": {
"uploaded_by": str(obj.uploaded_by)
if hasattr(obj, "uploaded_by") and obj.uploaded_by
@ -262,6 +336,7 @@ def _format_notification(obj: Any) -> Dict[str, Any]:
"object_id": obj.pk,
"title": _get_notification_search_title(obj),
"description": message[:150] + "..." if len(message) > 150 else message,
"image": _user_avatar(getattr(obj, "actor", None)),
"meta": {
"is_read": not getattr(obj, "unread", True),
"created_at": obj.timestamp.isoformat()
@ -271,6 +346,73 @@ def _format_notification(obj: Any) -> Dict[str, Any]:
}
def _format_task_board(obj: Any) -> Dict[str, Any]:
return {
"model_name": "task_board",
"object_id": obj.pk,
"title": obj.name,
"description": obj.description or "",
"image": _file_url(getattr(obj, "avatar", None)),
"meta": {
"access": obj.get_access_scope_display(),
},
}
def _format_task(obj: Any) -> Dict[str, Any]:
return {
"model_name": "task",
"object_id": obj.pk,
"title": obj.title,
"description": obj.description[:200] + "..."
if len(obj.description) > 200
else obj.description,
"image": _user_avatar(getattr(obj, "assignee", None)),
"meta": {
"board": str(obj.board),
"board_id": obj.board_id,
"priority": obj.get_priority_display(),
},
}
def _format_product(obj: Any) -> Dict[str, Any]:
images = list(obj.images.all())
image = next((item for item in images if item.is_primary), None)
image = image or (images[0] if images else None)
return {
"model_name": "product",
"object_id": str(obj.pk),
"title": obj.name,
"description": obj.description[:200] + "..."
if len(obj.description) > 200
else obj.description,
"image": _file_url(getattr(image, "thumbnail", None))
or _file_url(getattr(image, "file", None)),
"meta": {
"sku": obj.sku,
"barcode": obj.barcode,
"kind": obj.get_kind_display(),
},
}
def _format_guest(obj: Any) -> Dict[str, Any]:
return {
"model_name": "guest",
"object_id": obj.pk,
"title": obj.full_name,
"description": " · ".join(
value for value in [obj.organization, obj.position] if value
),
"image": _file_url(getattr(obj, "avatar", None)),
"meta": {
"email": obj.email,
"phone": obj.phone,
},
}
def _format_result(obj: Any, model_name: str) -> Dict[str, Any]:
"""Форматирует результат поиска в зависимости от типа модели."""
formatters = {
@ -286,6 +428,10 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]:
"equipment": _format_equipment,
"document": _format_document,
"notification": _format_notification,
"task_board": _format_task_board,
"task": _format_task,
"product": _format_product,
"guest": _format_guest,
}
formatter = formatters.get(model_name)
@ -298,6 +444,7 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]:
"object_id": obj.pk,
"title": str(obj),
"description": "",
"image": "",
"meta": {},
}
@ -309,24 +456,51 @@ def _is_hr(user: Any) -> bool:
)
def _can_view_search_result(user: Any, obj: Any, model_name: str) -> bool:
def _can_view_search_result(
user: Any,
obj: Any,
model_name: str,
access_cache: Dict[tuple[str, int], bool] | None = None,
) -> bool:
"""Не позволить глобальному индексу обходить права доменных API."""
if model_name == "request":
return _is_hr(user) or getattr(obj, "employee_id", None) == user.id
if model_name == "notification":
return getattr(obj, "recipient_id", None) == user.id
if model_name == "chat":
return user_can_access_chat(obj, user)
cache_key = ("chat", obj.pk)
if access_cache is not None and cache_key in access_cache:
return access_cache[cache_key]
allowed = user_can_access_chat(obj, user)
if access_cache is not None:
access_cache[cache_key] = allowed
return allowed
if model_name == "message":
return not getattr(obj, "is_deleted", False) and user_can_access_chat(
getattr(obj, "chat", None), user
)
if getattr(obj, "is_deleted", False):
return False
chat = getattr(obj, "chat", None)
cache_key = ("chat", getattr(obj, "chat_id", 0))
if access_cache is not None and cache_key in access_cache:
return access_cache[cache_key]
allowed = user_can_access_chat(chat, user)
if access_cache is not None:
access_cache[cache_key] = allowed
return allowed
if model_name == "document":
return user_can_view_document(user, obj)
if model_name == "schedule_event":
return user_can_view_event(user, obj)
if model_name == "procurement_request":
return user_can_view_procurement_request(user, obj)
if model_name == "task_board":
return not getattr(obj, "is_archived", False) and user_can_access_task_board(
user, obj
)
if model_name == "task":
board = getattr(obj, "board", None)
return not getattr(board, "is_archived", False) and user_can_access_task_board(
user, board
)
return True
@ -336,6 +510,8 @@ def _can_view_search_result(user: Any, obj: Any, model_name: str) -> bool:
parameters=[
OpenApiParameter("q", str, OpenApiParameter.QUERY, required=True),
OpenApiParameter("limit", int, OpenApiParameter.QUERY),
OpenApiParameter("offset", int, OpenApiParameter.QUERY),
OpenApiParameter("type", str, OpenApiParameter.QUERY),
],
responses=SearchResponseSerializer,
)
@ -348,6 +524,8 @@ def search_api_view(request: DRFRequest) -> Response:
Query Parameters:
- q (str): Поисковый запрос
- limit (int): Максимальное количество результатов на тип (default: 10)
- offset (int): Смещение внутри каждого типа результатов (default: 0)
- type (str): Вернуть записи только указанного типа
Returns:
{
@ -370,49 +548,73 @@ def search_api_view(request: DRFRequest) -> Response:
"employee": 3,
...
},
"total": 15
"hidden_counts": {
"request": 2,
...
},
"total": 15,
"hidden_total": 2
}
"""
raw_q = request.query_params.get("q", "") or ""
query = escape(raw_q.strip())
query = raw_q.strip()
try:
limit = int(request.query_params.get("limit", 10))
except (ValueError, TypeError):
limit = 10
limit = max(1, min(limit, 50))
try:
offset = int(request.query_params.get("offset", 0))
except (ValueError, TypeError):
offset = 0
offset = max(0, offset)
requested_model_type = (request.query_params.get("type", "") or "").strip()
if not query:
return Response(
{"query": "", "results": [], "counts": {}, "total": 0},
{
"query": "",
"results": [],
"counts": {},
"hidden_counts": {},
"total": 0,
"hidden_total": 0,
},
status=status.HTTP_200_OK,
)
# Ограничиваем объём кандидатов до object-level проверок. Иначе один общий
# термин может заставить API материализовать весь полнотекстовый индекс.
candidate_limit = max(200, min(limit * 20, 1000))
search_results = watson_search(query)[:candidate_limit]
# Проверяем весь набор совпадений: раннее обрезание общего индекса приводило
# к тому, что многочисленные недоступные записи вытесняли доступные из выдачи.
search_results = watson_search(query)
search_objects = _materialize_search_objects(search_results)
# Группируем и форматируем результаты
results = []
model_counts: Dict[str, int] = {}
hidden_counts: Dict[str, int] = {}
access_cache: Dict[tuple[str, int], bool] = {}
for search_result in search_results:
obj = search_result.object
for obj in search_objects:
# Пропускаем удаленные объекты
if obj is None:
continue
model_name = _get_model_name(obj)
if not _can_view_search_result(request.user, obj, model_name):
if not _can_view_search_result(
request.user, obj, model_name, access_cache
):
hidden_counts[model_name] = hidden_counts.get(model_name, 0) + 1
continue
# Подсчитываем
model_counts[model_name] = model_counts.get(model_name, 0) + 1
# Ограничиваем количество на тип
if model_counts[model_name] <= limit:
# Возвращаем нужную страницу конкретного типа. Счётчики при этом
# продолжают описывать весь доступный пользователю набор результатов.
result_position = model_counts[model_name]
type_matches = not requested_model_type or model_name == requested_model_type
if type_matches and offset < result_position <= offset + limit:
formatted = _format_result(obj, model_name)
results.append(formatted)
@ -421,7 +623,9 @@ def search_api_view(request: DRFRequest) -> Response:
"query": query,
"results": results,
"counts": model_counts,
"hidden_counts": hidden_counts,
"total": sum(model_counts.values()),
"hidden_total": sum(hidden_counts.values()),
}
# Валидируем через сериализатор (опционально, для проверки структуры)

View File

@ -1,6 +1,8 @@
from collections.abc import Iterator
from dataclasses import dataclass
import logging
import re
import time
from typing import Any
from urllib.parse import urlparse
@ -8,6 +10,9 @@ import httpx
from django.conf import settings
logger = logging.getLogger(__name__)
class MoySkladError(RuntimeError):
pass
@ -26,6 +31,9 @@ class MoySkladClient:
DEFAULT_BASE_URL = "https://api.moysklad.ru/api/remap/1.2"
MAX_IMAGE_BYTES = 25 * 1024 * 1024
ASSORTMENT_ENTITY_TYPES = ("product", "variant", "bundle", "service")
GET_RETRY_DELAYS = (2.0, 5.0, 10.0)
CONNECT_TIMEOUT_SECONDS = 30.0
READ_TIMEOUT_SECONDS = 90.0
def __init__(self, *, auth_type: str, credentials: dict[str, str]):
self.base_url = (
@ -62,7 +70,12 @@ class MoySkladClient:
base_url=self.base_url,
headers=headers,
auth=auth,
timeout=httpx.Timeout(30.0),
timeout=httpx.Timeout(
connect=self.CONNECT_TIMEOUT_SECONDS,
read=self.READ_TIMEOUT_SECONDS,
write=self.CONNECT_TIMEOUT_SECONDS,
pool=self.CONNECT_TIMEOUT_SECONDS,
),
follow_redirects=False,
)
@ -76,32 +89,94 @@ class MoySkladClient:
self.close()
def _get(self, path: str, *, params: dict[str, Any] | None = None) -> dict:
try:
response = self.client.get(path.lstrip("/"), params=params)
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
detail = self._error_detail(exc.response)
if status in {401, 403}:
message = "МойСклад отклонил реквизиты или у пользователя нет прав API."
elif status == 400:
message = "МойСклад не принял запрос. Проверьте access token и права пользователя."
elif status == 429:
message = "МойСклад временно ограничил частоту запросов."
else:
message = f"МойСклад вернул ошибку HTTP {status}."
if detail:
message = f"{message} Ответ сервиса: {detail}"
raise MoySkladError(message) from exc
except (httpx.HTTPError, ValueError) as exc:
raise MoySkladError(
"Не удалось получить корректный ответ от МойСклад."
) from exc
endpoint = path.lstrip("/")
retryable_errors = (
httpx.ReadTimeout,
httpx.ConnectTimeout,
httpx.ReadError,
)
total_attempts = len(self.GET_RETRY_DELAYS) + 1
payload: Any = None
for attempt in range(1, total_attempts + 1):
try:
response = self.client.get(endpoint, params=params)
response.raise_for_status()
payload = response.json()
break
except retryable_errors as exc:
log_context = {
"event": "commerce.moysklad.get_retry",
"endpoint": endpoint,
"request_offset": (params or {}).get("offset"),
"request_limit": (params or {}).get("limit"),
"attempt": attempt,
"max_attempts": total_attempts,
"error_type": type(exc).__name__,
"failure_reason": str(exc)[:500],
}
if attempt >= total_attempts:
logger.error(
"MoySklad GET failed after retries: %s",
type(exc).__name__,
extra={
**log_context,
"event": "commerce.moysklad.get_failed",
"retries_exhausted": True,
},
exc_info=True,
)
raise MoySkladError(
"МойСклад не ответил после повторных попыток."
) from exc
retry_delay = self.GET_RETRY_DELAYS[attempt - 1]
logger.warning(
"MoySklad GET transient failure %s; retrying in %.1f seconds",
type(exc).__name__,
retry_delay,
extra={**log_context, "retry_in_seconds": retry_delay},
)
time.sleep(retry_delay)
except httpx.HTTPStatusError as exc:
raise MoySkladError(self._http_status_message(exc.response)) from exc
except (httpx.HTTPError, ValueError) as exc:
logger.error(
"MoySklad GET failed without retry: %s",
type(exc).__name__,
extra={
"event": "commerce.moysklad.get_failed",
"endpoint": endpoint,
"request_offset": (params or {}).get("offset"),
"request_limit": (params or {}).get("limit"),
"error_type": type(exc).__name__,
"failure_reason": str(exc)[:500],
},
exc_info=True,
)
raise MoySkladError(
"Не удалось получить корректный ответ от МойСклад."
) from exc
if not isinstance(payload, dict):
raise MoySkladError("МойСклад вернул ответ неожиданного формата.")
return payload
@classmethod
def _http_status_message(cls, response: httpx.Response) -> str:
status = response.status_code
if status in {401, 403}:
message = "МойСклад отклонил реквизиты или у пользователя нет прав API."
elif status == 400:
message = (
"МойСклад не принял запрос. Проверьте access token и права пользователя."
)
elif status == 429:
message = "МойСклад временно ограничил частоту запросов."
else:
message = f"МойСклад вернул ошибку HTTP {status}."
detail = cls._error_detail(response)
return f"{message} Ответ сервиса: {detail}" if detail else message
def product_images(
self,
entity_type: str,

View File

@ -176,3 +176,47 @@ if Notification:
fields=("verb", "description"), # v2 поля
store=("verb", "unread", "timestamp"),
)
# 13. Доски задач
TaskBoard = get_model_safe("tasks", "TaskBoard")
if TaskBoard:
register(
TaskBoard,
fields=("name", "description"),
store=("name", "updated_at"),
)
# 14. Задачи
Task = get_model_safe("tasks", "Task")
if Task:
register(
Task,
fields=("title", "description"),
store=("title", "updated_at"),
)
# 15. Товары, услуги и комплекты
Product = get_model_safe("commerce", "Product")
if Product:
register(
Product,
fields=("name", "sku", "barcode", "description"),
store=("name", "sku", "updated_at"),
)
# 16. Гости
Guest = get_model_safe("guests", "Guest")
if Guest:
register(
Guest,
fields=(
"last_name",
"first_name",
"patronymic",
"email",
"phone",
"organization",
"position",
),
store=("last_name", "first_name", "patronymic", "email"),
)

View File

@ -16,11 +16,13 @@ from schedule.models import Calendar, CalendarRelation, Event
from api.v1.search.views import _can_view_search_result
from communications.models import Chat, Message
from commerce.models import Product
from documents.models import Document
from employees.models import Employee, Department, Position
from feed.models import Post
from notifications.models import Notification
from requests_app.models import Request
from tasks.models import TaskBoard, TaskBoardAccessScope
_phone_counter = itertools.count(100_000_000)
@ -245,6 +247,38 @@ class TestSearchAPI:
posts = [r for r in response.data['results'] if r['model_name'] == 'post']
assert len(posts) <= 2
def test_search_supports_category_pagination(self, api_client, sample_data):
"""Следующая страница возвращает новые записи только выбранного типа."""
from django.core.management import call_command
emp = sample_data['employees'][0]
for i in range(5):
Post.objects.create(
author=emp,
title=f"Пагинация поиска {i}",
body="Маркер пагинации поиска",
)
call_command('buildwatson', verbosity=0)
first = api_client.get(
'/api/v1/search/?q=Маркер пагинации поиска&limit=2&type=post'
)
second = api_client.get(
'/api/v1/search/?q=Маркер пагинации поиска&limit=2&offset=2&type=post'
)
assert first.status_code == status.HTTP_200_OK
assert second.status_code == status.HTTP_200_OK
assert len(first.data['results']) == 2
assert len(second.data['results']) == 2
assert all(result['model_name'] == 'post' for result in second.data['results'])
assert {
result['object_id'] for result in first.data['results']
}.isdisjoint({
result['object_id'] for result in second.data['results']
})
assert second.data['counts']['post'] == 5
def test_search_response_structure(self, api_client, sample_data):
"""Проверка структуры ответа API."""
@ -258,7 +292,9 @@ class TestSearchAPI:
assert 'query' in response.data
assert 'results' in response.data
assert 'counts' in response.data
assert 'hidden_counts' in response.data
assert 'total' in response.data
assert 'hidden_total' in response.data
# Проверяем структуру результата
if len(response.data['results']) > 0:
@ -341,6 +377,11 @@ class TestSearchAPI:
assert hidden_models.isdisjoint(
{"chat", "message", "schedule_event"}
)
assert hidden_response.data["hidden_counts"]["chat"] == 1
assert hidden_response.data["hidden_counts"]["message"] == 1
assert hidden_response.data["hidden_counts"]["document"] == 1
assert hidden_response.data["hidden_counts"]["schedule_event"] == 1
assert hidden_response.data["hidden_total"] >= 4
chat.participants.add(regular_user)
document.recipients.add(regular_user)
@ -357,6 +398,82 @@ class TestSearchAPI:
}
assert _can_view_search_result(regular_user, document, "document")
assert {"chat", "message", "schedule_event"} <= visible_models
assert visible_response.data["hidden_counts"].get("chat", 0) == 0
assert visible_response.data["hidden_counts"].get("message", 0) == 0
assert visible_response.data["hidden_counts"].get("document", 0) == 0
assert visible_response.data["hidden_counts"].get("schedule_event", 0) == 0
def test_search_returns_employee_avatar(self, api_client, sample_data):
"""Результат сотрудника содержит фото для карточки поиска."""
employee = sample_data["employees"][0]
employee.avatar = "users/avatars/search-test.jpg"
employee.save(update_fields=["avatar"])
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=Иванов")
assert response.status_code == status.HTTP_200_OK
employees = [
item
for item in response.data["results"]
if item["model_name"] == "employee"
]
assert employees
assert employees[0]["image"].endswith("/users/avatars/search-test.jpg")
def test_search_includes_products(self, api_client):
"""Глобальный поиск охватывает каталог товаров."""
product = Product.objects.create(
sku="SEARCH-SKU-001",
name="Уникальный поисковый товар",
barcode="4600000000001",
)
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=товар")
assert response.status_code == status.HTTP_200_OK
products = [
item
for item in response.data["results"]
if item["model_name"] == "product"
]
matching_product = next(
item for item in products if item["object_id"] == str(product.id)
)
assert matching_product["meta"]["sku"] == "SEARCH-SKU-001"
def test_search_counts_inaccessible_task_boards(
self, api_client, regular_user, make_user
):
"""Закрытые доски считаются, но не раскрываются в результатах."""
owner = make_user(email="search-board-owner@example.com")
board = TaskBoard.objects.create(
name="Секретная поисковая доска",
created_by=owner,
access_scope=TaskBoardAccessScope.PRIVATE,
)
call_command("buildwatson", verbosity=0)
hidden_response = api_client.get("/api/v1/search/?q=поисковая")
assert hidden_response.status_code == status.HTTP_200_OK
assert hidden_response.data["hidden_counts"]["task_board"] == 1
assert not any(
item["model_name"] == "task_board"
for item in hidden_response.data["results"]
)
board.access_scope = TaskBoardAccessScope.ALL
board.save(update_fields=["access_scope"])
visible_response = api_client.get("/api/v1/search/?q=поисковая")
assert visible_response.status_code == status.HTTP_200_OK
assert any(
item["model_name"] == "task_board"
and item["object_id"] == board.id
for item in visible_response.data["results"]
)
def test_search_cyrillic_query(self, api_client, sample_data):
"""Поиск по кириллице работает корректно."""

View File

@ -119,10 +119,80 @@ def test_moysklad_client_uses_vendor_accept_header():
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
try:
assert client.client.headers["Accept"] == "application/json;charset=utf-8"
assert client.client.timeout.read == 90.0
finally:
client.close()
def test_moysklad_client_retries_transient_get_errors_on_same_page(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
attempts = []
failures = [httpx.ReadTimeout, httpx.ConnectTimeout, httpx.ReadError]
def handler(request):
attempts.append(str(request.url))
if failures:
error_type = failures.pop(0)
raise error_type("temporary upstream failure", request=request)
return httpx.Response(200, json={"rows": []}, request=request)
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client.client.close()
client.client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)
with patch("commerce.integrations.moysklad.time.sleep") as sleep:
try:
payload = client._get(
"/entity/demand",
params={"limit": 100, "offset": 1600},
)
finally:
client.close()
assert payload == {"rows": []}
assert len(attempts) == 4
assert len(set(attempts)) == 1
assert "offset=1600" in attempts[0]
assert [call.args[0] for call in sleep.call_args_list] == [2.0, 5.0, 10.0]
def test_moysklad_client_logs_original_error_before_wrapping(
settings,
caplog,
):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
def handler(request):
raise httpx.ReadTimeout("upstream headers timeout", request=request)
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client.client.close()
client.client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)
with (
patch("commerce.integrations.moysklad.time.sleep"),
pytest.raises(MoySkladError) as raised,
):
try:
client._get(
"/entity/demand",
params={"limit": 100, "offset": 1600},
)
finally:
client.close()
assert isinstance(raised.value.__cause__, httpx.ReadTimeout)
assert any(
getattr(record, "error_type", "") == "ReadTimeout"
and getattr(record, "request_offset", None) == 1600
for record in caplog.records
)
def test_moysklad_client_reads_variant_images_and_downloads_file(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
image_content = _jpeg_bytes()

View File

@ -1,50 +1,54 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { Suspense, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
Bell,
Building,
Calendar,
ChevronRight,
FileText,
Inbox,
LockKeyhole,
LoaderCircle,
MessageCircle,
MessageSquare,
Package,
PanelsTopLeft,
Search,
ShoppingCart,
SquareCheckBig,
User,
UserRoundCheck,
type LucideIcon,
} from "lucide-react";
import { AppShell } from "../../components/AppShell";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { apiClient } from "@/lib/api";
import type { SearchResponse, SearchResult, SearchModelType } from "@/types/api";
import { Search, User, Building, FileText, MessageSquare, MessageCircle, Calendar, Inbox, ShoppingCart, Package, Bell } from "lucide-react";
import type { SearchModelType, SearchResponse, SearchResult } from "@/types/api";
function buildSearchHref(result: SearchResult): string {
switch (result.model_name) {
case "employee":
return `/users/${result.object_id}`;
case "department":
return `/departments/${result.object_id}`;
case "post":
return `/?post=${result.object_id}`;
case "request":
return `/requests?request=${result.object_id}`;
case "chat":
return `/messages/${result.object_id}`;
case "message": {
const chatId = typeof result.meta?.chat_id === "number"
? result.meta.chat_id
: Number(result.meta?.chat_id || 0);
return chatId ? `/messages/${chatId}?message=${result.object_id}` : "/messages";
}
case "event":
return "/calendar";
case "schedule_event":
return `/calendar?event=${result.object_id}`;
case "procurement_request":
return "/procurement";
case "equipment":
return "/equipment";
case "document":
return `/documents?document=${result.object_id}`;
case "notification":
return "/notifications";
default:
return "/search";
}
}
const modelOrder: SearchModelType[] = [
"employee",
"department",
"post",
"document",
"request",
"chat",
"message",
"event",
"schedule_event",
"procurement_request",
"equipment",
"product",
"task_board",
"task",
"guest",
"notification",
];
// Иконки для разных типов результатов
const modelIcons: Record<SearchModelType, React.ComponentType<{ size?: number; className?: string }>> = {
const modelIcons: Record<SearchModelType, LucideIcon> = {
employee: User,
department: Building,
post: Inbox,
@ -56,14 +60,17 @@ const modelIcons: Record<SearchModelType, React.ComponentType<{ size?: number; c
schedule_event: Calendar,
procurement_request: ShoppingCart,
equipment: Package,
product: Package,
task_board: PanelsTopLeft,
task: SquareCheckBig,
guest: UserRoundCheck,
notification: Bell,
};
// Названия категорий на русском
const modelNames: Record<SearchModelType, string> = {
employee: "Сотрудники",
department: "Отделы",
post: "Посты",
post: "Новости",
document: "Документы",
request: "Заявления",
chat: "Чаты",
@ -72,169 +79,307 @@ const modelNames: Record<SearchModelType, string> = {
schedule_event: "Расписание",
procurement_request: "Заявки на закупку",
equipment: "Оборудование",
product: "Товары",
task_board: "Доски",
task: "Задачи",
guest: "Гости",
notification: "Уведомления",
};
const hiddenMetaKeys = new Set([
"id",
"chat_id",
"created_at",
"uploaded_at",
"start",
"end",
"start_date",
"is_read",
]);
function buildSearchHref(result: SearchResult): string {
switch (result.model_name) {
case "employee": return `/users/${result.object_id}`;
case "department": return `/departments/${result.object_id}`;
case "post": return `/?post=${result.object_id}`;
case "request": return `/requests?request=${result.object_id}`;
case "chat": return `/messages/${result.object_id}`;
case "message": {
const chatId = typeof result.meta?.chat_id === "number"
? result.meta.chat_id
: Number(result.meta?.chat_id || 0);
return chatId ? `/messages/${chatId}?message=${result.object_id}` : "/messages";
}
case "event": return "/calendar";
case "schedule_event": return `/calendar?event=${result.object_id}`;
case "procurement_request": return "/procurement";
case "equipment": return "/equipment";
case "product": return `/products/${result.object_id}`;
case "task_board": return `/tasks?board=${result.object_id}`;
case "task": {
const boardId = result.meta?.board_id;
return boardId ? `/tasks?board=${boardId}&task=${result.object_id}` : "/tasks";
}
case "guest": return `/guests?guest=${result.object_id}`;
case "document": return `/documents?document=${result.object_id}`;
case "notification": return "/notifications";
default: return "/search";
}
}
function getResultInitials(title: string): string {
const parts = title.trim().split(/\s+/).filter(Boolean);
return parts.slice(0, 2).map((part) => part[0]).join("").toLocaleUpperCase("ru-RU") || "?";
}
function getMetaValues(meta?: Record<string, unknown>): string[] {
if (!meta) return [];
return Object.entries(meta)
.filter(([key, value]) => {
if (hiddenMetaKeys.has(key) || value === null || value === undefined || value === "") return false;
return typeof value === "string" || typeof value === "number";
})
.map(([, value]) => String(value))
.slice(0, 3);
}
function SearchResultVisual({ result }: { result: SearchResult }) {
const Icon = modelIcons[result.model_name];
if (result.image) {
return <RequestAvatar alt={result.title} fallback={getResultInitials(result.title)} size="lg" src={result.image} />;
}
return (
<span className="app-avatar-fallback flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<Icon size={15} aria-hidden="true" />
</span>
);
}
function LoadingPanel({ label }: { label: string }) {
return (
<div className="app-surface-muted app-text-muted rounded-xl p-6 text-center text-sm">
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
<p className="mt-2">{label}</p>
</div>
);
}
export default function SearchPage() {
return (
<Suspense fallback={
<AppShell>
<section className="app-surface rounded-2xl p-4">
<div className="app-surface-muted app-text-muted rounded-xl p-6 text-center text-sm">
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
<p className="mt-2">Загрузка...</p>
</div>
</section>
</AppShell>
}>
<Suspense fallback={<AppShell><section className="app-surface rounded-2xl p-4"><LoadingPanel label="Загрузка..." /></section></AppShell>}>
<SearchPageContent />
</Suspense>
);
}
function SearchPageContent() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const queryRaw = searchParams.get("q") || "";
const query = queryRaw.trim();
const requestedCategory = searchParams.get("type");
const [searchResponse, setSearchResponse] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMoreCategory, setLoadingMoreCategory] = useState<SearchModelType | null>(null);
const [loadMoreErrors, setLoadMoreErrors] = useState<Partial<Record<SearchModelType, string>>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query) {
setSearchResponse(null);
setError(null);
setLoading(false);
return;
}
if (!query) return;
let cancelled = false;
async function runSearch() {
try {
setLoading(true);
setLoadingMoreCategory(null);
setLoadMoreErrors({});
setError(null);
const response: SearchResponse = await apiClient.search(queryRaw, 10);
if (cancelled) return;
setSearchResponse(response);
const response = await apiClient.search(queryRaw, { limit: 50 });
if (!cancelled) setSearchResponse(response);
} catch (err) {
if (!cancelled) {
console.error("Ошибка поиска:", err);
setError("Не удалось выполнить поиск");
}
} finally {
if (!cancelled) {
setLoading(false);
}
if (!cancelled) setLoading(false);
}
}
runSearch();
return () => {
cancelled = true;
};
void runSearch();
return () => { cancelled = true; };
}, [query, queryRaw]);
// Группируем результаты по типам
const resultsByType: Partial<Record<SearchModelType, SearchResult[]>> = {};
if (searchResponse) {
searchResponse.results.forEach((result) => {
if (!resultsByType[result.model_name]) {
resultsByType[result.model_name] = [];
}
resultsByType[result.model_name]!.push(result);
const resultsByType = useMemo(() => {
const grouped: Partial<Record<SearchModelType, SearchResult[]>> = {};
searchResponse?.results.forEach((result) => {
(grouped[result.model_name] ??= []).push(result);
});
}
return grouped;
}, [searchResponse]);
const availableCategories = useMemo(() => modelOrder.filter((modelType) => (
(searchResponse?.counts[modelType] || 0) + (searchResponse?.hidden_counts[modelType] || 0) > 0
)), [searchResponse]);
const activeCategory: "all" | SearchModelType = requestedCategory
&& availableCategories.includes(requestedCategory as SearchModelType)
? requestedCategory as SearchModelType
: "all";
const displayedCategories = activeCategory === "all" ? availableCategories : [activeCategory];
const hasAnyMatches = Boolean(searchResponse && (searchResponse.total > 0 || searchResponse.hidden_total > 0));
const selectCategory = (category: "all" | SearchModelType) => {
const params = new URLSearchParams(searchParams.toString());
if (category === "all") params.delete("type");
else params.set("type", category);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
};
const loadMore = async (modelType: SearchModelType) => {
if (loadingMoreCategory || !searchResponse) return;
const offset = resultsByType[modelType]?.length || 0;
const requestQuery = query;
setLoadingMoreCategory(modelType);
setLoadMoreErrors((previous) => ({ ...previous, [modelType]: undefined }));
try {
const response = await apiClient.search(queryRaw, {
limit: 50,
offset,
modelType,
});
setSearchResponse((previous) => {
if (!previous || previous.query.trim() !== requestQuery) return previous;
const existingKeys = new Set(
previous.results.map((result) => `${result.model_name}:${result.object_id}`),
);
const nextResults = response.results.filter(
(result) => !existingKeys.has(`${result.model_name}:${result.object_id}`),
);
return {
...previous,
results: [...previous.results, ...nextResults],
counts: response.counts,
hidden_counts: response.hidden_counts,
total: response.total,
hidden_total: response.hidden_total,
};
});
} catch (loadError) {
console.error(`Не удалось загрузить дополнительные результаты ${modelType}:`, loadError);
setLoadMoreErrors((previous) => ({
...previous,
[modelType]: "Не удалось загрузить следующие результаты",
}));
} finally {
setLoadingMoreCategory(null);
}
};
return (
<AppShell>
<section className="app-surface rounded-2xl p-4">
<div className="mb-4 flex items-center gap-2">
<Search size={16} className="app-text-muted" />
<p className="app-text-muted text-sm">
{query ? `Результаты по запросу: "${queryRaw}"` : "Введите запрос в поиске сверху"}
</p>
<div className="flex items-start gap-2">
<Search size={17} className="app-text-muted mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="app-text-muted text-sm">
{query ? <>Результаты по запросу: <span className="text-[var(--foreground)]">«{queryRaw}»</span></> : "Введите запрос в поиске сверху"}
</p>
{query && searchResponse ? (
<p className="app-text-muted mt-1 text-xs">
Доступно: <span className="font-semibold text-[var(--foreground)]">{searchResponse.total}</span>
{searchResponse.hidden_total > 0 ? <> · Нет доступа: <span className="font-semibold">{searchResponse.hidden_total}</span></> : null}
</p>
) : null}
</div>
</div>
{query && searchResponse && (
<div className="app-text-muted mb-4 text-sm">
Найдено результатов: <span className="font-semibold">{searchResponse.total}</span>
</div>
)}
{loading ? (
<div className="app-surface-muted app-text-muted rounded-xl p-6 text-center text-sm">
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
<p className="mt-2">Поиск...</p>
</div>
) : error ? (
<div className="app-feedback-danger rounded-xl p-6 text-center text-sm">{error}</div>
) : !query ? (
<div className="app-surface-muted app-text-muted rounded-xl p-6 text-center text-sm">
Начните вводить запрос в строке поиска
</div>
) : searchResponse?.total === 0 ? (
<div className="app-surface-muted app-text-muted rounded-xl p-6 text-center text-sm">
Ничего не найдено по запросу &quot;{queryRaw}&quot;
</div>
) : (
<div className="space-y-6">
{(Object.keys(resultsByType) as SearchModelType[]).map((modelType) => {
const results = resultsByType[modelType]!;
{query && !loading && !error && hasAnyMatches ? (
<HorizontalChipScroller className="mt-4" ariaLabel="Категории поиска">
<button type="button" onClick={() => selectCategory("all")} aria-pressed={activeCategory === "all"} className={`${activeCategory === "all" ? "app-pill-active" : "app-pill"} inline-flex items-center gap-1.5 rounded-full px-3 py-2 text-xs font-medium`}>
Все <span className="app-badge app-pill-count px-1.5 py-0.5 text-[10px] font-bold">{searchResponse?.total || 0}</span>
</button>
{availableCategories.map((modelType) => {
const Icon = modelIcons[modelType];
const categoryName = modelNames[modelType];
return (
<div key={modelType}>
<div className="mb-3 flex items-center gap-2">
<Icon size={16} className="app-text-muted" />
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">
{categoryName} <span className="font-normal">({results.length})</span>
</p>
</div>
<div className="space-y-2">
{results.map((result) => (
<Link
key={`${result.model_name}-${result.object_id}`}
href={buildSearchHref(result)}
className="app-surface-muted block rounded-lg p-3 transition-all hover:border-[color:var(--accent-primary)] hover:bg-[color:var(--accent-soft)] hover:shadow-[var(--shadow-card)]"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium text-[var(--foreground)]">{result.title}</p>
{result.description && (
<p className="app-text-muted mt-1 line-clamp-2 text-xs">{result.description}</p>
)}
{result.meta && Object.keys(result.meta).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{Object.entries(result.meta).map(([key, value]) => {
if (!value || key === 'id') return null;
return (
<span
key={key}
className="app-badge inline-flex items-center px-2 py-0.5 text-xs"
>
{String(value)}
</span>
);
})}
</div>
)}
</div>
</div>
</Link>
))}
</div>
</div>
<button key={modelType} type="button" onClick={() => selectCategory(modelType)} aria-pressed={activeCategory === modelType} className={`${activeCategory === modelType ? "app-pill-active" : "app-pill"} inline-flex items-center gap-1.5 rounded-full px-3 py-2 text-xs font-medium`}>
<Icon size={13} aria-hidden="true" />
{modelNames[modelType]}
<span className="app-badge app-pill-count px-1.5 py-0.5 text-[10px] font-bold">{searchResponse?.counts[modelType] || 0}</span>
</button>
);
})}
</div>
)}
</HorizontalChipScroller>
) : null}
<div className="mt-4">
{!query ? (
<div className="app-surface-muted app-text-muted rounded-xl p-6 text-center text-sm">Начните вводить запрос в строке поиска</div>
) : loading ? <LoadingPanel label="Поиск..." /> : error ? (
<div className="app-feedback-danger rounded-xl p-6 text-center text-sm">{error}</div>
) : !hasAnyMatches ? (
<div className="app-surface-muted app-text-muted rounded-xl p-6 text-center text-sm">Ничего не найдено по запросу «{queryRaw}»</div>
) : (
<div className="space-y-5">
{displayedCategories.map((modelType) => {
const results = resultsByType[modelType] || [];
const Icon = modelIcons[modelType];
const visibleCount = searchResponse?.counts[modelType] || 0;
const hiddenCount = searchResponse?.hidden_counts[modelType] || 0;
const hasMore = results.length < visibleCount;
const isLoadingMore = loadingMoreCategory === modelType;
return (
<section key={modelType} aria-labelledby={`search-category-${modelType}`}>
<div className="mb-2 flex items-center gap-2 px-1">
<Icon size={15} className="app-text-muted" aria-hidden="true" />
<h2 id={`search-category-${modelType}`} className="app-text-muted text-xs font-semibold uppercase tracking-wide">
{modelNames[modelType]} <span className="font-normal">({visibleCount})</span>
</h2>
</div>
<div className="space-y-2">
{results.map((result) => {
const metaValues = getMetaValues(result.meta);
return (
<Link key={`${result.model_name}-${result.object_id}`} href={buildSearchHref(result)} className="app-surface-muted group flex items-center gap-3 rounded-xl p-3 transition hover:border-[var(--accent-primary)] hover:bg-[var(--accent-soft)]">
<SearchResultVisual result={result} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{result.title}</p>
{result.description ? <p className="app-text-muted mt-0.5 line-clamp-2 text-xs">{result.description}</p> : null}
{metaValues.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{metaValues.map((value, index) => <span key={`${value}-${index}`} className="app-badge max-w-full truncate px-2 py-0.5 text-[11px]">{value}</span>)}
</div>
) : null}
</div>
<ChevronRight size={16} className="app-text-muted shrink-0 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
</Link>
);
})}
</div>
<div className="mt-2 space-y-2 px-1">
<div className="app-text-muted flex flex-wrap items-center justify-between gap-2 text-[11px]">
<span>{hasMore ? `Показано ${results.length} из ${visibleCount}` : `${visibleCount} доступно`}</span>
<span className="inline-flex items-center gap-1"><LockKeyhole size={12} aria-hidden="true" /> Нет доступа: {hiddenCount}</span>
</div>
{hasMore ? (
<button
type="button"
onClick={() => void loadMore(modelType)}
disabled={Boolean(loadingMoreCategory)}
className="app-action-secondary inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-xs font-medium disabled:cursor-wait disabled:opacity-60"
>
{isLoadingMore ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : null}
{isLoadingMore ? "Загрузка..." : "Показать ещё"}
</button>
) : null}
{loadMoreErrors[modelType] ? (
<p className="text-xs text-red-400" role="alert">{loadMoreErrors[modelType]}</p>
) : null}
</div>
</section>
);
})}
</div>
)}
</div>
</section>
</AppShell>
);

View File

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { createPortal, flushSync } from "react-dom";
@ -97,6 +98,7 @@ import { useUser } from "@/contexts/UserContext";
import { useTaskBoardScroll } from "@/hooks/useTaskBoardScroll";
import { useTaskBoardRealtime } from "@/hooks/useTaskBoardRealtime";
import { apiClient } from "@/lib/api";
import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image";
import type { MediaPreview } from "@/lib/media";
import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared";
import { buildTaskBoardLayout, taskPlacementKey } from "@/lib/tasks/boardLayout";
@ -135,6 +137,10 @@ import type {
User,
} from "@/types/api";
const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), {
ssr: false,
});
type TaskFormState = {
id: number | null;
title: string;
@ -3741,6 +3747,8 @@ function TasksPageContent() {
const [cardMenuTaskId, setCardMenuTaskId] = useState<number | null>(null);
const [columnMenuId, setColumnMenuId] = useState<number | null>(null);
const [boardModalOpen, setBoardModalOpen] = useState(false);
const [boardAvatarCropperImage, setBoardAvatarCropperImage] = useState<string | null>(null);
const [boardAvatarFileName, setBoardAvatarFileName] = useState("board-avatar.jpg");
const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false);
const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false);
const [automationModalOpen, setAutomationModalOpen] = useState(false);
@ -5226,6 +5234,8 @@ function TasksPageContent() {
const openCreateBoard = useCallback(() => {
setBoardForm(emptyBoardForm);
setBoardAvatarCropperImage(null);
setBoardAvatarFileName("board-avatar.jpg");
setBoardMemberSearch("");
setBoardDepartmentSearch("");
setBoardActionsMenuOpen(false);
@ -5278,6 +5288,8 @@ function TasksPageContent() {
setBoardMemberSearch("");
setBoardDepartmentSearch("");
setBoardActionsMenuOpen(false);
setBoardAvatarCropperImage(null);
setBoardAvatarFileName("board-avatar.jpg");
setBoardModalOpen(true);
}, [board]);
@ -5285,6 +5297,8 @@ function TasksPageContent() {
if (saving) return;
setBoardModalOpen(false);
setBoardForm(emptyBoardForm);
setBoardAvatarCropperImage(null);
setBoardAvatarFileName("board-avatar.jpg");
setBoardMemberSearch("");
setBoardDepartmentSearch("");
}, [saving]);
@ -5376,16 +5390,29 @@ function TasksPageContent() {
const reader = new FileReader();
reader.onloadend = () => {
setBoardForm((current) => ({
...current,
avatar_file: file,
avatar_preview: typeof reader.result === "string" ? reader.result : null,
avatar_removed: false,
}));
if (typeof reader.result !== "string") return;
setBoardAvatarFileName(buildCroppedImageFileName(file.name, "board-avatar"));
setBoardAvatarCropperImage(reader.result);
};
reader.readAsDataURL(file);
}, []);
const handleBoardAvatarCropComplete = useCallback((croppedImage: string) => {
try {
const croppedFile = imageDataUrlToFile(croppedImage, boardAvatarFileName);
setBoardForm((current) => ({
...current,
avatar_file: croppedFile,
avatar_preview: croppedImage,
avatar_removed: false,
}));
setBoardAvatarCropperImage(null);
} catch (cropError) {
console.error("Не удалось обработать аватар доски:", cropError);
setError("Не удалось обработать изображение");
}
}, [boardAvatarFileName]);
const removeBoardAvatar = useCallback(() => {
setBoardForm((current) => ({
...current,
@ -8155,7 +8182,8 @@ function TasksPageContent() {
onClose={closeBoardModal}
title={boardForm.id ? "Редактировать доску" : "Новая доска"}
size="lg"
closeOnClickOutside
closeOnEsc={!boardAvatarCropperImage}
closeOnClickOutside={!boardAvatarCropperImage}
footer={(
<div className="flex justify-end gap-2">
<button
@ -8393,6 +8421,16 @@ function TasksPageContent() {
</div>
</Modal>
{boardAvatarCropperImage ? (
<AvatarCropper
initialImage={boardAvatarCropperImage}
mode="avatar"
title="Аватар доски"
onCropComplete={handleBoardAvatarCropComplete}
onCancel={() => setBoardAvatarCropperImage(null)}
/>
) : null}
<Modal
isOpen={boardGroupModalOpen}
onClose={closeBoardGroupModal}

View File

@ -9,15 +9,24 @@ interface AvatarCropperProps {
onCropComplete: (croppedImage: string) => void;
onCancel: () => void;
initialImage: string;
mode?: 'profile' | 'avatar';
title?: string;
}
export default function AvatarCropper({ onCropComplete, onCancel, initialImage }: AvatarCropperProps) {
export default function AvatarCropper({
onCropComplete,
onCancel,
initialImage,
mode = 'profile',
title,
}: AvatarCropperProps) {
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
// Фиксированный размер области обрезки 3:4 (240x320px)
const cropSize = { width: 240, height: 320 };
const isProfile = mode === 'profile';
const cropSize = isProfile
? { width: 240, height: 320 }
: { width: 260, height: 260 };
const onCropAreaChange = useCallback((croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
@ -64,10 +73,12 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
{/* Заголовок */}
<div className="app-divider border-b px-6 py-4">
<h3 className="text-lg font-semibold text-[var(--foreground)]">
Настройка фото профиля
{title || (isProfile ? 'Настройка фото профиля' : 'Настройка аватара')}
</h3>
<p className="app-text-muted mt-1 text-sm">
Разместите лицо в центре овала. Фото будет использоваться для распознавания.
{isProfile
? 'Разместите лицо в центре овала. Фото будет использоваться для распознавания.'
: 'Выберите область изображения, которая будет видна в круглом аватаре.'}
</p>
</div>
@ -77,9 +88,9 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
image={initialImage}
crop={crop}
zoom={zoom}
aspect={3 / 4}
aspect={isProfile ? 3 / 4 : 1}
cropSize={cropSize}
cropShape="rect"
cropShape={isProfile ? 'rect' : 'round'}
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
@ -88,8 +99,8 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
restrictPosition={false}
/>
{/* Направляющие рамки */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
{isProfile ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<svg
width={cropSize.width}
height={cropSize.height}
@ -128,7 +139,8 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
opacity="0.5"
/>
</svg>
</div>
</div>
) : null}
</div>
{/* Контролы масштабирования */}

View File

@ -639,9 +639,10 @@ export function BigCalendar() {
<button
type="button"
onClick={() => setCalendarScope("accessible")}
aria-pressed={calendarScope === "accessible"}
className={`min-w-0 rounded-lg px-3 py-1.5 text-center text-xs font-medium transition ${
calendarScope === "accessible"
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
? "app-selected app-accent-text shadow-sm"
: "app-text-muted hover:text-[var(--foreground)]"
}`}
>
@ -650,9 +651,10 @@ export function BigCalendar() {
<button
type="button"
onClick={() => setCalendarScope("all")}
aria-pressed={calendarScope === "all"}
className={`min-w-0 rounded-lg px-3 py-1.5 text-center text-xs font-medium transition ${
calendarScope === "all"
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
? "app-selected app-accent-text shadow-sm"
: "app-text-muted hover:text-[var(--foreground)]"
}`}
>

View File

@ -521,9 +521,10 @@ export const CalendarCard = memo(function CalendarCard({
<button
type="button"
onClick={() => setCalendarScope("accessible")}
aria-pressed={calendarScope === "accessible"}
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
calendarScope === "accessible"
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
? "app-selected app-accent-text shadow-sm"
: "app-text-muted hover:text-[var(--foreground)]"
}`}
>
@ -532,9 +533,10 @@ export const CalendarCard = memo(function CalendarCard({
<button
type="button"
onClick={() => setCalendarScope("all")}
aria-pressed={calendarScope === "all"}
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
calendarScope === "all"
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
? "app-selected app-accent-text shadow-sm"
: "app-text-muted hover:text-[var(--foreground)]"
}`}
>

View File

@ -1,6 +1,7 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
@ -152,8 +153,14 @@ export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFVie
return undefined;
}, [fitMode, containerWidth]);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-900">
return createPortal(
<div
data-overlay-root="true"
className="fixed inset-0 z-[300] flex flex-col bg-gray-900"
role="dialog"
aria-modal="true"
aria-label={fileName ? `Просмотр документа: ${fileName}` : "Просмотр документа"}
>
{/* Шапка */}
<div className="flex items-center justify-between border-b border-gray-700 bg-gray-800 px-2 sm:px-4 py-2 sm:py-3 text-white">
<div className="flex items-center gap-2 sm:gap-4">
@ -361,6 +368,7 @@ export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFVie
</div>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import dynamic from "next/dynamic";
import { useEffect, useState, type ChangeEvent } from "react";
import { Pencil, Pin } from "lucide-react";
@ -8,11 +9,16 @@ import EditChatModal from "@/components/messages/EditChatModal";
import ChatNotificationPreferences from "@/components/messages/ChatNotificationPreferences";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image";
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
import { getChatTypeLabel } from "@/lib/messages/chatSettingsUtils";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat, ChatUserSettings } from "@/types/api";
const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), {
ssr: false,
});
type ChatSettingsModalProps = {
chatId: number;
currentUserId?: number;
@ -37,6 +43,8 @@ export default function ChatSettingsModal({
const [editDescription, setEditDescription] = useState("");
const [editAvatarFile, setEditAvatarFile] = useState<File | null>(null);
const [editAvatarPreview, setEditAvatarPreview] = useState<string | null>(null);
const [editAvatarCropperImage, setEditAvatarCropperImage] = useState<string | null>(null);
const [editAvatarFileName, setEditAvatarFileName] = useState("chat-avatar.jpg");
useEffect(() => {
if (!open) return;
@ -94,6 +102,8 @@ export default function ChatSettingsModal({
setEditDescription(chat.description || "");
setEditAvatarFile(null);
setEditAvatarPreview(null);
setEditAvatarCropperImage(null);
setEditAvatarFileName("chat-avatar.jpg");
setEditOpen(true);
};
@ -101,15 +111,31 @@ export default function ChatSettingsModal({
setEditOpen(false);
setEditAvatarFile(null);
setEditAvatarPreview(null);
setEditAvatarCropperImage(null);
};
const handleAvatarChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !file.type.startsWith("image/")) return;
setEditAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => setEditAvatarPreview(reader.result as string);
reader.onloadend = () => {
if (typeof reader.result !== "string") return;
setEditAvatarFileName(buildCroppedImageFileName(file.name, "chat-avatar"));
setEditAvatarCropperImage(reader.result);
};
reader.readAsDataURL(file);
event.target.value = "";
};
const handleAvatarCropComplete = (croppedImage: string) => {
try {
setEditAvatarFile(imageDataUrlToFile(croppedImage, editAvatarFileName));
setEditAvatarPreview(croppedImage);
setEditAvatarCropperImage(null);
} catch (cropError) {
console.error("Не удалось обработать аватар чата:", cropError);
setError("Не удалось обработать изображение");
}
};
const saveEdit = async () => {
@ -146,7 +172,8 @@ export default function ChatSettingsModal({
onClose={onClose}
title="Настройки чата"
size="md"
closeOnClickOutside
closeOnEsc={!editOpen && !editAvatarCropperImage}
closeOnClickOutside={!editOpen && !editAvatarCropperImage}
>
{loading ? (
<div className="app-text-muted flex min-h-48 items-center justify-center text-sm">
@ -220,6 +247,7 @@ export default function ChatSettingsModal({
editName={editName}
editDescription={editDescription}
editAvatarPreview={editAvatarPreview}
avatarCropperOpen={Boolean(editAvatarCropperImage)}
actionLoading={actionLoading}
onClose={closeEdit}
onNameChange={setEditName}
@ -227,6 +255,15 @@ export default function ChatSettingsModal({
onAvatarChange={handleAvatarChange}
onSave={() => void saveEdit()}
/>
{editAvatarCropperImage ? (
<AvatarCropper
initialImage={editAvatarCropperImage}
mode="avatar"
title="Аватар чата"
onCropComplete={handleAvatarCropComplete}
onCancel={() => setEditAvatarCropperImage(null)}
/>
) : null}
</>
);
}

View File

@ -1,13 +1,19 @@
"use client";
import Image from "next/image";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useState, type ChangeEvent } from "react";
import { Globe, ImagePlus, Radio, Trash2, Users } from "lucide-react";
import { apiClient } from "@/lib/api";
import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image";
import { Modal } from "@/components/ui";
const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), {
ssr: false,
});
type CreateChatModalProps = {
isOpen: boolean;
onClose: () => void;
@ -48,6 +54,8 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
const [description, setDescription] = useState("");
const [avatar, setAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarCropperImage, setAvatarCropperImage] = useState<string | null>(null);
const [avatarCropperFileName, setAvatarCropperFileName] = useState("chat-avatar.jpg");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -57,6 +65,8 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
setDescription("");
setAvatar(null);
setAvatarPreview(null);
setAvatarCropperImage(null);
setAvatarCropperFileName("chat-avatar.jpg");
setError(null);
};
@ -106,19 +116,36 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
}
setError(null);
setAvatar(file);
const reader = new FileReader();
reader.onloadend = () => setAvatarPreview(reader.result as string);
reader.onloadend = () => {
if (typeof reader.result !== "string") return;
setAvatarCropperFileName(buildCroppedImageFileName(file.name, "chat-avatar"));
setAvatarCropperImage(reader.result);
};
reader.readAsDataURL(file);
event.target.value = "";
};
const handleAvatarCropComplete = (croppedImage: string) => {
try {
setAvatar(imageDataUrlToFile(croppedImage, avatarCropperFileName));
setAvatarPreview(croppedImage);
setAvatarCropperImage(null);
} catch (cropError) {
console.error("Не удалось обработать аватар чата:", cropError);
setError("Не удалось обработать изображение");
}
};
return (
<Modal
<>
<Modal
isOpen={isOpen}
onClose={close}
title="Создать чат"
size="md"
closeOnEsc={!creating}
closeOnEsc={!creating && !avatarCropperImage}
closeOnClickOutside={!avatarCropperImage}
className="h-[100dvh] max-w-full rounded-none sm:h-auto sm:rounded-2xl"
footer={(
<div className="flex flex-wrap items-center justify-end gap-2">
@ -252,6 +279,16 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
</div>
</section>
</form>
</Modal>
</Modal>
{avatarCropperImage ? (
<AvatarCropper
initialImage={avatarCropperImage}
mode="avatar"
title="Аватар чата"
onCropComplete={handleAvatarCropComplete}
onCancel={() => setAvatarCropperImage(null)}
/>
) : null}
</>
);
}

View File

@ -15,6 +15,7 @@ type EditChatModalProps = {
editName: string;
editDescription: string;
editAvatarPreview: string | null;
avatarCropperOpen?: boolean;
actionLoading: string | null;
onClose: () => void;
onNameChange: (value: string) => void;
@ -30,6 +31,7 @@ export default function EditChatModal({
editName,
editDescription,
editAvatarPreview,
avatarCropperOpen = false,
actionLoading,
onClose,
onNameChange,
@ -59,7 +61,15 @@ export default function EditChatModal({
);
return (
<Modal isOpen={open && !!chat} onClose={onClose} title="Редактировать чат" size="sm" closeOnEsc={actionLoading !== "edit"} footer={footerContent}>
<Modal
isOpen={open && !!chat}
onClose={onClose}
title="Редактировать чат"
size="sm"
closeOnEsc={actionLoading !== "edit" && !avatarCropperOpen}
closeOnClickOutside={!avatarCropperOpen}
footer={footerContent}
>
{chat && (
<div className="space-y-4">
<div>

View File

@ -41,6 +41,13 @@ const priorityLabels: Record<TaskPriority, string> = {
critical: "Критический",
};
const priorityTitleShiftClasses: Record<TaskPriority, string> = {
low: "group-hover/task-pill:translate-x-10 group-focus-visible/task-pill:translate-x-10",
medium: "group-hover/task-pill:translate-x-12 group-focus-visible/task-pill:translate-x-12",
high: "group-hover/task-pill:translate-x-12 group-focus-visible/task-pill:translate-x-12",
critical: "group-hover/task-pill:translate-x-16 group-focus-visible/task-pill:translate-x-16",
};
function normalizeHexColor(value?: string | null): string {
const color = String(value || "").trim();
if (/^#[0-9a-f]{6}$/i.test(color)) return color;
@ -77,11 +84,10 @@ export default function TaskLinkPill({
const priority = task.priority || "medium";
const priorityColor = priorityColors[priority];
const priorityLabel = task.priority_display || priorityLabels[priority];
const priorityTitleShiftClass = priorityTitleShiftClasses[priority];
const textColor = getReadableTextColor(columnColor);
const isOnAccent = tone === "onAccent";
const title = `${task.board_name ? `${task.board_name}: ` : ""}${task.title}${
task.priority_display ? `, ${task.priority_display}` : ""
}`;
const title = `${task.board_name ? `${task.board_name}: ` : ""}${task.title}, ${priorityLabel}`;
const style: CSSProperties = {
backgroundColor: columnColor,
boxShadow: isOnAccent ? "inset 0 1px 0 rgba(255, 255, 255, 0.18)" : undefined,
@ -89,26 +95,29 @@ export default function TaskLinkPill({
};
const href = `/tasks?board=${task.board_id}&task=${task.id}`;
const canOpen = task.can_open !== false;
const pillClassName = `group/task-pill inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-medium shadow-sm transition ${
const pillClassName = `group/task-pill inline-flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden rounded-full px-2 py-1 text-[11px] font-medium shadow-sm transition ${
canOpen
? "hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45"
: "cursor-pointer hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45"
} ${className}`;
const content = (
<>
<span
className="inline-flex h-2.5 min-w-2.5 max-w-2.5 shrink-0 items-center justify-center overflow-hidden rounded-full border transition-[height,max-width,padding] duration-200 ease-out group-hover/task-pill:h-4 group-hover/task-pill:max-w-24 group-hover/task-pill:px-1.5 group-focus-visible/task-pill:h-4 group-focus-visible/task-pill:max-w-24 group-focus-visible/task-pill:px-1.5"
style={{
backgroundColor: priorityColor,
borderColor: textColor,
}}
aria-hidden="true"
>
<span className="max-w-0 whitespace-nowrap text-[9px] font-semibold leading-none text-white opacity-0 transition-[max-width,opacity] duration-200 ease-out group-hover/task-pill:max-w-20 group-hover/task-pill:opacity-100 group-focus-visible/task-pill:max-w-20 group-focus-visible/task-pill:opacity-100">
{priorityLabel}
<span className="relative h-4 w-2.5 shrink-0" aria-hidden="true">
<span
className="absolute left-0 top-1/2 z-10 inline-flex h-2.5 min-w-2.5 max-w-2.5 -translate-y-1/2 items-center justify-center overflow-hidden rounded-full border transition-[height,max-width,padding] duration-200 ease-out group-hover/task-pill:h-4 group-hover/task-pill:max-w-24 group-hover/task-pill:px-1.5 group-focus-visible/task-pill:h-4 group-focus-visible/task-pill:max-w-24 group-focus-visible/task-pill:px-1.5"
style={{
backgroundColor: priorityColor,
borderColor: textColor,
}}
>
<span className="max-w-0 whitespace-nowrap text-[9px] font-semibold leading-none text-white opacity-0 transition-[max-width,opacity] duration-200 ease-out group-hover/task-pill:max-w-20 group-hover/task-pill:opacity-100 group-focus-visible/task-pill:max-w-20 group-focus-visible/task-pill:opacity-100">
{priorityLabel}
</span>
</span>
</span>
<span className={`${maxTitleClassName} truncate`}>{task.title}</span>
<span className={`${maxTitleClassName} min-w-0 truncate transition-transform duration-200 ease-out ${priorityTitleShiftClass}`}>
{task.title}
</span>
</>
);

View File

@ -1,10 +1,14 @@
"use client";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { useEffect, useId, useRef, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
let openModalCount = 0;
const openModalStack: string[] = [];
const subscribeToBrowserReady = () => () => {};
const getBrowserSnapshot = () => true;
const getServerSnapshot = () => false;
export interface ModalProps {
isOpen: boolean;
@ -43,6 +47,11 @@ export function Modal({
const modalRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const browserReady = useSyncExternalStore(
subscribeToBrowserReady,
getBrowserSnapshot,
getServerSnapshot,
);
useEffect(() => {
if (!isOpen) return;
@ -75,7 +84,7 @@ export function Modal({
}, [isOpen, closeOnEsc, onClose, titleId]);
useEffect(() => {
if (!isOpen || !contentRef.current) return;
if (!isOpen || !browserReady || !contentRef.current) return;
const focusableElements = contentRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
@ -106,9 +115,9 @@ export function Modal({
document.addEventListener("keydown", handleTab);
return () => document.removeEventListener("keydown", handleTab);
}, [isOpen, titleId]);
}, [isOpen, titleId, browserReady]);
if (!isOpen) return null;
if (!isOpen || !browserReady) return null;
const handleBackdropClick = (e: React.MouseEvent) => {
if (
@ -132,7 +141,7 @@ export function Modal({
const showHeader = !noHeader && (title || showCloseButton);
return (
return createPortal(
<div
ref={modalRef}
onClick={handleBackdropClick}
@ -190,6 +199,7 @@ export function Modal({
{/* Bottom padding */}
{!noPadding && <div className="pb-4 sm:pb-6" />}
</div>
</div>
</div>,
document.body,
);
}

View File

@ -31,6 +31,7 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialLoadDone = useRef(false);
const calendarsRequestIdRef = useRef(0);
const realtimeSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shouldReloadCalendarsRef = useRef(false);
const canUseAllCalendarsMode = Boolean(
@ -38,6 +39,7 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
);
const loadCalendars = useCallback(async () => {
const requestId = ++calendarsRequestIdRef.current;
try {
setLoading(true);
setError(null);
@ -50,14 +52,21 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
});
// API возвращает пагинированный ответ: {count, results}
const cals = Array.isArray(result) ? result : (result?.results || []);
if (requestId !== calendarsRequestIdRef.current) return;
setCalendars(cals);
setSelectedCalendarId((current) => (
current !== null && !cals.some((calendar) => calendar.id === current)
? null
: current
));
initialLoadDone.current = true;
} catch (err) {
if (requestId !== calendarsRequestIdRef.current) return;
console.error("Ошибка загрузки календарей:", err);
setError("Не удалось загрузить календари");
} finally {
setLoading(false);
if (requestId === calendarsRequestIdRef.current) setLoading(false);
}
}, [
calendarScope,
@ -68,6 +77,7 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
if (user) {
loadCalendars();
} else {
calendarsRequestIdRef.current += 1;
setCalendars([]);
setSelectedCalendarId(null);
setCalendarScope("accessible");

View File

@ -6,6 +6,8 @@ import type {
LoginResponse,
QrLoginRequestCreateResult,
QrLoginRequestStatusResult,
SearchModelType,
SearchResponse,
} from "@/types/api";
interface LoginCredentials {
@ -349,11 +351,16 @@ export class ApiClientBase {
return this.request('/api/v1/employees/me/');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async search(query: string, limit?: number): Promise<any> {
async search(query: string, options: {
limit?: number;
offset?: number;
modelType?: SearchModelType;
} = {}): Promise<SearchResponse> {
const queryParams = new URLSearchParams();
queryParams.append('q', query);
if (limit) queryParams.append('limit', limit.toString());
if (options.limit) queryParams.append('limit', options.limit.toString());
if (options.offset) queryParams.append('offset', options.offset.toString());
if (options.modelType) queryParams.append('type', options.modelType);
return this.request(`/api/v1/search/?${queryParams.toString()}`);
}

17
frontend/src/lib/image.ts Normal file
View File

@ -0,0 +1,17 @@
export function buildCroppedImageFileName(fileName: string, fallback = "avatar") {
const baseName = fileName.trim().replace(/\.[^.]+$/, "") || fallback;
return `${baseName}.jpg`;
}
export function imageDataUrlToFile(dataUrl: string, fileName: string) {
const [header, base64Data] = dataUrl.split(",");
if (!base64Data) throw new Error("Invalid image data URL");
const mime = header.match(/^data:(.*?);base64$/)?.[1] || "image/jpeg";
const binary = window.atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type: mime });
}

View File

@ -1860,19 +1860,22 @@ export interface RegisterData {
}
// Search types (django-watson)
export type SearchModelType = 'post' | 'employee' | 'department' | 'request' | 'chat' | 'message' | 'event' | 'schedule_event' | 'procurement_request' | 'equipment' | 'document' | 'notification';
export type SearchModelType = 'post' | 'employee' | 'department' | 'request' | 'chat' | 'message' | 'event' | 'schedule_event' | 'procurement_request' | 'equipment' | 'document' | 'notification' | 'task_board' | 'task' | 'product' | 'guest';
export interface SearchResult {
model_name: SearchModelType;
object_id: number;
object_id: number | string;
title: string;
description?: string;
image?: string | null;
meta?: Record<string, unknown>;
}
export interface SearchResponse {
query: string;
results: SearchResult[];
counts: Record<SearchModelType, number>;
counts: Partial<Record<SearchModelType, number>>;
hidden_counts: Partial<Record<SearchModelType, number>>;
total: number;
hidden_total: number;
}