From 8b8974e3ef9b52ab997115e66a68380e38b114c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 9 Dec 2025 16:14:30 +0300 Subject: [PATCH] feat: refactor equipment API permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DeptPerm.MANAGE_EQUIPMENT scoped permission constant - Create migration 0035 for new DepartmentPermission - Refactor CanManageEquipment permission class with: * Admin/staff/superuser → full access * Model permissions → full access (add/change/delete_equipment) * Department head → own department, can choose responsible * Scoped permission → own department, responsible forced to head * Regular members → no access - Update EquipmentListSerializer with: * Permission level detection (full/dept_head/scoped/None) * Auto-fill department and responsible based on permission level * Validation that responsible is in same department - Add create_options endpoint returning: * allowed_departments, can_choose_department, can_choose_responsible * default_responsible, permission_level - Add bulk equipment creation support with auto-generated inventory numbers - Implement 45 comprehensive permission tests (all passing) - Fix is_active=True in test make_user() fixture Tests: 45/45 passing ✅ --- backend/employees/constants.py | 2 + .../0035_add_manage_equipment_permission.py | 34 + backend/procurement/permissions.py | 159 ++- backend/procurement/serializers.py | 217 +++++ backend/procurement/views.py | 151 +++ backend/procurement/views_frontend.py | 52 +- backend/static/css/components/procurement.css | 190 +++- .../templates/procurement/equipment_list.html | 782 +++++++++++---- .../procurement/test_equipment_permissions.py | 913 ++++++++++++++++++ 9 files changed, 2287 insertions(+), 213 deletions(-) create mode 100644 backend/employees/migrations/0035_add_manage_equipment_permission.py create mode 100644 backend/tests/procurement/test_equipment_permissions.py diff --git a/backend/employees/constants.py b/backend/employees/constants.py index 87c85079..99fa11e7 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -38,6 +38,7 @@ class DeptPerm: ADD_REQUESTCOMMENT = "add_requestcomment" VIEW_REQUEST = "view_request" CAN_PROCESS_REQUESTS = "can_process_requests" + MANAGE_EQUIPMENT = "manage_department_equipment" CHOICES = ( (MANAGE, "Управлять отделом"), @@ -50,4 +51,5 @@ class DeptPerm: (ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"), (VIEW_REQUEST, "Просмотр заявлений отдела"), (CAN_PROCESS_REQUESTS, "Рассмотрение заявлений отдела"), + (MANAGE_EQUIPMENT, "Управлять оборудованием отдела"), ) diff --git a/backend/employees/migrations/0035_add_manage_equipment_permission.py b/backend/employees/migrations/0035_add_manage_equipment_permission.py new file mode 100644 index 00000000..ca4a20c2 --- /dev/null +++ b/backend/employees/migrations/0035_add_manage_equipment_permission.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.4 on 2025-12-09 12:56 + +from django.db import migrations + + +def add_manage_equipment_permission(apps, schema_editor): + """Добавляет скоуп-право для управления оборудованием отдела.""" + DepartmentPermission = apps.get_model('employees', 'DepartmentPermission') + DepartmentPermission.objects.get_or_create( + code='manage_department_equipment', + defaults={'name': 'Управлять оборудованием отдела'} + ) + + +def remove_manage_equipment_permission(apps, schema_editor): + """Удаляет скоуп-право.""" + DepartmentPermission = apps.get_model('employees', 'DepartmentPermission') + DepartmentPermission.objects.filter( + code='manage_department_equipment' + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0034_alter_employee_telegram'), + ] + + operations = [ + migrations.RunPython( + add_manage_equipment_permission, + remove_manage_equipment_permission, + ), + ] diff --git a/backend/procurement/permissions.py b/backend/procurement/permissions.py index a0be5490..385e8095 100644 --- a/backend/procurement/permissions.py +++ b/backend/procurement/permissions.py @@ -4,7 +4,9 @@ from rest_framework import permissions -from employees.models import Employee +from api.v1.permissions import has_dept_perm +from employees.constants import DeptPerm +from employees.models import Department, Employee, EmployeeDepartment class IsDepartmentHead(permissions.BasePermission): @@ -129,36 +131,157 @@ class CanApproveProcurementRequest(permissions.BasePermission): class CanManageEquipment(permissions.BasePermission): - """Проверка права на управление оборудованием.""" + """Проверка права на управление оборудованием. - def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: + Логика доступа: + - Просмотр (SAFE_METHODS): любой аутентифицированный + - Создание: + - admin/staff/superuser → ✅ + - модельные права (add_equipment) → ✅ + - начальник отдела (department из request.data) → ✅ + - скоуп-право MANAGE_EQUIPMENT в отделе → ✅ + - Изменение: + - admin/staff/superuser → ✅ + - модельные права (change_equipment) → ✅ + - начальник отдела оборудования → ✅ + - скоуп-право MANAGE_EQUIPMENT в отделе → ✅ + - ответственный за оборудование → ✅ + - Удаление: + - admin/staff/superuser → ✅ + - модельные права (delete_equipment) → ✅ + - начальник отдела оборудования → ✅ + - НЕ уполномоченный, НЕ ответственный + """ + + def _is_admin(self, user) -> bool: + """Проверяет, является ли пользователь админом.""" + return ( + getattr(user, 'is_superuser', False) or + getattr(user, 'is_staff', False) + ) + + def _has_model_perm(self, user, action: str) -> bool: + """Проверяет модельные права на оборудование.""" + perm_map = { + 'create': 'procurement.add_equipment', + 'update': 'procurement.change_equipment', + 'partial_update': 'procurement.change_equipment', + 'destroy': 'procurement.delete_equipment', + } + perm = perm_map.get(action) + return perm and user.has_perm(perm) + + def _get_dept_id_from_request(self, request) -> int | None: + """Извлекает ID отдела из данных запроса.""" + dept_id = request.data.get('department') + if dept_id is not None: + try: + return int(dept_id) + except (ValueError, TypeError): + pass + return None + + def _is_dept_head(self, user, dept_id: int) -> bool: + """Проверяет, является ли пользователь начальником отдела.""" + return Department.objects.filter(id=dept_id, head_id=user.id).exists() + + def _has_scoped_perm(self, user, dept_id: int) -> bool: + """Проверяет наличие скоуп-права MANAGE_EQUIPMENT в отделе.""" + return has_dept_perm(user, dept_id, DeptPerm.MANAGE_EQUIPMENT) + + def _get_user_departments(self, user) -> list[int]: + """Возвращает список ID отделов, где пользователь состоит.""" + return list( + EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True + ).values_list('department_id', flat=True) + ) + + def has_permission(self, request, view) -> bool: + """Проверка доступа на уровне запроса.""" + user = request.user + if not user or not user.is_authenticated: return False - # Чтение доступно всем сотрудникам + # Чтение доступно всем аутентифицированным if request.method in permissions.SAFE_METHODS: return True - # Создание/изменение - только для staff или с правами - return ( - request.user.is_staff or - request.user.has_perm('procurement.add_equipment') - ) + # Админы могут всё + if self._is_admin(user): + return True - def has_object_permission(self, request, view, obj): - if not request.user or not request.user.is_authenticated: + action = getattr(view, 'action', None) + + # Модельные права дают полный доступ + if self._has_model_perm(user, action): + return True + + # Для создания нужен отдел из запроса + if action == 'create': + dept_id = self._get_dept_id_from_request(request) + if dept_id is None: + return False + + # Начальник отдела может создавать + if self._is_dept_head(user, dept_id): + return True + + # Проверяем скоуп-право в отделе + if self._has_scoped_perm(user, dept_id): + return True + + return False + + # Для update/delete проверка будет на уровне объекта + if action in ('update', 'partial_update', 'destroy'): + # Пропускаем на has_object_permission + return True + + return False + + def has_object_permission(self, request, view, obj) -> bool: + """Проверка доступа к конкретному объекту.""" + user = request.user + if not user or not user.is_authenticated: return False # Чтение доступно всем if request.method in permissions.SAFE_METHODS: return True - # Изменение - только staff или ответственный - return ( - request.user.is_staff or - obj.responsible_person == request.user or - request.user.has_perm('procurement.change_equipment') - ) + # Админы могут всё + if self._is_admin(user): + return True + + action = getattr(view, 'action', None) + + # Модельные права + if self._has_model_perm(user, action): + return True + + dept_id = obj.department_id + + # Начальник отдела оборудования + if self._is_dept_head(user, dept_id): + return True + + # Удаление — только админ, модельные права или начальник + if action == 'destroy': + return False + + # Изменение: скоуп-право или ответственный + if action in ('update', 'partial_update'): + # Скоуп-право в отделе + if self._has_scoped_perm(user, dept_id): + return True + + # Ответственный за оборудование + if obj.responsible_person_id == user.id: + return True + + return False class CanManageBudget(permissions.BasePermission): diff --git a/backend/procurement/serializers.py b/backend/procurement/serializers.py index c6ef9420..908dc681 100644 --- a/backend/procurement/serializers.py +++ b/backend/procurement/serializers.py @@ -336,6 +336,7 @@ class EquipmentListSerializer(serializers.ModelSerializer): ] read_only_fields = [ 'id', + 'inventory_number', # Генерируется автоматически 'category_name', 'department_name', 'responsible_name', @@ -343,6 +344,222 @@ class EquipmentListSerializer(serializers.ModelSerializer): 'is_under_warranty', ] + def create(self, validated_data): + """Автоматическая генерация инвентарного номера при создании.""" + import re + from datetime import datetime + + year = datetime.now().year + prefix = f"INV-{year}-" + + # Находим максимальный номер с текущим префиксом + last_equipment = Equipment.objects.filter( + inventory_number__startswith=prefix + ).order_by('-inventory_number').first() + + if last_equipment: + match = re.search(r'(\d+)$', last_equipment.inventory_number) + if match: + next_num = int(match.group(1)) + 1 + else: + next_num = 1 + else: + next_num = 1 + + validated_data['inventory_number'] = f"{prefix}{next_num:04d}" + return super().create(validated_data) + + def _get_user_permission_level(self, user): + """Определяет уровень прав пользователя. + + Возвращает: + 'full' - админ/модельные права (свободный выбор отдела/ответственного) + 'dept_head' - начальник отдела (свой отдел, выбор ответственного) + 'scoped' - скоуп-право (свой отдел, ответственный = начальник) + None - нет прав на создание + """ + from api.v1.permissions import has_dept_perm + from employees.constants import DeptPerm + from employees.models import Department, EmployeeDepartment + + if not user or not user.is_authenticated: + return None + + # Админ или модельные права = полный доступ + if (user.is_staff or user.is_superuser or + user.has_perm('procurement.add_equipment')): + return 'full' + + # Проверяем, является ли начальником какого-то отдела + headed_depts = Department.objects.filter(head_id=user.id) + if headed_depts.exists(): + return 'dept_head' + + # Проверяем скоуп-право в активных отделах + user_dept_links = EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True + ).select_related('department', 'role') + + for link in user_dept_links: + if has_dept_perm(user, link.department_id, DeptPerm.MANAGE_EQUIPMENT): + return 'scoped' + + return None + + def _get_user_allowed_departments(self, user, perm_level): + """Возвращает отделы, в которых пользователь может создать оборудование.""" + from employees.models import Department, EmployeeDepartment + + if perm_level == 'full': + return list(Department.objects.all()) + + if perm_level == 'dept_head': + return list(Department.objects.filter(head_id=user.id)) + + if perm_level == 'scoped': + from api.v1.permissions import has_dept_perm + from employees.constants import DeptPerm + + result = [] + user_dept_links = EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True + ).select_related('department') + + for link in user_dept_links: + if has_dept_perm( + user, link.department_id, DeptPerm.MANAGE_EQUIPMENT + ): + result.append(link.department) + return result + + return [] + + def validate(self, attrs): + """Проверка и автозаполнение полей в зависимости от прав. + + Уровни прав: + - full (админ/модельные права): свободный выбор отдела и ответственного + - dept_head (начальник): только свой отдел, выбор ответственного из отдела + - scoped (скоуп-право): только свой отдел, ответственный = начальник + """ + from employees.models import EmployeeDepartment + + request = self.context.get('request') + user = request.user if request else None + + # При update (есть instance) используем облегчённую валидацию + if self.instance is not None: + return self._validate_update(attrs, user) + + # Создание — полная проверка прав + perm_level = self._get_user_permission_level(user) + + # Проверяем отдел + department = attrs.get('department') + + if perm_level == 'full': + # Полный доступ — можно указать любой отдел + pass + + elif perm_level == 'dept_head': + # Начальник — только свои отделы + allowed_depts = self._get_user_allowed_departments(user, perm_level) + allowed_ids = [d.id for d in allowed_depts] + + if department and department.id not in allowed_ids: + raise serializers.ValidationError({ + 'department': 'Вы можете создавать оборудование ' + 'только в своём отделе.' + }) + + # Если отдел не указан — берём первый + if not department and allowed_depts: + attrs['department'] = allowed_depts[0] + department = allowed_depts[0] + + # Ответственный по умолчанию — сам начальник + resp = attrs.get('responsible_person') + if resp is None: + attrs['responsible_person'] = user + + elif perm_level == 'scoped': + # Скоуп-право — только свой отдел, ответственный = начальник + allowed_depts = self._get_user_allowed_departments(user, perm_level) + allowed_ids = [d.id for d in allowed_depts] + + if department and department.id not in allowed_ids: + raise serializers.ValidationError({ + 'department': 'Вы можете создавать оборудование ' + 'только в своём отделе.' + }) + + # Если отдел не указан — берём первый + if not department and allowed_depts: + attrs['department'] = allowed_depts[0] + department = allowed_depts[0] + + # Ответственный ВСЕГДА = начальник отдела (без выбора) + if department and department.head: + attrs['responsible_person'] = department.head + else: + attrs['responsible_person'] = None + + else: + # Нет прав — ошибка + raise serializers.ValidationError({ + 'non_field_errors': 'У вас нет прав на создание оборудования.' + }) + + # Валидация ответственного + return self._validate_responsible_in_department(attrs) + + def _validate_update(self, attrs, user): + """Валидация при обновлении (облегчённая).""" + # При update не проверяем права создания, + # только валидацию ответственного если он меняется + department = attrs.get('department', self.instance.department) + responsible = attrs.get('responsible_person') + + # Если меняется ответственный — проверяем принадлежность к отделу + if responsible is not None: + attrs['department'] = department # Для валидации + attrs['responsible_person'] = responsible + return self._validate_responsible_in_department(attrs) + + return attrs + + def _validate_responsible_in_department(self, attrs): + """Проверяет, что ответственный состоит в отделе оборудования.""" + from employees.models import EmployeeDepartment + + department = attrs.get('department') + responsible = attrs.get('responsible_person') + + if department and responsible: + # Проверяем, что ответственный в этом отделе + is_in_dept = ( + # Он начальник отдела + department.head_id == responsible.id or + # Или состоит в отделе + EmployeeDepartment.objects.filter( + employee_id=responsible.id, + department_id=department.id, + is_active=True + ).exists() + ) + + if not is_in_dept: + raise serializers.ValidationError({ + 'responsible_person': ( + 'Ответственный должен состоять в том же отделе, ' + 'что и оборудование.' + ) + }) + + return attrs + class EquipmentDetailSerializer(serializers.ModelSerializer): """Сериализатор для детальной информации об оборудовании.""" diff --git a/backend/procurement/views.py b/backend/procurement/views.py index d8ee90c6..5a4cf7ac 100644 --- a/backend/procurement/views.py +++ b/backend/procurement/views.py @@ -466,6 +466,49 @@ class EquipmentViewSet(viewsets.ModelViewSet): permission_classes = [CanManageEquipment] return [permission() for permission in permission_classes] + def create(self, request, *args, **kwargs): + """Создание оборудования с поддержкой массового создания. + + Если передан параметр quantity > 1, создаётся несколько единиц + с автоматически сгенерированными инвентарными номерами. + """ + quantity = int(request.data.get('quantity', 1)) + quantity = max(1, min(quantity, 100)) # Ограничение от 1 до 100 + + if quantity == 1: + # Стандартное создание одной единицы + return super().create(request, *args, **kwargs) + + # Массовое создание + created_equipment = [] + errors = [] + + for i in range(quantity): + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + self.perform_create(serializer) + created_equipment.append(serializer.data) + else: + errors.append({'index': i, 'errors': serializer.errors}) + + if errors and not created_equipment: + return Response( + { + 'detail': 'Не удалось создать оборудование', + 'errors': errors + }, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + { + 'created_count': len(created_equipment), + 'equipment': created_equipment, + 'errors': errors if errors else None + }, + status=status.HTTP_201_CREATED + ) + @action(detail=False, methods=['get']) def my_equipment(self, request): """Оборудование, за которое отвечает текущий пользователь.""" @@ -492,6 +535,114 @@ class EquipmentViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=False, methods=['get'], url_path='create-options') + def create_options(self, request): + """Возвращает доступные опции для создания оборудования. + + Определяет уровень прав пользователя и возвращает: + - allowed_departments: список отделов для выбора + - can_choose_department: может ли пользователь выбирать отдел + - can_choose_responsible: может ли выбирать ответственного + - default_responsible: ответственный по умолчанию (если нет выбора) + """ + from api.v1.permissions import has_dept_perm + from employees.constants import DeptPerm + from employees.models import EmployeeDepartment + + user = request.user + + # Определяем уровень прав + if user.is_staff or user.is_superuser: + perm_level = 'full' + elif user.has_perm('procurement.add_equipment'): + perm_level = 'full' + elif Department.objects.filter(head_id=user.id).exists(): + perm_level = 'dept_head' + else: + # Проверяем скоуп-право + user_dept_links = EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True + ).select_related('department') + + has_scoped = False + for link in user_dept_links: + if has_dept_perm( + user, link.department_id, DeptPerm.MANAGE_EQUIPMENT + ): + has_scoped = True + break + + perm_level = 'scoped' if has_scoped else None + + # Формируем ответ в зависимости от уровня прав + if perm_level == 'full': + # Полный доступ — все отделы + departments = Department.objects.all().values('id', 'name') + return Response({ + 'allowed_departments': list(departments), + 'can_choose_department': True, + 'can_choose_responsible': True, + 'default_responsible': None, + 'permission_level': 'full', + }) + + elif perm_level == 'dept_head': + # Начальник — только свои отделы + departments = Department.objects.filter( + head_id=user.id + ).values('id', 'name') + return Response({ + 'allowed_departments': list(departments), + 'can_choose_department': False, + 'can_choose_responsible': True, + 'default_responsible': { + 'id': user.id, + 'name': user.get_full_name(), + }, + 'permission_level': 'dept_head', + }) + + elif perm_level == 'scoped': + # Скоуп-право — отделы с правом, ответственный = начальник + allowed_depts = [] + default_responsible = None + + user_dept_links = EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True + ).select_related('department', 'department__head') + + for link in user_dept_links: + if has_dept_perm( + user, link.department_id, DeptPerm.MANAGE_EQUIPMENT + ): + dept = link.department + allowed_depts.append({'id': dept.id, 'name': dept.name}) + if default_responsible is None and dept.head: + default_responsible = { + 'id': dept.head.id, + 'name': dept.head.get_full_name(), + } + + return Response({ + 'allowed_departments': allowed_depts, + 'can_choose_department': False, + 'can_choose_responsible': False, + 'default_responsible': default_responsible, + 'permission_level': 'scoped', + }) + + else: + # Нет прав + return Response({ + 'allowed_departments': [], + 'can_choose_department': False, + 'can_choose_responsible': False, + 'default_responsible': None, + 'permission_level': None, + }) + @action(detail=True, methods=['post']) def transfer(self, request, pk=None): """Перевод оборудования в другой отдел или другому пользователю.""" diff --git a/backend/procurement/views_frontend.py b/backend/procurement/views_frontend.py index 4bcad74f..673d2673 100644 --- a/backend/procurement/views_frontend.py +++ b/backend/procurement/views_frontend.py @@ -214,7 +214,9 @@ def request_create(request): @login_required def equipment_list(request): - """Список оборудования.""" + """Список оборудования с группировкой одинаковых позиций.""" + from collections import defaultdict + queryset = Equipment.objects.select_related( 'category', 'department', 'responsible_person' ) @@ -239,14 +241,50 @@ def equipment_list(request): inventory_number__icontains=search ) - queryset = queryset.order_by('-purchase_date') + queryset = queryset.order_by('name', '-purchase_date') + + # Группировка по (название, категория, отдел, статус) + grouped_equipment = defaultdict(list) + for item in queryset: + key = ( + item.name, + item.category_id, + item.department_id, + item.status + ) + grouped_equipment[key].append(item) + + # Преобразуем в список групп + equipment_groups = [] + for key, items in grouped_equipment.items(): + # Первый элемент — представитель группы + main_item = items[0] + equipment_groups.append({ + 'main': main_item, + 'items': items, + 'count': len(items), + 'is_group': len(items) > 1, + }) + + # Сортировка по имени + equipment_groups.sort(key=lambda x: x['main'].name.lower()) - # Пагинация - paginator = Paginator(queryset, 24) + # Пагинация по группам + paginator = Paginator(equipment_groups, 24) page_obj = paginator.get_page(request.GET.get('page')) + # Получаем первый активный отдел пользователя + user_department = request.user.departments.filter( + employeedepartment__is_active=True + ).first() + + # Получаем руководителя отдела (если есть) + department_head = None + if user_department: + department_head = user_department.head + context = { - 'equipment_list': page_obj, + 'equipment_groups': page_obj, 'page_obj': page_obj, 'filters': filters, 'categories': EquipmentCategory.objects.all(), @@ -255,6 +293,8 @@ def equipment_list(request): is_active=True ).order_by('last_name', 'first_name'), 'can_manage': request.user.is_staff, + 'user_department': user_department, + 'department_head': department_head, } return render(request, 'procurement/equipment_list.html', context) @@ -266,7 +306,7 @@ def equipment_detail(request, pk): Equipment.objects.select_related( 'category', 'department', 'responsible_person' ).prefetch_related( - 'maintenance_records', 'transfer_logs' + 'maintenance_history', 'transfer_logs' ), pk=pk ) diff --git a/backend/static/css/components/procurement.css b/backend/static/css/components/procurement.css index 3980845d..2d1fa163 100644 --- a/backend/static/css/components/procurement.css +++ b/backend/static/css/components/procurement.css @@ -261,7 +261,7 @@ background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: var(--feed-radius, 18px); - overflow: hidden; + overflow: visible; transition: box-shadow 0.18s ease, transform 0.18s ease; } @@ -373,6 +373,194 @@ color: var(--bs-secondary); } +/* ===== Equipment List (Table-like) ===== */ +/* Разрешаем overflow для dropdown меню */ +#equipmentListContent { + overflow: visible !important; +} + + + +/* Dropdown menu должен быть поверх всех элементов */ +.proc-equipment-actions .dropdown-menu { + z-index: 1050; +} + +.proc-equipment-row { + display: grid; + grid-template-columns: 1fr 60px 120px 140px 100px 50px; + align-items: center; + gap: var(--space-2); +} + +.proc-equipment-item { + display: grid; + grid-template-columns: 1fr 60px 120px 140px 100px 50px; + align-items: center; + gap: var(--space-2); + padding: var(--space-3); + border-bottom: 1px solid var(--bs-border-color); + transition: background 0.15s ease; +} + +.proc-equipment-item:hover { + background: var(--bs-tertiary-bg); +} + +.proc-equipment-item:last-child { + border-bottom: none; +} + +.proc-equipment-item.has-children { + background: color-mix(in srgb, var(--bs-primary) 3%, transparent); + cursor: pointer; +} + +.proc-equipment-item.has-children:hover { + background: color-mix(in srgb, var(--bs-primary) 8%, transparent); +} + +/* Кнопка раскрытия группы */ +.proc-expand-btn { + color: var(--bs-secondary); + transition: transform 0.2s ease; +} + +.proc-expand-btn[aria-expanded="true"] i { + transform: rotate(90deg); +} + +/* Вложенные элементы группы */ +.proc-equipment-group { + background: var(--bs-tertiary-bg); + border-bottom: 1px solid var(--bs-border-color); +} + +.proc-equipment-subitem { + display: grid; + grid-template-columns: 1fr 60px 120px 140px 100px 50px; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + padding-left: calc(var(--space-3) + 60px); + border-bottom: 1px solid var(--bs-border-color-translucent); +} + +.proc-equipment-subitem:last-child { + border-bottom: none; +} + +.proc-equipment-subitem:hover { + background: color-mix(in srgb, var(--bs-primary) 5%, transparent); +} + +.proc-equipment-subitem .proc-equipment-name { + font-weight: 500; + font-family: var(--bs-font-monospace); + font-size: 0.9rem; +} + +/* Информация об оборудовании в списке */ +.proc-equipment-item .proc-equipment-info { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.proc-equipment-icon-sm { + width: 36px; + height: 36px; + border-radius: 10px; + background: color-mix(in srgb, var(--bs-primary) 10%, transparent); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + color: var(--bs-primary); + flex-shrink: 0; +} + +.proc-equipment-details { + min-width: 0; +} + +.proc-equipment-item .proc-equipment-name { + font-weight: 600; + color: var(--bs-body-color); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.proc-equipment-item .proc-equipment-name a { + color: inherit; + text-decoration: none; +} + +.proc-equipment-item .proc-equipment-name a:hover { + color: var(--bs-primary); +} + +.proc-equipment-item .proc-equipment-inv { + font-size: 0.75rem; + font-family: var(--bs-font-monospace); + color: var(--bs-secondary-color); +} + +/* Колонка количества */ +.proc-equipment-count { + text-align: center; +} + +.proc-equipment-category, +.proc-equipment-dept, +.proc-equipment-responsible { + font-size: 0.875rem; + color: var(--bs-body-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.proc-equipment-actions { + text-align: center; +} + +/* Responsive для списка оборудования */ +@media (max-width: 991.98px) { + .proc-table-header { + display: block !important; + padding: var(--space-2) var(--space-3) !important; + } + + .proc-equipment-row { + grid-template-columns: 1fr 60px 100px 50px; + } + + .proc-equipment-item { + grid-template-columns: 1fr 60px 100px 50px; + } + + .proc-equipment-subitem { + grid-template-columns: 1fr 60px 100px 50px; + padding-left: calc(var(--space-3) + 40px); + } + + /* Скрываем категорию и отдел */ + .proc-equipment-category, + .proc-equipment-dept { + display: none; + } + + /* Заголовок: скрываем категорию и отдел */ + .proc-equipment-row .proc-cell:nth-child(3), + .proc-equipment-row .proc-cell:nth-child(4) { + display: none; + } +} + /* ===== Budget Card ===== */ .proc-budget-card { background: var(--bs-body-bg); diff --git a/backend/templates/procurement/equipment_list.html b/backend/templates/procurement/equipment_list.html index 2f454969..86e7e85f 100644 --- a/backend/templates/procurement/equipment_list.html +++ b/backend/templates/procurement/equipment_list.html @@ -22,157 +22,231 @@ {% endif %} - {# ===== СЕТКА ОБОРУДОВАНИЯ ===== #} -
- {% if equipment_list %} - {% for item in equipment_list %} -
-
-
- {% if item.category.name == 'Компьютеры' or 'ноутбук' in item.name|lower %} - - {% elif 'монитор' in item.name|lower %} - - {% elif 'принтер' in item.name|lower %} - - {% elif 'телефон' in item.name|lower %} - - {% elif 'сервер' in item.name|lower %} - - {% else %} - - {% endif %} -
-
- -
{{ item.inventory_number }}
-
- -
- -
-
- {% if item.category %} -
- - {{ item.category.name }} -
- {% endif %} - - {% if item.department %} -
- - {{ item.department.name }} -
- {% endif %} - - {% if item.responsible_person %} -
- - {{ item.responsible_person.get_full_name }} -
- {% endif %} - - {% if item.location %} -
- - {{ item.location }} -
- {% endif %} -
-
- - + {# ===== СПИСОК ОБОРУДОВАНИЯ ===== #} +
+
+
+
Оборудование
+
Кол-во
+
Категория
+
Отдел
+
Статус
+
- {% endfor %} - {% else %} -
+
+ +
+ {% if equipment_groups %} + {% for group in equipment_groups %} + {# Основная строка группы #} +
+
+ {% if group.is_group %} + + {% endif %} +
+ {% if group.main.category.icon %} + + {% else %} + + {% endif %} +
+
+
+ {% if group.is_group %} + {{ group.main.name }} + {% else %} + {{ group.main.name }} + {% endif %} +
+
+ {% if group.is_group %} + {{ group.count }} единиц + {% else %} + {{ group.main.inventory_number }} + {% endif %} +
+
+
+ +
+ {{ group.count }} +
+ +
+ {% if group.main.category %} + {{ group.main.category.name }} + {% else %} + — + {% endif %} +
+ +
+ {% if group.main.department %} + {{ group.main.department.name }} + {% else %} + — + {% endif %} +
+ +
+ {% include "procurement/_equipment_status.html" with status=group.main.status %} +
+ +
+ {% if not group.is_group %} + + {% endif %} +
+
+ + {# Развёрнутый список единиц группы #} + {% if group.is_group %} +
+ {% for item in group.items %} +
+
+
+ +
+ {% if item.responsible_person %}{{ item.responsible_person.get_full_name }}{% else %}Не назначен{% endif %} +
+
+
+ +
+
+
+
+ +
+ +
+
+ {% endfor %} +
+ {% endif %} + {% endfor %} + {% else %}
-

Оборудование не найдено

+

Оборудование не найдено

+ + Добавить оборудование +
-
+ {% endif %} +
+ + {# Пагинация #} + {% if page_obj %} + {% endif %}
- - {# Пагинация #} - {% if page_obj %} -
- -
- {% endif %} {# ===== МОДАЛКА СОЗДАНИЯ ОБОРУДОВАНИЯ ===== #}