Update application features and supporting components
This commit is contained in:
parent
bd515fd6d5
commit
205fd7252d
@ -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
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -24,6 +24,7 @@ class EmployeeBriefSerializer(serializers.ModelSerializer):
|
||||
"display_name",
|
||||
"full_name",
|
||||
"avatar",
|
||||
"is_active",
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": (
|
||||
|
||||
@ -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:
|
||||
"""Создание заявки.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"] = []
|
||||
|
||||
@ -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", []))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 не может добавлять участников."""
|
||||
|
||||
@ -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
|
||||
|
||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@ -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
6610
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
frontend/pnpm-workspace.yaml
Normal file
4
frontend/pnpm-workspace.yaml
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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`}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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="Заявления и статистика">
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}
|
||||
|
||||
90
frontend/src/components/ui/AutoRevealStickyHeader.tsx
Normal file
90
frontend/src/components/ui/AutoRevealStickyHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
frontend/src/components/ui/CompactFilterMenu.tsx
Normal file
246
frontend/src/components/ui/CompactFilterMenu.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
267
frontend/src/components/ui/CompactSortMenu.tsx
Normal file
267
frontend/src/components/ui/CompactSortMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/ui/HorizontalChipScroller.tsx
Normal file
92
frontend/src/components/ui/HorizontalChipScroller.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/ui/ModulePrimaryAction.tsx
Normal file
92
frontend/src/components/ui/ModulePrimaryAction.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user