commit
c7d698a8f2
@ -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="Общее количество недоступных пользователю результатов"
|
||||
)
|
||||
|
||||
@ -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()),
|
||||
}
|
||||
|
||||
# Валидируем через сериализатор (опционально, для проверки структуры)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
@ -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):
|
||||
"""Поиск по кириллице работает корректно."""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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">
|
||||
Ничего не найдено по запросу "{queryRaw}"
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* Контролы масштабирования */}
|
||||
|
||||
@ -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)]"
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -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)]"
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
17
frontend/src/lib/image.ts
Normal 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 });
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user