Update application features and supporting components

This commit is contained in:
lizerk 2026-08-19 12:09:01 +03:00
parent bd515fd6d5
commit 205fd7252d
72 changed files with 9594 additions and 1033 deletions

View File

@ -101,3 +101,7 @@ COMMERCE_CREDENTIAL_KEY=
# lost queue messages as failed after this timeout; running imports use heartbeat.
COMMERCE_QUEUED_STALE_SECONDS=120
COMMERCE_RUNNING_STALE_SECONDS=600
# Long-running imports use task-specific limits instead of the global Celery
# 25/30 minute limits. The soft limit must remain below the hard limit.
COMMERCE_TASK_SOFT_TIME_LIMIT=5400
COMMERCE_TASK_TIME_LIMIT=7200

View File

@ -95,6 +95,28 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
"tag_ids",
)
def _validate_requested_users(self, attrs, field_name, relation_name) -> None:
# Creation keeps the established API contract: unknown/inactive IDs are
# ignored by the application command. On update, however, only already
# linked inactive users may be retained.
if self.instance is None or field_name not in attrs:
return
requested_ids = set(attrs[field_name])
existing_ids = (
set(getattr(self.instance, relation_name).values_list("id", flat=True))
if self.instance is not None
else set()
)
allowed_ids = set(
User.objects.filter(id__in=requested_ids)
.filter(Q(is_active=True) | Q(id__in=existing_ids))
.values_list("id", flat=True)
)
if requested_ids - allowed_ids:
raise serializers.ValidationError(
{field_name: "Выбирать можно только активных сотрудников."}
)
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Согласованность полей.
@ -123,6 +145,12 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
attrs.pop("title", None)
instance = self.instance
self._validate_requested_users(attrs, "recipient_ids", "recipients")
self._validate_requested_users(
attrs,
"acknowledgement_recipient_ids",
"acknowledgement_recipients",
)
sent_to_all = attrs.get(
"sent_to_all",
instance.sent_to_all if instance is not None else True,
@ -204,7 +232,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
}
)
allowed_recipients = (
allowed_recipients = set(
User.objects.filter(is_active=True)
.filter(
Q(pk__in=recipient_ids)
@ -219,7 +247,17 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
)
.values_list("pk", flat=True)
)
invalid_recipients = acknowledgement_recipient_ids - set(allowed_recipients)
existing_acknowledgement_ids = (
set(
instance.acknowledgement_recipients.values_list("pk", flat=True)
)
if instance is not None
else set()
)
allowed_recipients.update(
acknowledgement_recipient_ids & existing_acknowledgement_ids
)
invalid_recipients = acknowledgement_recipient_ids - allowed_recipients
if invalid_recipients:
raise serializers.ValidationError(
{

View File

@ -24,6 +24,7 @@ class EmployeeBriefSerializer(serializers.ModelSerializer):
"display_name",
"full_name",
"avatar",
"is_active",
)
@extend_schema_field(serializers.CharField())

View File

@ -287,7 +287,13 @@ class GroupViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
users = Employee.objects.filter(id__in=member_ids)
requested_ids = set(member_ids)
users = Employee.objects.filter(id__in=requested_ids, is_active=True)
if requested_ids - set(users.values_list("id", flat=True)):
return Response(
{"member_ids": ["Добавлять можно только активных сотрудников."]},
status=status.HTTP_400_BAD_REQUEST,
)
grp.user_set.add(*users)
ok_user_ids = [u.id for u in users]
@ -338,7 +344,21 @@ class GroupViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
users = Employee.objects.filter(id__in=member_ids)
requested_ids = set(member_ids)
existing_ids = set(grp.user_set.values_list("id", flat=True))
users = Employee.objects.filter(id__in=requested_ids).filter(
Q(is_active=True) | Q(id__in=existing_ids)
)
if requested_ids - set(users.values_list("id", flat=True)):
return Response(
{
"member_ids": [
"Добавлять можно только активных сотрудников; "
"уже состоящие в группе неактивные сотрудники могут быть сохранены."
]
},
status=status.HTTP_400_BAD_REQUEST,
)
grp.user_set.set(users)
ok_user_ids = [u.id for u in users]

View File

@ -237,10 +237,14 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet):
if not employee_id:
return Response({"detail": "employee_id is required."}, status=400)
try:
employee = Employee.objects.get(id=employee_id)
except Employee.DoesNotExist:
employee = Employee.objects.filter(id=employee_id).first()
if employee is None:
return Response({"detail": "Сотрудник не найден."}, status=404)
if not employee.is_active:
return Response(
{"employee_id": ["Роль можно назначить только активному сотруднику."]},
status=status.HTTP_400_BAD_REQUEST,
)
assigned_by = request.user if request.user.is_authenticated else None

View File

@ -205,10 +205,14 @@ class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView):
{"management_user_ids": "Некорректный идентификатор пользователя."}
)
eligible_users = Employee.objects.filter(
id__in=requested_ids,
is_active=True,
).filter(Q(is_staff=True) | Q(is_superuser=True))
finance_settings = FinanceSettings.get_solo()
existing_ids = set(
finance_settings.management_users.values_list("id", flat=True)
)
eligible_users = Employee.objects.filter(id__in=requested_ids).filter(
Q(id__in=existing_ids)
| (Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True)))
)
eligible_ids = set(eligible_users.values_list("id", flat=True))
if requested_ids - eligible_ids:
raise ValidationError(
@ -219,6 +223,5 @@ class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView):
}
)
finance_settings = FinanceSettings.get_solo()
finance_settings.management_users.set(eligible_users)
return Response(self._payload())

View File

@ -79,6 +79,15 @@ class EquipmentListSerializer(serializers.ModelSerializer):
try:
if self.instance is None:
responsible = attrs.get("responsible_person")
if responsible is not None and not responsible.is_active:
raise EquipmentPolicyValidationError(
{
"responsible_person": (
"Ответственным может быть только активный сотрудник."
)
}
)
return EquipmentCreationPolicy.resolve(user).validate_create(attrs)
department = attrs.get("department", self.instance.department)
@ -86,10 +95,31 @@ class EquipmentListSerializer(serializers.ModelSerializer):
"responsible_person",
self.instance.responsible_person,
)
validate_responsible_department(
department=department,
responsible=responsible,
responsible_changed = (
"responsible_person" in attrs
and getattr(responsible, "id", None)
!= self.instance.responsible_person_id
)
department_changed = (
"department" in attrs and department.id != self.instance.department_id
)
if responsible_changed and responsible is not None and not responsible.is_active:
raise EquipmentPolicyValidationError(
{
"responsible_person": (
"Ответственным может быть только активный сотрудник."
)
}
)
if (
responsible is not None
and responsible.is_active
and (responsible_changed or department_changed)
):
validate_responsible_department(
department=department,
responsible=responsible,
)
return attrs
except EquipmentPolicyValidationError as exc:
raise serializers.ValidationError(exc.errors) from exc

View File

@ -249,12 +249,12 @@ class EquipmentViewSet(viewsets.ModelViewSet):
User = get_user_model()
try:
to_person = User.objects.get(pk=to_person_id)
to_person = User.objects.get(pk=to_person_id, is_active=True)
equipment.responsible_person = to_person
except User.DoesNotExist:
return Response(
{"error": "Пользователь не найден"},
status=status.HTTP_404_NOT_FOUND,
{"error": "Активный сотрудник не найден"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
to_person = from_person

View File

@ -57,8 +57,7 @@ class RequestWriteValidationMixin:
{"recipient_ids": "Автор не может быть получателем"}
)
@staticmethod
def _validate_allowed_recipients(recipient_ids) -> None:
def _validate_allowed_recipients(self, recipient_ids) -> None:
settings = RequestSettings.get_solo()
if not settings.recipients_are_restricted():
return
@ -68,7 +67,12 @@ class RequestWriteValidationMixin:
flat=True,
)
)
if set(recipient_ids) - allowed_ids:
existing_recipient_ids = (
set(self.instance.recipients.values_list("id", flat=True))
if self.instance is not None
else set()
)
if set(recipient_ids) - allowed_ids - existing_recipient_ids:
raise serializers.ValidationError(
{
"recipient_ids": (

View File

@ -4,6 +4,7 @@ import logging
from typing import Any, Mapping
from django.contrib.auth import get_user_model
from django.db.models import Q
from rest_framework import serializers
from employees.models import Department
@ -161,20 +162,42 @@ class RequestWriteSerializer(
return super().to_internal_value(data)
def _set_recipients(self, request_obj, recipient_ids, is_cc=False):
"""Устанавливает получателей (фильтрует только активных)"""
"""Устанавливает получателей, сохраняя выбранные исторические связи."""
relation = request_obj.cc_users if is_cc else request_obj.recipients
if not recipient_ids:
if is_cc:
request_obj.cc_users.clear()
else:
request_obj.recipients.clear()
relation.clear()
return
users = User.objects.filter(id__in=recipient_ids, is_active=True)
existing_ids = set(relation.values_list("id", flat=True))
users = User.objects.filter(id__in=recipient_ids).filter(
Q(is_active=True) | Q(id__in=existing_ids)
)
relation.set(users)
if is_cc:
request_obj.cc_users.set(users)
else:
request_obj.recipients.set(users)
def _validate_requested_users(self, attrs, field_name, relation_name):
if field_name not in attrs:
return
requested_ids = set(attrs[field_name])
existing_ids = (
set(getattr(self.instance, relation_name).values_list("id", flat=True))
if self.instance is not None
else set()
)
allowed_ids = set(
User.objects.filter(id__in=requested_ids)
.filter(Q(is_active=True) | Q(id__in=existing_ids))
.values_list("id", flat=True)
)
if requested_ids - allowed_ids:
raise serializers.ValidationError(
{field_name: "Выбирать можно только активных сотрудников."}
)
def validate(self, attrs):
attrs = super().validate(attrs)
self._validate_requested_users(attrs, "recipient_ids", "recipients")
self._validate_requested_users(attrs, "cc_user_ids", "cc_users")
return attrs
def create(self, validated_data: dict[str, Any]) -> Request:
"""Создание заявки.

View File

@ -60,9 +60,18 @@ class CalendarRelationSerializer(serializers.ModelSerializer):
"last_name": getattr(obj.content_object, "last_name", ""),
"email": getattr(obj.content_object, "email", ""),
"avatar": avatar_url,
"is_active": getattr(obj.content_object, "is_active", False),
}
return None
def validate_user_id(self, value):
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(id=value, is_active=True).exists():
raise serializers.ValidationError("Выберите активного сотрудника.")
return value
def create(self, validated_data):
"""Создать связь календаря с пользователем."""
from django.contrib.auth import get_user_model
@ -74,7 +83,7 @@ class CalendarRelationSerializer(serializers.ModelSerializer):
distinction = validated_data.get("distinction", "viewer")
inheritable = validated_data.get("inheritable", True)
user = User.objects.get(id=user_id)
user = User.objects.get(id=user_id, is_active=True)
# Используем встроенный метод create_relation
calendar.create_relation(user, distinction=distinction, inheritable=inheritable)
@ -217,6 +226,7 @@ class CalendarSerializer(serializers.ModelSerializer):
),
"full_name": str(rel.content_object),
"role": rel.distinction,
"is_active": getattr(rel.content_object, "is_active", False),
}
)
return participants

View File

@ -83,6 +83,31 @@ class EventRelationSerializer(serializers.ModelSerializer):
return str(avatar)
return None
def validate(self, attrs):
attrs = super().validate(attrs)
from django.contrib.auth import get_user_model
User = get_user_model()
user_content_type = ContentType.objects.get_for_model(User)
content_type = attrs.get(
"content_type",
getattr(self.instance, "content_type", user_content_type),
)
object_id = attrs.get("object_id", getattr(self.instance, "object_id", None))
relation_changed = self.instance is None or (
content_type.id != self.instance.content_type_id
or object_id != self.instance.object_id
)
if (
relation_changed
and content_type == user_content_type
and not User.objects.filter(id=object_id, is_active=True).exists()
):
raise serializers.ValidationError(
{"object_id": "Выберите активного сотрудника."}
)
return attrs
def create(self, validated_data):
"""Автоматически устанавливает content_type для User."""
if "content_type" not in validated_data:

View File

@ -143,6 +143,19 @@ class TaskBoardSerializer(serializers.ModelSerializer):
TaskBoardAccessScope.ALL,
)
attrs["access_scope"] = access_scope
if access_scope == TaskBoardAccessScope.RESTRICTED and "members" in attrs:
existing_member_ids = (
set(self.instance.members.values_list("id", flat=True))
if self.instance is not None
else set()
)
if any(
not member.is_active and member.id not in existing_member_ids
for member in attrs["members"]
):
raise serializers.ValidationError(
{"members": "Добавлять на доску можно только активных сотрудников."}
)
if access_scope != TaskBoardAccessScope.RESTRICTED:
attrs["members"] = []
attrs["departments"] = []

View File

@ -28,7 +28,11 @@ class TaskSerializer(serializers.ModelSerializer):
participants = EmployeeBriefSerializer(many=True, read_only=True)
participant_ids = serializers.PrimaryKeyRelatedField(
source="participants",
queryset=User.objects.filter(is_active=True),
# Existing tasks may still contain dismissed employees. Resolve them here
# and distinguish retained historical participants from newly added ones
# in validate(), otherwise any edit that resubmits the participant list
# becomes impossible after an employee is deactivated.
queryset=User.objects.all(),
many=True,
required=False,
write_only=True,
@ -314,8 +318,6 @@ class TaskSerializer(serializers.ModelSerializer):
row = (
attrs.get("row") if "row" in attrs else getattr(self.instance, "row", None)
)
participants = attrs.get("participants")
if board and not user_can_access_task_board(user, board):
raise serializers.ValidationError(
{"board": "У вас нет доступа к этой доске."}
@ -338,13 +340,75 @@ class TaskSerializer(serializers.ModelSerializer):
{"row": "Дорожка должна принадлежать основной колонке задачи."}
)
if participants is not None and board:
inaccessible_participants = [
participant.id
self._validate_task_users(attrs, board)
return attrs
def _validate_task_users(self, attrs, board):
existing_assignee_id = (
self.instance.assignee_id if self.instance is not None else None
)
board_changed = bool(
board
and (
self.instance is None
or self.instance.board_id != board.id
)
)
assignee = attrs.get("assignee")
if "assignee" in attrs and assignee is not None:
assignee_changed = assignee.id != existing_assignee_id
if assignee_changed and not assignee.is_active:
raise serializers.ValidationError(
{"assignee_id": "Исполнителем может быть только активный сотрудник."}
)
should_check_assignee_access = assignee_changed or (
board_changed and assignee.is_active
)
if should_check_assignee_access and not user_can_access_task_board(
assignee, board
):
raise serializers.ValidationError(
{
"assignee_id": (
"Исполнителем может быть только сотрудник с доступом "
"к этой доске."
)
}
)
participants = attrs.get("participants")
if participants is not None:
existing_participant_ids = (
set(self.instance.participants.values_list("id", flat=True))
if self.instance is not None
else set()
)
if self.instance is not None:
existing_participant_ids.add(self.instance.created_by_id)
if existing_assignee_id:
existing_participant_ids.add(existing_assignee_id)
new_participants = [
participant
for participant in participants
if not user_can_access_task_board(participant, board)
if participant.id not in existing_participant_ids
]
if inaccessible_participants:
if any(not participant.is_active for participant in new_participants):
raise serializers.ValidationError(
{
"participant_ids": (
"Добавлять в задачу можно только активных сотрудников."
)
}
)
participants_to_check = (
[participant for participant in participants if participant.is_active]
if board_changed
else new_participants
)
if board and any(
not user_can_access_task_board(participant, board)
for participant in participants_to_check
):
raise serializers.ValidationError(
{
"participant_ids": (
@ -354,8 +418,6 @@ class TaskSerializer(serializers.ModelSerializer):
}
)
return attrs
def create(self, validated_data):
labels = validated_data.pop("labels", [])
participants = list(validated_data.pop("participants", []))

View File

@ -268,7 +268,8 @@ class TaskViewSet(
status=status.HTTP_400_BAD_REQUEST,
)
if task.assignee_id:
previous_assignee_id = task.assignee_id
if task.assignee_id and task.assignee.is_active:
if task.assignee_id == request.user.id:
return Response(self.get_serializer(task).data)
return Response(
@ -288,7 +289,7 @@ class TaskViewSet(
notify_task_updated(
task,
request.user,
{"assignee_id": None},
{"assignee_id": previous_assignee_id},
{"assignee_id": request.user.id},
)
send_task_board_update(

View File

@ -11,6 +11,7 @@ import tempfile
from typing import TYPE_CHECKING, Callable
import warnings
from celery.exceptions import SoftTimeLimitExceeded
from dateutil import parser as date_parser
from django.core.files.base import ContentFile
from django.db import transaction
@ -36,6 +37,14 @@ class OversizedProductImage(ValueError):
pass
def _reraise_task_control_exception(error: Exception) -> None:
if isinstance(error, SoftTimeLimitExceeded):
# Celery uses this exception to stop a long-running task cleanly.
# Treating it as a recoverable image failure makes the worker run
# until the hard limit kills the process without updating the run.
raise error
def _source_updated_at(value: str):
if not value:
return None
@ -308,6 +317,7 @@ def sync_moysklad_product_images(
retained_ids.append(remote.external_id)
synchronized += 1
except Exception as error:
_reraise_task_control_exception(error)
if image is not None and image.pk:
retained_ids.append(remote.external_id)
if error_callback is None:

View File

@ -7,6 +7,7 @@ from time import monotonic
from typing import Any, Callable
from urllib.parse import urlparse
from celery.exceptions import SoftTimeLimitExceeded
from dateutil import parser as date_parser
from django.db import transaction
from django.utils import timezone
@ -738,6 +739,8 @@ def _sync_images_stage(
stage_started=stage_started,
),
)
except SoftTimeLimitExceeded:
raise
except Exception as error: # noqa: BLE001 - one product must not abort facts
stats.image_errors += 1
logger.warning(

View File

@ -3,6 +3,7 @@ from datetime import timedelta
from time import monotonic
from celery import shared_task
from celery.exceptions import SoftTimeLimitExceeded
from celery.utils import uuid as celery_uuid
from django.conf import settings
from django.db import transaction
@ -30,6 +31,7 @@ logger = logging.getLogger(__name__)
QUEUE_PUBLISH_ERROR = "Не удалось поставить синхронизацию в очередь."
MISSING_RUN_ERROR = "Запуск синхронизации не найден. Повторите импорт."
SYNC_TIME_LIMIT_ERROR = "Синхронизация превысила допустимое время выполнения."
class SyncRunNoLongerActive(RuntimeError):
@ -429,11 +431,12 @@ def _mark_sync_failed(run, connection, exc, *, task_started):
"duration_ms": round((monotonic() - task_started) * 1000, 2),
},
)
message = (
str(exc)
if isinstance(exc, (MoySkladError, WildberriesError, CredentialError))
else "Внутренняя ошибка синхронизации."
)
if isinstance(exc, SoftTimeLimitExceeded):
message = SYNC_TIME_LIMIT_ERROR
elif isinstance(exc, (MoySkladError, WildberriesError, CredentialError)):
message = str(exc)
else:
message = "Внутренняя ошибка синхронизации."
finished_at = timezone.now()
IntegrationSyncRun.objects.filter(
pk=run.pk,
@ -526,6 +529,8 @@ def _complete_sync(run, connection, stats):
bind=True,
name="commerce.sync_connection_run",
reject_on_worker_lost=False,
soft_time_limit=settings.COMMERCE_TASK_SOFT_TIME_LIMIT,
time_limit=settings.COMMERCE_TASK_TIME_LIMIT,
)
def sync_connection_run(self, run_id: str, connection_id: str | None = None):
task_id = self.request.id or ""

View File

@ -35,8 +35,15 @@ def _get_active_memberships(obj, *, limit: int | None = None):
memberships = list(obj.memberships.all())
else:
memberships = list(
obj.memberships.filter(is_active=True).select_related("user")
obj.memberships.filter(is_active=True, user__is_active=True).select_related(
"user"
)
)
memberships = [
membership
for membership in memberships
if membership.is_active and membership.user.is_active
]
if limit is not None:
memberships = memberships[:limit]

View File

@ -275,6 +275,35 @@ class ChatViewSet(viewsets.ModelViewSet):
def create(self, request, *args, **kwargs):
"""Создание чата"""
raw_participant_ids = (
request.data.getlist("participants")
if hasattr(request.data, "getlist")
else request.data.get("participants", [])
)
if raw_participant_ids in (None, ""):
raw_participant_ids = []
elif not isinstance(raw_participant_ids, (list, tuple)):
raw_participant_ids = [raw_participant_ids]
try:
participant_ids = {
int(user_id) for user_id in raw_participant_ids if user_id not in (None, "")
}
except (TypeError, ValueError):
return Response(
{"participants": ["Идентификаторы участников должны быть числами."]},
status=status.HTTP_400_BAD_REQUEST,
)
valid_participant_ids = set(
User.objects.filter(id__in=participant_ids, is_active=True).values_list(
"id", flat=True
)
)
if participant_ids - valid_participant_ids:
return Response(
{"participants": ["Добавлять можно только активных сотрудников."]},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
@ -300,13 +329,12 @@ class ChatViewSet(viewsets.ModelViewSet):
)
# Добавляем остальных участников из запроса
participant_ids = request.data.get("participants", [])
if participant_ids:
for user_id in participant_ids:
if user_id == request.user.id:
continue
try:
participant = User.objects.get(id=user_id)
participant = User.objects.get(id=user_id, is_active=True)
chat.participants.add(participant)
ChatMembership.objects.get_or_create(
chat=chat,
@ -446,12 +474,15 @@ class ChatViewSet(viewsets.ModelViewSet):
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
try:
user_to_add = User.objects.get(pk=user_id)
except User.DoesNotExist:
user_to_add = User.objects.filter(pk=user_id).first()
if user_to_add is None:
return Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
)
if not user_to_add.is_active:
return Response(
{"error": "Active user not found"}, status=status.HTTP_400_BAD_REQUEST
)
# MIGRATION: Проверяем через memberships вместо participants
if ChatMembership.objects.filter(
@ -581,12 +612,15 @@ class ChatViewSet(viewsets.ModelViewSet):
status=status.HTTP_403_FORBIDDEN,
)
try:
user_to_change = User.objects.get(pk=user_id)
except User.DoesNotExist:
user_to_change = User.objects.filter(pk=user_id).first()
if user_to_change is None:
return Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
)
if not user_to_change.is_active:
return Response(
{"error": "Active user not found"}, status=status.HTTP_400_BAD_REQUEST
)
# Нельзя изменить роль владельца
if chat.created_by == user_to_change:

View File

@ -7,6 +7,7 @@ from typing import Any
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from documents.models import Document, DocumentTag
from documents.notifications.handlers import notify_document_audience
@ -41,7 +42,10 @@ def _set_departments(document: Document, department_ids: Sequence[int]) -> None:
def _set_recipients(document: Document, recipient_ids: Sequence[int]) -> None:
recipients = User.objects.filter(is_active=True, id__in=set(recipient_ids))
existing_ids = set(document.recipients.values_list("id", flat=True))
recipients = User.objects.filter(id__in=set(recipient_ids)).filter(
Q(is_active=True) | Q(id__in=existing_ids)
)
document.recipients.set(recipients)
logger.debug(
"Document recipients updated",
@ -67,9 +71,13 @@ def _set_acknowledgement_recipients(
document: Document,
recipient_ids: Sequence[int],
) -> None:
document.acknowledgement_recipients.set(
User.objects.filter(is_active=True, id__in=set(recipient_ids))
existing_ids = set(
document.acknowledgement_recipients.values_list("id", flat=True)
)
recipients = User.objects.filter(id__in=set(recipient_ids)).filter(
Q(is_active=True) | Q(id__in=existing_ids)
)
document.acknowledgement_recipients.set(recipients)
def _set_tags(document: Document, tag_ids: Sequence[int]) -> None:

View File

@ -760,6 +760,19 @@ COMMERCE_QUEUED_STALE_SECONDS = int(
COMMERCE_RUNNING_STALE_SECONDS = int(
os.getenv("COMMERCE_RUNNING_STALE_SECONDS", "600")
)
# Commerce imports walk large remote datasets and must not inherit the short
# global limits used by routine background tasks.
COMMERCE_TASK_SOFT_TIME_LIMIT = int(
os.getenv("COMMERCE_TASK_SOFT_TIME_LIMIT", str(90 * 60))
)
COMMERCE_TASK_TIME_LIMIT = int(
os.getenv("COMMERCE_TASK_TIME_LIMIT", str(120 * 60))
)
if COMMERCE_TASK_SOFT_TIME_LIMIT >= COMMERCE_TASK_TIME_LIMIT:
raise ValueError(
"COMMERCE_TASK_SOFT_TIME_LIMIT must be lower than "
"COMMERCE_TASK_TIME_LIMIT."
)
MOYSKLAD_API_BASE_URL = os.getenv(
"MOYSKLAD_API_BASE_URL", "https://api.moysklad.ru/api/remap/1.2"
)

View File

@ -5,6 +5,7 @@ class NotificationActorSerializer(serializers.Serializer):
id = serializers.IntegerField(allow_null=True)
name = serializers.CharField()
type = serializers.CharField(required=False, allow_null=True)
avatar = serializers.CharField(required=False, allow_blank=True, allow_null=True)
class NotificationItemSerializer(serializers.Serializer):

View File

@ -68,6 +68,18 @@ def _parse_time_or_none(value):
return datetime.strptime(value, '%H:%M').time()
def _actor_avatar_url(actor):
if actor is None:
return None
avatar = getattr(actor, 'avatar', None)
if not avatar:
return None
try:
return avatar.url
except (AttributeError, ValueError):
return None
@extend_schema(
tags=["Notifications"],
summary="Получить список уведомлений",
@ -166,6 +178,7 @@ def get_notifications(request):
'id': n.actor.id if n.actor else None,
'name': str(n.actor) if n.actor else 'Система',
'type': n.actor_content_type.model if n.actor else None,
'avatar': _actor_avatar_url(n.actor),
} if n.actor else None,
'unread': n.unread,
'public': n.public,

View File

@ -35,6 +35,7 @@ def user1(db):
first_name="User",
last_name="One",
phone_number="+79991234567",
is_active=True,
send_activation_email=False,
)
@ -48,6 +49,7 @@ def user2(db):
first_name="User",
last_name="Two",
phone_number="+79991234568",
is_active=True,
send_activation_email=False,
)
@ -61,6 +63,7 @@ def user3(db):
first_name="User",
last_name="Three",
phone_number="+79991234569",
is_active=True,
send_activation_email=False,
)
@ -224,6 +227,26 @@ class TestChatViewSet:
assert response.status_code == status.HTTP_201_CREATED
assert response.data["name"] == "Test Group"
def test_cannot_create_chat_with_inactive_participant(
self, auth_client, user1, user2
):
"""Уволенного сотрудника нельзя добавить в новый чат."""
user2.is_active = False
user2.save(update_fields=["is_active"])
response = auth_client.post(
"/api/v1/communications/chats/",
{
"type": "private",
"name": "Inactive participant",
"participants": [user1.id, user2.id],
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "participants" in response.data
def test_pin_chat(self, auth_client, private_chat, user1):
"""Закрепление чата"""
url = f"/api/v1/communications/chats/{private_chat.pk}/pin/"

View File

@ -1066,6 +1066,51 @@ class TestUpdate:
d = Document.objects.get(pk=doc_id)
assert list(d.recipients.values_list("id", flat=True)) == [c.id]
def test_update_keeps_existing_inactive_audience_but_rejects_new_one(
self, auth_client, make_user, api_urls
):
"""Историческая аудитория не блокирует PATCH документа."""
author = make_user("inactive-audience-author@example.com")
grant_perms(author, "change_document")
existing = make_user("inactive-audience-existing@example.com")
new_inactive = make_user("inactive-audience-new@example.com")
document = make_document(
uploaded_by=author,
sent_to_all=False,
acknowledgement_required=True,
acknowledgement_for_all=False,
recipients=[existing],
)
document.acknowledgement_recipients.set([existing])
existing.is_active = False
existing.save(update_fields=["is_active"])
new_inactive.is_active = False
new_inactive.save(update_fields=["is_active"])
keep_response = auth_client(author).patch(
api_urls["detail"](document.pk),
{
"title": "Обновлённый документ",
"recipient_ids": [existing.id],
"acknowledgement_recipient_ids": [existing.id],
},
format="json",
)
assert keep_response.status_code == status.HTTP_200_OK, keep_response.content
document.refresh_from_db()
assert document.recipients.filter(id=existing.id).exists()
assert document.acknowledgement_recipients.filter(id=existing.id).exists()
reject_response = auth_client(author).patch(
api_urls["detail"](document.pk),
{"recipient_ids": [existing.id, new_inactive.id]},
format="json",
)
assert reject_response.status_code == status.HTTP_400_BAD_REQUEST
assert "recipient_ids" in reject_response.json()
# -------------------- E. Удаление (DELETE) --------------------
class TestDelete:

View File

@ -79,6 +79,38 @@ def test_add_members_without_ldap(api_client, admin_user, settings):
assert group.user_set.count() == 2
def test_group_membership_rejects_new_inactive_but_retains_existing(
api_client, admin_user, settings
):
settings.LDAP_ENABLED = False
group = Group.objects.create(name="HistoricalMembers")
historical = make_user("historical.group@test.com")
new_inactive = make_user("new.inactive.group@test.com")
group.user_set.add(historical)
User.objects.filter(id__in=[historical.id, new_inactive.id]).update(
is_active=False
)
api_client.force_authenticate(user=admin_user)
keep_response = api_client.post(
reverse("api:v1:groups-replace-members", args=[group.id]),
{"member_ids": [historical.id]},
format="json",
)
assert keep_response.status_code == status.HTTP_200_OK
assert group.user_set.filter(id=historical.id).exists()
reject_response = api_client.post(
reverse("api:v1:groups-replace-members", args=[group.id]),
{"member_ids": [historical.id, new_inactive.id]},
format="json",
)
assert reject_response.status_code == status.HTTP_400_BAD_REQUEST
assert not group.user_set.filter(id=new_inactive.id).exists()
def test_destroy_group_without_ldap(api_client, admin_user, settings):
settings.LDAP_ENABLED = False
group = Group.objects.create(name="TestGroup")

View File

@ -25,6 +25,7 @@ def make_user(email: str, is_staff: bool = False) -> User:
password="pwd12345",
phone_number=_unique_phone(),
is_staff=is_staff,
is_active=True,
send_activation_email=False,
)
@ -86,6 +87,29 @@ class TestRoleAssignmentEndpoints:
assert RoleAssignment.objects.filter(
employee=other_user, role=role, is_active=True
).exists()
def test_cannot_assign_role_to_inactive_employee(self, api_client: APIClient):
"""Уволенному сотруднику нельзя выдать новую роль и права."""
admin = make_user("inactive-role-admin@test.com", is_staff=True)
dept = make_dept("Inactive role department")
role = make_role(dept, "Inactive target role", [])
inactive = make_user("inactive-role-target@test.com")
inactive.is_active = False
inactive.save(update_fields=["is_active"])
api_client.force_authenticate(admin)
response = api_client.post(
f"/api/v1/department-roles/{role.id}/assign/",
{"employee_id": inactive.id},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "employee_id" in response.data
assert not RoleAssignment.objects.filter(
employee=inactive,
role=role,
is_active=True,
).exists()
def test_revoke_role(self, api_client: APIClient):
"""Отзыв роли деактивирует RoleAssignment."""

View File

@ -23,6 +23,8 @@ class TestNotificationList:
def test_notification_actors_are_prefetched(self, api_client, user_factory):
recipient = user_factory()
actor = user_factory()
actor.avatar = "avatars/notification-actor.jpg"
actor.save(update_fields=["avatar"])
actor_content_type = ContentType.objects.get_for_model(actor)
Notification.objects.bulk_create([
Notification(
@ -49,6 +51,10 @@ class TestNotificationList:
item["actor"]["id"] == actor.id
for item in response.data["notifications"]
)
assert all(
item["actor"]["avatar"].endswith("/media/avatars/notification-actor.jpg")
for item in response.data["notifications"]
)
assert len(queries) <= 5
def test_notification_pagination_is_bounded(self, api_client, user_factory):

View File

@ -714,6 +714,45 @@ def test_inactive_configured_recipient_does_not_disable_restriction(
assert _result_ids(response.json()) == set()
def test_update_keeps_existing_inactive_recipient_but_rejects_new_one(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:
"""Уволенный получатель не блокирует правку, но добавить другого нельзя."""
existing = make_user(email="request-existing-inactive@example.com")
new_inactive = make_user(email="request-new-inactive@example.com")
employee_request = make_request(
employee=regular_user,
type_=RequestType.OTHER,
)
employee_request.recipients.add(existing)
existing.is_active = False
existing.save(update_fields=["is_active"])
new_inactive.is_active = False
new_inactive.save(update_fields=["is_active"])
keep_response = auth_client(regular_user).patch(
f"{API_BASE}{employee_request.id}/",
{
"comment": "Обновлённый комментарий",
"recipient_ids": [existing.id],
},
format="json",
)
assert keep_response.status_code == 200, keep_response.content
assert employee_request.recipients.filter(id=existing.id).exists()
reject_response = auth_client(regular_user).patch(
f"{API_BASE}{employee_request.id}/",
{"recipient_ids": [existing.id, new_inactive.id]},
format="json",
)
assert reject_response.status_code == 400
assert "recipient_ids" in reject_response.json()
assert not employee_request.recipients.filter(id=new_inactive.id).exists()
def test_maternity_request_requires_and_accepts_period(
auth_client, regular_user: models.Model, make_user
) -> None:

View File

@ -1526,6 +1526,115 @@ def test_task_participants_can_change_assignee_in_same_update(api_client, user):
}
def test_inactive_assignee_does_not_block_reassignment(api_client, user):
previous_assignee = make_user(
"task-dismissed-assignee@example.com",
"+79994440117",
)
next_assignee = make_user(
"task-active-replacement@example.com",
"+79994440118",
)
board = TaskBoard.objects.create(name="Доска после увольнения", created_by=user)
board.members.add(previous_assignee, next_assignee)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Переназначить после увольнения",
created_by=user,
assignee=previous_assignee,
)
task.participants.set([user, previous_assignee])
previous_assignee.is_active = False
previous_assignee.save(update_fields=["is_active"])
api_client.force_authenticate(user=user)
response = api_client.patch(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}),
{
"assignee_id": next_assignee.id,
# The task form resubmits all current participants, including the
# dismissed previous assignee.
"participant_ids": [user.id, previous_assignee.id, next_assignee.id],
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data["assignee"]["id"] == next_assignee.id
dismissed_payload = next(
item
for item in response.data["participants"]
if item["id"] == previous_assignee.id
)
assert dismissed_payload["is_active"] is False
task.refresh_from_db()
assert task.assignee_id == next_assignee.id
def test_inactive_users_cannot_be_newly_assigned_or_added_to_task(api_client, user):
inactive_user = make_user(
"task-inactive-candidate@example.com",
"+79994440119",
is_active=False,
)
board = TaskBoard.objects.create(name="Доска активных", created_by=user)
board.members.add(inactive_user)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
api_client.force_authenticate(user=user)
task_url = reverse("api:v1:tasks:task-list")
base_payload = {
"board": board.id,
"column": column.id,
"title": "Проверка неактивного сотрудника",
}
assignee_response = api_client.post(
task_url,
{**base_payload, "assignee_id": inactive_user.id},
format="json",
)
participant_response = api_client.post(
task_url,
{**base_payload, "participant_ids": [inactive_user.id]},
format="json",
)
assert assignee_response.status_code == status.HTTP_400_BAD_REQUEST
assert "assignee_id" in assignee_response.data
assert participant_response.status_code == status.HTTP_400_BAD_REQUEST
assert "participant_ids" in participant_response.data
def test_assignee_must_have_access_to_task_board(api_client, user):
outsider = make_user(
"task-board-outsider@example.com",
"+79994440120",
)
board = TaskBoard.objects.create(
name="Закрытая доска назначения",
created_by=user,
access_scope="restricted",
)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
api_client.force_authenticate(user=user)
response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": board.id,
"column": column.id,
"title": "Недоступный исполнитель",
"assignee_id": outsider.id,
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "assignee_id" in response.data
def test_member_can_claim_unassigned_task(api_client, user):
claimant = make_user("task-claimant@example.com", "+79994440095")
other_member = make_user("task-claim-other@example.com", "+79994440096")
@ -1607,6 +1716,42 @@ def test_member_can_claim_unassigned_task(api_client, user):
assert completed_task.assignee_id is None
def test_member_can_claim_task_from_inactive_assignee(api_client, user):
inactive_assignee = make_user(
"task-inactive-claim-assignee@example.com",
"+79994440121",
)
claimant = make_user("task-inactive-claimant@example.com", "+79994440122")
board = TaskBoard.objects.create(name="Доска освобождения задачи", created_by=user)
board.members.add(inactive_assignee, claimant)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Зависшая задача",
created_by=user,
assignee=inactive_assignee,
)
task.participants.set([user, inactive_assignee])
inactive_assignee.is_active = False
inactive_assignee.save(update_fields=["is_active"])
api_client.force_authenticate(user=claimant)
with patch("api.v1.tasks.viewsets.tasks.notify_task_updated") as notify_updated:
response = api_client.post(
reverse("api:v1:tasks:task-claim", kwargs={"pk": task.id})
)
assert response.status_code == status.HTTP_200_OK
task.refresh_from_db()
assert task.assignee_id == claimant.id
assert task.participants.filter(id=inactive_assignee.id).exists()
notify_updated.assert_called_once()
_, _, previous, current = notify_updated.call_args.args
assert previous == {"assignee_id": inactive_assignee.id}
assert current == {"assignee_id": claimant.id}
def test_only_assignee_can_complete_task_in_configured_done_column(api_client, user):
assignee = make_user("task-assignee@example.com", "+79994440093")
member = make_user("task-member@example.com", "+79994440094")
@ -2429,6 +2574,48 @@ def test_create_board_accepts_members_and_departments(api_client, user):
assert TaskColumn.objects.filter(board_id=response.data["id"]).count() == 4
def test_board_edit_retains_existing_inactive_member_but_rejects_new_one(
api_client,
user,
):
existing_member = make_user(
"task-board-dismissed-member@example.com",
"+79994440123",
)
new_inactive_member = make_user(
"task-board-new-inactive-member@example.com",
"+79994440124",
is_active=False,
)
board = TaskBoard.objects.create(
name="Доска с уволенным участником",
created_by=user,
access_scope="restricted",
)
board.members.add(existing_member)
existing_member.is_active = False
existing_member.save(update_fields=["is_active"])
api_client.force_authenticate(user=user)
url = reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
retained_response = api_client.patch(
url,
{"members": [existing_member.id]},
format="json",
)
rejected_response = api_client.patch(
url,
{"members": [existing_member.id, new_inactive_member.id]},
format="json",
)
assert retained_response.status_code == status.HTTP_200_OK
assert retained_response.data["member_details"][0]["is_active"] is False
assert rejected_response.status_code == status.HTTP_400_BAD_REQUEST
assert "members" in rejected_response.data
assert set(board.members.values_list("id", flat=True)) == {existing_member.id}
def test_private_board_is_available_only_to_owner(api_client):
owner = make_user("task-private-owner@example.com", "+79994440901")
selected_member = make_user("task-private-member@example.com", "+79994440902")

View File

@ -6,6 +6,7 @@ from unittest.mock import Mock, patch
import httpx
import pytest
from celery.exceptions import SoftTimeLimitExceeded
from PIL import Image
from django.db import connection as db_connection, transaction
from django.test.utils import CaptureQueriesContext
@ -38,6 +39,7 @@ from commerce.media import (
from commerce.realtime import COMMERCE_CONNECTIONS_GROUP, send_connection_update
from commerce.services.moysklad_sync import (
MoySkladSyncStats,
_sync_images_stage,
_position_cost,
_position_revenue,
normalize_moysklad_assortment_row,
@ -49,6 +51,7 @@ from commerce.services.moysklad_sync import (
from commerce.services.sync_runs import STALE_RUNNING_ERROR
from commerce.tasks import (
STALE_QUEUED_ERROR,
SYNC_TIME_LIMIT_ERROR,
dispatch_due_connection_syncs,
enqueue_connection_sync,
sync_moysklad_run,
@ -391,6 +394,67 @@ def test_one_broken_product_image_does_not_abort_remaining_images():
).exists()
def test_product_image_sync_does_not_swallow_celery_soft_time_limit():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
)
row = {
"id": "external-product-timeout",
"name": "Товар с долгой загрузкой",
"article": "PHOTO-TIMEOUT",
"meta": {"type": "product"},
"images": {"meta": {"size": 1}},
}
normalize_moysklad_assortment_row(
connection=connection,
row=row,
captured_at=timezone.now(),
)
remote = MoySkladRemoteImage(
external_id="slow-image",
filename="slow.jpg",
title="Долгая загрузка",
size=100,
updated="2026-08-17 12:00:00.000",
download_href="https://api.example/download/slow",
)
client = Mock()
client.product_images.return_value = [remote]
client.download_image.side_effect = SoftTimeLimitExceeded()
with pytest.raises(SoftTimeLimitExceeded):
sync_moysklad_product_images(
connection=connection,
row=row,
client=client,
error_callback=Mock(),
)
def test_image_stage_does_not_swallow_celery_soft_time_limit():
connection = Mock(pk=uuid.uuid4(), provider="moysklad")
with (
patch(
"commerce.services.moysklad_sync.sync_moysklad_product_images",
side_effect=SoftTimeLimitExceeded(),
),
pytest.raises(SoftTimeLimitExceeded),
):
_sync_images_stage(
connection,
Mock(),
[{"id": "slow-product", "meta": {}, "images": {}}],
MoySkladSyncStats(),
Mock(),
run_id="run-timeout",
task_id="task-timeout",
)
def test_moysklad_client_includes_safe_upstream_error_detail(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
@ -722,6 +786,35 @@ def test_sync_run_persists_product_image_progress():
assert connection.last_stats["images"] == 12
def test_sync_run_records_explicit_soft_time_limit_error():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
provider="moysklad",
encrypted_credentials="unused",
status=IntegrationStatus.QUEUED,
task_id="sync-timeout-task",
)
run = connection.sync_runs.create(task_id="sync-timeout-task")
with (
patch(
"commerce.tasks.sync_moysklad_connection",
side_effect=SoftTimeLimitExceeded(),
),
patch("commerce.tasks.send_connection_update"),
pytest.raises(SoftTimeLimitExceeded),
):
sync_moysklad_run.run(str(run.pk))
run.refresh_from_db()
connection.refresh_from_db()
assert run.status == "error"
assert run.error == SYNC_TIME_LIMIT_ERROR
assert connection.status == IntegrationStatus.ERROR
assert connection.last_error == SYNC_TIME_LIMIT_ERROR
def test_beat_dispatcher_queues_due_connection(django_capture_on_commit_callbacks):
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(

View File

@ -387,6 +387,58 @@ def test_superuser_can_update_finance_management_allow_list_from_api(
assert unrestricted_response.data["restricted"] is False
def test_finance_allow_list_can_retain_inactive_historical_admin(
settings,
user_factory,
api_client,
):
"""Уволенный администратор не блокирует редактирование списка доступа."""
settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True}
superuser = user_factory(
email="historical.settings.finance@example.test",
staff=True,
superuser=True,
)
historical_admin = user_factory(
email="historical.finance@example.test",
staff=True,
)
new_inactive_admin = user_factory(
email="new.inactive.finance@example.test",
staff=True,
)
finance_settings = FinanceSettings.get_solo()
finance_settings.management_users.add(historical_admin)
historical_admin.is_active = False
historical_admin.save(update_fields=["is_active"])
new_inactive_admin.is_active = False
new_inactive_admin.save(update_fields=["is_active"])
settings_url = reverse("api:v1:finance-payroll:management-access-settings")
api_client.force_authenticate(user=superuser)
keep_response = api_client.patch(
settings_url,
{"management_user_ids": [historical_admin.id]},
format="json",
)
assert keep_response.status_code == 200, keep_response.content
assert keep_response.data["management_user_ids"] == [historical_admin.id]
reject_response = api_client.patch(
settings_url,
{
"management_user_ids": [
historical_admin.id,
new_inactive_admin.id,
]
},
format="json",
)
assert reject_response.status_code == 400
def test_regular_payroll_admin_cannot_change_management_allow_list(
settings,
user_factory,

View File

@ -590,6 +590,47 @@ class TestEquipmentUpdateAccess:
assert response.status_code == status.HTTP_200_OK
assert response.data["name"] == "Updated Laptop"
def test_inactive_current_responsible_does_not_block_update(
self, api_client, admin_user, category, dept_it, dept_member
):
"""Карточку можно править после увольнения текущего ответственного."""
equipment = make_equipment("Legacy laptop", dept_it, category, dept_member)
dept_member.is_active = False
dept_member.save(update_fields=["is_active"])
EmployeeDepartment.objects.filter(
employee=dept_member,
department=dept_it,
).update(is_active=False)
api_client.force_authenticate(user=admin_user)
response = api_client.patch(
equipment_detail_url(equipment.id),
{"name": "Updated legacy laptop"},
format="json",
)
assert response.status_code == status.HTTP_200_OK, response.content
equipment.refresh_from_db()
assert equipment.responsible_person_id == dept_member.id
def test_cannot_assign_inactive_responsible(
self, api_client, admin_user, category, dept_it, dept_head, dept_member
):
"""Новым ответственным нельзя назначить уволенного сотрудника."""
equipment = make_equipment("Active laptop", dept_it, category, dept_head)
dept_member.is_active = False
dept_member.save(update_fields=["is_active"])
api_client.force_authenticate(user=admin_user)
response = api_client.patch(
equipment_detail_url(equipment.id),
{"responsible_person": dept_member.id},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "responsible_person" in response.data
def test_model_perm_user_can_update_any(
self, api_client, model_perm_user, category, dept_it, dept_head
):

View File

@ -31,3 +31,6 @@ def test_commerce_import_has_dedicated_queue_without_automatic_redelivery():
"reject_on_worker_lost": False
}
assert sync_connection_run.reject_on_worker_lost is False
assert sync_connection_run.soft_time_limit == settings.COMMERCE_TASK_SOFT_TIME_LIMIT
assert sync_connection_run.time_limit == settings.COMMERCE_TASK_TIME_LIMIT
assert sync_connection_run.soft_time_limit < sync_connection_run.time_limit

View File

@ -37,7 +37,8 @@ def user1(db):
password='testpass123',
first_name='User',
last_name='One',
phone_number='+79991234567' # Правильное имя параметра
phone_number='+79991234567', # Правильное имя параметра
is_active=True,
)
@ -50,7 +51,8 @@ def user2(db):
password='testpass123',
first_name='User',
last_name='Two',
phone_number='+79991234568'
phone_number='+79991234568',
is_active=True,
)
@ -63,7 +65,8 @@ def user3(db):
password='testpass123',
first_name='User',
last_name='Three',
phone_number='+79991234569'
phone_number='+79991234569',
is_active=True,
)
@ -170,6 +173,26 @@ class TestCalendarPermissions:
assert len(calendars) == 1
assert calendars[0]['id'] == calendar_id
def test_cannot_add_inactive_participant_to_calendar(
self, api_client, user1, user2
):
"""Уволенного сотрудника нельзя заново добавить в календарь."""
api_client.force_authenticate(user=user1)
calendar_response = api_client.post(
'/api/v1/schedule/calendars/',
{'name': 'Active participants only'},
)
user2.is_active = False
user2.save(update_fields=['is_active'])
response = api_client.post(
f"/api/v1/schedule/calendars/{calendar_response.data['id']}/add-participant/",
{'user_id': user2.id, 'role': 'viewer'},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert 'user_id' in response.data
def test_non_owner_cannot_add_participant(self, api_client, user1, user2, user3):
"""Не-owner не может добавлять участников."""

View File

@ -37,6 +37,12 @@ Celery Beat раз в минуту выполняет reconciliation. Запус
задаются `COMMERCE_QUEUED_STALE_SECONDS` и
`COMMERCE_RUNNING_STALE_SECONDS`. Ручная безопасная проверка:
Долгие импорты имеют отдельные лимиты Celery:
`COMMERCE_TASK_SOFT_TIME_LIMIT` (по умолчанию 90 минут) и
`COMMERCE_TASK_TIME_LIMIT` (по умолчанию 120 минут). Мягкий лимит всегда должен
быть меньше жёсткого. `SoftTimeLimitExceeded` не считается локальной ошибкой
изображения: запуск завершается с явным сообщением о превышении времени.
```bash
python manage.py reconcile_commerce_sync_runs --dry-run
python manage.py reconcile_commerce_sync_runs

View File

@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

6610
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
allowBuilds:
esbuild: set this to true or false
tesseract.js: set this to true or false
unrs-resolver: set this to true or false

View File

@ -17,6 +17,8 @@ import {
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import {
AttendanceDayEventsModal,
type AttendanceDayEventsPreview,
@ -1340,21 +1342,19 @@ export default function AttendancePage() {
<AppShell>
<div className="mx-auto flex max-w-6xl flex-col gap-4">
<section className="app-surface rounded-2xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<AutoRevealStickyHeader className="rounded-b-xl">
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<div className="shrink-0">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Посещаемость</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
<div className="ml-auto flex min-w-0 shrink items-center gap-2">
<ModulePrimaryAction
onClick={() => void loadStatistics()}
disabled={loadingStats || loadingEmployees || loadingSchedule}
className="app-action-primary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingStats ? <Loader2 size={16} className="animate-spin" /> : <CalendarCheck size={16} />}
Показать
</button>
icon={loadingStats ? <Loader2 size={16} className="animate-spin" /> : <CalendarCheck size={16} />}
label="Показать"
/>
<div ref={actionsMenuRef} className="relative">
<button
type="button"
@ -1410,6 +1410,7 @@ export default function AttendancePage() {
</div>
</div>
</div>
</AutoRevealStickyHeader>
<div className="mb-4 app-surface-muted rounded-2xl p-3 sm:p-4">
<button

View File

@ -2,6 +2,9 @@
import { AppShell } from "../../components/AppShell";
import { Modal } from "@/components/ui/Modal";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { loadAllPages } from "@/lib/shared";
@ -133,16 +136,15 @@ export default function DepartmentsPage() {
</div>
) : (
<section className="app-surface rounded-2xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Отделы</p>
<AutoRevealStickyHeader className="rounded-b-xl">
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<p className="app-text-muted shrink-0 text-sm font-semibold uppercase tracking-wide">Отделы</p>
{canCreateDepartment ? (
<button
type="button"
<ModulePrimaryAction
onClick={openCreateDepartment}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} /> Создать отдел
</button>
icon={<Plus size={14} />}
label="Создать отдел"
/>
) : null}
</div>
@ -156,7 +158,7 @@ export default function DepartmentsPage() {
/>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<HorizontalChipScroller className="mb-4" ariaLabel="Разделы отделов">
{scopeTabs.map((tab) => (
<button
key={tab.value}
@ -176,7 +178,8 @@ export default function DepartmentsPage() {
</span>
</button>
))}
</div>
</HorizontalChipScroller>
</AutoRevealStickyHeader>
<div className="space-y-3">
{filteredDepartments.length === 0 ? (

View File

@ -43,6 +43,10 @@ import { BulkActionsToolbar, useDocumentSelection } from "@/components/documents
import { TagManagementModal } from "@/components/documents/tags";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { CompactSortMenu, type CompactSortOption } from "@/components/ui/CompactSortMenu";
import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { canPreviewDocument, getDocumentFileExtension } from "@/lib/document-preview";
import { useNotifications } from "@/contexts/NotificationsContext";
import { useUser } from "@/contexts/UserContext";
@ -132,6 +136,15 @@ async function loadAllPaginated<T>(
}
}
type DocumentSortValue = "date:desc" | "date:asc" | "title:asc" | "title:desc";
const documentSortOptions = [
{ value: "date:desc", label: "Сначала новые", icon: "date-desc" },
{ value: "date:asc", label: "Сначала старые", icon: "date-asc" },
{ value: "title:asc", label: "По названию А–Я", icon: "alphabetical" },
{ value: "title:desc", label: "По названию Я–А", icon: "alphabetical-reverse" },
] satisfies readonly CompactSortOption<DocumentSortValue>[];
export default function DocumentsPage() {
return (
<Suspense fallback={<DocumentsPageFallback />}>
@ -584,6 +597,16 @@ function DocumentsPageContent() {
);
const activeFilterCount = selectedTags.length + (dateFrom ? 1 : 0) + (dateTo ? 1 : 0);
const documentSortValue = `${sortBy}:${sortOrder}` as DocumentSortValue;
const updateDocumentSort = (value: DocumentSortValue) => {
const [nextSortBy, nextSortOrder] = value.split(":") as [
"date" | "title",
"asc" | "desc",
];
setSortBy(nextSortBy);
setSortOrder(nextSortOrder);
};
const openCreateFolderModal = (parentId: number | null) => {
setCreateFolderParentId(parentId);
@ -851,21 +874,20 @@ function DocumentsPageContent() {
return (
<AppShell>
<div className="space-y-4">
<section className="app-surface rounded-2xl p-4">
{/* Top Bar */}
<div className="app-surface rounded-2xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">
<>
<AutoRevealStickyHeader className="rounded-2xl">
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<p className="app-text-muted shrink-0 text-sm font-semibold uppercase tracking-wide">
{activeSection === "regulations" ? "Регламенты" : "Документы"}
</p>
{(activeSection !== "regulations" || canManageRegulations) ? (
<button
<ModulePrimaryAction
onClick={() => setShowUploadForm(true)}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} />
{activeSection === "regulations" ? "Создать регламент" : "Загрузить документ"}
</button>
icon={<Plus size={14} />}
label={activeSection === "regulations" ? "Создать регламент" : "Загрузить документ"}
/>
) : null}
</div>
@ -885,6 +907,7 @@ function DocumentsPageContent() {
/>
</div>
<button
id="documents-filter-trigger"
type="button"
onClick={() => setShowFilters(!showFilters)}
className={`relative inline-flex items-center justify-center rounded-lg border p-2.5 transition ${
@ -901,6 +924,12 @@ function DocumentsPageContent() {
</span>
)}
</button>
<CompactSortMenu
value={documentSortValue}
options={documentSortOptions}
onChange={updateDocumentSort}
ariaLabel="Сортировка документов"
/>
</div>
{activeSection === "folders" && (
@ -1135,6 +1164,7 @@ function DocumentsPageContent() {
</div>
</div>
)}
</AutoRevealStickyHeader>
{/* Active Filters Tags */}
{(selectedTags.length > 0 || dateFrom || dateTo) && (
@ -1195,8 +1225,8 @@ function DocumentsPageContent() {
)}
{/* Filters Panel */}
{showFilters && (
<div className="app-surface-muted mb-3 flex flex-col gap-4 rounded-xl p-3">
<CompactFilterPanel open={showFilters} anchorId="documents-filter-trigger" onClose={() => setShowFilters(false)} ariaLabel="Фильтры документов">
<div className="flex flex-col gap-4">
{/* Tags Filter */}
<div>
<div className="mb-2 flex items-center justify-between">
@ -1210,7 +1240,7 @@ function DocumentsPageContent() {
</button>
</div>
{availableTags.length > 0 ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
<div className="grid grid-cols-2 gap-2">
{availableTags.map(tag => (
<label
key={tag.id}
@ -1263,36 +1293,6 @@ function DocumentsPageContent() {
</div>
</div>
{/* Sorting */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Сортировать по
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as "date" | "title")}
className="app-select w-full rounded-lg px-3 py-2 text-sm"
>
<option value="date">Дата создания</option>
<option value="title">Название</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Порядок
</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as "asc" | "desc")}
className="app-select w-full rounded-lg px-3 py-2 text-sm"
>
<option value="desc">По убыванию</option>
<option value="asc">По возрастанию</option>
</select>
</div>
</div>
{activeFilterCount > 0 && (
<button
type="button"
@ -1307,13 +1307,13 @@ function DocumentsPageContent() {
</button>
)}
</div>
)}
</div>
</CompactFilterPanel>
</>
{/* Main Content */}
<div className="space-y-4">
{loading ? (
<div className="app-surface rounded-2xl p-8 text-center">
<div className="app-surface-muted rounded-xl p-8 text-center">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
<p className="app-text-muted text-sm">Загрузка документов...</p>
</div>
@ -1325,7 +1325,7 @@ function DocumentsPageContent() {
<>
{/* Bulk Actions Toolbar */}
{selectionMode && selection.selectedIds.length > 0 && (
<div className="app-surface rounded-2xl p-4">
<div className="app-surface-muted rounded-xl p-4">
<BulkActionsToolbar
selectedIds={selection.selectedIds}
availableFolders={bulkFolderOptions}
@ -1383,7 +1383,7 @@ function DocumentsPageContent() {
)}
{/* Documents List */}
<div className="app-surface rounded-2xl p-4">
<div className="border-t border-[var(--border-subtle)] pt-4">
<div className="space-y-3">
{visibleFolders.length === 0
&& visibleRegulationDepartments.length === 0
@ -2110,7 +2110,7 @@ function DocumentsPageContent() {
</>
)}
</div>
</div>
</section>
{/* Upload Modal */}
<Modal

View File

@ -16,6 +16,7 @@ import {
} from "lucide-react";
import { AppShell } from "../../components/AppShell";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
@ -390,21 +391,23 @@ export default function EmployeesPage() {
return (
<AppShell>
<section className="app-surface rounded-2xl p-4">
<EmployeeStatsSummary
error={statsError}
loading={statsLoading}
stats={stats}
/>
<div className="relative mb-4">
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Поиск по сотрудникам"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
<AutoRevealStickyHeader className="rounded-b-xl">
<EmployeeStatsSummary
error={statsError}
loading={statsLoading}
stats={stats}
/>
</div>
<div className="relative mb-4">
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Поиск по сотрудникам"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
</AutoRevealStickyHeader>
<div className="space-y-3">
{filteredEmployees.map((employee) => {

View File

@ -7,12 +7,17 @@ import { useUser } from "@/contexts/UserContext";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Archive, ArrowRightLeft, ArrowUpDown, Check, ChevronDown, ChevronRight, Copy, Download, ExternalLink, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench } from "lucide-react";
import { Archive, ArrowRightLeft, Check, ChevronDown, ChevronRight, Copy, Download, ExternalLink, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench } from "lucide-react";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { formatDate, formatMoney } from "@/lib/shared";
import { useEquipmentPage } from "@/hooks/useEquipmentPage";
import { Modal } from "@/components/ui";
import { CompactSortMenu, type CompactSortOption } from "@/components/ui/CompactSortMenu";
import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import type { Equipment, EquipmentTransferHistoryEntry, MaintenanceRecord } from "@/types/api";
const listModeMeta = [
@ -22,12 +27,12 @@ const listModeMeta = [
] as const;
const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "name", label: "По названию" },
{ value: "purchase_date", label: "По дате покупки ↑" },
{ value: "-purchase_date", label: "По дате покупки ↓" },
];
{ value: "-created_at", label: "Сначала новые", icon: "date-desc" },
{ value: "created_at", label: "Сначала старые", icon: "date-asc" },
{ value: "name", label: "По названию", icon: "alphabetical" },
{ value: "purchase_date", label: "По дате покупки ↑", icon: "purchase-date-asc" },
{ value: "-purchase_date", label: "По дате покупки ↓", icon: "purchase-date-desc" },
] satisfies readonly CompactSortOption[];
/* ──── status badges ──── */
const statusMeta: Record<string, { label: string; className: string; accentClass: string; surfaceClass: string }> = {
@ -477,16 +482,15 @@ function EquipmentPageContent() {
</div>
) : (
<section className="app-surface rounded-2xl p-4">
<AutoRevealStickyHeader className="rounded-b-xl">
{/* Header */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Оборудование</p>
<button
type="button"
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<p className="app-text-muted shrink-0 text-sm font-semibold uppercase tracking-wide">Оборудование</p>
<ModulePrimaryAction
onClick={handleOpenCreateEquipmentModal}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} /> Добавить оборудование
</button>
icon={<Plus size={14} />}
label="Добавить оборудование"
/>
</div>
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
@ -504,6 +508,7 @@ function EquipmentPageContent() {
/>
</div>
<button
id="equipment-filter-trigger"
type="button"
title="Фильтры"
onClick={() => setFiltersOpen((v) => !v)}
@ -516,23 +521,15 @@ function EquipmentPageContent() {
</span>
)}
</button>
<div className="relative w-[148px] shrink-0">
<ArrowUpDown size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<select
value={ordering}
onChange={(e) => setOrdering(e.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
aria-label="Сортировка списка оборудования"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
<CompactSortMenu
value={ordering}
options={orderingOptions}
onChange={setOrdering}
ariaLabel="Сортировка списка оборудования"
/>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<HorizontalChipScroller className="mb-4" ariaLabel="Разделы оборудования">
{listModeMeta.map((mode) => (
<button
key={mode.value}
@ -547,12 +544,13 @@ function EquipmentPageContent() {
<span>{mode.label}</span>
</button>
))}
</div>
</HorizontalChipScroller>
</AutoRevealStickyHeader>
{/* Filters panel */}
{filtersOpen && (
<div className="app-surface-muted mb-4 flex flex-col gap-2 rounded-xl p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
<CompactFilterPanel open={filtersOpen} anchorId="equipment-filter-trigger" onClose={() => setFiltersOpen(false)} ariaLabel="Фильтры оборудования">
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="">Все статусы</option>
{Object.entries(statusMeta).map(([key, meta]) => (
@ -590,7 +588,7 @@ function EquipmentPageContent() {
</button>
)}
</div>
)}
</CompactFilterPanel>
{/* Items list */}
<div className="space-y-3">

View File

@ -17,6 +17,7 @@ import { AppShell } from "@/components/AppShell";
import { PayrollDailyWorkSection } from "@/components/finance/PayrollDailyWorkSection";
import { PayrollStatementDetailModal } from "@/components/finance/PayrollStatementDetailModal";
import { PayrollAdminWorkspace } from "@/components/finance/admin/PayrollAdminWorkspace";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { useUser } from "@/contexts/UserContext";
import { usePayrollDesktopWideMode } from "@/hooks/usePayrollDesktopWideMode";
import { usePayrollFinanceTab } from "@/hooks/usePayrollTabs";
@ -139,12 +140,12 @@ export default function FinancesPage() {
>
<div className={`min-w-0 space-y-6 ${activeTab === "management" && desktopWideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""}`}>
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<p className="app-card-caption">Финансы</p>
<div className="flex min-w-0 flex-nowrap items-start justify-between gap-3">
<p className="app-card-caption shrink-0">Финансы</p>
{activeTab === "management" && showPayrollAdmin ? (
<div id="finance-management-actions" className="ml-auto max-w-full" />
<div id="finance-management-actions" className="ml-auto min-w-0 max-w-full shrink" />
) : activeTab === "work" ? (
<div id="finance-work-period-selector" className="ml-auto w-full max-w-full sm:w-auto" />
<div id="finance-work-period-selector" className="ml-auto min-w-0 flex-1 sm:w-auto sm:flex-none" />
) : null}
</div>
@ -152,38 +153,43 @@ export default function FinancesPage() {
<div id="finance-management-header" className="mt-4" />
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setActiveTab("statement")}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "statement" ? "app-pill-active" : "app-pill"}`}
<div className="mt-4 flex min-w-0 items-center gap-2">
<HorizontalChipScroller
className="min-w-0 flex-1"
ariaLabel="Разделы финансов"
>
Расчётный лист
</button>
<button
type="button"
onClick={() => {
setSelectedStatement(null);
setActiveTab("work");
}}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "work" ? "app-pill-active" : "app-pill"}`}
>
Выработка
</button>
{showPayrollAdmin ? (
<button
type="button"
onClick={() => setActiveTab("statement")}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "statement" ? "app-pill-active" : "app-pill"}`}
>
Расчётный лист
</button>
<button
type="button"
onClick={() => {
setSelectedStatement(null);
setActiveTab("management");
setActiveTab("work");
}}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "management" ? "app-pill-active" : "app-pill"}`}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "work" ? "app-pill-active" : "app-pill"}`}
>
Управление
Выработка
</button>
) : null}
{showPayrollAdmin ? (
<button
type="button"
onClick={() => {
setSelectedStatement(null);
setActiveTab("management");
}}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "management" ? "app-pill-active" : "app-pill"}`}
>
Управление
</button>
) : null}
</HorizontalChipScroller>
{activeTab === "management" && showPayrollAdmin ? (
<div id="finance-management-access" className="ml-auto" />
<div id="finance-management-access" className="ml-auto shrink-0" />
) : null}
</div>
</section>

View File

@ -5,6 +5,7 @@
--foreground: #131a24;
--muted-foreground: #667287;
--surface-primary: rgba(255, 255, 255, 0.97);
--surface-primary-solid: #ffffff;
--surface-elevated: #ffffff;
--surface-secondary: #f2f5fa;
--surface-tertiary: #e8eef7;
@ -40,6 +41,7 @@ html[data-bs-theme="dark"] {
--foreground: #eef2f7;
--muted-foreground: #97a3b7;
--surface-primary: rgba(18, 24, 32, 0.92);
--surface-primary-solid: #121820;
--surface-elevated: #10161d;
--surface-secondary: #1b212a;
--surface-tertiary: #232c37;
@ -64,7 +66,7 @@ html[data-bs-theme="dark"] {
}
html, body {
overflow-x: hidden;
overflow-x: clip;
overscroll-behavior-x: none;
-webkit-overflow-scrolling: touch;
}
@ -649,19 +651,22 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
gap: 0.5rem;
}
.payroll-admin-toolbar-search,
.payroll-admin-toolbar-main,
.payroll-admin-toolbar-status,
.payroll-admin-toolbar-sort,
.payroll-admin-toolbar-actions {
width: 100%;
}
.payroll-admin-toolbar-search {
flex: 1 1 0%;
}
@container (min-width: 42rem) {
.payroll-admin-toolbar-layout {
flex-direction: row;
}
.payroll-admin-toolbar-search {
.payroll-admin-toolbar-main {
width: auto;
flex: 1 1 0%;
}
@ -670,10 +675,6 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
width: 11rem;
}
.payroll-admin-toolbar-sort {
width: 12rem;
}
.payroll-admin-toolbar-actions {
width: auto;
flex-shrink: 0;

View File

@ -1,12 +1,11 @@
"use client";
import { Suspense, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Suspense, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import {
AlertTriangle,
ArrowUpDown,
Ban,
BriefcaseBusiness,
Building2,
@ -17,7 +16,6 @@ import {
ChevronRight,
Clock,
FileText,
Filter,
History,
IdCard,
Info,
@ -47,6 +45,11 @@ import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks";
import { Modal } from "@/components/ui";
import { CompactSortMenu, type CompactSortOption } from "@/components/ui/CompactSortMenu";
import { CompactFilterMenu } from "@/components/ui/CompactFilterMenu";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { canManageGuestAccounts, canViewAllGuestVisits } from "@/lib/permissions";
@ -143,22 +146,22 @@ const scopeOptions: { value: VisitScope; label: string; adminOnly?: boolean }[]
];
const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "access_starts_at", label: "Начало доступа ↑" },
{ value: "-access_starts_at", label: "Начало доступа ↓" },
{ value: "access_expires_at", label: "Окончание доступа ↑" },
{ value: "-access_expires_at", label: "Окончание доступа ↓" },
];
{ value: "-created_at", label: "Сначала новые", icon: "date-desc" },
{ value: "created_at", label: "Сначала старые", icon: "date-asc" },
{ value: "access_starts_at", label: "Начало доступа ↑", icon: "access-start-asc" },
{ value: "-access_starts_at", label: "Начало доступа ↓", icon: "access-start-desc" },
{ value: "access_expires_at", label: "Окончание доступа ↑", icon: "access-end-asc" },
{ value: "-access_expires_at", label: "Окончание доступа ↓", icon: "access-end-desc" },
] satisfies readonly CompactSortOption[];
const guestOrderingOptions = [
{ value: "last_name", label: "По фамилии ↑" },
{ value: "-last_name", label: "По фамилии ↓" },
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "-updated_at", label: "Недавно обновленные" },
{ value: "-ldap_last_synced_at", label: "Недавно синхронизированные" },
];
{ value: "last_name", label: "По фамилии ↑", icon: "alphabetical" },
{ value: "-last_name", label: "По фамилии ↓", icon: "alphabetical-reverse" },
{ value: "-created_at", label: "Сначала новые", icon: "date-desc" },
{ value: "created_at", label: "Сначала старые", icon: "date-asc" },
{ value: "-updated_at", label: "Недавно обновленные", icon: "updated" },
{ value: "-ldap_last_synced_at", label: "Недавно синхронизированные", icon: "synced" },
] satisfies readonly CompactSortOption[];
const formatDateOnlyInput = (date: Date): string => {
const year = date.getFullYear();
@ -1259,51 +1262,39 @@ function GuestsPageContent() {
setScope(canAdminGuests ? "all" : "mine");
};
const guestSectionTabs = (
<HorizontalChipScroller ariaLabel="Разделы гостевого доступа">
<button
type="button"
onClick={() => setActiveTab("visits")}
className={`inline-flex h-8 items-center rounded-full px-3 text-xs font-medium transition ${activeTab === "visits" ? "app-pill-active" : "app-pill"}`}
>
Заявки
</button>
<button
type="button"
onClick={() => setActiveTab("guests")}
className={`inline-flex h-8 items-center rounded-full px-3 text-xs font-medium transition ${activeTab === "guests" ? "app-pill-active" : "app-pill"}`}
>
Гости
</button>
</HorizontalChipScroller>
);
return (
<AppShell>
<div className="space-y-4">
<section className="app-surface flex flex-col gap-4 rounded-2xl p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">ГОСТИ</p>
<button
type="button"
<section className="app-surface rounded-2xl p-4 sm:p-5">
<AutoRevealStickyHeader className="rounded-b-xl">
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<p className="app-text-muted shrink-0 text-sm font-semibold uppercase tracking-wide">ГОСТИ</p>
<ModulePrimaryAction
onClick={openCreate}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition"
>
<Plus size={14} />
Новая заявка
</button>
icon={<Plus size={14} />}
label="Новая заявка"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setActiveTab("visits")}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "visits" ? "app-pill-active" : "app-pill"}`}
>
Заявки
</button>
<button
type="button"
onClick={() => setActiveTab("guests")}
className={`inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition ${activeTab === "guests" ? "app-pill-active" : "app-pill"}`}
>
Гости
</button>
</div>
</section>
{actionSuccess ? (
<div className="app-feedback-success flex items-center justify-between gap-3 rounded-xl p-3 text-sm">
<span>{actionSuccess}</span>
<button type="button" onClick={() => setActionSuccess(null)} className="app-icon-button rounded-lg p-1">
<XCircle size={14} />
</button>
</div>
) : null}
{activeTab === "visits" ? (
<section className="app-surface rounded-2xl p-4">
{activeTab === "visits" ? (
<GuestVisitControls
accessFrom={accessFrom}
accessTo={accessTo}
@ -1316,6 +1307,7 @@ function GuestsPageContent() {
scope={scope}
scopeCounts={scopeCounts}
search={search}
sectionTabs={guestSectionTabs}
status={status}
unlimitedFilter={unlimitedFilter}
onAccessFromChange={setAccessFrom}
@ -1333,7 +1325,40 @@ function GuestsPageContent() {
onToggleFilters={() => setFiltersOpen((value) => !value)}
onUnlimitedFilterChange={setUnlimitedFilter}
/>
) : (
<GuestRegistryControls
activeFiltersCount={activeGuestFiltersCount}
blacklistFilter={guestBlacklistFilter}
filtersOpen={guestFiltersOpen}
guestSearch={guestSearch}
ordering={guestOrdering}
placementFilter={guestPlacementFilter}
sectionTabs={guestSectionTabs}
onBlacklistFilterChange={setGuestBlacklistFilter}
onClearFilters={() => {
setGuestBlacklistFilter("");
setGuestPlacementFilter("");
setGuestOrdering("last_name");
}}
onGuestSearchChange={setGuestSearch}
onOrderingChange={setGuestOrdering}
onPlacementFilterChange={setGuestPlacementFilter}
onToggleFilters={() => setGuestFiltersOpen((value) => !value)}
/>
)}
</AutoRevealStickyHeader>
{actionSuccess ? (
<div className="app-feedback-success mt-4 flex items-center justify-between gap-3 rounded-xl p-3 text-sm">
<span>{actionSuccess}</span>
<button type="button" onClick={() => setActionSuccess(null)} className="app-icon-button rounded-lg p-1">
<XCircle size={14} />
</button>
</div>
) : null}
{activeTab === "visits" ? (
<>
{loading ? (
<div className="app-surface-muted mt-4 rounded-xl p-8 text-center">
<Loader2 className="mx-auto animate-spin app-accent-text" size={28} />
@ -1396,23 +1421,17 @@ function GuestsPageContent() {
) : null}
</div>
)}
</section>
</>
) : (
<GuestRegistryPanel
busyKey={busyKey}
canEditGuests={canAdminGuests}
canManageLdap={canManageLdap}
filtersOpen={guestFiltersOpen}
activeFiltersCount={activeGuestFiltersCount}
blacklistFilter={guestBlacklistFilter}
canDeleteAllComments={canAdminGuests}
commentDrafts={guestCommentDrafts}
commentsByGuest={guestComments}
commentsOpenId={guestCommentsOpenId}
currentUserId={user?.id}
ordering={guestOrdering}
placementFilter={guestPlacementFilter}
guestSearch={guestSearch}
guests={guests}
loading={guestsLoading}
onEditGuest={openGuestEdit}
@ -1422,24 +1441,14 @@ function GuestsPageContent() {
onOpenGuest={openGuestDetails}
onOpenGuestPhoto={setPhotoGuest}
onOpenGuestVisits={openGuestVisits}
onBlacklistFilterChange={setGuestBlacklistFilter}
onClearFilters={() => {
setGuestBlacklistFilter("");
setGuestPlacementFilter("");
setGuestOrdering("last_name");
}}
onOrderingChange={setGuestOrdering}
onPlacementFilterChange={setGuestPlacementFilter}
onAddComment={addGuestComment}
onCommentDraftChange={updateGuestCommentDraft}
onDeleteComment={deleteGuestComment}
onGuestSearchChange={setGuestSearch}
onToggleComments={toggleGuestComments}
onToggleFilters={() => setGuestFiltersOpen((value) => !value)}
onRunGuestAction={runGuestAdminAction}
/>
)}
</div>
</section>
<GuestVisitFormModal
key={isFormOpen ? (editingVisit ? `edit-${editingVisit.id}` : "create") : "closed"}
@ -1614,6 +1623,7 @@ function GuestVisitControls({
scope,
scopeCounts,
search,
sectionTabs,
status,
unlimitedFilter,
onAccessFromChange,
@ -1639,6 +1649,7 @@ function GuestVisitControls({
scope: VisitScope;
scopeCounts: Record<VisitScope, number>;
search: string;
sectionTabs: ReactNode;
status: "" | GuestVisitStatus;
unlimitedFilter: string;
onAccessFromChange: (value: string) => void;
@ -1657,7 +1668,7 @@ function GuestVisitControls({
return (
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
@ -1667,40 +1678,67 @@ function GuestVisitControls({
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<button
type="button"
title="Фильтры"
onClick={onToggleFilters}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
<CompactFilterMenu
open={filtersOpen}
onToggle={onToggleFilters}
activeCount={activeFiltersCount}
ariaLabel="Фильтры заявок гостей"
>
<Filter size={16} />
{activeFiltersCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{activeFiltersCount}</span>
) : null}
</button>
<div className="relative w-full shrink-0 sm:w-[148px]">
<ArrowUpDown size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<select
value={ordering}
onChange={(event) => onOrderingChange(event.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
aria-label="Сортировка заявок гостей"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Статус</span>
<select
value={status}
onChange={(event) => onStatusChange(event.target.value as "" | GuestVisitStatus)}
className="app-select h-10 w-full rounded-lg px-3 text-sm"
>
{statusOptions.map((option) => (
<option key={option.value || "all"} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Доступ с</span>
<input type="date" value={accessFrom} onChange={(event) => onAccessFromChange(event.target.value)} className="app-input h-10 w-full rounded-lg px-3 text-sm" />
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Доступ до</span>
<input type="date" value={accessTo} onChange={(event) => onAccessToChange(event.target.value)} className="app-input h-10 w-full rounded-lg px-3 text-sm" />
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Бессрочный</span>
<select value={unlimitedFilter} onChange={(event) => onUnlimitedFilterChange(event.target.value)} className="app-select h-10 w-full rounded-lg px-3 text-sm">
<option value="">Любой</option><option value="true">Да</option><option value="false">Нет</option>
</select>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Статус гостя</span>
<select value={placementFilter} onChange={(event) => onPlacementFilterChange(event.target.value)} className="app-select h-10 w-full rounded-lg px-3 text-sm">
<option value="">Любой</option><option value="true">Доступ есть</option><option value="false">Нет доступа</option>
</select>
</label>
<div className="flex min-w-0 items-end">
<button type="button" onClick={onClearFilters} className="app-action-secondary h-10 w-full rounded-lg px-3 text-sm font-medium">Сбросить</button>
</div>
</div>
</CompactFilterMenu>
<CompactSortMenu
value={ordering}
options={orderingOptions}
onChange={onOrderingChange}
ariaLabel="Сортировка заявок гостей"
/>
</div>
<div className="flex flex-wrap gap-2">
{sectionTabs}
<HorizontalChipScroller ariaLabel="Разделы заявок гостей">
{visibleScopes.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onScopeChange(option.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
className={`inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium transition ${
scope === option.value ? "app-pill-active" : "app-pill"
}`}
>
@ -1712,7 +1750,7 @@ function GuestVisitControls({
</span>
</button>
))}
</div>
</HorizontalChipScroller>
{guestFilterLabel ? (
<div className="flex flex-wrap gap-2">
@ -1728,73 +1766,6 @@ function GuestVisitControls({
</div>
) : null}
{filtersOpen ? (
<div className="app-surface-muted grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3 rounded-xl border border-[var(--border-subtle)] p-3">
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Статус</span>
<select
value={status}
onChange={(event) => onStatusChange(event.target.value as "" | GuestVisitStatus)}
className="app-select h-10 w-full rounded-lg px-3 text-sm"
>
{statusOptions.map((option) => (
<option key={option.value || "all"} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Доступ с</span>
<input
type="date"
value={accessFrom}
onChange={(event) => onAccessFromChange(event.target.value)}
className="app-input h-10 w-full rounded-lg px-3 text-sm"
/>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Доступ до</span>
<input
type="date"
value={accessTo}
onChange={(event) => onAccessToChange(event.target.value)}
className="app-input h-10 w-full rounded-lg px-3 text-sm"
/>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Бессрочный</span>
<select
value={unlimitedFilter}
onChange={(event) => onUnlimitedFilterChange(event.target.value)}
className="app-select h-10 w-full rounded-lg px-3 text-sm"
>
<option value="">Любой</option>
<option value="true">Да</option>
<option value="false">Нет</option>
</select>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Статус гостя</span>
<select
value={placementFilter}
onChange={(event) => onPlacementFilterChange(event.target.value)}
className="app-select h-10 w-full rounded-lg px-3 text-sm"
>
<option value="">Любой</option>
<option value="true">Доступ есть</option>
<option value="false">Нет доступа</option>
</select>
</label>
<div className="flex min-w-0 items-end">
<button
type="button"
onClick={onClearFilters}
className="app-action-secondary h-10 w-full rounded-lg px-3 text-sm font-medium"
>
Сбросить
</button>
</div>
</div>
) : null}
</div>
);
}
@ -1901,8 +1872,8 @@ function GuestVisitRow({
</button>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<button type="button" onClick={() => void onOpen(visit)} className="min-w-0 flex-1 text-left">
<div className="flex min-w-0 flex-col items-start gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<button type="button" onClick={() => void onOpen(visit)} className="w-full min-w-0 text-left sm:flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<GuestAccessStatusBadge guest={visit.guest} />
{visit.inviter_inactive ? (
@ -2180,9 +2151,9 @@ function VisitActionButtons({
const hasSecondaryActions = true;
return (
<div className="flex shrink-0 flex-col items-start gap-2 lg:items-end">
<div ref={isMenuOpen ? menuRef : null} className="flex items-center gap-2">
<span className={`app-status-pill guest-status-pill ${meta.className}`}>{meta.label}</span>
<div className="flex w-full min-w-0 flex-col items-start gap-2 sm:w-auto sm:shrink-0 lg:items-end">
<div ref={isMenuOpen ? menuRef : null} className="flex w-full min-w-0 items-center justify-between gap-2 sm:w-auto">
<span className={`app-status-pill px-3 py-1.5 text-[13px] font-semibold ${meta.className}`}>{meta.label}</span>
{hasSecondaryActions ? (
<div className="relative">
<button
@ -3462,9 +3433,121 @@ function FeedbackModal({
);
}
function GuestRegistryPanel({
function GuestRegistryControls({
blacklistFilter,
activeFiltersCount,
filtersOpen,
guestSearch,
ordering,
placementFilter,
sectionTabs,
onBlacklistFilterChange,
onClearFilters,
onGuestSearchChange,
onOrderingChange,
onPlacementFilterChange,
onToggleFilters,
}: {
blacklistFilter: string;
activeFiltersCount: number;
filtersOpen: boolean;
guestSearch: string;
ordering: string;
placementFilter: string;
sectionTabs: ReactNode;
onBlacklistFilterChange: (value: string) => void;
onClearFilters: () => void;
onGuestSearchChange: (value: string) => void;
onOrderingChange: (value: string) => void;
onPlacementFilterChange: (value: string) => void;
onToggleFilters: () => void;
}) {
const quickFilter = blacklistFilter === "true"
? "blacklisted"
: placementFilter === "true"
? "allowed"
: placementFilter === "false"
? "blocked"
: "all";
const selectQuickFilter = (value: "all" | "allowed" | "blocked" | "blacklisted") => {
onBlacklistFilterChange(value === "blacklisted" ? "true" : "");
onPlacementFilterChange(
value === "allowed" ? "true" : value === "blocked" ? "false" : "",
);
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={guestSearch}
onChange={(event) => onGuestSearchChange(event.target.value)}
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
placeholder="Поиск по гостям..."
/>
</div>
<CompactFilterMenu
open={filtersOpen}
onToggle={onToggleFilters}
activeCount={activeFiltersCount}
ariaLabel="Фильтры гостей"
>
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Черный список</span>
<select value={blacklistFilter} onChange={(event) => onBlacklistFilterChange(event.target.value)} className="app-select h-10 w-full rounded-lg px-3 text-sm">
<option value="">Любой</option><option value="false">Не в черном списке</option><option value="true">В черном списке</option>
</select>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Доступ</span>
<select value={placementFilter} onChange={(event) => onPlacementFilterChange(event.target.value)} className="app-select h-10 w-full rounded-lg px-3 text-sm">
<option value="">Любой</option><option value="true">Доступ есть</option><option value="false">Нет доступа</option>
</select>
</label>
<div className="flex min-w-0 items-end">
<button type="button" onClick={onClearFilters} className="app-action-secondary h-10 w-full rounded-lg px-3 text-sm font-medium">Сбросить</button>
</div>
</div>
</CompactFilterMenu>
<CompactSortMenu
value={ordering}
options={guestOrderingOptions}
onChange={onOrderingChange}
ariaLabel="Сортировка гостей"
/>
</div>
{sectionTabs}
<HorizontalChipScroller ariaLabel="Быстрые фильтры гостей">
{([
["all", "Все"],
["allowed", "С доступом"],
["blocked", "Нет доступа"],
["blacklisted", "Черный список"],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => selectQuickFilter(value)}
className={`inline-flex h-8 items-center rounded-full px-3 text-xs font-medium transition ${
quickFilter === value ? "app-pill-active" : "app-pill"
}`}
>
{label}
</button>
))}
</HorizontalChipScroller>
</div>
);
}
function GuestRegistryPanel({
busyKey,
canDeleteAllComments,
canEditGuests,
@ -3473,15 +3556,9 @@ function GuestRegistryPanel({
commentsByGuest,
commentsOpenId,
currentUserId,
filtersOpen,
guestSearch,
guests,
loading,
ordering,
placementFilter,
onBlacklistFilterChange,
onAddComment,
onClearFilters,
onCommentDraftChange,
onDeleteComment,
onEditGuest,
@ -3489,15 +3566,9 @@ function GuestRegistryPanel({
onOpenGuest,
onOpenGuestPhoto,
onOpenGuestVisits,
onGuestSearchChange,
onOrderingChange,
onPlacementFilterChange,
onRunGuestAction,
onToggleComments,
onToggleFilters,
}: {
blacklistFilter: string;
activeFiltersCount: number;
busyKey: string | null;
canDeleteAllComments: boolean;
canEditGuests: boolean;
@ -3506,15 +3577,9 @@ function GuestRegistryPanel({
commentsByGuest: Record<number, GuestVisitComment[]>;
commentsOpenId: number | null;
currentUserId?: number | null;
filtersOpen: boolean;
guestSearch: string;
guests: Guest[];
loading: boolean;
ordering: string;
placementFilter: string;
onBlacklistFilterChange: (value: string) => void;
onAddComment: (guest: Guest) => void | Promise<void>;
onClearFilters: () => void;
onCommentDraftChange: (guestId: number, value: string) => void;
onDeleteComment: (guest: Guest, commentId: number) => void | Promise<void>;
onEditGuest: (guest: Guest) => void;
@ -3522,12 +3587,8 @@ function GuestRegistryPanel({
onOpenGuest: (guest: Guest) => void;
onOpenGuestPhoto: (guest: Guest) => void;
onOpenGuestVisits: (guest: Guest) => void;
onGuestSearchChange: (value: string) => void;
onOrderingChange: (value: string) => void;
onPlacementFilterChange: (value: string) => void;
onRunGuestAction: (guest: Guest, action: "sync" | "blacklist" | "unblacklist") => void | Promise<void>;
onToggleComments: (guest: Guest) => void | Promise<void>;
onToggleFilters: () => void;
}) {
const [guestMenuOpenId, setGuestMenuOpenId] = useState<number | null>(null);
const guestMenuRef = useRef<HTMLDivElement | null>(null);
@ -3550,83 +3611,7 @@ function GuestRegistryPanel({
}, [guestMenuOpenId]);
return (
<section className="app-surface rounded-2xl p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={guestSearch}
onChange={(event) => onGuestSearchChange(event.target.value)}
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
placeholder="Поиск по гостям..."
/>
</div>
<button
type="button"
title="Фильтры"
onClick={onToggleFilters}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
>
<Filter size={16} />
{activeFiltersCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{activeFiltersCount}</span>
) : null}
</button>
<div className="relative w-full shrink-0 sm:w-[172px]">
<ArrowUpDown size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<select
value={ordering}
onChange={(event) => onOrderingChange(event.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
aria-label="Сортировка гостей"
>
{guestOrderingOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
</div>
{filtersOpen ? (
<div className="app-surface-muted mt-3 grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3 rounded-xl border border-[var(--border-subtle)] p-3">
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Черный список</span>
<select
value={blacklistFilter}
onChange={(event) => onBlacklistFilterChange(event.target.value)}
className="app-select h-10 w-full rounded-lg px-3 text-sm"
>
<option value="">Любой</option>
<option value="false">Не в черном списке</option>
<option value="true">В черном списке</option>
</select>
</label>
<label className="block min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Доступ</span>
<select
value={placementFilter}
onChange={(event) => onPlacementFilterChange(event.target.value)}
className="app-select h-10 w-full rounded-lg px-3 text-sm"
>
<option value="">Любой</option>
<option value="true">Доступ есть</option>
<option value="false">Нет доступа</option>
</select>
</label>
<div className="flex min-w-0 items-end">
<button
type="button"
onClick={onClearFilters}
className="app-action-secondary h-10 w-full rounded-lg px-3 text-sm font-medium"
>
Сбросить
</button>
</div>
</div>
) : null}
<div className="mt-4 space-y-3">
<div className="mt-4 space-y-3">
{loading ? (
<div className="app-surface-muted rounded-xl p-8 text-center">
<Loader2 className="mx-auto animate-spin app-accent-text" size={26} />
@ -3651,7 +3636,7 @@ function GuestRegistryPanel({
return (
<article key={guest.id} className={`app-surface-muted rounded-xl p-4 ${isGuestMenuOpen ? "relative z-20 overflow-visible" : ""}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-1 gap-3">
<div className="relative w-12 shrink-0">
<button
@ -3677,14 +3662,17 @@ function GuestRegistryPanel({
</button>
</div>
<div className="min-w-0 flex-1">
<button
type="button"
onClick={() => onOpenGuest(guest)}
className="block max-w-full min-w-0 text-left"
title="Открыть карточку гостя"
>
<h3 className="truncate text-base font-semibold text-[var(--foreground)]">{guestName(guest)}</h3>
</button>
<div className="flex min-w-0 flex-col items-start gap-1.5 sm:flex-row sm:items-center sm:gap-2">
<button
type="button"
onClick={() => onOpenGuest(guest)}
className="block w-full max-w-full min-w-0 text-left sm:flex-1"
title="Открыть карточку гостя"
>
<h3 className="truncate text-base font-semibold text-[var(--foreground)]">{guestName(guest)}</h3>
</button>
<GuestAccessStatusBadge guest={guest} />
</div>
<div className="app-text-muted mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs">
{metaItems.map((item) => {
const Icon = item.icon;
@ -3695,7 +3683,7 @@ function GuestRegistryPanel({
title={`${item.label}: ${item.value}`}
>
<Icon size={13} className="shrink-0" />
<span className="min-w-0 break-all text-[var(--foreground)]/80">{item.value}</span>
<span className="min-w-0 break-words text-[var(--foreground)]/80">{item.value}</span>
</span>
);
})}
@ -3715,8 +3703,7 @@ function GuestRegistryPanel({
{guest.ldap_last_error ? <p className="app-feedback-danger mt-2 rounded-lg p-2 text-xs">{guest.ldap_last_error}</p> : null}
</div>
</div>
<div ref={isGuestMenuOpen ? guestMenuRef : null} className="flex shrink-0 items-center gap-2">
<GuestAccessStatusBadge guest={guest} />
<div ref={isGuestMenuOpen ? guestMenuRef : null} className="flex shrink-0 items-center">
{hasActions ? (
<div className="relative">
<button
@ -3854,8 +3841,7 @@ function GuestRegistryPanel({
</article>
);
})}
</div>
</section>
</div>
);
}

View File

@ -5,6 +5,8 @@ import MessagesChatListControls from "@/components/messages/MessagesChatListCont
import MessagesChatListItem from "@/components/messages/MessagesChatListItem";
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
import MessagesInstallButton from "@/components/messages/MessagesInstallButton";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { useMessagesWorkspace } from "@/components/messages/MessagesWorkspaceContext";
import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
@ -53,23 +55,25 @@ function StandardMessagesList() {
}
return (
<section className="app-surface flex h-[calc(100dvh_-_8rem_-_env(safe-area-inset-bottom))] min-h-0 flex-col overflow-hidden rounded-2xl p-4 lg:block lg:h-auto lg:overflow-visible">
<div className="mb-4 flex shrink-0 flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Сообщения</p>
<div className="flex items-center gap-1">
<MessagesFocusModeButton enabled={false} onChange={(enabled) => setMode(enabled ? "wide" : "standard")} />
<MessagesInstallButton />
<button type="button" onClick={openCreateChat} className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition">
<Plus size={14} /> Создать чат
</button>
<section className="app-surface rounded-2xl p-4">
<AutoRevealStickyHeader className="rounded-b-xl">
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<p className="app-text-muted shrink-0 text-sm font-semibold uppercase tracking-wide">Сообщения</p>
<div className="ml-auto flex min-w-0 shrink items-center gap-1">
<MessagesFocusModeButton enabled={false} onChange={(enabled) => setMode(enabled ? "wide" : "standard")} />
<MessagesInstallButton />
<ModulePrimaryAction
onClick={openCreateChat}
icon={<Plus size={14} />}
label="Создать чат"
/>
</div>
</div>
</div>
<div className="shrink-0">
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
</div>
</AutoRevealStickyHeader>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-y-contain pr-1 lg:overflow-visible lg:pr-0">
<div className="relative z-0 space-y-2">
{visibleChats.length === 0 ? (
<div className="app-surface-muted rounded-xl p-6 text-center">
<p className="app-text-muted text-sm">Чаты не найдены</p>

View File

@ -1,9 +1,31 @@
'use client';
import { AppShell } from '@/components/AppShell';
import { RequestAvatar } from '@/components/requests/RequestAvatar';
import { ViewportAwareMenu } from '@/components/shared/ViewportAwareMenu';
import { HorizontalChipScroller } from '@/components/ui/HorizontalChipScroller';
import { ModulePrimaryAction } from '@/components/ui/ModulePrimaryAction';
import { AutoRevealStickyHeader } from '@/components/ui/AutoRevealStickyHeader';
import { CompactFilterPanel } from '@/components/ui/CompactFilterMenu';
import { apiClient } from '@/lib/api';
import { Bell, CheckCheck, Filter, Search, Trash2 } from 'lucide-react';
import { useState, useMemo, useEffect } from 'react';
import {
Banknote,
Bell,
CalendarDays,
CheckCheck,
ChevronRight,
ClipboardList,
FileText,
Filter,
MessageSquare,
Monitor,
Search,
ShoppingCart,
Trash2,
UserRound,
type LucideIcon,
} from 'lucide-react';
import { useState, useMemo, useEffect, useRef } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale/ru';
import { getVerbCategory, getVerbName } from '@/lib/verbTranslations';
@ -14,9 +36,30 @@ import type { NotificationItem } from '@/contexts/NotificationsContext';
type NotificationReadFilter = 'all' | 'unread' | 'read';
type NotificationPageItem = NotificationItem & {
color?: string;
icon?: string;
};
function getNotificationCategoryIcon(categoryCode?: string): LucideIcon {
const category = getVerbCategory(categoryCode || '');
const iconMap: Record<string, LucideIcon> = {
'Сообщения': MessageSquare,
'Заявления': FileText,
'Документы': FileText,
'Регламенты': FileText,
'Календарь': CalendarDays,
'Финансы': Banknote,
'Закупки': ShoppingCart,
'Оборудование': Monitor,
'Доска': ClipboardList,
'Гости': UserRound,
};
return iconMap[category] || Bell;
}
function getActorInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase() || 'С';
}
export default function NotificationsPage() {
// Локальное состояние для ВСЕХ уведомлений (не только непрочитанных)
const [notifications, setNotifications] = useState<NotificationPageItem[]>([]);
@ -26,6 +69,10 @@ export default function NotificationsPage() {
const [filterRead, setFilterRead] = useState<NotificationReadFilter>('all');
const [filterCategory, setFilterCategory] = useState<string>('');
const [filtersOpen, setFiltersOpen] = useState(false);
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
const [openNotificationMenuId, setOpenNotificationMenuId] = useState<number | null>(null);
const headerMenuRef = useRef<HTMLDivElement | null>(null);
const notificationMenuRef = useRef<HTMLDivElement | null>(null);
// Загрузка всех уведомлений (100 последних)
useEffect(() => {
@ -44,6 +91,36 @@ export default function NotificationsPage() {
fetchAllNotifications();
}, []);
useEffect(() => {
if (!headerMenuOpen && openNotificationMenuId === null) return;
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (headerMenuOpen && headerMenuRef.current && !headerMenuRef.current.contains(target)) {
setHeaderMenuOpen(false);
}
if (
openNotificationMenuId !== null
&& notificationMenuRef.current
&& !notificationMenuRef.current.contains(target)
) {
setOpenNotificationMenuId(null);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
setHeaderMenuOpen(false);
setOpenNotificationMenuId(null);
};
window.document.addEventListener('mousedown', handlePointerDown);
window.document.addEventListener('keydown', handleEscape);
return () => {
window.document.removeEventListener('mousedown', handlePointerDown);
window.document.removeEventListener('keydown', handleEscape);
};
}, [headerMenuOpen, openNotificationMenuId]);
// Функции для операций с уведомлениями
const markAsRead = async (id: number) => {
try {
@ -155,54 +232,55 @@ export default function NotificationsPage() {
}
};
const getCategoryIconEmoji = (categoryCode?: string, icon?: string) => {
if (icon) return icon;
// Маппинг категорий на эмодзи
const iconMap: Record<string, string> = {
'communications': '💬',
'messages': '💬',
'requests': '📝',
'documents': '📄',
'calendar': '📅',
'system': '⚙️',
'finance': '💰',
'procurement': '🛒',
'equipment': '🖥️',
};
return iconMap[categoryCode || ''] || '📋';
};
return (
<AppShell>
<section className="mx-auto max-w-5xl app-surface rounded-2xl p-4 sm:p-5">
<AutoRevealStickyHeader className="rounded-b-xl">
{/* Заголовок */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Уведомления</p>
<div className="flex flex-wrap gap-2">
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<p className="app-text-muted shrink-0 text-sm font-semibold uppercase tracking-wide">Уведомления</p>
<div className="ml-auto flex min-w-0 shrink items-center gap-2">
{unreadCount > 0 && (
<button
<ModulePrimaryAction
onClick={markAllAsRead}
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
>
<CheckCheck size={16} />
Прочитать все
</button>
icon={<CheckCheck size={16} />}
label="Прочитать все"
/>
)}
{readCount > 0 && (
<div ref={headerMenuRef} className="relative shrink-0">
<button
onClick={async () => {
const count = await deleteAllRead();
if (count > 0) {
console.log(`Удалено ${count} прочитанных уведомлений`);
}
}}
className="app-feedback-danger inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
type="button"
onClick={() => setHeaderMenuOpen((current) => !current)}
className="app-action-ghost flex h-9 w-9 items-center justify-center rounded-lg"
title="Действия с уведомлениями"
aria-label="Действия с уведомлениями"
aria-expanded={headerMenuOpen}
aria-haspopup="menu"
>
<Trash2 size={16} />
Удалить прочитанные
<ChevronRight
size={16}
className={`transition-transform duration-200 ${headerMenuOpen ? 'rotate-90' : ''}`}
/>
</button>
{headerMenuOpen ? (
<ViewportAwareMenu className="app-menu absolute right-0 top-full z-30 mt-2 w-56 rounded-xl py-1.5" role="menu">
<button
type="button"
onClick={() => {
setHeaderMenuOpen(false);
void deleteAllRead();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
role="menuitem"
>
<Trash2 size={14} />
Удалить прочитанные
</button>
</ViewportAwareMenu>
) : null}
</div>
)}
</div>
</div>
@ -220,6 +298,7 @@ export default function NotificationsPage() {
/>
</div>
<button
id="notifications-filter-trigger"
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${
@ -237,8 +316,13 @@ export default function NotificationsPage() {
</button>
</div>
{filtersOpen && (
<div className="app-surface-muted flex flex-col gap-2 rounded-xl p-3">
<CompactFilterPanel
open={filtersOpen}
anchorId="notifications-filter-trigger"
onClose={() => setFiltersOpen(false)}
ariaLabel="Фильтры уведомлений"
>
<div className="flex flex-col gap-2">
<select
value={filterRead}
onChange={(e) => setFilterRead(e.target.value as NotificationReadFilter)}
@ -275,10 +359,10 @@ export default function NotificationsPage() {
</button>
)}
</div>
)}
</CompactFilterPanel>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<HorizontalChipScroller className="mb-4" ariaLabel="Статус уведомлений">
{[
{ key: 'all', label: 'Все', count: notifications.length },
{ key: 'unread', label: 'Непрочитанные', count: unreadCount },
@ -300,11 +384,11 @@ export default function NotificationsPage() {
</span>
</button>
))}
</div>
</HorizontalChipScroller>
{/* Бейджи категорий с счетчиками */}
{categoryCounts.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
<HorizontalChipScroller className="mb-4" ariaLabel="Категории уведомлений">
<button
onClick={() => setFilterCategory('')}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
@ -313,7 +397,7 @@ export default function NotificationsPage() {
: 'app-pill'
}`}
>
<span>Все</span>
<span>Все категории</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
filterCategory === ''
? 'app-pill-count-active'
@ -351,19 +435,20 @@ export default function NotificationsPage() {
</span>
</button>
))}
</div>
</HorizontalChipScroller>
)}
</AutoRevealStickyHeader>
{/* Список уведомлений */}
{loading ? (
<div className="app-surface-muted rounded-xl p-12 text-center">
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
<div className="app-surface-muted rounded-xl p-8 text-center">
<div className="mx-auto mb-3 h-7 w-7 animate-spin rounded-full border-[3px] border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
<p className="app-text-muted text-sm">Загрузка уведомлений...</p>
</div>
) : filteredNotifications.length === 0 ? (
<div className="app-surface-muted rounded-xl p-12 text-center">
<Bell size={48} className="app-text-muted mx-auto mb-3 opacity-40" />
<p className="text-base font-medium text-[var(--foreground)]">
<div className="app-surface-muted rounded-xl p-8 text-center">
<Bell size={32} className="app-text-muted mx-auto mb-3 opacity-40" />
<p className="text-sm font-medium text-[var(--foreground)]">
{search || filterRead !== 'all' || filterCategory
? 'Нет уведомлений по заданным критериям'
: 'Нет уведомлений'}
@ -378,60 +463,77 @@ export default function NotificationsPage() {
<div className="space-y-2">
{filteredNotifications.map((notification) => {
const regulationScope = getRegulationNotificationScopeLabel(notification);
const actor = notification.actor;
const CategoryIcon = getNotificationCategoryIcon(notification.category);
return (
<article
<article
key={notification.id}
className={`group overflow-hidden rounded-xl border transition ${
className={`group rounded-xl border transition ${
openNotificationMenuId === notification.id ? 'relative z-20 overflow-visible' : 'overflow-hidden'
} ${
notification.is_read
? 'app-surface hover:border-[var(--border-strong)]'
: 'app-unread-surface border-[color:color-mix(in_srgb,var(--accent-primary)_20%,var(--border-subtle))] hover:border-[color:var(--accent-primary)]'
}`}
>
<div className="flex gap-4 p-4">
<div className="flex items-start gap-3 p-3">
{/* Иконка */}
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xl ${
notification.is_read ? 'bg-[var(--surface-secondary)]' : 'bg-[color:color-mix(in_srgb,var(--accent-primary)_14%,var(--surface-primary))]'
}`}
style={
notification.color
? { backgroundColor: notification.color + '20', color: notification.color }
: undefined
}
>
{getCategoryIconEmoji(notification.category, notification.icon)}
</div>
{actor ? (
<RequestAvatar
alt={actor.name}
fallback={getActorInitials(actor.name)}
size="lg"
src={actor.avatar}
/>
) : (
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
notification.is_read
? 'app-surface-muted app-text-muted'
: 'app-selected app-accent-text'
}`}
style={
notification.color
? { color: notification.color }
: undefined
}
aria-hidden="true"
>
<CategoryIcon size={16} />
</span>
)}
{/* Содержимое */}
<div
<button
type="button"
onClick={() => handleNotificationClick(notification)}
className="min-w-0 flex-1 cursor-pointer"
className="min-w-0 flex-1 cursor-pointer text-left"
>
<div className="mb-1 flex items-start justify-between gap-2">
<h3 className="font-semibold text-[var(--foreground)] transition group-hover:text-[var(--accent-primary-strong)]">
<span className="flex items-start gap-2">
<span className="line-clamp-2 min-w-0 flex-1 text-sm font-semibold text-[var(--foreground)] transition group-hover:text-[var(--accent-primary-strong)]">
{notification.title || (
typeof notification.data?.title === 'string'
? notification.data.title
: getVerbName(notification.verb || notification.category || '')
)}
</h3>
</span>
{!notification.is_read && (
<div className="app-dot-accent mt-1.5 h-2 w-2 shrink-0 rounded-full"></div>
<span className="app-dot-accent mt-1.5 h-2 w-2 shrink-0 rounded-full" aria-label="Непрочитанное уведомление" />
)}
</div>
</span>
<p className="app-text-muted mb-2 line-clamp-2 text-sm">
<span className="app-text-muted mt-1 block line-clamp-2 text-sm">
{notification.short_message || notification.message}
</p>
</span>
{regulationScope ? (
<p className="app-accent-text mb-2 text-xs font-medium">
<span className="app-accent-text mt-1.5 block text-xs font-medium">
{regulationScope}
</p>
</span>
) : null}
<div className="app-text-muted flex items-center gap-3 text-xs">
<span className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{notification.category && (
<span className="app-badge px-2 py-0.5 font-medium">
{getVerbCategory(notification.category)}
@ -451,23 +553,65 @@ export default function NotificationsPage() {
}
})()}
</span>
</div>
</div>
{/* Кнопка удаления */}
<button
onClick={(e) => {
e.stopPropagation();
deleteNotification(notification.id);
}}
className="app-action-danger flex-shrink-0 rounded-lg p-2 transition-all"
aria-label="Удалить"
title="Удалить уведомление"
>
<Trash2 size={18} />
</span>
</button>
<div
ref={openNotificationMenuId === notification.id ? notificationMenuRef : null}
className="relative shrink-0"
>
<button
type="button"
onClick={() => setOpenNotificationMenuId((current) => (
current === notification.id ? null : notification.id
))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с уведомлением"
aria-label="Действия с уведомлением"
aria-expanded={openNotificationMenuId === notification.id}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${
openNotificationMenuId === notification.id ? 'rotate-90' : ''
}`}
/>
</button>
{openNotificationMenuId === notification.id ? (
<ViewportAwareMenu className="app-menu absolute right-0 top-full z-30 mt-2 w-56 rounded-xl py-1.5" role="menu">
{!notification.is_read ? (
<button
type="button"
onClick={() => {
setOpenNotificationMenuId(null);
void markAsRead(notification.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
role="menuitem"
>
<CheckCheck size={14} />
Отметить прочитанным
</button>
) : null}
<button
type="button"
onClick={() => {
setOpenNotificationMenuId(null);
void deleteNotification(notification.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
role="menuitem"
>
<Trash2 size={14} />
Удалить
</button>
</ViewportAwareMenu>
) : null}
</div>
</div>
</article>
</article>
);
})}
</div>

View File

@ -11,6 +11,8 @@ import { DocumentDetailModal } from "@/components/documents/DocumentDetailModal"
import { DocumentMetadataEditor } from "@/components/documents/DocumentMetadataEditor";
import { DocumentTaskLinks } from "@/components/documents/DocumentTaskLinks";
import { Modal } from "@/components/ui";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { PostCommentsModal } from "@/components/feed/PostCommentsModal";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks";
@ -905,7 +907,11 @@ function HomePageContent() {
return (
<AppShell>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<AutoRevealStickyHeader className="rounded-xl" surface="transparent">
<HorizontalChipScroller
ariaLabel="Источники ленты"
fadeColor="transparent"
>
{postSourceOptions.map(({ key, label, total, unread }) => {
const active = key === "regulations" ? regulationsOnly : sourceFilter === key;
@ -942,7 +948,8 @@ function HomePageContent() {
</button>
);
})}
</div>
</HorizontalChipScroller>
</AutoRevealStickyHeader>
{posts.length === 0 && regulations.length === 0 ? (
<div className="app-surface-muted rounded-2xl p-8 text-center">

View File

@ -3,6 +3,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { CompactSortMenu, type CompactSortOption } from "@/components/ui/CompactSortMenu";
import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { useUser } from "@/contexts/UserContext";
import Link from "next/link";
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
@ -21,7 +26,6 @@ import type {
} from "@/types/api";
import {
AlertTriangle,
ArrowUpDown,
CalendarDays,
Check,
ChevronDown,
@ -133,12 +137,12 @@ const urgencyMeta: Record<string, { label: string; cls: string }> = {
};
const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "title", label: "По названию" },
{ value: "urgency", label: "По срочности ↑" },
{ value: "-urgency", label: "По срочности ↓" },
];
{ value: "-created_at", label: "Сначала новые", icon: "date-desc" },
{ value: "created_at", label: "Сначала старые", icon: "date-asc" },
{ value: "title", label: "По названию", icon: "alphabetical" },
{ value: "urgency", label: "По срочности ↑", icon: "priority-asc" },
{ value: "-urgency", label: "По срочности ↓", icon: "priority-desc" },
] satisfies readonly CompactSortOption[];
type ScopeTab = "all" | "mine" | "department" | "processing_department" | "pending_approvals" | "my_work" | "available";
const scopeTabs: { value: ScopeTab; label: string }[] = [
@ -926,8 +930,9 @@ export default function ProcurementPage() {
) : (
<section className={`app-surface rounded-2xl p-4 ${desktopWideMode ? "lg:min-h-0 lg:flex-1 lg:flex lg:flex-col lg:overflow-hidden" : ""}`}>
{/* ── header ── */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<AutoRevealStickyHeader className="rounded-b-xl">
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<div className="shrink-0">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Закупки</p>
{/* <div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => setActiveSection("requests")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "requests" ? "app-pill-active" : "app-pill"}`}>
@ -941,7 +946,7 @@ export default function ProcurementPage() {
</button>
</div> */}
</div>
<div className="flex items-center gap-2">
<div className="ml-auto flex min-w-0 shrink items-center gap-2">
<button
type="button"
onClick={() => changeDesktopWideMode(!desktopWideMode)}
@ -953,12 +958,15 @@ export default function ProcurementPage() {
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
{activeSection === "requests" && (
<button type="button" onClick={openCreate} className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition">
<Plus size={14} /> Создать заявку
</button>
<ModulePrimaryAction
onClick={openCreate}
icon={<Plus size={14} />}
label="Создать заявку"
/>
)}
</div>
</div>
</AutoRevealStickyHeader>
{/* ── alerts ── */}
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
@ -984,6 +992,7 @@ export default function ProcurementPage() {
/>
</div>
<button
id="procurement-filter-trigger"
type="button"
title="Фильтры"
onClick={() => setFiltersOpen((v) => !v)}
@ -994,24 +1003,16 @@ export default function ProcurementPage() {
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{activeFilterCount}</span>
)}
</button>
<div className="relative w-[148px] shrink-0">
<ArrowUpDown size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<select
value={ordering}
onChange={(e) => setOrdering(e.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
aria-label="Сортировка списка закупок"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
<CompactSortMenu
value={ordering}
options={orderingOptions}
onChange={setOrdering}
ariaLabel="Сортировка списка закупок"
/>
</div>
{/* ── scope tabs ── */}
<div className="mb-4 flex flex-wrap gap-2">
<HorizontalChipScroller className="mb-4" ariaLabel="Разделы закупок">
{scopeTabs.map((tab) => (
<button
key={tab.value}
@ -1031,11 +1032,11 @@ export default function ProcurementPage() {
</span>
</button>
))}
</div>
</HorizontalChipScroller>
{/* ── filters panel ── */}
{filtersOpen && (
<div className="app-surface-muted mb-3 flex flex-col gap-2 rounded-xl p-3">
<CompactFilterPanel open={filtersOpen} anchorId="procurement-filter-trigger" onClose={() => setFiltersOpen(false)} ariaLabel="Фильтры закупок">
<div className="flex flex-col gap-2">
<div ref={statusFilterRef} className="relative">
<button
type="button"
@ -1092,7 +1093,7 @@ export default function ProcurementPage() {
</div>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Срочность</span>
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
@ -1213,7 +1214,7 @@ export default function ProcurementPage() {
</button>
)}
</div>
)}
</CompactFilterPanel>
{/* ══════════ Wide master-detail workspace ══════════ */}
<div className={`${desktopWideMode ? "hidden lg:grid" : "hidden"} min-h-0 flex-1 grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-3`}>

View File

@ -1,17 +1,22 @@
"use client";
import { AppShell } from "@/components/AppShell";
import {
CompactSortMenu,
type CompactSortOption,
} from "@/components/ui/CompactSortMenu";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import type { CommerceProduct } from "@/types/commerce";
import {
ArrowDownAZ,
ArrowLeft,
ArrowRight,
Archive,
Boxes,
ChevronDown,
ChevronRight,
ImageIcon,
Layers3,
@ -44,6 +49,13 @@ const kindFilters: Array<{
{ value: "bundle", label: "Комплекты", icon: Layers3 },
];
const orderingOptions = [
{ value: "name", label: "По названию", icon: "alphabetical" },
{ value: "sku", label: "По артикулу", icon: "identifier" },
{ value: "-updated_at", label: "Недавно обновлённые", icon: "updated" },
{ value: "-created_at", label: "Недавно добавленные", icon: "created" },
] satisfies readonly CompactSortOption[];
export default function ProductsPage() {
const { user } = useUser();
const [products, setProducts] = useState<CommerceProduct[]>([]);
@ -118,12 +130,12 @@ export default function ProductsPage() {
const pageFrom = count === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
const pageTo = Math.min(page * PAGE_SIZE, count);
return (
<AppShell>
<section className="app-surface min-w-0 overflow-hidden rounded-2xl p-4">
<header className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<section className="app-surface min-w-0 rounded-2xl p-4">
<AutoRevealStickyHeader className="rounded-b-xl">
<header className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-3">
<div className="flex shrink-0 items-center gap-3">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">
Товары
</p>
@ -132,16 +144,15 @@ export default function ProductsPage() {
</span>
</div>
{canManage ? (
<Link
<ModulePrimaryAction
href="/products/manage"
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Settings2 className="h-4 w-4" /> Управление
</Link>
icon={<Settings2 className="h-4 w-4" />}
label="Управление"
/>
) : null}
</header>
<div className="mb-4 flex min-w-0 flex-col gap-2 sm:flex-row">
<div className="mb-4 flex min-w-0 items-center gap-2">
<label className="relative min-w-0 flex-1">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<input
@ -153,40 +164,32 @@ export default function ProductsPage() {
aria-label="Поиск по товарам"
/>
</label>
<label className="relative w-full shrink-0 sm:w-44">
<ArrowDownAZ className="app-text-muted pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<select
value={ordering}
onChange={(event) => updateOrdering(event.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
aria-label="Сортировка товаров"
>
<option value="name">По названию</option>
<option value="sku">По артикулу</option>
<option value="-updated_at">Недавно обновлённые</option>
<option value="-created_at">Недавно добавленные</option>
</select>
<ChevronDown className="app-text-muted pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
</label>
<CompactSortMenu
value={ordering}
options={orderingOptions}
onChange={updateOrdering}
ariaLabel="Сортировка товаров"
/>
</div>
<div className="mb-4 flex gap-2 overflow-x-auto pb-1" aria-label="Тип товара">
{kindFilters.map((filter) => {
const Icon = filter.icon;
const active = filter.value === kind;
return (
<button
key={filter.value || "all"}
type="button"
onClick={() => updateKind(filter.value)}
className={`${active ? "app-selected" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"} inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition`}
aria-pressed={active}
>
<Icon className="h-3.5 w-3.5" /> {filter.label}
</button>
);
})}
</div>
<HorizontalChipScroller className="mb-4" ariaLabel="Тип товара">
{kindFilters.map((filter) => {
const Icon = filter.icon;
const active = filter.value === kind;
return (
<button
key={filter.value || "all"}
type="button"
onClick={() => updateKind(filter.value)}
className={`${active ? "app-selected" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"} inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition`}
aria-pressed={active}
>
<Icon className="h-3.5 w-3.5" /> {filter.label}
</button>
);
})}
</HorizontalChipScroller>
</AutoRevealStickyHeader>
{error ? (
<div className="app-feedback-danger mb-4 rounded-xl p-4 text-sm">{error}</div>

View File

@ -11,6 +11,7 @@ import { RequestStatisticsPanel } from "@/components/requests/RequestStatisticsP
import { RequestSwipeModePanel } from "@/components/requests/RequestSwipeModePanel";
import { RequestTaskLinks } from "@/components/requests/RequestTaskLinks";
import { RequestWideListItem } from "@/components/requests/RequestWideListItem";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { useUser } from "@/contexts/UserContext";
import { Suspense, useState, type RefObject } from "react";
import { useRequestsPage } from "@/hooks/useRequestsPage";
@ -135,6 +136,7 @@ function RequestsPageContent() {
/>
) : (
<>
<AutoRevealStickyHeader className="rounded-b-xl">
<RequestListControls
actions={requestControlsActions}
desktopWideMode={desktopWideMode}
@ -142,6 +144,7 @@ function RequestsPageContent() {
onToggleDesktopWideMode={() => changeDesktopWideMode(!desktopWideMode)}
state={requestControlsState}
/>
</AutoRevealStickyHeader>
{desktopWideLayout ? (
<div className="grid min-h-0 flex-1 grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-3">
<aside className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)]" aria-label="Заявления и статистика">

View File

@ -81,6 +81,7 @@ import {
} from "react";
import { AppShell } from "@/components/AppShell";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar";
import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator";
import TaskCoverPreview from "@/components/tasks/TaskCoverPreview";
@ -90,6 +91,7 @@ import MediaPreviewModal from "@/components/shared/MediaPreviewModal";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { Modal } from "@/components/ui";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { useNotifications } from "@/contexts/NotificationsContext";
import { useUser } from "@/contexts/UserContext";
import { useTaskBoardScroll } from "@/hooks/useTaskBoardScroll";
@ -687,7 +689,7 @@ const TaskCardContent = memo(function TaskCardContent({
}: TaskCardViewProps & TaskCardDragBinding) {
const priority = priorityMeta[task.priority] ?? priorityMeta.medium;
const dueDateClass = getTaskDueDateBadgeClass(task);
const canClaim = !task.assignee && !task.completed_at;
const canClaim = (!task.assignee || !task.assignee.is_active) && !task.completed_at;
const canComplete = task.assignee?.id === currentUserId && !task.completed_at;
return (
@ -4607,6 +4609,10 @@ function TasksPageContent() {
if (!form.id) return user?.id;
return (board?.tasks || []).find((task) => task.id === form.id)?.created_by?.id;
}, [board?.tasks, form.id, user?.id]);
const taskFormExistingAssignee = useMemo(() => {
if (!form.id) return null;
return (board?.tasks || []).find((task) => task.id === form.id)?.assignee || null;
}, [board?.tasks, form.id]);
const searchedTasks = useMemo(() => {
const tasks = board?.tasks || [];
@ -7464,13 +7470,14 @@ function TasksPageContent() {
wideMode={desktopWideMode}
onWideModeChange={changeDesktopWideMode}
userId={user?.id ?? null}
className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh_-_8rem_-_env(safe-area-inset-bottom))] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : ""}`}
className={`${mobileBoardOpen ? "hidden" : "flex"} w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : "lg:h-auto"}`}
/>
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : ""}`}>
{board ? (
<>
<section className="app-surface relative z-50 shrink-0 overflow-visible rounded-2xl p-4">
<AutoRevealStickyHeader className="rounded-b-2xl">
<section className="app-surface relative z-[35] shrink-0 overflow-visible rounded-2xl p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<button
@ -7623,7 +7630,11 @@ function TasksPageContent() {
</label>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<HorizontalChipScroller
className="mt-4"
contentClassName="items-center"
ariaLabel="Фильтры доски задач"
>
<button
type="button"
onClick={() => setOnlyMine((current) => !current)}
@ -7720,7 +7731,7 @@ function TasksPageContent() {
>
<Plus size={14} />
</button>
</div>
</HorizontalChipScroller>
{error ? (
<div className="app-feedback-danger mt-3 rounded-xl px-4 py-3 text-sm">
@ -7728,9 +7739,10 @@ function TasksPageContent() {
</div>
) : null}
</section>
</AutoRevealStickyHeader>
{boardLoading ? (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-2xl bg-[color-mix(in_srgb,var(--background)_72%,transparent)]">
<div className="pointer-events-none absolute inset-0 z-[35] flex items-center justify-center rounded-2xl bg-[color-mix(in_srgb,var(--background)_72%,transparent)]">
<Loader2 size={24} className="animate-spin text-sky-500" />
</div>
) : null}
@ -8650,7 +8662,7 @@ function TasksPageContent() {
</TaskAddMenuPopover>
) : null}
</div>
{!viewTask.assignee && !viewTask.completed_at ? (
{(!viewTask.assignee || !viewTask.assignee.is_active) && !viewTask.completed_at ? (
<button
type="button"
onClick={() => void claimTask(viewTask)}
@ -8790,6 +8802,11 @@ function TasksPageContent() {
size="sm"
/>
<span className="max-w-32 truncate">{displayUserName(viewTask.assignee)}</span>
{!viewTask.assignee.is_active ? (
<span className="app-feedback-danger rounded-full px-1.5 py-0.5 text-[9px]">
неактивен
</span>
) : null}
</span>
) : (
<span className="app-text-muted text-xs">Не назначен</span>
@ -10661,6 +10678,13 @@ function TasksPageContent() {
className="app-select w-full rounded-xl px-3 py-2 text-sm"
>
<option value="">Не назначен</option>
{taskFormExistingAssignee
&& !taskFormExistingAssignee.is_active
&& !employees.some((employee) => employee.id === taskFormExistingAssignee.id) ? (
<option value={taskFormExistingAssignee.id} disabled>
{displayUserName(taskFormExistingAssignee)} (неактивен)
</option>
) : null}
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}

View File

@ -5,6 +5,7 @@ import { Search, Users } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { apiClient } from "@/lib/api";
import type { User } from "@/types/api";
@ -76,18 +77,20 @@ export default function UsersPage() {
</section>
) : (
<section className="app-surface rounded-2xl p-4">
<div className="relative mb-4">
<Search
size={16}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Поиск пользователя"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<AutoRevealStickyHeader className="rounded-b-xl">
<div className="relative mb-4">
<Search
size={16}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Поиск пользователя"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
</AutoRevealStickyHeader>
<div className="space-y-2">
{filteredUsers.length === 0 ? (

View File

@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { X, Search, CheckCircle, AlertCircle, Users, Download } from "lucide-react";
import { Search, CheckCircle, AlertCircle, Users, Download } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
import { Modal } from "@/components/ui";
@ -116,156 +116,152 @@ export function DocumentAcknowledgementsReport({
};
const displayList = getDisplayList();
const totalCount = data?.counts.total || 0;
const acknowledgedCount = data?.counts.acknowledged || 0;
const unacknowledgedCount = data?.counts.unacknowledged || 0;
const acknowledgedPercent = totalCount > 0 ? Math.round((acknowledgedCount / totalCount) * 100) : 0;
const unacknowledgedPercent = totalCount > 0 ? Math.round((unacknowledgedCount / totalCount) * 100) : 0;
const statusFilters = [
{
value: "all" as const,
label: "Все",
count: totalCount,
detail: null,
icon: Users,
activeClassName: "app-selected app-accent-text",
},
{
value: "acknowledged" as const,
label: "Ознакомлены",
mobileLabel: "Готово",
count: acknowledgedCount,
detail: `${acknowledgedPercent}%`,
icon: CheckCircle,
activeClassName: "app-feedback-success",
},
{
value: "unacknowledged" as const,
label: "Не ознакомлены",
mobileLabel: "Ожидают",
count: unacknowledgedCount,
detail: `${unacknowledgedPercent}%`,
icon: AlertCircle,
activeClassName: "app-feedback-warning",
},
];
return (
<Modal isOpen onClose={onClose} noHeader noPadding size="xl" className="h-[95vh] sm:h-[90vh]">
{/* Header */}
<div className="app-divider flex shrink-0 items-start justify-between gap-2 border-b p-3 sm:gap-3 sm:p-6">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-[var(--foreground)] sm:text-lg">Ведомость ознакомлений</h2>
<p className="app-text-muted mt-1 truncate text-xs" title={documentTitle}>{documentTitle}</p>
</div>
<button
onClick={onClose}
className="app-action-secondary shrink-0 rounded-lg p-1.5 sm:p-2"
>
<X size={18} className="sm:w-5 sm:h-5" />
</button>
</div>
<Modal
isOpen
onClose={onClose}
title="Ведомость ознакомлений"
noPadding
size="lg"
className="max-h-[calc(100dvh-1rem)] sm:max-h-[min(46rem,90vh)]"
>
<div className="app-divider shrink-0 border-b px-4 pb-4 sm:px-6">
<p className="app-text-muted truncate text-xs" title={documentTitle}>{documentTitle}</p>
{/* Stats */}
{data && (
<div className="app-divider grid shrink-0 grid-cols-1 gap-2 border-b p-3 sm:grid-cols-3 sm:gap-4 sm:p-6">
<div className="app-selected rounded-lg p-3 sm:rounded-xl sm:p-4">
<div className="app-accent-text flex items-center gap-1.5 sm:gap-2">
<Users size={16} className="sm:w-5 sm:h-5" />
<span className="text-xs sm:text-sm font-medium">Всего</span>
</div>
<p className="mt-1 text-xl font-bold text-[var(--foreground)] sm:mt-2 sm:text-2xl">{data.counts.total}</p>
</div>
<div className="app-feedback-success rounded-lg p-3 sm:rounded-xl sm:p-4">
<div className="flex items-center gap-1.5 sm:gap-2">
<CheckCircle size={16} className="sm:w-5 sm:h-5" />
<span className="text-xs sm:text-sm font-medium">Ознакомлены</span>
</div>
<p className="mt-1 text-xl font-bold sm:mt-2 sm:text-2xl">{data.counts.acknowledged}</p>
<p className="text-xs opacity-80">
{data.counts.total > 0 ? Math.round((data.counts.acknowledged / data.counts.total) * 100) : 0}%
</p>
</div>
<div className="app-feedback-warning rounded-lg p-3 sm:rounded-xl sm:p-4">
<div className="flex items-center gap-1.5 sm:gap-2">
<AlertCircle size={16} className="sm:w-5 sm:h-5" />
<span className="text-xs sm:text-sm font-medium">Не ознакомлены</span>
</div>
<p className="mt-1 text-xl font-bold sm:mt-2 sm:text-2xl">{data.counts.unacknowledged}</p>
<p className="text-xs opacity-80">
{data.counts.total > 0 ? Math.round((data.counts.unacknowledged / data.counts.total) * 100) : 0}%
</p>
</div>
<div className="mt-3 grid grid-cols-3 gap-1.5 sm:gap-2" role="group" aria-label="Статус ознакомления">
{statusFilters.map((filter) => {
const Icon = filter.icon;
const isActive = activeTab === filter.value;
return (
<button
key={filter.value}
type="button"
onClick={() => setActiveTab(filter.value)}
aria-pressed={isActive}
className={`min-w-0 rounded-xl border p-2 text-left transition sm:p-3 ${
isActive
? filter.activeClassName
: "app-surface-muted hover:bg-[var(--surface-tertiary)]"
}`}
>
<span className="flex min-w-0 items-center gap-1.5 text-[10px] font-medium sm:text-xs">
<Icon size={14} className="shrink-0" />
<span className="min-w-0 truncate">
{filter.mobileLabel ? <span className="sm:hidden">{filter.mobileLabel}</span> : null}
<span className={filter.mobileLabel ? "hidden sm:inline" : ""}>{filter.label}</span>
</span>
</span>
<span className="mt-1 flex items-baseline gap-1.5">
<strong className="text-lg leading-none text-[var(--foreground)] sm:text-xl">{filter.count}</strong>
{filter.detail ? <span className="app-text-muted truncate text-[10px] sm:text-xs">{filter.detail}</span> : null}
</span>
</button>
);
})}
</div>
)}
{/* Tabs & Search */}
<div className="app-divider shrink-0 border-b p-3 sm:p-4">
<div className="mb-3 flex flex-col items-stretch gap-2 sm:mb-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search size={14} className="app-text-muted absolute left-2.5 top-1/2 -translate-y-1/2 sm:left-3 sm:h-4 sm:w-4" />
<div className="mt-3 flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по ФИО или email..."
className="app-input w-full py-1.5 pl-8 pr-3 text-xs sm:py-2 sm:pl-10 sm:pr-4 sm:text-sm"
className="app-input h-10 w-full rounded-lg pl-9 pr-3 text-xs sm:text-sm"
/>
</div>
<button
type="button"
onClick={exportToCSV}
className="app-action-secondary inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
disabled={!data || totalCount === 0}
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center gap-2 rounded-lg text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:px-3"
title="Экспортировать CSV"
aria-label="Экспортировать CSV"
>
<Download size={14} className="sm:w-4 sm:h-4" />
<Download size={16} />
<span className="hidden sm:inline">Экспорт CSV</span>
<span className="sm:hidden">Экспорт</span>
</button>
</div>
<div className="flex gap-1.5 sm:gap-2 overflow-x-auto pb-1">
<button
onClick={() => setActiveTab("all")}
className={`shrink-0 px-3 py-1.5 text-xs sm:px-4 sm:py-2 sm:text-sm ${
activeTab === "all"
? "app-pill-active"
: "app-pill"
}`}
>
Все ({data?.counts.total || 0})
</button>
<button
onClick={() => setActiveTab("acknowledged")}
className={`shrink-0 px-3 py-1.5 text-xs sm:px-4 sm:py-2 sm:text-sm ${
activeTab === "acknowledged"
? "app-pill-active"
: "app-pill"
}`}
>
Ознакомлены ({data?.counts.acknowledged || 0})
</button>
<button
onClick={() => setActiveTab("unacknowledged")}
className={`shrink-0 px-3 py-1.5 text-xs sm:px-4 sm:py-2 sm:text-sm ${
activeTab === "unacknowledged"
? "app-pill-active"
: "app-pill"
}`}
>
Не ознакомлены ({data?.counts.unacknowledged || 0})
</button>
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4">
<div className="min-h-28 max-h-[52dvh] overflow-y-auto overscroll-contain px-4 py-3 sm:max-h-[34rem] sm:px-6 sm:py-4">
{loading ? (
<div className="flex items-center justify-center py-8 sm:py-12">
<div className="h-6 w-6 animate-spin rounded-full border-[3px] border-[var(--border-subtle)] border-t-[var(--accent-primary)] sm:h-8 sm:w-8 sm:border-4" />
<div className="flex items-center justify-center py-8">
<div className="h-7 w-7 animate-spin rounded-full border-[3px] border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
</div>
) : displayList.length === 0 ? (
<div className="py-8 sm:py-12 text-center">
<p className="app-text-muted text-xs sm:text-sm">Сотрудников не найдено</p>
<div className="flex flex-col items-center justify-center py-7 text-center">
<Users size={24} className="app-text-muted opacity-60" />
<p className="app-text-muted mt-2 text-sm">Сотрудников не найдено</p>
{search ? <p className="app-text-muted mt-1 text-xs">Попробуйте изменить запрос</p> : null}
</div>
) : (
<div className="space-y-1.5 sm:space-y-2">
<div className="space-y-2">
{displayList.map((emp) => {
const isAcknowledged = data!.acknowledged.some((a) => a.id === emp.id);
return (
<div
key={emp.id}
className={`flex items-center justify-between gap-2 rounded-lg p-2 sm:p-3 ${
isAcknowledged
? "app-feedback-success"
: "app-feedback-warning"
}`}
className="app-surface-muted flex items-center gap-2 rounded-xl p-2.5 sm:gap-3 sm:p-3"
>
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
isAcknowledged ? "app-feedback-success" : "app-feedback-warning"
}`}>
{isAcknowledged ? (
<CheckCircle size={16} className="shrink-0 sm:h-5 sm:w-5" />
<CheckCircle size={16} />
) : (
<AlertCircle size={16} className="shrink-0 sm:h-5 sm:w-5" />
<AlertCircle size={16} />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-[var(--foreground)] sm:text-sm">
{emp.last_name} {emp.first_name} {emp.patronymic || ""}
</p>
<div className="app-text-muted flex flex-wrap gap-1 text-xs sm:gap-2">
<span className="truncate">{emp.email}</span>
{emp.position && <span className="shrink-0">• {emp.position.name}</span>}
{emp.department && <span className="shrink-0 hidden sm:inline">• {emp.department.name}</span>}
</div>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--foreground)]">
{emp.last_name} {emp.first_name} {emp.patronymic || ""}
</p>
<div className="app-text-muted flex min-w-0 items-center gap-1 text-xs">
<span className="min-w-0 truncate">{emp.email}</span>
{emp.position ? <span className="hidden shrink-0 sm:inline">• {emp.position.name}</span> : null}
{emp.department ? <span className="hidden shrink-0 md:inline">• {emp.department.name}</span> : null}
</div>
</div>
<span
className="shrink-0 text-xs font-medium"
>
<span className={`app-status-pill shrink-0 px-2 py-1 text-[11px] ${
isAcknowledged ? "app-feedback-success" : "app-feedback-warning"
}`}>
{isAcknowledged ? "Ознакомлен" : "Требуется"}
</span>
</div>

View File

@ -8,6 +8,7 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import MediaPreviewModal from "@/components/shared/MediaPreviewModal";
import { Modal } from "@/components/ui";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { apiClient } from "@/lib/api";
import { getFileExtension, getMediaKind } from "@/lib/media";
@ -440,7 +441,11 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum
{!loading && !error && (
<>
{excelSheets.length > 1 && (
<div className="app-divider mb-3 flex shrink-0 gap-2 overflow-x-auto border-b pb-2">
<HorizontalChipScroller
className="app-divider mb-3 shrink-0 border-b"
contentClassName="pb-2"
ariaLabel="Листы таблицы"
>
{excelSheets.map((sheet, index) => (
<button
key={sheet.name}
@ -453,7 +458,7 @@ export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: Docum
{sheet.name}
</button>
))}
</div>
</HorizontalChipScroller>
)}
<div className="app-surface-elevated min-h-0 flex-1 overflow-auto rounded-lg">
{excelSheets[activeSheet]?.rows.length ? (

View File

@ -3,7 +3,6 @@
import {
AlertCircle,
ArrowLeft,
ArrowUpDown,
Banknote,
CalendarCheck2,
Calculator,
@ -43,6 +42,8 @@ import { PayrollInputCopyModal } from "@/components/finance/admin/PayrollInputCo
import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal";
import { PayrollCarryoversModal } from "@/components/finance/admin/PayrollCarryoversModal";
import { PayrollManagementAccessModal } from "@/components/finance/admin/PayrollManagementAccessModal";
import { CompactSortMenu, type CompactSortOption } from "@/components/ui/CompactSortMenu";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { ViewportAwareMenu } from "@/components/shared/ViewportAwareMenu";
import { PayrollWorkbookImportModal } from "@/components/finance/admin/PayrollWorkbookImportModal";
import {
@ -107,6 +108,13 @@ type PendingAction = {
type PayrollInputSort = "newest" | "oldest" | "employee_asc" | "employee_desc";
const payrollInputSortOptions = [
{ value: "newest", label: "Сначала новые", icon: "date-desc" },
{ value: "oldest", label: "Сначала старые", icon: "date-asc" },
{ value: "employee_asc", label: "По сотруднику А–Я", icon: "alphabetical" },
{ value: "employee_desc", label: "По сотруднику Я–А", icon: "alphabetical-reverse" },
] satisfies readonly CompactSortOption<PayrollInputSort>[];
function sortPayrollInputRecords<
T extends { id: number; created_at: string; employee: { display_name: string } },
>(records: T[], sort: PayrollInputSort): T[] {
@ -303,27 +311,29 @@ function TableToolbar({
return (
<div className="payroll-admin-toolbar-responsive mb-4">
<div className="payroll-admin-toolbar-layout">
<label className="payroll-admin-toolbar-search relative min-w-0">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" value={search} placeholder="Поиск по сотруднику" onChange={(event) => onSearch(event.target.value)} />
</label>
<div className="payroll-admin-toolbar-main flex min-w-0 items-center gap-2">
<label className="payroll-admin-toolbar-search relative min-w-0">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" value={search} placeholder="Поиск по сотруднику" onChange={(event) => onSearch(event.target.value)} />
</label>
<CompactSortMenu
value={sort}
options={payrollInputSortOptions}
onChange={onSort}
ariaLabel="Сортировка записей"
/>
</div>
<select className="payroll-admin-toolbar-status app-select min-w-0 rounded-lg px-3 py-2.5 text-sm" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
<option value="">Все статусы</option><option value="draft">Черновики</option><option value="approved">Утверждённые</option><option value="voided">Аннулированные</option>
</select>
<label className="payroll-admin-toolbar-sort relative min-w-0">
<ArrowUpDown className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<select className="app-select w-full rounded-lg py-2.5 pl-9 pr-8 text-sm" value={sort} onChange={(event) => onSort(event.target.value as PayrollInputSort)} aria-label="Сортировка записей">
<option value="newest">Сначала новые</option>
<option value="oldest">Сначала старые</option>
<option value="employee_asc">По сотруднику А–Я</option>
<option value="employee_desc">По сотруднику Я–А</option>
</select>
</label>
{onAdd ? (
<div ref={addMenuRef} className="payroll-admin-toolbar-actions relative flex min-w-0 items-center gap-1">
<button type="button" className="app-action-primary inline-flex min-w-0 flex-1 items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
<Plus size={16} /> {addLabel}
</button>
<ModulePrimaryAction
className="flex-1"
onClick={onAdd}
icon={<Plus size={16} />}
label={addLabel}
/>
{hasAdditionalActions ? (
<button
type="button"
@ -976,7 +986,7 @@ export function PayrollAdminWorkspace({
</div>
);
const managementActions = (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-nowrap items-center gap-2">
{onDesktopWideModeChange ? (
<button
type="button"
@ -990,8 +1000,8 @@ export function PayrollAdminWorkspace({
</button>
) : null}
{workspace.periods.length ? (
<div className="flex items-center gap-2">
<select className="app-select min-w-52 rounded-lg px-3 py-2.5 text-sm" value={selectedPeriod?.id || ""} onChange={(event) => void loadWorkspace(Number(event.target.value), true)} aria-label="Расчётный период">
<div className="flex min-w-0 flex-1 items-center gap-2">
<select className="app-select min-w-0 flex-1 rounded-lg px-3 py-2.5 text-sm sm:min-w-52" value={selectedPeriod?.id || ""} onChange={(event) => void loadWorkspace(Number(event.target.value), true)} aria-label="Расчётный период">
{workspace.periods.map((period) => <option value={period.id} key={period.id}>{period.name || period.code}</option>)}
</select>
{selectedPeriod?.status === "open" && permissions.manage_inputs && !workspace.current_run ? (
@ -1010,7 +1020,16 @@ export function PayrollAdminWorkspace({
) : null}
</div>
) : null}
{permissions.manage_inputs ? <button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold" onClick={() => { setEditingPeriod(null); setPeriodModalOpen(true); }}><Plus size={16} /> Новый период</button> : null}
{permissions.manage_inputs ? (
<ModulePrimaryAction
onClick={() => {
setEditingPeriod(null);
setPeriodModalOpen(true);
}}
icon={<Plus size={16} />}
label="Новый период"
/>
) : null}
</div>
);
const accessLabel = workspace.access_settings.restricted

View File

@ -1,8 +1,10 @@
"use client";
import { useState } from "react";
import { Filter, Search } from "lucide-react";
import { Search } from "lucide-react";
import { CompactFilterMenu } from "@/components/ui/CompactFilterMenu";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import {
MESSAGE_CHAT_TYPE_OPTIONS,
type MessagePinnedFilter,
@ -49,65 +51,27 @@ export default function MessagesChatListControls({
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<button
type="button"
onClick={() => setFiltersOpen((current) => !current)}
className={`relative inline-flex shrink-0 items-center justify-center rounded-lg p-2.5 transition ${
filtersOpen
? "app-selected app-accent-text"
: "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
}`}
title="Дополнительные фильтры"
aria-label="Дополнительные фильтры"
aria-expanded={filtersOpen}
<CompactFilterMenu
open={filtersOpen}
onToggle={() => setFiltersOpen((current) => !current)}
activeCount={[filterUnread !== "all", filterPinned !== "all"].filter(Boolean).length}
ariaLabel="Дополнительные фильтры чатов"
>
<Filter size={16} />
{filterUnread !== "all" || filterPinned !== "all" ? (
<span className="app-badge app-badge-accent absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border px-1 text-[10px] font-bold">
{[filterUnread !== "all", filterPinned !== "all"].filter(Boolean).length}
</span>
) : null}
</button>
<div className="flex flex-col gap-2">
<select value={filterUnread} onChange={(event) => setFilterUnread(event.target.value as MessageUnreadFilter)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="all">Все чаты</option><option value="unread">Только с непрочитанными</option><option value="read">Только прочитанные</option>
</select>
<select value={filterPinned} onChange={(event) => setFilterPinned(event.target.value as MessagePinnedFilter)} className="app-select rounded-lg px-3 py-2 text-sm">
<option value="all">Все чаты</option><option value="pinned">Только закрепленные</option><option value="unpinned">Только незакрепленные</option>
</select>
{filterUnread !== "all" || filterPinned !== "all" ? (
<button type="button" onClick={() => { setFilterUnread("all"); setFilterPinned("all"); }} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Очистить фильтры</button>
) : null}
</div>
</CompactFilterMenu>
</div>
{filtersOpen ? (
<div className="app-surface-muted mb-4 flex flex-col gap-2 rounded-xl p-3">
<select
value={filterUnread}
onChange={(event) => setFilterUnread(event.target.value as MessageUnreadFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="all">Все чаты</option>
<option value="unread">Только с непрочитанными</option>
<option value="read">Только прочитанные</option>
</select>
<select
value={filterPinned}
onChange={(event) => setFilterPinned(event.target.value as MessagePinnedFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="all">Все чаты</option>
<option value="pinned">Только закрепленные</option>
<option value="unpinned">Только незакрепленные</option>
</select>
{filterUnread !== "all" || filterPinned !== "all" ? (
<button
type="button"
onClick={() => {
setFilterUnread("all");
setFilterPinned("all");
}}
className="app-surface-elevated rounded-lg px-3 py-2 text-sm font-medium text-[var(--muted-foreground)] transition hover:bg-[var(--surface-secondary)]"
>
Очистить фильтры
</button>
) : null}
</div>
) : null}
<div className="mb-4 flex flex-wrap gap-2">
<HorizontalChipScroller className="mb-4" ariaLabel="Тип чата">
{MESSAGE_CHAT_TYPE_OPTIONS
.filter(({ key }) => key === "all" || (chatTypeCounts[key]?.total ?? 0) > 0)
.map(({ key, label }) => {
@ -141,7 +105,7 @@ export default function MessagesChatListControls({
</button>
);
})}
</div>
</HorizontalChipScroller>
</>
);
}

View File

@ -251,7 +251,7 @@ export default function MessagesChatListItem({
</div>
</Link>
<div ref={menuRef} className="absolute right-2 top-2 z-30">
<div ref={menuRef} className="absolute right-2 top-2 z-10">
<button
type="button"
onClick={() => setMenuOpen((current) => !current)}

View File

@ -7,6 +7,7 @@ import MessagesChatListControls from "@/components/messages/MessagesChatListCont
import MessagesChatListItem from "@/components/messages/MessagesChatListItem";
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
import MessagesInstallButton from "@/components/messages/MessagesInstallButton";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
import type { MessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
@ -93,20 +94,17 @@ export default function MessagesFocusSidebar({
</>
)}
</button>
{!sidebarCollapsed ? <div className="flex shrink-0 items-center gap-1">
{!sidebarCollapsed ? <div className="ml-auto flex min-w-0 shrink items-center gap-1">
<MessagesFocusModeButton
enabled={mode !== "standard"}
onChange={(enabled) => onModeChange(enabled ? "wide" : "standard")}
/>
<MessagesInstallButton />
<button
type="button"
<ModulePrimaryAction
onClick={onCreateChat}
className="app-action-primary inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold"
>
<Plus size={14} />
Создать чат
</button>
icon={<Plus size={14} />}
label="Создать чат"
/>
</div> : null}
</div>

View File

@ -5,9 +5,13 @@ import {
type RequestQuickScope,
type RequestScopeCounts,
} from "@/hooks/useRequestsPage";
import { CompactSortMenu } from "@/components/ui/CompactSortMenu";
import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { displayUserName } from "@/lib/shared";
import type { User } from "@/types/api";
import { ArrowUpDown, ChevronDown, Filter, Maximize2, Minimize2, Plus, Search, Zap } from "lucide-react";
import { Filter, Maximize2, Minimize2, Plus, Search, Zap } from "lucide-react";
type RequestListControlsState = {
activeFiltersCount: number;
@ -74,8 +78,8 @@ export function RequestListControls({
return (
<>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<div className="flex shrink-0 items-center gap-3">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Заявления</p>
{state.pendingDecisionCount > 0 ? (
<button
@ -88,7 +92,7 @@ export function RequestListControls({
</button>
) : null}
</div>
<div className="flex items-center gap-2">
<div className="ml-auto flex min-w-0 shrink items-center gap-2">
{onToggleDesktopWideMode ? (
<button
type="button"
@ -101,13 +105,11 @@ export function RequestListControls({
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
) : null}
<button
type="button"
<ModulePrimaryAction
onClick={actions.openCreate}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} /> Создать заявление
</button>
icon={<Plus size={14} />}
label="Создать заявление"
/>
</div>
</div>
@ -130,6 +132,7 @@ export function RequestListControls({
</div>
<button
id="requests-filter-trigger"
type="button"
title="Фильтры"
onClick={actions.toggleFilters}
@ -143,25 +146,15 @@ export function RequestListControls({
) : null}
</button>
<div className="relative w-[148px] shrink-0">
<ArrowUpDown size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<select
value={state.ordering}
onChange={(event) => actions.setOrdering(event.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
aria-label="Сортировка"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown size={14} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
</div>
<CompactSortMenu
value={state.ordering}
options={orderingOptions}
onChange={actions.setOrdering}
ariaLabel="Сортировка заявлений"
/>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<HorizontalChipScroller className="mb-4" ariaLabel="Разделы заявлений">
{quickScopes.map((scope) => {
const active = state.view === scope.value;
return (
@ -178,11 +171,16 @@ export function RequestListControls({
</button>
);
})}
</div>
</HorizontalChipScroller>
{state.filtersOpen ? (
<div className="app-surface-muted mb-4 grid grid-cols-1 gap-2 rounded-xl p-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="flex min-h-7 items-center justify-between gap-3 sm:col-span-2 xl:col-span-4">
<CompactFilterPanel
open={state.filtersOpen}
anchorId="requests-filter-trigger"
onClose={actions.toggleFilters}
ariaLabel="Фильтры заявлений"
>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="flex min-h-7 items-center justify-between gap-3 sm:col-span-2">
<span className="app-text-muted text-xs font-semibold uppercase tracking-wide">Фильтры</span>
{state.hasActiveFilters ? (
<button
@ -244,7 +242,7 @@ export function RequestListControls({
))}
</select>
<div className="min-w-0 space-y-1.5 sm:col-span-2 xl:col-span-2">
<div className="min-w-0 space-y-1.5 sm:col-span-2">
<label className="app-text-muted px-1 text-xs font-medium">Дата создания</label>
<div className="grid min-w-0 grid-cols-2 gap-2">
<input
@ -262,7 +260,7 @@ export function RequestListControls({
</div>
</div>
<div className="min-w-0 space-y-1.5 sm:col-span-2 xl:col-span-2">
<div className="min-w-0 space-y-1.5 sm:col-span-2">
<label className="app-text-muted px-1 text-xs font-medium">Период заявления</label>
<div className="grid min-w-0 grid-cols-2 gap-2">
<input
@ -281,7 +279,7 @@ export function RequestListControls({
</div>
</div>
) : null}
</CompactFilterPanel>
</>
);
}

View File

@ -24,11 +24,16 @@ import {
Trash2,
Users,
} from "lucide-react";
import { createPortal } from "react-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { Modal } from "@/components/ui";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { displayUserName } from "@/lib/shared";
import type { TaskBoardGroup, TaskBoardSummary } from "@/types/api";
@ -212,6 +217,7 @@ function BoardRow({
{menuOpen ? (
<div className="app-surface-elevated absolute right-2 top-10 z-30 min-w-52 max-w-[calc(100dvw-1rem)] overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<button
id="task-boards-filter-trigger"
type="button"
onClick={onEdit}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
@ -305,7 +311,12 @@ export default function TaskBoardNavigator({
const [groupFilter, setGroupFilter] = useState<BoardGroupFilter>("all");
const [boardMenuId, setBoardMenuId] = useState<number | null>(null);
const [groupMenuId, setGroupMenuId] = useState<number | null>(null);
const [groupMenuPosition, setGroupMenuPosition] = useState<{
left: number;
top: number;
} | null>(null);
const [navigatorCollapsed, setNavigatorCollapsed] = useState(false);
const [desktopNavigatorLayout, setDesktopNavigatorLayout] = useState(false);
const [moveBoardTarget, setMoveBoardTarget] = useState<TaskBoardSummary | null>(null);
const [moveGroupIds, setMoveGroupIds] = useState<number[]>([]);
const [moveGroupQuery, setMoveGroupQuery] = useState("");
@ -313,6 +324,8 @@ export default function TaskBoardNavigator({
const [participantsBoard, setParticipantsBoard] = useState<TaskBoardSummary | null>(null);
const [hiddenGroupsOpen, setHiddenGroupsOpen] = useState(false);
const menusRef = useRef<HTMLDivElement>(null);
const groupMenuRef = useRef<HTMLDivElement>(null);
const groupMenuButtonRefs = useRef(new Map<number, HTMLButtonElement>());
const defaultGroupInitializedRef = useRef(false);
const normalizedQuery = query.trim().toLowerCase();
const visibleGroups = useMemo(
@ -323,6 +336,9 @@ export default function TaskBoardNavigator({
() => groups.filter((group) => group.is_hidden),
[groups],
);
const activeGroupMenu = groupMenuId === null
? null
: visibleGroups.find((group) => group.id === groupMenuId) || null;
const effectiveGroupFilter = (
typeof groupFilter !== "number" || visibleGroups.some((group) => group.id === groupFilter)
) ? groupFilter : "all";
@ -362,7 +378,7 @@ export default function TaskBoardNavigator({
[visibleGroups],
);
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
const compactNavigator = wideMode && navigatorCollapsed;
const compactNavigator = desktopNavigatorLayout && wideMode && navigatorCollapsed;
const filteredMoveGroups = useMemo(() => {
const moveQuery = moveGroupQuery.trim().toLowerCase();
return moveQuery
@ -377,6 +393,14 @@ export default function TaskBoardNavigator({
defaultGroupInitializedRef.current = true;
}, [visibleGroups]);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const updateLayout = () => setDesktopNavigatorLayout(mediaQuery.matches);
updateLayout();
mediaQuery.addEventListener("change", updateLayout);
return () => mediaQuery.removeEventListener("change", updateLayout);
}, []);
useEffect(() => {
if (!userId) {
setNavigatorCollapsed(false);
@ -432,7 +456,11 @@ export default function TaskBoardNavigator({
useEffect(() => {
if (boardMenuId === null && groupMenuId === null) return undefined;
const closeMenus = (event: MouseEvent) => {
if (!menusRef.current?.contains(event.target as Node)) {
const target = event.target as Node;
if (
!menusRef.current?.contains(target)
&& !groupMenuRef.current?.contains(target)
) {
setBoardMenuId(null);
setGroupMenuId(null);
}
@ -441,22 +469,55 @@ export default function TaskBoardNavigator({
return () => document.removeEventListener("mousedown", closeMenus);
}, [boardMenuId, groupMenuId]);
useEffect(() => {
if (groupMenuId === null) return undefined;
const updateGroupMenuPosition = () => {
const anchor = groupMenuButtonRefs.current.get(groupMenuId);
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const viewportPadding = 8;
const menuWidth = 176;
const estimatedMenuHeight = 168;
const left = Math.min(
Math.max(viewportPadding, rect.left),
window.innerWidth - menuWidth - viewportPadding,
);
const top = rect.bottom + estimatedMenuHeight + viewportPadding <= window.innerHeight
? rect.bottom + 6
: Math.max(viewportPadding, rect.top - estimatedMenuHeight - 6);
setGroupMenuPosition({ left, top });
};
updateGroupMenuPosition();
window.addEventListener("resize", updateGroupMenuPosition);
window.addEventListener("scroll", updateGroupMenuPosition, true);
return () => {
window.removeEventListener("resize", updateGroupMenuPosition);
window.removeEventListener("scroll", updateGroupMenuPosition, true);
};
}, [groupMenuId]);
return (
<>
<aside
ref={menusRef}
className={`flex min-h-0 flex-col overflow-hidden transition-[width] duration-200 ${
className={`flex min-h-0 flex-col overflow-visible transition-[width] duration-200 lg:overflow-hidden ${
compactNavigator ? "bg-transparent" : "app-surface rounded-2xl"
} ${
wideMode ? (compactNavigator ? "lg:w-[4.75rem]" : "lg:w-[24rem]") : ""
} ${className}`}
>
<AutoRevealStickyHeader className={compactNavigator ? "" : "rounded-2xl"}>
<div
className={`flex items-center gap-2 pb-0 ${
className={`flex min-w-0 items-center gap-2 pb-0 ${
compactNavigator ? "justify-center p-3" : "justify-between p-4"
}`}
>
{wideMode ? (
{wideMode && desktopNavigatorLayout ? (
<button
type="button"
onClick={toggleNavigatorCollapsed}
@ -480,7 +541,7 @@ export default function TaskBoardNavigator({
<p className="app-card-caption min-w-0 truncate">Доски</p>
)}
{!compactNavigator ? (
<div className="flex shrink-0 items-center gap-1">
<div className="ml-auto flex min-w-0 shrink items-center gap-1">
<button
type="button"
onClick={() => onWideModeChange(!wideMode)}
@ -491,14 +552,11 @@ export default function TaskBoardNavigator({
>
{wideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
<button
type="button"
<ModulePrimaryAction
onClick={onCreate}
className="app-action-primary inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold"
>
<Plus size={14} />
Создать доску
</button>
icon={<Plus size={14} />}
label="Создать доску"
/>
</div>
) : null}
</div>
@ -537,8 +595,13 @@ export default function TaskBoardNavigator({
</button>
</div>
{filtersOpen ? (
<div className="app-surface-muted mb-4 flex flex-col gap-2 rounded-xl p-3">
<CompactFilterPanel
open={filtersOpen}
anchorId="task-boards-filter-trigger"
onClose={() => setFiltersOpen(false)}
ariaLabel="Фильтры досок"
>
<div className="flex flex-col gap-2">
<select
value={unreadFilter}
onChange={(event) => setUnreadFilter(event.target.value as BoardUnreadFilter)}
@ -572,9 +635,9 @@ export default function TaskBoardNavigator({
</button>
) : null}
</div>
) : null}
</CompactFilterPanel>
<div className="mb-4 flex flex-wrap gap-2">
<HorizontalChipScroller className="mb-4" ariaLabel="Группы досок">
{[
{ key: "all" as const, label: "Все", stats: groupStats.all },
{ key: "ungrouped" as const, label: "Без группы", stats: groupStats.ungrouped },
@ -636,6 +699,10 @@ export default function TaskBoardNavigator({
</span>
</button>
<button
ref={(button) => {
if (button) groupMenuButtonRefs.current.set(group.id, button);
else groupMenuButtonRefs.current.delete(group.id);
}}
type="button"
onClick={() => setGroupMenuId((current) => current === group.id ? null : group.id)}
className="absolute right-0.5 flex h-6 w-6 items-center justify-center rounded-full"
@ -645,61 +712,6 @@ export default function TaskBoardNavigator({
>
<ChevronRight size={12} className={`transition-transform ${groupMenuId === group.id ? "rotate-90" : ""}`} />
</button>
{groupMenuId === group.id ? (
<div className="app-surface-elevated absolute left-0 top-8 z-30 min-w-44 max-w-[calc(100dvw-1rem)] overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<button
type="button"
onClick={() => {
onToggleDefaultGroup(group);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Star
size={14}
className={group.is_default_for_current_user ? "app-accent-text fill-current" : ""}
/>
{group.is_default_for_current_user
? "Убрать группу по умолчанию"
: "Сделать группой по умолчанию"}
</button>
<button
type="button"
onClick={() => {
onEditGroup(group);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
<button
type="button"
onClick={() => {
if (groupFilter === group.id) setGroupFilter("all");
onToggleHiddenGroup(group);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<EyeOff size={14} />
Скрыть лично
</button>
<button
type="button"
onClick={() => {
if (groupFilter === group.id) setGroupFilter("all");
onDeleteGroup(group);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
</div>
);
})}
@ -726,11 +738,12 @@ export default function TaskBoardNavigator({
</span>
</button>
) : null}
</div>
</HorizontalChipScroller>
</div>
) : null}
</AutoRevealStickyHeader>
<div className={`tasks-column-scroll min-h-0 flex-1 overflow-y-auto overscroll-y-contain pb-4 ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
<div className={`tasks-column-scroll pb-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
{visibleBoards.length > 0 ? (
<div className="space-y-1">
{visibleBoards.map((board) => (
@ -791,6 +804,72 @@ export default function TaskBoardNavigator({
</div>
</aside>
{activeGroupMenu && groupMenuPosition && typeof document !== "undefined"
? createPortal(
<div
ref={groupMenuRef}
className="app-surface-elevated fixed z-[100] w-44 max-w-[calc(100dvw-1rem)] overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl"
style={{
left: groupMenuPosition.left,
top: groupMenuPosition.top,
}}
>
<button
type="button"
onClick={() => {
onToggleDefaultGroup(activeGroupMenu);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Star
size={14}
className={activeGroupMenu.is_default_for_current_user ? "app-accent-text fill-current" : ""}
/>
{activeGroupMenu.is_default_for_current_user
? "Убрать группу по умолчанию"
: "Сделать группой по умолчанию"}
</button>
<button
type="button"
onClick={() => {
onEditGroup(activeGroupMenu);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
<button
type="button"
onClick={() => {
if (groupFilter === activeGroupMenu.id) setGroupFilter("all");
onToggleHiddenGroup(activeGroupMenu);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<EyeOff size={14} />
Скрыть лично
</button>
<button
type="button"
onClick={() => {
if (groupFilter === activeGroupMenu.id) setGroupFilter("all");
onDeleteGroup(activeGroupMenu);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>,
document.body,
)
: null}
<Modal
isOpen={hiddenGroupsOpen}
onClose={() => setHiddenGroupsOpen(false)}

View File

@ -158,7 +158,9 @@ export default function TaskReadOnlyPreviewModal({
<DetailCard
icon={UserRound}
label="Ответственный"
value={task.assignee ? displayUserName(task.assignee) : "Не назначен"}
value={task.assignee
? `${displayUserName(task.assignee)}${task.assignee.is_active ? "" : " (неактивен)"}`
: "Не назначен"}
/>
<DetailCard
icon={UserRound}

View File

@ -0,0 +1,90 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
type AutoRevealStickyHeaderProps = {
children: ReactNode;
className?: string;
surface?: "solid" | "transparent";
};
export function AutoRevealStickyHeader({
children,
className = "",
surface = "solid",
}: AutoRevealStickyHeaderProps) {
const { mobileNavPlacement } = useMobileNavPlacement();
const [visible, setVisible] = useState(true);
const [elevated, setElevated] = useState(false);
const lastScrollYRef = useRef(0);
const scrollDeltaRef = useRef(0);
const scrollDirectionRef = useRef<"up" | "down" | null>(null);
useEffect(() => {
lastScrollYRef.current = Math.max(0, window.scrollY);
let frameId: number | null = null;
const update = () => {
frameId = null;
const currentScrollY = Math.max(0, window.scrollY);
const delta = currentScrollY - lastScrollYRef.current;
setElevated(currentScrollY > 8);
if (currentScrollY <= 8) {
setVisible(true);
scrollDeltaRef.current = 0;
scrollDirectionRef.current = null;
lastScrollYRef.current = currentScrollY;
return;
}
if (Math.abs(delta) >= 1) {
const direction = delta > 0 ? "down" : "up";
if (scrollDirectionRef.current !== direction) {
scrollDirectionRef.current = direction;
scrollDeltaRef.current = 0;
}
scrollDeltaRef.current += delta;
if (direction === "down" && scrollDeltaRef.current >= 24 && currentScrollY > 80) {
setVisible(false);
scrollDeltaRef.current = 0;
} else if (direction === "up" && scrollDeltaRef.current <= -12) {
setVisible(true);
scrollDeltaRef.current = 0;
}
}
lastScrollYRef.current = currentScrollY;
};
const handleScroll = () => {
if (frameId !== null) return;
frameId = window.requestAnimationFrame(update);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (frameId !== null) window.cancelAnimationFrame(frameId);
};
}, []);
return (
<div
data-auto-reveal-header="true"
onFocusCapture={() => setVisible(true)}
className={`sticky z-30 transition-[transform,box-shadow] duration-200 ease-out will-change-transform lg:static lg:translate-y-0 lg:bg-transparent lg:shadow-none ${
surface === "transparent" ? "bg-transparent" : "bg-[var(--surface-primary-solid)]"
} ${
mobileNavPlacement === "top" ? "top-10" : "top-0"
} ${visible ? "translate-y-0" : "-translate-y-[calc(100%+0.75rem)]"} ${
elevated && visible && surface === "solid" ? "shadow-[var(--shadow-card)]" : ""
} ${className}`}
>
{children}
</div>
);
}

View File

@ -0,0 +1,246 @@
"use client";
import { Filter } from "lucide-react";
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
type CompactFilterMenuProps = {
open: boolean;
onToggle: () => void;
activeCount?: number;
ariaLabel?: string;
children: ReactNode;
width?: number;
};
type CompactFilterPanelProps = {
open: boolean;
anchorId: string;
onClose: () => void;
ariaLabel?: string;
children: ReactNode;
width?: number;
};
type MenuPosition = {
left: number;
top: number;
width: number;
};
export function CompactFilterMenu({
open,
onToggle,
activeCount = 0,
ariaLabel = "Фильтры",
children,
width = 360,
}: CompactFilterMenuProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null);
const menuId = useId();
useEffect(() => {
if (!open) {
setMenuPosition(null);
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (
!containerRef.current?.contains(target) &&
!menuRef.current?.contains(target)
) {
onToggle();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onToggle();
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onToggle, open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const margin = 12;
const gap = 8;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const nextWidth = Math.min(width, window.innerWidth - margin * 2);
const left = Math.min(
Math.max(triggerRect.right - nextWidth, margin),
window.innerWidth - nextWidth - margin,
);
const fitsBelow = triggerRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(margin, triggerRect.top - gap - menuRect.height);
setMenuPosition({ left, top, width: nextWidth });
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open, width]);
return (
<div ref={containerRef} className="relative shrink-0">
<button
ref={triggerRef}
type="button"
title={ariaLabel}
aria-label={ariaLabel}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={onToggle}
className={`${
open
? "app-selected app-accent-text"
: "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
} relative inline-flex h-[42px] w-[42px] items-center justify-center rounded-lg transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent-primary)]`}
>
<Filter className="h-4 w-4" aria-hidden="true" />
{activeCount > 0 ? (
<span className="app-counter absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center px-1 text-[10px] font-bold">
{activeCount}
</span>
) : null}
</button>
{open
? createPortal(
<div
ref={menuRef}
id={menuId}
role="dialog"
aria-label={ariaLabel}
className="app-card fixed z-[100] max-h-[calc(100dvh-1.5rem)] overflow-y-auto rounded-xl p-3 shadow-xl"
style={{
left: menuPosition?.left ?? 0,
top: menuPosition?.top ?? 0,
width: menuPosition?.width ?? width,
visibility: menuPosition ? "visible" : "hidden",
}}
>
{children}
</div>,
document.body,
)
: null}
</div>
);
}
export function CompactFilterPanel({
open,
anchorId,
onClose,
ariaLabel = "Фильтры",
children,
width = 360,
}: CompactFilterPanelProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null);
useEffect(() => {
if (!open) {
setMenuPosition(null);
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
const anchor = document.getElementById(anchorId);
if (!anchor?.contains(target) && !menuRef.current?.contains(target)) onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
document.getElementById(anchorId)?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [anchorId, onClose, open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const anchor = document.getElementById(anchorId);
const menu = menuRef.current;
if (!anchor || !menu) return;
const margin = 12;
const gap = 8;
const anchorRect = anchor.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const nextWidth = Math.min(width, window.innerWidth - margin * 2);
const left = Math.min(
Math.max(anchorRect.right - nextWidth, margin),
window.innerWidth - nextWidth - margin,
);
const fitsBelow = anchorRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
const top = fitsBelow
? anchorRect.bottom + gap
: Math.max(margin, anchorRect.top - gap - menuRect.height);
setMenuPosition({ left, top, width: nextWidth });
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [anchorId, open, width]);
if (!open) return null;
return createPortal(
<div
ref={menuRef}
role="dialog"
aria-label={ariaLabel}
className="app-card fixed z-[100] max-h-[calc(100dvh-1.5rem)] overflow-y-auto rounded-xl p-3 shadow-xl"
style={{
left: menuPosition?.left ?? 0,
top: menuPosition?.top ?? 0,
width: menuPosition?.width ?? width,
visibility: menuPosition ? "visible" : "hidden",
}}
>
{children}
</div>,
document.body,
);
}

View File

@ -0,0 +1,267 @@
"use client";
import {
ArrowDown,
ArrowDownAZ,
ArrowUp,
ArrowUpDown,
ArrowUpZA,
Barcode,
CalendarArrowDown,
CalendarArrowUp,
Check,
Clock3,
LogIn,
LogOut,
PackagePlus,
RefreshCw,
SignalHigh,
SignalLow,
ShoppingCart,
type LucideIcon,
} from "lucide-react";
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
export type SortIconType =
| "alphabetical"
| "alphabetical-reverse"
| "identifier"
| "updated"
| "created"
| "date-asc"
| "date-desc"
| "access-start-asc"
| "access-start-desc"
| "access-end-asc"
| "access-end-desc"
| "purchase-date-asc"
| "purchase-date-desc"
| "priority-asc"
| "priority-desc"
| "synced"
| "generic";
export type CompactSortOption<T extends string = string> = {
value: T;
label: string;
icon: SortIconType;
};
const SORT_ICONS: Record<SortIconType, LucideIcon> = {
alphabetical: ArrowDownAZ,
"alphabetical-reverse": ArrowUpZA,
identifier: Barcode,
updated: Clock3,
created: PackagePlus,
"date-asc": CalendarArrowUp,
"date-desc": CalendarArrowDown,
"access-start-asc": LogIn,
"access-start-desc": LogIn,
"access-end-asc": LogOut,
"access-end-desc": LogOut,
"purchase-date-asc": ShoppingCart,
"purchase-date-desc": ShoppingCart,
"priority-asc": SignalLow,
"priority-desc": SignalHigh,
synced: RefreshCw,
generic: ArrowUpDown,
};
const SORT_DIRECTIONS: Partial<Record<SortIconType, LucideIcon>> = {
"access-start-asc": ArrowUp,
"access-start-desc": ArrowDown,
"access-end-asc": ArrowUp,
"access-end-desc": ArrowDown,
"purchase-date-asc": ArrowUp,
"purchase-date-desc": ArrowDown,
};
function SortGlyph({ type, className = "" }: { type: SortIconType; className?: string }) {
const Icon = SORT_ICONS[type];
const Direction = SORT_DIRECTIONS[type];
return (
<span className={`relative inline-flex shrink-0 ${className}`} aria-hidden="true">
<Icon className="h-full w-full" />
{Direction ? (
<Direction className="absolute -bottom-1 -right-1 h-2.5 w-2.5 rounded-full bg-[var(--surface-primary-solid)] p-px" />
) : null}
</span>
);
}
type CompactSortMenuProps<T extends string> = {
value: T;
options: readonly CompactSortOption<T>[];
onChange: (value: T) => void;
ariaLabel?: string;
};
type MenuPosition = {
left: number;
top: number;
width: number;
};
export function CompactSortMenu<T extends string>({
value,
options,
onChange,
ariaLabel = "Сортировка",
}: CompactSortMenuProps<T>) {
const [open, setOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuId = useId();
const activeOption = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node;
if (
!containerRef.current?.contains(target) &&
!menuRef.current?.contains(target)
) {
setOpen(false);
setMenuPosition(null);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
setMenuPosition(null);
triggerRef.current?.focus();
}
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useLayoutEffect(() => {
if (!open) return;
function updatePosition() {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const margin = 12;
const gap = 8;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const width = Math.min(224, window.innerWidth - margin * 2);
const left = Math.min(
Math.max(triggerRect.right - width, margin),
window.innerWidth - width - margin,
);
const fitsBelow =
triggerRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(margin, triggerRect.top - gap - menuRect.height);
setMenuPosition({ left, top, width });
}
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open]);
if (!activeOption) return null;
const menu = open
? createPortal(
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={ariaLabel}
className="app-card fixed z-[100] max-h-[calc(100vh-1.5rem)] overflow-y-auto rounded-xl p-1.5 shadow-xl"
style={{
left: menuPosition?.left ?? 0,
top: menuPosition?.top ?? 0,
width: menuPosition?.width ?? 224,
visibility: menuPosition ? "visible" : "hidden",
}}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={selected}
onClick={() => {
onChange(option.value);
setOpen(false);
setMenuPosition(null);
triggerRef.current?.focus();
}}
className={`${
selected
? "app-selected app-accent-text"
: "app-text-muted hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"
} flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition`}
>
<SortGlyph type={option.icon} className="h-4 w-4" />
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{selected ? (
<Check className="h-4 w-4 shrink-0" aria-hidden="true" />
) : null}
</button>
);
})}
</div>,
document.body,
)
: null;
return (
<div ref={containerRef} className="relative shrink-0">
<button
ref={triggerRef}
type="button"
onClick={() => {
setMenuPosition(null);
setOpen((current) => !current);
}}
aria-label={`${ariaLabel}: ${activeOption.label}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
title={activeOption.label}
className={`${
open
? "app-selected app-accent-text"
: "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
} flex h-[42px] w-[42px] items-center justify-center rounded-lg transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent-primary)]`}
>
<SortGlyph type={activeOption.icon} className="h-4 w-4" />
</button>
{menu}
</div>
);
}

View File

@ -0,0 +1,92 @@
"use client";
import {
type CSSProperties,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
type HorizontalChipScrollerProps = {
children: ReactNode;
className?: string;
contentClassName?: string;
ariaLabel?: string;
fadeColor?: string;
};
export function HorizontalChipScroller({
children,
className = "",
contentClassName = "",
ariaLabel,
fadeColor = "var(--surface-primary)",
}: HorizontalChipScrollerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollHints, setScrollHints] = useState({
left: false,
right: false,
});
const updateScrollHints = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const left = container.scrollLeft > 1;
const right =
container.scrollLeft + container.clientWidth < container.scrollWidth - 1;
setScrollHints((current) =>
current.left === left && current.right === right
? current
: { left, right },
);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const frame = window.requestAnimationFrame(updateScrollHints);
const resizeObserver = new ResizeObserver(updateScrollHints);
resizeObserver.observe(container);
return () => {
window.cancelAnimationFrame(frame);
resizeObserver.disconnect();
};
}, [children, updateScrollHints]);
const fadeStyle = {
"--chip-scroll-fade-color": fadeColor,
} as CSSProperties;
return (
<div
className={`relative min-w-0 ${className}`}
style={fadeStyle}
data-chip-scroller="true"
>
<div
ref={containerRef}
onScroll={updateScrollHints}
className={`flex min-w-0 gap-2 overflow-x-auto overscroll-x-contain pb-1 touch-pan-x [scrollbar-width:none] [&>*]:shrink-0 [&::-webkit-scrollbar]:hidden ${contentClassName}`}
aria-label={ariaLabel}
>
{children}
</div>
<span
aria-hidden="true"
data-scroll-fade="left"
className={`${scrollHints.left ? "opacity-100" : "opacity-0"} pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[var(--chip-scroll-fade-color)] to-transparent backdrop-blur-[2px] transition-opacity duration-200 [mask-image:linear-gradient(to_right,black,transparent)]`}
/>
<span
aria-hidden="true"
data-scroll-fade="right"
className={`${scrollHints.right ? "opacity-100" : "opacity-0"} pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[var(--chip-scroll-fade-color)] to-transparent backdrop-blur-[2px] transition-opacity duration-200 [mask-image:linear-gradient(to_left,black,transparent)]`}
/>
</div>
);
}

View File

@ -0,0 +1,92 @@
"use client";
import Link from "next/link";
import type {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ReactNode,
} from "react";
type ModulePrimaryActionBaseProps = {
icon: ReactNode;
label: string;
className?: string;
};
type ModulePrimaryActionButtonProps = ModulePrimaryActionBaseProps &
Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
href?: never;
};
type ModulePrimaryActionLinkProps = ModulePrimaryActionBaseProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "children" | "href"> & {
href: string;
};
export type ModulePrimaryActionProps =
| ModulePrimaryActionButtonProps
| ModulePrimaryActionLinkProps;
const baseClassName =
"app-action-primary inline-flex h-10 max-w-full min-w-10 shrink items-center justify-center gap-2 overflow-hidden rounded-lg px-3 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60";
function ActionContents({ icon, label }: Pick<ModulePrimaryActionBaseProps, "icon" | "label">) {
return (
<>
<span className="flex shrink-0 items-center justify-center" aria-hidden="true">
{icon}
</span>
<span className="min-w-0 overflow-hidden whitespace-nowrap">{label}</span>
</>
);
}
export function ModulePrimaryAction(props: ModulePrimaryActionProps) {
const {
icon,
label,
className = "",
"aria-label": ariaLabel,
title,
} = props;
const sharedProps = {
"aria-label": ariaLabel || label,
className: `${baseClassName} ${className}`,
title: title || label,
};
if (props.href) {
const linkVariant = props as ModulePrimaryActionLinkProps;
const {
href,
icon: _icon,
label: _label,
className: _className,
...linkProps
} = linkVariant;
void _icon;
void _label;
void _className;
return (
<Link href={href} {...linkProps} {...sharedProps}>
<ActionContents icon={icon} label={label} />
</Link>
);
}
const buttonVariant = props as ModulePrimaryActionButtonProps;
const {
icon: _icon,
label: _label,
className: _className,
...buttonProps
} = buttonVariant;
void _icon;
void _label;
void _className;
return (
<button type="button" {...buttonProps} {...sharedProps}>
<ActionContents icon={icon} label={label} />
</button>
);
}

View File

@ -25,6 +25,12 @@ export type NotificationItem = {
created_at?: string;
action_url?: string;
data?: Record<string, unknown> | null;
actor?: {
id: number | null;
name: string;
type?: string | null;
avatar?: string | null;
} | null;
[key: string]: unknown;
};

View File

@ -62,12 +62,12 @@ export const requestTypeLabels: Record<string, string> = {
};
export const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "title", label: "По названию" },
{ value: "date_from", label: "По периоду ↑" },
{ value: "-date_from", label: "По периоду ↓" },
];
{ value: "-created_at", label: "Сначала новые", icon: "date-desc" },
{ value: "created_at", label: "Сначала старые", icon: "date-asc" },
{ value: "title", label: "По названию", icon: "alphabetical" },
{ value: "date_from", label: "По периоду ↑", icon: "date-asc" },
{ value: "-date_from", label: "По периоду ↓", icon: "date-desc" },
] as const;
export type RequestQuickScope = "" | "mine" | "addressed" | "decision";