refactor(ui): stabilize equipment deep links and qr flow
This commit is contained in:
parent
1552bbe84b
commit
e3ba6ea925
@ -27,7 +27,7 @@ from procurement.models import (
|
||||
)
|
||||
from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from procurement.services import ProcurementApprovalResolver, QRCodeGenerator
|
||||
from procurement.services import ProcurementApprovalResolver
|
||||
from .permissions import (
|
||||
CanApproveProcurementRequest,
|
||||
CanManageEquipment,
|
||||
@ -198,16 +198,43 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
resolved_approvals, missing_priorities = (
|
||||
ProcurementApprovalResolver.resolve_required_approvers(
|
||||
resolved_approvals, missing_routes = (
|
||||
ProcurementApprovalResolver.resolve_required_approvers_detailed(
|
||||
procurement_request
|
||||
)
|
||||
)
|
||||
if missing_priorities:
|
||||
if missing_routes:
|
||||
missing_priorities = [
|
||||
route["priority"] for route in missing_routes
|
||||
]
|
||||
missing_department_head = next(
|
||||
(
|
||||
route
|
||||
for route in missing_routes
|
||||
if route["reason"] == "department_head_missing"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if missing_department_head:
|
||||
return Response(
|
||||
{
|
||||
"error": (
|
||||
"У выбранного отдела не назначен руководитель. "
|
||||
"Заявку нельзя отправить на согласование, "
|
||||
"пока не будет назначен начальник отдела."
|
||||
),
|
||||
"code": "department_head_missing",
|
||||
"missing_priorities": missing_priorities,
|
||||
"missing_routes": missing_routes,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"error": "Не настроены согласующие для обязательных этапов",
|
||||
"code": "approval_routes_incomplete",
|
||||
"missing_priorities": missing_priorities,
|
||||
"missing_routes": missing_routes,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@ -1186,21 +1213,6 @@ class EquipmentViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def qr_code(self, request, pk=None):
|
||||
"""Получить QR-код для оборудования."""
|
||||
equipment = self.get_object()
|
||||
|
||||
qr_file = QRCodeGenerator.generate_for_equipment(equipment)
|
||||
|
||||
from django.http import HttpResponse
|
||||
|
||||
response = HttpResponse(qr_file.read(), content_type="image/png")
|
||||
response["Content-Disposition"] = (
|
||||
f'inline; filename="equipment_{equipment.id}_qr.png"'
|
||||
)
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def transfer_history(self, request, pk=None):
|
||||
"""История переводов оборудования."""
|
||||
|
||||
@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
from typing import Any # было: Any, Dict, List, Type
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, OuterRef, Q, QuerySet, Subquery
|
||||
from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404 # раскомментируйте импорт
|
||||
from employees.models import (
|
||||
Department,
|
||||
@ -299,13 +300,19 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
chat__type="comments",
|
||||
chat__context_content_type=request_ct,
|
||||
chat__context_object_id=OuterRef("pk"),
|
||||
is_deleted=False,
|
||||
)
|
||||
.values("chat")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
qs = qs.annotate(comments_count=Subquery(comments_subquery))
|
||||
qs = qs.annotate(
|
||||
comments_count=Coalesce(
|
||||
Subquery(comments_subquery, output_field=IntegerField()),
|
||||
Value(0),
|
||||
)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
|
||||
### 3. Инвентаризация оборудования
|
||||
- Учет оборудования с инвентарными номерами
|
||||
- QR-коды для маркировки (автогенерация)
|
||||
- Иерархические категории оборудования
|
||||
- Статусы: на складе, в использовании, на обслуживании, списано
|
||||
- Привязка к отделам и ответственным лицам
|
||||
@ -286,7 +285,6 @@ COMPLETED (Завершено)
|
||||
- Прогнозирование расходов
|
||||
|
||||
### Stage 5: Инвентаризация
|
||||
- QR-коды для оборудования
|
||||
- Мобильное сканирование
|
||||
- Планирование ТО
|
||||
|
||||
@ -315,7 +313,7 @@ COMPLETED (Завершено)
|
||||
- Telegram: Bot API
|
||||
- **Scheduler**: APScheduler 3.11+ (для periodic tasks)
|
||||
- **Charts**: Chart.js (планируется)
|
||||
- **Barcodes**: python-barcode, qrcode (планируется)
|
||||
- **Barcodes**: python-barcode (планируется)
|
||||
- **Reports**: reportlab (планируется)
|
||||
|
||||
## Разработка
|
||||
|
||||
@ -2,13 +2,8 @@
|
||||
Сервисы для модуля закупок и инвентаря.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import qrcode
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
@ -90,68 +85,6 @@ class InventoryNumberGenerator:
|
||||
return True
|
||||
|
||||
|
||||
class QRCodeGenerator:
|
||||
"""Генератор QR-кодов для оборудования."""
|
||||
|
||||
QR_CODE_DIR = "qr_codes"
|
||||
|
||||
@classmethod
|
||||
def generate_for_equipment(cls, equipment) -> ContentFile:
|
||||
"""
|
||||
Генерирует QR-код для оборудования.
|
||||
|
||||
QR содержит URL на страницу оборудования и основные данные.
|
||||
|
||||
Returns:
|
||||
ContentFile с PNG изображением QR-кода
|
||||
"""
|
||||
# Данные для QR-кода
|
||||
data = cls._build_qr_data(equipment)
|
||||
|
||||
# Генерируем QR-код
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Создаём изображение
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Сохраняем в буфер
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
filename = f"{equipment.inventory_number}.png"
|
||||
return ContentFile(buffer.read(), name=filename)
|
||||
|
||||
@classmethod
|
||||
def _build_qr_data(cls, equipment) -> str:
|
||||
"""
|
||||
Формирует данные для QR-кода.
|
||||
|
||||
Формат: URL с параметрами для быстрого доступа.
|
||||
"""
|
||||
# Формируем URL на страницу оборудования
|
||||
base_url = getattr(settings, "SITE_URL", "http://corp.robotail.pro")
|
||||
equipment_url = f"{base_url}/procurement/equipment/{equipment.id}/"
|
||||
|
||||
# Можно также добавить vCard или JSON формат
|
||||
# Но URL проще для сканирования
|
||||
return equipment_url
|
||||
|
||||
@classmethod
|
||||
def get_qr_code_path(cls, inventory_number: str) -> str:
|
||||
"""
|
||||
Возвращает путь к файлу QR-кода.
|
||||
"""
|
||||
return os.path.join(cls.QR_CODE_DIR, f"{inventory_number}.png")
|
||||
|
||||
|
||||
class ProcurementApprovalResolver:
|
||||
"""Определяет согласующих для заявки на закупку.
|
||||
|
||||
@ -212,6 +145,42 @@ class ProcurementApprovalResolver:
|
||||
resolved.sort(key=lambda item: item[0])
|
||||
return resolved, missing
|
||||
|
||||
@classmethod
|
||||
def resolve_required_approvers_detailed(cls, procurement_request):
|
||||
"""Возвращает согласующих и подробности по пропущенным этапам."""
|
||||
from .models import ApprovalRoute
|
||||
|
||||
resolved = []
|
||||
missing = []
|
||||
|
||||
for route in procurement_request.get_required_approval_routes():
|
||||
step = cls.resolve_approval_step(procurement_request, route)
|
||||
if step:
|
||||
resolved.append(step)
|
||||
continue
|
||||
|
||||
step_label = route.name or get_default_approval_step_name(
|
||||
route.priority,
|
||||
resolver_type=route.resolver_type,
|
||||
)
|
||||
reason = "unknown"
|
||||
if route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD:
|
||||
reason = "department_head_missing"
|
||||
elif route.resolver_type == ApprovalRoute.ResolverType.FIXED_EMPLOYEE:
|
||||
reason = "approver_missing_or_inactive"
|
||||
|
||||
missing.append(
|
||||
{
|
||||
"priority": route.priority,
|
||||
"step_name": step_label,
|
||||
"resolver_type": route.resolver_type,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
|
||||
resolved.sort(key=lambda item: item[0])
|
||||
return resolved, missing
|
||||
|
||||
@classmethod
|
||||
def get_available_approval(cls, user, procurement_request):
|
||||
if not user or not user.is_authenticated:
|
||||
|
||||
@ -64,7 +64,6 @@ http_ece==1.2.1
|
||||
|
||||
# Image Processing
|
||||
pillow==11.3.0
|
||||
qrcode==8.1
|
||||
|
||||
# Authentication and Security
|
||||
PyJWT==2.10.1
|
||||
|
||||
@ -188,8 +188,6 @@ export class EquipmentListHandler {
|
||||
this.bindPaginationEvents();
|
||||
}
|
||||
|
||||
// QR modals
|
||||
this.bindQRModals();
|
||||
}
|
||||
|
||||
renderEquipmentCard(eq) {
|
||||
@ -218,12 +216,6 @@ export class EquipmentListHandler {
|
||||
<a href="/procurement/equipment/${eq.id}/" class="btn btn-outline-primary btn-sm flex-grow-1">
|
||||
<i class="bi-eye me-1"></i>Подробнее
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-qr-url="/api/v1/procurement/equipment/${eq.id}/qr_code/"
|
||||
data-qr-name="${this.escapeHtml(eq.name)}"
|
||||
title="QR-код">
|
||||
<i class="bi-qr-code"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -254,50 +246,6 @@ export class EquipmentListHandler {
|
||||
return '<i class="bi-pc-display-horizontal"></i>';
|
||||
}
|
||||
|
||||
bindQRModals() {
|
||||
document.querySelectorAll('[data-qr-url]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const url = btn.dataset.qrUrl;
|
||||
const name = btn.dataset.qrName;
|
||||
this.showQRModal(url, name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showQRModal(qrUrl, name) {
|
||||
// Удаляем существующую модалку
|
||||
document.querySelector('#qrModal')?.remove();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'qrModal';
|
||||
modal.className = 'modal fade';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">QR-код</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img src="${qrUrl}" alt="QR Code" class="img-fluid mb-2" style="max-width: 200px;">
|
||||
<p class="small text-muted mb-0">${name}</p>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center">
|
||||
<a href="${qrUrl}" download class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi-download me-1"></i>Скачать
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
bsModal.show();
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', () => modal.remove());
|
||||
}
|
||||
|
||||
renderPagination(totalCount) {
|
||||
const totalPages = Math.ceil(totalCount / this.pageSize);
|
||||
let html = '<nav class="mt-4"><ul class="pagination justify-content-center">';
|
||||
|
||||
@ -42,13 +42,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<a href="/api/v1/procurement/equipment/{{ equipment.id }}/qr_code/"
|
||||
target="_blank"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi-qr-code me-1"></i>QR-код
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -478,11 +478,6 @@
|
||||
<i class="bi-pencil me-2"></i>Редактировать
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="/api/v1/procurement/equipment/${main.id}/qr-code/" target="_blank">
|
||||
<i class="bi-qr-code me-2"></i>QR-код
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#transferModal" data-id="${main.id}">
|
||||
@ -546,11 +541,6 @@
|
||||
<i class="bi-pencil me-2"></i>Редактировать
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="/api/v1/procurement/equipment/${item.id}/qr-code/" target="_blank">
|
||||
<i class="bi-qr-code me-2"></i>QR-код
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#transferModal" data-id="${item.id}">
|
||||
|
||||
@ -403,22 +403,6 @@ class TestEquipmentActions:
|
||||
assert equipment.responsible_person == new_user
|
||||
assert equipment.location == 'Офис 2.10'
|
||||
|
||||
def test_generate_qr_code(
|
||||
self, api_client, user, equipment
|
||||
):
|
||||
"""Генерация QR-кода."""
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:equipment-qr-code',
|
||||
kwargs={'pk': equipment.id}
|
||||
)
|
||||
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response['Content-Type'] == 'image/png'
|
||||
assert response.content[:4] == b'\x89PNG'
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ТЕСТЫ КАТЕГОРИЙ
|
||||
# ==============================================================================
|
||||
|
||||
@ -458,6 +458,85 @@ class TestProcurementRequestWorkflow:
|
||||
'Финальное одобрение',
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("unit_price", "expected_priorities"),
|
||||
[
|
||||
(Decimal("12.00"), [HEAD_PRIORITY]),
|
||||
(Decimal("12000.00"), [HEAD_PRIORITY, FINANCE_PRIORITY]),
|
||||
(Decimal("120000.00"), [HEAD_PRIORITY, FINANCE_PRIORITY, DIRECTOR_PRIORITY]),
|
||||
],
|
||||
)
|
||||
def test_submit_request_applies_amount_thresholds(
|
||||
self, api_client, user, department, unit_price, expected_priorities
|
||||
):
|
||||
"""Маршруты согласования включаются по порогам суммы заявки."""
|
||||
procurement_request = ProcurementRequest.objects.create(
|
||||
title="Пороговая заявка",
|
||||
description="Проверка маршрутов",
|
||||
department=department,
|
||||
requestor=user,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
request=procurement_request,
|
||||
name="Тестовая позиция",
|
||||
quantity=1,
|
||||
unit="шт",
|
||||
estimated_unit_price=unit_price,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
procurement_request.refresh_from_db()
|
||||
assert list(
|
||||
procurement_request.approvals.order_by('priority').values_list('priority', flat=True)
|
||||
) == expected_priorities
|
||||
|
||||
def test_submit_request_without_department_head_returns_explicit_error(
|
||||
self, api_client, user, department
|
||||
):
|
||||
"""Если обязательный этап = руководитель отдела, ошибка должна быть явной."""
|
||||
department.head = None
|
||||
department.save(update_fields=["head"])
|
||||
|
||||
procurement_request = ProcurementRequest.objects.create(
|
||||
title="Заявка без начальника отдела",
|
||||
description="Проверка понятной ошибки",
|
||||
department=department,
|
||||
requestor=user,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
ProcurementItem.objects.create(
|
||||
request=procurement_request,
|
||||
name="Тестовая позиция",
|
||||
quantity=1,
|
||||
unit="шт",
|
||||
estimated_unit_price=Decimal("12.00"),
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-submit',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.data["code"] == "department_head_missing"
|
||||
assert "не назначен руководитель" in response.data["error"].lower()
|
||||
assert response.data["missing_priorities"] == [HEAD_PRIORITY]
|
||||
assert response.data["missing_routes"][0]["reason"] == "department_head_missing"
|
||||
|
||||
def test_submit_returns_manual_step_name(
|
||||
self, api_client, user, procurement_request, procurement_item, budget
|
||||
):
|
||||
|
||||
@ -403,6 +403,11 @@ def test_comment_author_can_delete_own_comment(
|
||||
assert post_resp.status_code == 201
|
||||
comment_id = post_resp.json()["id"]
|
||||
|
||||
list_before = client.get(API_BASE)
|
||||
assert list_before.status_code == 200
|
||||
item_before = next(item for item in _results(list_before.json()) if item["id"] == req.id)
|
||||
assert item_before["comments_count"] == 1
|
||||
|
||||
delete_resp = client.delete(f"{API_BASE}{req.id}/comments/{comment_id}/")
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
@ -411,6 +416,11 @@ def test_comment_author_can_delete_own_comment(
|
||||
ids = {item["id"] for item in _results(list_resp.json())}
|
||||
assert comment_id not in ids
|
||||
|
||||
list_after = client.get(API_BASE)
|
||||
assert list_after.status_code == 200
|
||||
item_after = next(item for item in _results(list_after.json()) if item["id"] == req.id)
|
||||
assert item_after["comments_count"] == 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 8) ФИЛЬТРЫ
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""
|
||||
Тесты для Inventory - transfer, write_off, QR-коды, инвентарные номера.
|
||||
Тесты для Inventory - transfer, write_off и инвентарные номера.
|
||||
"""
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
@ -11,7 +11,7 @@ from procurement.models import (
|
||||
MaintenanceRecord,
|
||||
)
|
||||
from procurement.constants import EquipmentStatus
|
||||
from procurement.services import InventoryNumberGenerator, QRCodeGenerator
|
||||
from procurement.services import InventoryNumberGenerator
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -75,54 +75,6 @@ class TestInventoryNumberGenerator:
|
||||
assert InventoryNumberGenerator.validate('') is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestQRCodeGenerator:
|
||||
"""Тесты для генератора QR-кодов."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, department_factory, user_factory, link_factory):
|
||||
"""Настройка тестовых данных."""
|
||||
self.department = department_factory(name='IT QR Test')
|
||||
self.user = user_factory(staff=True,
|
||||
email='qr_user@test.com',
|
||||
first_name='Test',
|
||||
last_name='QR',
|
||||
)
|
||||
link_factory(self.user, self.department, is_active=True)
|
||||
self.category = EquipmentCategory.objects.create(
|
||||
name='Monitors',
|
||||
description='Мониторы',
|
||||
)
|
||||
self.equipment = Equipment.objects.create(
|
||||
name='Monitor Dell 27',
|
||||
inventory_number='INV-2025-0100',
|
||||
category=self.category,
|
||||
department=self.department,
|
||||
responsible_person=self.user,
|
||||
status=EquipmentStatus.IN_USE,
|
||||
purchase_date='2025-01-01',
|
||||
purchase_cost='10000.00',
|
||||
)
|
||||
|
||||
def test_generate_qr_code(self):
|
||||
"""Тест: генерация QR-кода для оборудования."""
|
||||
qr_file = QRCodeGenerator.generate_for_equipment(self.equipment)
|
||||
|
||||
assert qr_file is not None
|
||||
assert qr_file.name.endswith('.png')
|
||||
# Файл должен содержать PNG данные
|
||||
content = qr_file.read()
|
||||
assert len(content) > 0
|
||||
# PNG начинается с signature
|
||||
assert content[:4] == b'\x89PNG'
|
||||
|
||||
def test_qr_code_path(self):
|
||||
"""Тест: путь к файлу QR-кода."""
|
||||
path = QRCodeGenerator.get_qr_code_path('INV-2025-0100')
|
||||
|
||||
assert 'INV-2025-0100.png' in path
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentTransferEndpoint:
|
||||
"""Тесты для /equipment/{id}/transfer/ endpoint."""
|
||||
@ -341,53 +293,6 @@ class TestEquipmentMaintenanceEndpoint:
|
||||
assert record.performed_by == self.user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentQRCodeEndpoint:
|
||||
"""Тесты для /equipment/{id}/qr_code/ endpoint."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(
|
||||
self, api_client, department_factory, user_factory, link_factory
|
||||
):
|
||||
"""Настройка тестовых данных."""
|
||||
self.client = api_client
|
||||
self.department = department_factory(name='IT QR Code')
|
||||
self.user = user_factory(staff=True,
|
||||
email='qrcode_user@test.com',
|
||||
first_name='Test',
|
||||
last_name='QRCode',
|
||||
)
|
||||
link_factory(self.user, self.department, is_active=True)
|
||||
|
||||
self.category = EquipmentCategory.objects.create(
|
||||
name='Networking',
|
||||
description='Сетевое оборудование',
|
||||
)
|
||||
self.equipment = Equipment.objects.create(
|
||||
name='Cisco Router',
|
||||
inventory_number='INV-2025-0500',
|
||||
category=self.category,
|
||||
department=self.department,
|
||||
responsible_person=self.user,
|
||||
status=EquipmentStatus.IN_USE,
|
||||
purchase_date='2025-01-01',
|
||||
purchase_cost='10000.00',
|
||||
)
|
||||
|
||||
def test_get_qr_code(self):
|
||||
"""Тест: получение QR-кода для оборудования."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get(
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/qr_code/'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response['Content-Type'] == 'image/png'
|
||||
# PNG signature
|
||||
assert response.content[:4] == b'\x89PNG'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentTransferHistoryEndpoint:
|
||||
"""Тесты для /equipment/{id}/transfer_history/ endpoint."""
|
||||
|
||||
284
frontend/package-lock.json
generated
284
frontend/package-lock.json
generated
@ -15,6 +15,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"date-fns": "^4.1.0",
|
||||
@ -25,6 +26,7 @@
|
||||
"next": "16.1.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-avatar-editor": "^14.0.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
@ -2371,7 +2373,6 @@
|
||||
"version": "20.19.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@ -2383,6 +2384,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@ -3047,11 +3057,19 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
@ -3443,6 +3461,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001770",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
|
||||
@ -3508,6 +3535,17 @@
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
@ -3530,7 +3568,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
@ -3541,8 +3578,7 @@
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/colord": {
|
||||
"version": "2.9.3",
|
||||
@ -3900,6 +3936,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
@ -3964,6 +4009,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
@ -4899,6 +4950,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@ -5405,6 +5465,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@ -6653,6 +6722,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
@ -6675,7 +6753,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -6751,6 +6828,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@ -6822,6 +6908,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@ -7224,6 +7327,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
@ -7373,6 +7491,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@ -7644,6 +7768,26 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@ -7751,6 +7895,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@ -8141,8 +8297,7 @@
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
"version": "1.11.1",
|
||||
@ -8374,6 +8529,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
@ -8422,6 +8583,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
@ -8452,12 +8627,105 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"date-fns": "^4.1.0",
|
||||
@ -27,6 +28,7 @@
|
||||
"next": "16.1.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-avatar-editor": "^14.0.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
|
||||
@ -681,7 +681,7 @@ function DocumentsPageContent() {
|
||||
className={`overflow-hidden rounded-xl transition ${
|
||||
isSelected
|
||||
? "app-selected shadow-[var(--shadow-card)]"
|
||||
: "app-surface hover:border-[var(--border-strong)]"
|
||||
: "app-surface-muted hover:border-[var(--border-strong)]"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
@ -806,7 +806,7 @@ function DocumentsPageContent() {
|
||||
)}
|
||||
|
||||
{doc.description && (
|
||||
<p className="mt-3 line-clamp-3 text-sm leading-relaxed text-[var(--foreground)]">
|
||||
<p className="app-text-wrap mt-3 line-clamp-3 text-sm leading-relaxed text-[var(--foreground)]">
|
||||
{doc.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import QRCode from "qrcode";
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import Link from "next/link";
|
||||
import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Archive, ArrowRightLeft, ArrowUpDown, Check, ChevronDown, Copy, Download, ExternalLink, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react";
|
||||
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import { formatDate, formatMoney } from "@/lib/shared";
|
||||
import { useEquipmentPage } from "@/hooks/useEquipmentPage";
|
||||
import { Modal } from "@/components/ui";
|
||||
import type { Equipment, EquipmentTransferHistoryEntry, MaintenanceRecord } from "@/types/api";
|
||||
|
||||
const listModeMeta = [
|
||||
{ value: "all", label: "Весь реестр" },
|
||||
@ -27,31 +31,31 @@ const orderingOptions = [
|
||||
const statusMeta: Record<string, { label: string; className: string; accentClass: string; surfaceClass: string }> = {
|
||||
available: {
|
||||
label: "Доступно",
|
||||
className: "bg-emerald-50 text-emerald-700 ring-emerald-100",
|
||||
className: "app-feedback-success",
|
||||
accentClass: "bg-emerald-500",
|
||||
surfaceClass: "border-emerald-100",
|
||||
},
|
||||
in_use: {
|
||||
label: "В использовании",
|
||||
className: "bg-sky-50 text-sky-700 ring-sky-100",
|
||||
className: "app-selected",
|
||||
accentClass: "bg-sky-500",
|
||||
surfaceClass: "border-sky-100",
|
||||
},
|
||||
maintenance: {
|
||||
label: "На обслуживании",
|
||||
className: "bg-amber-50 text-amber-700 ring-amber-100",
|
||||
className: "app-feedback-warning",
|
||||
accentClass: "bg-amber-500",
|
||||
surfaceClass: "border-amber-100",
|
||||
},
|
||||
retired: {
|
||||
label: "Списано",
|
||||
className: "bg-gray-100 text-gray-700 ring-gray-200",
|
||||
className: "app-badge",
|
||||
accentClass: "bg-gray-400",
|
||||
surfaceClass: "border-gray-200",
|
||||
},
|
||||
broken: {
|
||||
label: "Сломано",
|
||||
className: "bg-rose-50 text-rose-700 ring-rose-100",
|
||||
className: "app-feedback-danger",
|
||||
accentClass: "bg-rose-500",
|
||||
surfaceClass: "border-rose-100",
|
||||
},
|
||||
@ -59,14 +63,132 @@ const statusMeta: Record<string, { label: string; className: string; accentClass
|
||||
|
||||
const defaultStatusMeta = {
|
||||
label: "—",
|
||||
className: "bg-gray-50 text-gray-700 ring-gray-200",
|
||||
className: "app-badge",
|
||||
accentClass: "bg-gray-300",
|
||||
surfaceClass: "border-gray-200",
|
||||
};
|
||||
|
||||
/* ──── main page ──── */
|
||||
type EquipmentMetaItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function EquipmentDetailContent({
|
||||
detailItem,
|
||||
metaItems,
|
||||
transferHistory,
|
||||
maintenanceRecords,
|
||||
canManage,
|
||||
onTransfer,
|
||||
onMaintenance,
|
||||
onWriteOff,
|
||||
}: {
|
||||
detailItem: Equipment;
|
||||
metaItems: EquipmentMetaItem[];
|
||||
transferHistory: EquipmentTransferHistoryEntry[];
|
||||
maintenanceRecords: MaintenanceRecord[];
|
||||
canManage: boolean;
|
||||
onTransfer: () => void;
|
||||
onMaintenance: () => void;
|
||||
onWriteOff: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={onTransfer} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
<ArrowRightLeft size={15} /> Перевести
|
||||
</button>
|
||||
<button type="button" onClick={onMaintenance} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
<Wrench size={15} /> Обслуживание
|
||||
</button>
|
||||
{detailItem.status !== "retired" && (
|
||||
<button type="button" onClick={onWriteOff} className="app-action-danger inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
<Archive size={15} /> Списать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailItem.notes && (
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<p className="app-text-muted text-xs font-medium uppercase tracking-wide">Заметки</p>
|
||||
<p className="app-text-wrap mt-2 text-sm leading-6 text-[var(--foreground)]">{detailItem.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{metaItems.map((meta) => (
|
||||
<div key={meta.label} className="app-surface-muted rounded-xl px-3 py-3">
|
||||
<p className="app-text-muted text-[11px] font-medium uppercase tracking-wide">{meta.label}</p>
|
||||
<p className="app-text-wrap mt-1 text-sm font-medium text-[var(--foreground)]">{meta.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<p className="app-text-muted mb-3 text-xs font-medium uppercase tracking-wide">История переводов</p>
|
||||
{transferHistory.length === 0 ? (
|
||||
<p className="app-text-muted text-sm">Переводы пока не выполнялись</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{transferHistory.map((entry) => (
|
||||
<div key={entry.id} className="app-surface rounded-lg px-3 py-2 text-sm text-[var(--foreground)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">{formatDate(entry.date)}</span>
|
||||
<span className="app-text-muted text-xs">{entry.created_by || "—"}</span>
|
||||
</div>
|
||||
<p className="app-text-wrap app-text-muted mt-1 text-xs">{entry.from_department || "—"} → {entry.to_department || "—"}</p>
|
||||
{(entry.from_person || entry.to_person) && <p className="app-text-wrap app-text-muted mt-1 text-xs">{entry.from_person || "—"} → {entry.to_person || "—"}</p>}
|
||||
{entry.reason && <p className="app-text-wrap app-text-muted mt-1 text-xs">Причина: {entry.reason}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<p className="app-text-muted mb-3 text-xs font-medium uppercase tracking-wide">История обслуживания</p>
|
||||
{maintenanceRecords.length === 0 ? (
|
||||
<p className="app-text-muted text-sm">Записей обслуживания пока нет</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{maintenanceRecords.map((record) => (
|
||||
<div key={record.id} className="app-surface rounded-lg px-3 py-2 text-sm text-[var(--foreground)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">{record.type_display || record.type}</span>
|
||||
<span className="app-text-muted text-xs">{formatDate(record.date)}</span>
|
||||
</div>
|
||||
{record.description && <p className="app-text-wrap app-text-muted mt-1 text-xs">{record.description}</p>}
|
||||
<div className="app-text-muted mt-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span>{record.performed_by_name || "—"}</span>
|
||||
<span>{formatMoney(record.cost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EquipmentPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppShell><div className="app-surface rounded-2xl p-8 text-center"><p className="app-text-muted text-sm">Загрузка оборудования...</p></div></AppShell>}>
|
||||
<EquipmentPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── main page ──── */
|
||||
function EquipmentPageContent() {
|
||||
const { user } = useUser();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const {
|
||||
auth,
|
||||
actionError,
|
||||
@ -104,7 +226,6 @@ export default function EquipmentPage() {
|
||||
handleDeleteComment,
|
||||
handleLoadMore,
|
||||
handleMaintenance,
|
||||
handleOpenQr,
|
||||
handleSave,
|
||||
handleTransfer,
|
||||
handleWriteOff,
|
||||
@ -120,6 +241,7 @@ export default function EquipmentPage() {
|
||||
nextPage,
|
||||
openCreateModal,
|
||||
openEdit,
|
||||
openEquipmentById,
|
||||
openOperationModal,
|
||||
operationModal,
|
||||
ordering,
|
||||
@ -149,6 +271,141 @@ export default function EquipmentPage() {
|
||||
toggleRow,
|
||||
writeOffReason,
|
||||
} = useEquipmentPage(user);
|
||||
|
||||
const [qrEquipment, setQrEquipment] = useState<typeof filteredItems[number] | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [qrError, setQrError] = useState<string | null>(null);
|
||||
const [qrCopySuccess, setQrCopySuccess] = useState(false);
|
||||
const handledLinkedEquipmentRef = useRef<number | null>(null);
|
||||
|
||||
const linkedEquipmentParam = searchParams.get("item");
|
||||
const linkedEquipmentId = Number(linkedEquipmentParam || "");
|
||||
const linkedEquipment = useMemo(
|
||||
() => (linkedEquipmentId > 0 ? detailsMap[linkedEquipmentId] || filteredItems.find((item) => item.id === linkedEquipmentId) || null : null),
|
||||
[detailsMap, filteredItems, linkedEquipmentId],
|
||||
);
|
||||
const linkedEquipmentMeta = useMemo(
|
||||
() => (linkedEquipment ? getEquipmentMeta(linkedEquipment) : []),
|
||||
[getEquipmentMeta, linkedEquipment],
|
||||
);
|
||||
const linkedTransferHistory = linkedEquipmentId > 0 ? transferHistoryMap[linkedEquipmentId] || [] : [];
|
||||
const linkedMaintenanceRecords = linkedEquipmentId > 0 ? maintenanceMap[linkedEquipmentId] || [] : [];
|
||||
|
||||
const buildEquipmentLink = useCallback((equipmentId: number) => {
|
||||
if (typeof window === "undefined") return `/equipment?item=${equipmentId}`;
|
||||
return `${window.location.origin}/equipment?item=${equipmentId}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedEquipmentParam || !Number.isFinite(linkedEquipmentId) || linkedEquipmentId <= 0) {
|
||||
handledLinkedEquipmentRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledLinkedEquipmentRef.current === linkedEquipmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledLinkedEquipmentRef.current = linkedEquipmentId;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
await openEquipmentById(linkedEquipmentId, { expand: false });
|
||||
if (cancelled) return;
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [linkedEquipmentId, linkedEquipmentParam, openEquipmentById]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qrEquipment) {
|
||||
setQrDataUrl(null);
|
||||
setQrError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
setQrError(null);
|
||||
const nextDataUrl = await QRCode.toDataURL(buildEquipmentLink(qrEquipment.id), {
|
||||
width: 320,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: "M",
|
||||
color: {
|
||||
dark: "#0f172a",
|
||||
light: "#ffffffff",
|
||||
},
|
||||
});
|
||||
if (!cancelled) {
|
||||
setQrDataUrl(nextDataUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setQrError(String((error as Error)?.message || "Не удалось сформировать QR-код"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [buildEquipmentLink, qrEquipment]);
|
||||
|
||||
const handleOpenQrModal = useCallback((equipment: typeof filteredItems[number]) => {
|
||||
setQrCopySuccess(false);
|
||||
setQrEquipment(equipment);
|
||||
}, []);
|
||||
|
||||
const handleCloseQrModal = useCallback(() => {
|
||||
setQrEquipment(null);
|
||||
setQrDataUrl(null);
|
||||
setQrError(null);
|
||||
setQrCopySuccess(false);
|
||||
}, []);
|
||||
|
||||
const handleCopyQrLink = useCallback(async () => {
|
||||
if (!qrEquipment) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildEquipmentLink(qrEquipment.id));
|
||||
setQrCopySuccess(true);
|
||||
window.setTimeout(() => setQrCopySuccess(false), 1600);
|
||||
} catch {
|
||||
setQrError("Не удалось скопировать ссылку");
|
||||
}
|
||||
}, [buildEquipmentLink, qrEquipment]);
|
||||
|
||||
const handleDownloadQr = useCallback(() => {
|
||||
if (!qrEquipment || !qrDataUrl) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = qrDataUrl;
|
||||
link.download = `${qrEquipment.inventory_number || `equipment-${qrEquipment.id}`}.png`;
|
||||
link.click();
|
||||
}, [qrDataUrl, qrEquipment]);
|
||||
|
||||
const handleOpenEquipmentLink = useCallback(() => {
|
||||
if (!qrEquipment) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set("item", String(qrEquipment.id));
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
handleCloseQrModal();
|
||||
}, [handleCloseQrModal, pathname, qrEquipment, router, searchParams]);
|
||||
|
||||
const handleCloseLinkedEquipmentModal = useCallback(() => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("item");
|
||||
const nextQuery = nextParams.toString();
|
||||
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false });
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
const qrLink = useMemo(
|
||||
() => (qrEquipment ? buildEquipmentLink(qrEquipment.id) : ""),
|
||||
[buildEquipmentLink, qrEquipment],
|
||||
);
|
||||
/* ──── render ──── */
|
||||
return (
|
||||
<AppShell>
|
||||
@ -176,7 +433,7 @@ export default function EquipmentPage() {
|
||||
</div>
|
||||
|
||||
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
|
||||
{actionSuccess && <p className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionSuccess}</p>}
|
||||
{actionSuccess && <p className="app-feedback-success mb-3 rounded-lg px-3 py-2 text-sm">{actionSuccess}</p>}
|
||||
|
||||
{/* Search + filter toggle */}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
@ -281,9 +538,9 @@ export default function EquipmentPage() {
|
||||
{/* Items list */}
|
||||
<div className="space-y-2">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50 p-8 text-center">
|
||||
<Monitor size={22} className="mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">Записи об оборудовании не найдены</p>
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<Monitor size={22} className="app-text-muted mx-auto mb-2" />
|
||||
<p className="app-text-muted text-sm">Записи об оборудовании не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@ -306,14 +563,14 @@ export default function EquipmentPage() {
|
||||
const rowLoading = Boolean(loadingRowDetails[item.id]);
|
||||
|
||||
return (
|
||||
<article key={item.id} className="overflow-hidden rounded-xl border border-gray-200 bg-white transition hover:border-gray-300">
|
||||
<article id={`equipment-${item.id}`} key={item.id} className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(item.id)}
|
||||
aria-label={rowOpen ? "Свернуть детали" : "Развернуть детали"}
|
||||
className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-500 transition hover:bg-gray-100"
|
||||
className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
>
|
||||
<ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
@ -323,28 +580,28 @@ export default function EquipmentPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${st.accentClass}`} />
|
||||
<h3 className="truncate text-sm font-semibold text-gray-900">{item.name || "Без названия"}</h3>
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--foreground)]">{item.name || "Без названия"}</h3>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">{item.inventory_number || "Без инв. номера"}</span>
|
||||
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="font-medium text-[var(--foreground)]">{item.inventory_number || "Без инв. номера"}</span>
|
||||
{item.serial_number && <span>SN: {item.serial_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button type="button" title={`Комментарии (${commentsTotal})`} onClick={() => toggleComments(item.id)} className="relative inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 transition hover:border-sky-200 hover:bg-sky-50 hover:text-sky-700">
|
||||
<button type="button" title={`Комментарии (${commentsTotal})`} onClick={() => toggleComments(item.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<MessageSquare size={15} />
|
||||
{commentsTotal > 0 && (
|
||||
<span className="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
|
||||
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
|
||||
)}
|
||||
</button>
|
||||
{canEditThis && (
|
||||
<button type="button" title="Редактировать" onClick={() => openEdit(item)} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50">
|
||||
<button type="button" title="Редактировать" onClick={() => openEdit(item)} className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
)}
|
||||
{canDeleteThis && (
|
||||
<button type="button" title="Удалить" onClick={() => handleDelete(item.id)} disabled={busyKey === `delete-${item.id}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-rose-200 bg-rose-50 text-rose-600 transition hover:bg-rose-100 disabled:opacity-60">
|
||||
<button type="button" title="Удалить" onClick={() => handleDelete(item.id)} disabled={busyKey === `delete-${item.id}`} className="app-action-danger inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-60">
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
@ -352,32 +609,32 @@ export default function EquipmentPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{statusKey && <span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${st.className}`}>{st.label}</span>}
|
||||
{statusKey && <span className={`app-status-pill shrink-0 ${st.className}`}>{st.label}</span>}
|
||||
{item.is_under_warranty && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200" title="На гарантии">
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium" title="На гарантии">
|
||||
<Shield size={11} /> Гарантия
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs text-gray-500 sm:grid-cols-2">
|
||||
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-gray-400">Стоимость:</span>{" "}
|
||||
<span className="font-medium text-gray-700">{formatMoney(item.purchase_cost)}</span>
|
||||
<span>Стоимость:</span>{" "}
|
||||
<span className="font-medium text-[var(--foreground)]">{formatMoney(item.purchase_cost)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Покупка:</span>{" "}
|
||||
<span className="font-medium text-gray-700">{formatDate(item.purchase_date) || "—"}</span>
|
||||
<span>Покупка:</span>{" "}
|
||||
<span className="font-medium text-[var(--foreground)]">{formatDate(item.purchase_date) || "—"}</span>
|
||||
</div>
|
||||
{responsibleId ? (
|
||||
<div className="col-span-2 min-w-0">
|
||||
<span className="text-gray-400">Ответственный:</span>{" "}
|
||||
<Link href={responsibleLink} className="font-medium text-sky-700 hover:text-sky-800">
|
||||
<span>Ответственный:</span>{" "}
|
||||
<Link href={responsibleLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">
|
||||
{responsibleName}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-span-2 text-gray-400">Ответственный не назначен</div>
|
||||
<div className="col-span-2">Ответственный не назначен</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -385,7 +642,7 @@ export default function EquipmentPage() {
|
||||
</div>
|
||||
|
||||
{(rowOpen || commentsOpen) && (
|
||||
<div className="app-surface-muted mt-4 rounded-xl p-4">
|
||||
<div className="mt-4 space-y-3 px-4 pb-4">
|
||||
{commentsOpen && (
|
||||
<div className="app-surface rounded-xl p-3">
|
||||
<div className="space-y-2">
|
||||
@ -403,7 +660,7 @@ export default function EquipmentPage() {
|
||||
{canDel && <button type="button" onClick={() => handleDeleteComment(item.id, c.id)} className="app-action-danger rounded-md px-1.5 py-0.5">удалить</button>}
|
||||
</div>
|
||||
</div>
|
||||
<p>{c.text}</p>
|
||||
<p className="app-text-wrap">{c.text}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@ -419,19 +676,19 @@ export default function EquipmentPage() {
|
||||
{rowOpen && (
|
||||
<>
|
||||
{rowLoading && (
|
||||
<div className={`${commentsOpen ? "mt-3 " : ""}app-selected mb-3 rounded-xl px-3 py-2 text-sm`}>
|
||||
<div className="app-selected rounded-xl px-3 py-2 text-sm">
|
||||
Загружаем детали оборудования...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${commentsOpen ? "mt-3 " : ""}mb-3 flex flex-wrap gap-2`}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => openOperationModal("transfer", detailItem)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
<ArrowRightLeft size={15} /> Перевести
|
||||
</button>
|
||||
<button type="button" onClick={() => openOperationModal("maintenance", detailItem)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
<Wrench size={15} /> Обслуживание
|
||||
</button>
|
||||
<button type="button" onClick={() => handleOpenQr(item.id)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
<button type="button" onClick={() => handleOpenQrModal(detailItem)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
<QrCode size={15} /> QR-код
|
||||
</button>
|
||||
{item.status !== "retired" && (
|
||||
@ -444,7 +701,7 @@ export default function EquipmentPage() {
|
||||
{detailItem.notes && (
|
||||
<div className="app-surface mb-3 rounded-xl p-4">
|
||||
<p className="app-text-muted text-xs font-medium uppercase tracking-wide">Заметки</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--foreground)]">{detailItem.notes}</p>
|
||||
<p className="app-text-wrap mt-2 text-sm leading-6 text-[var(--foreground)]">{detailItem.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -452,7 +709,7 @@ export default function EquipmentPage() {
|
||||
{metaItems.map((meta) => (
|
||||
<div key={meta.label} className="app-surface rounded-xl px-3 py-3">
|
||||
<p className="app-text-muted text-[11px] font-medium uppercase tracking-wide">{meta.label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">{meta.value}</p>
|
||||
<p className="app-text-wrap mt-1 text-sm font-medium text-[var(--foreground)]">{meta.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -470,9 +727,9 @@ export default function EquipmentPage() {
|
||||
<span className="font-medium">{formatDate(entry.date)}</span>
|
||||
<span className="app-text-muted text-xs">{entry.created_by || "—"}</span>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-xs">{entry.from_department || "—"} → {entry.to_department || "—"}</p>
|
||||
{(entry.from_person || entry.to_person) && <p className="app-text-muted mt-1 text-xs">{entry.from_person || "—"} → {entry.to_person || "—"}</p>}
|
||||
{entry.reason && <p className="app-text-muted mt-1 text-xs">Причина: {entry.reason}</p>}
|
||||
<p className="app-text-wrap app-text-muted mt-1 text-xs">{entry.from_department || "—"} → {entry.to_department || "—"}</p>
|
||||
{(entry.from_person || entry.to_person) && <p className="app-text-wrap app-text-muted mt-1 text-xs">{entry.from_person || "—"} → {entry.to_person || "—"}</p>}
|
||||
{entry.reason && <p className="app-text-wrap app-text-muted mt-1 text-xs">Причина: {entry.reason}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -491,7 +748,7 @@ export default function EquipmentPage() {
|
||||
<span className="font-medium">{record.type_display || record.type}</span>
|
||||
<span className="app-text-muted text-xs">{formatDate(record.date)}</span>
|
||||
</div>
|
||||
{record.description && <p className="app-text-muted mt-1 text-xs">{record.description}</p>}
|
||||
{record.description && <p className="app-text-wrap app-text-muted mt-1 text-xs">{record.description}</p>}
|
||||
<div className="app-text-muted mt-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span>{record.performed_by_name || "—"}</span>
|
||||
<span>{formatMoney(record.cost)}</span>
|
||||
@ -504,7 +761,6 @@ export default function EquipmentPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
@ -517,7 +773,7 @@ export default function EquipmentPage() {
|
||||
{/* Load more */}
|
||||
{nextPage && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button type="button" onClick={handleLoadMore} disabled={loadingMore} className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60">
|
||||
<button type="button" onClick={handleLoadMore} disabled={loadingMore} className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60">
|
||||
{loadingMore ? "Загружаем..." : "Загрузить ещё"}
|
||||
</button>
|
||||
</div>
|
||||
@ -728,6 +984,117 @@ export default function EquipmentPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={Boolean(linkedEquipmentParam && linkedEquipmentId > 0)}
|
||||
onClose={handleCloseLinkedEquipmentModal}
|
||||
title="Карточка оборудования"
|
||||
size="lg"
|
||||
>
|
||||
{linkedEquipment ? (
|
||||
<div className="space-y-4">
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${(statusMeta[String(linkedEquipment.status || "").toLowerCase()] ?? defaultStatusMeta).accentClass}`} />
|
||||
<h3 className="text-base font-semibold text-[var(--foreground)]">
|
||||
{linkedEquipment.name || "Без названия"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
{linkedEquipment.inventory_number || `ID ${linkedEquipment.id}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{String(linkedEquipment.status || "").toLowerCase() && (
|
||||
<span className={`app-status-pill ${(statusMeta[String(linkedEquipment.status || "").toLowerCase()] ?? defaultStatusMeta).className}`}>
|
||||
{(statusMeta[String(linkedEquipment.status || "").toLowerCase()] ?? defaultStatusMeta).label}
|
||||
</span>
|
||||
)}
|
||||
{linkedEquipment.is_under_warranty && (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Shield size={11} /> Гарантия
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EquipmentDetailContent
|
||||
detailItem={linkedEquipment}
|
||||
metaItems={linkedEquipmentMeta}
|
||||
transferHistory={linkedTransferHistory}
|
||||
maintenanceRecords={linkedMaintenanceRecords}
|
||||
canManage={canManage}
|
||||
onTransfer={() => openOperationModal("transfer", linkedEquipment)}
|
||||
onMaintenance={() => openOperationModal("maintenance", linkedEquipment)}
|
||||
onWriteOff={() => openOperationModal("writeoff", linkedEquipment)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl p-6 text-center">
|
||||
<p className="app-text-muted text-sm">Загружаем карточку оборудования...</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!qrEquipment}
|
||||
onClose={handleCloseQrModal}
|
||||
title="QR-код оборудования"
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={handleCopyQrLink} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
{qrCopySuccess ? <Check size={15} /> : <Copy size={15} />}
|
||||
{qrCopySuccess ? "Скопировано" : "Скопировать ссылку"}
|
||||
</button>
|
||||
<button type="button" onClick={handleDownloadQr} disabled={!qrDataUrl} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">
|
||||
<Download size={15} />
|
||||
Скачать PNG
|
||||
</button>
|
||||
<button type="button" onClick={handleOpenEquipmentLink} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
|
||||
<ExternalLink size={15} />
|
||||
Открыть запись
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{qrEquipment ? (
|
||||
<div className="space-y-4">
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">
|
||||
{qrEquipment.name || "Без названия"}
|
||||
</p>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
{qrEquipment.inventory_number || `ID ${qrEquipment.id}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted flex items-center justify-center rounded-2xl p-5">
|
||||
{qrError ? (
|
||||
<p className="app-feedback-danger rounded-lg px-3 py-2 text-sm">{qrError}</p>
|
||||
) : qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={`QR-код для ${qrEquipment.inventory_number || qrEquipment.name || "оборудования"}`}
|
||||
className="h-72 w-72 rounded-xl bg-white p-3"
|
||||
/>
|
||||
) : (
|
||||
<div className="app-text-muted flex h-72 w-72 items-center justify-center rounded-xl bg-white text-sm">
|
||||
Формируем QR-код...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Содержимое</p>
|
||||
<p className="app-text-wrap app-text-muted mt-2 text-sm">{qrLink}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{/* ===== Attachment preview removed (no attachment fields in model) ===== */}
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@ -271,6 +271,11 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.app-text-wrap {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-card-caption {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
@ -456,6 +461,69 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.reaction-picker-surface {
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.reaction-picker-modal .EmojiPickerReact,
|
||||
.reaction-picker-modal aside.EmojiPickerReact {
|
||||
--epr-bg-color: var(--surface-secondary);
|
||||
--epr-picker-border-color: var(--border-subtle);
|
||||
--epr-text-color: var(--muted-foreground);
|
||||
--epr-search-input-bg-color: var(--surface-primary);
|
||||
--epr-search-input-bg-color-active: var(--surface-primary);
|
||||
--epr-search-border-color: var(--border-strong);
|
||||
--epr-search-border-color-active: var(--accent-primary);
|
||||
--epr-search-input-text-color: var(--foreground);
|
||||
--epr-search-input-placeholder-color: var(--muted-foreground);
|
||||
--epr-hover-bg-color: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary));
|
||||
--epr-hover-bg-color-reduced-opacity: color-mix(in srgb, var(--accent-primary) 10%, transparent);
|
||||
--epr-focus-bg-color: color-mix(in srgb, var(--accent-primary) 18%, var(--surface-primary));
|
||||
--epr-highlight-color: var(--accent-primary);
|
||||
--epr-category-label-bg-color: color-mix(in srgb, var(--surface-secondary) 88%, transparent);
|
||||
--epr-category-label-text-color: var(--muted-foreground);
|
||||
--epr-category-icon-active-color: var(--accent-primary-strong);
|
||||
--epr-emoji-variation-picker-bg-color: var(--surface-secondary);
|
||||
--epr-preview-border-color: var(--border-subtle);
|
||||
--epr-dark-bg-color: var(--surface-secondary);
|
||||
--epr-dark-picker-border-color: var(--border-subtle);
|
||||
--epr-dark-text-color: var(--muted-foreground);
|
||||
--epr-dark-search-input-bg-color: var(--surface-primary);
|
||||
--epr-dark-search-input-bg-color-active: var(--surface-primary);
|
||||
--epr-dark-category-label-bg-color: color-mix(in srgb, var(--surface-secondary) 88%, transparent);
|
||||
--epr-dark-hover-bg-color: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary));
|
||||
--epr-dark-hover-bg-color-reduced-opacity: color-mix(in srgb, var(--accent-primary) 10%, transparent);
|
||||
--epr-dark-focus-bg-color: color-mix(in srgb, var(--accent-primary) 18%, var(--surface-primary));
|
||||
--epr-dark-highlight-color: var(--accent-primary);
|
||||
--epr-dark-category-icon-active-color: var(--accent-primary-strong);
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.reaction-picker-modal .EmojiPickerReact * {
|
||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif !important;
|
||||
}
|
||||
|
||||
.reaction-picker-modal .epr-search-container input {
|
||||
background: var(--surface-primary) !important;
|
||||
border: 1px solid var(--border-strong) !important;
|
||||
color: var(--foreground) !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04) !important;
|
||||
}
|
||||
|
||||
.reaction-picker-modal .epr-search-container input::placeholder {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
.reaction-picker-modal .epr-category-label {
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0 !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.app-overlay {
|
||||
background: rgba(2, 6, 23, 0.56);
|
||||
backdrop-filter: blur(12px);
|
||||
|
||||
@ -629,10 +629,10 @@ function HomePageContent() {
|
||||
) : null}
|
||||
</header>
|
||||
{post.title ? (
|
||||
<h3 className="mb-1 text-base font-semibold text-[var(--foreground)]">{post.title}</h3>
|
||||
<h3 className="app-text-wrap mb-1 text-base font-semibold text-[var(--foreground)]">{post.title}</h3>
|
||||
) : null}
|
||||
{postText ? (
|
||||
<p className="text-sm leading-6 text-[var(--foreground)] whitespace-pre-line">{postText}</p>
|
||||
<p className="app-text-wrap whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">{postText}</p>
|
||||
) : null}
|
||||
{post.image && (
|
||||
<div className="mt-3 overflow-hidden rounded-lg">
|
||||
@ -990,7 +990,7 @@ function HomePageContent() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{commentText && <p className="mt-1 text-sm leading-6 text-[var(--foreground)]">{commentText}</p>}
|
||||
{commentText && <p className="app-text-wrap mt-1 text-sm leading-6 text-[var(--foreground)]">{commentText}</p>}
|
||||
{comment.image && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg">
|
||||
<img src={resolveMediaUrl(comment.image)} alt="" className="max-h-60 rounded-lg" />
|
||||
|
||||
@ -37,15 +37,15 @@ import { Modal } from "@/components/ui";
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
const statusMeta: Record<string, { label: string; cls: string }> = {
|
||||
draft: { label: "Черновик", cls: "bg-slate-100 text-slate-700 ring-slate-200" },
|
||||
pending: { label: "На согласовании", cls: "bg-amber-50 text-amber-700 ring-amber-100" },
|
||||
approved: { label: "Одобрено", cls: "bg-emerald-50 text-emerald-700 ring-emerald-100" },
|
||||
in_progress: { label: "В работе", cls: "bg-sky-50 text-sky-700 ring-sky-100" },
|
||||
completed: { label: "Завершено", cls: "bg-teal-50 text-teal-700 ring-teal-100" },
|
||||
rejected: { label: "Отклонено", cls: "bg-rose-50 text-rose-700 ring-rose-100" },
|
||||
cancelled: { label: "Отменено", cls: "bg-gray-100 text-gray-600 ring-gray-200" },
|
||||
draft: { label: "Черновик", cls: "app-badge" },
|
||||
pending: { label: "На согласовании", cls: "app-feedback-warning" },
|
||||
approved: { label: "Одобрено", cls: "app-feedback-success" },
|
||||
in_progress: { label: "В работе", cls: "app-selected" },
|
||||
completed: { label: "Завершено", cls: "app-selected" },
|
||||
rejected: { label: "Отклонено", cls: "app-feedback-danger" },
|
||||
cancelled: { label: "Отменено", cls: "app-badge" },
|
||||
};
|
||||
const defaultStatusMeta = { label: "—", cls: "bg-gray-50 text-gray-700 ring-gray-200" };
|
||||
const defaultStatusMeta = { label: "—", cls: "app-badge" };
|
||||
|
||||
const urgencyMeta: Record<string, { label: string; cls: string }> = {
|
||||
low: { label: "Низкая", cls: "text-gray-500" },
|
||||
@ -194,7 +194,7 @@ export default function ProcurementPage() {
|
||||
|
||||
{/* ── alerts ── */}
|
||||
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
|
||||
{actionSuccess && <p className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionSuccess}</p>}
|
||||
{actionSuccess && <p className="app-feedback-success mb-3 rounded-lg px-3 py-2 text-sm">{actionSuccess}</p>}
|
||||
|
||||
{activeSection === "stats" && <ProcurementStatsPanel />}
|
||||
|
||||
@ -295,9 +295,9 @@ export default function ProcurementPage() {
|
||||
{/* ══════════ Request cards ══════════ */}
|
||||
<div className="space-y-3">
|
||||
{filteredRequests.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50 p-8 text-center">
|
||||
<ShoppingCart size={22} className="mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">Заявок на закупку не найдено</p>
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<ShoppingCart size={22} className="app-text-muted mx-auto mb-2" />
|
||||
<p className="app-text-muted text-sm">Заявок на закупку не найдено</p>
|
||||
</div>
|
||||
) : filteredRequests.map((req) => {
|
||||
const st = String(req.status || "").toLowerCase();
|
||||
@ -320,14 +320,14 @@ export default function ProcurementPage() {
|
||||
const approvalsCount = resolvedDetail.approvals?.length ?? 0;
|
||||
|
||||
return (
|
||||
<article key={req.id} className="overflow-hidden rounded-xl border border-gray-200 bg-white transition hover:border-gray-300">
|
||||
<article key={req.id} className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(req.id)}
|
||||
aria-label={expanded ? "Свернуть детали" : "Развернуть детали"}
|
||||
className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-500 transition hover:bg-gray-100"
|
||||
className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
>
|
||||
<ChevronDown size={15} className={`transition ${expanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
@ -337,47 +337,47 @@ export default function ProcurementPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${st === "completed" ? "bg-teal-500" : st === "approved" ? "bg-emerald-500" : st === "pending" ? "bg-amber-500" : st === "in_progress" ? "bg-sky-500" : st === "rejected" ? "bg-rose-500" : "bg-slate-400"}`} />
|
||||
<h3 className="truncate text-sm font-semibold text-gray-900">{req.title || "Без названия"}</h3>
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--foreground)]">{req.title || "Без названия"}</h3>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">{getDeptName(req)}</span>
|
||||
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="font-medium text-[var(--foreground)]">{getDeptName(req)}</span>
|
||||
<span>{fmt(req.created_at)}</span>
|
||||
{getRequestAmount(req) && <span className="font-medium text-gray-700">{money(getRequestAmount(req))}</span>}
|
||||
{getRequestAmount(req) && <span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(req))}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 ${sMeta.cls}`}>{sMeta.label}</span>
|
||||
<span className={`app-status-pill shrink-0 ${sMeta.cls}`}>{sMeta.label}</span>
|
||||
<span className={`text-[11px] font-medium ${urg.cls}`}>{urg.label} срочность</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs text-gray-500 sm:grid-cols-2">
|
||||
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-gray-400">Заявитель:</span>{" "}
|
||||
<span>Заявитель:</span>{" "}
|
||||
{requestorLink
|
||||
? <Link href={requestorLink} className="font-medium text-sky-700 hover:text-sky-800">{requestorName}</Link>
|
||||
: <span className="font-medium text-gray-700">{requestorName}</span>}
|
||||
? <Link href={requestorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{requestorName}</Link>
|
||||
: <span className="font-medium text-[var(--foreground)]">{requestorName}</span>}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-gray-400">Исполнитель:</span>{" "}
|
||||
<span>Исполнитель:</span>{" "}
|
||||
{req.executor ? (
|
||||
executorLink
|
||||
? <Link href={executorLink} className="font-medium text-sky-700 hover:text-sky-800">{executorName}</Link>
|
||||
: <span className="font-medium text-gray-700">{executorName}</span>
|
||||
? <Link href={executorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{executorName}</Link>
|
||||
: <span className="font-medium text-[var(--foreground)]">{executorName}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">не назначен</span>
|
||||
<span>не назначен</span>
|
||||
)}
|
||||
</div>
|
||||
{(itemsCount > 0 || approvalsCount > 0) && (
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
|
||||
{itemsCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200">
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Package size={11} /> {itemsCount} поз.
|
||||
</span>
|
||||
)}
|
||||
{approvalsCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200">
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Check size={11} /> {approvalsCount} соглас.
|
||||
</span>
|
||||
)}
|
||||
@ -389,10 +389,10 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="app-surface-muted mt-4 rounded-xl p-4">
|
||||
<div className="app-surface mb-3 rounded-xl p-4">
|
||||
<p className="app-text-muted text-xs font-medium uppercase tracking-wide">Описание</p>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">{resolvedDetail.description || "—"}</p>
|
||||
<div className="mt-4 space-y-3 px-4 pb-4">
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Описание</p>
|
||||
<p className="app-text-wrap mt-2 whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">{resolvedDetail.description || "—"}</p>
|
||||
<div className="mt-3 grid gap-2 text-xs sm:grid-cols-3">
|
||||
<div className="app-surface-muted rounded-lg px-3 py-2">
|
||||
<p className="app-text-muted text-[11px] uppercase tracking-wide">Отправлена</p>
|
||||
@ -415,16 +415,16 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
|
||||
{detail?.items && detail.items.length > 0 && (
|
||||
<div className="app-surface mb-3 rounded-xl p-4">
|
||||
<p className="app-text-muted mb-3 text-xs font-medium uppercase tracking-wide">Позиции</p>
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">Позиции</p>
|
||||
<div className="space-y-2">
|
||||
{detail.items.map((it, idx) => (
|
||||
<div key={idx} className="app-surface-muted rounded-lg px-3 py-3 text-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-[var(--foreground)]">{it.name}</p>
|
||||
{it.description && <p className="mt-1 text-[var(--muted-foreground)]">{it.description}</p>}
|
||||
{it.supplier_info && <p className="mt-1 text-[var(--muted-foreground)]">Поставщик: {it.supplier_info}</p>}
|
||||
<p className="app-text-wrap font-medium text-[var(--foreground)]">{it.name}</p>
|
||||
{it.description && <p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">{it.description}</p>}
|
||||
{it.supplier_info && <p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">Поставщик: {it.supplier_info}</p>}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-[var(--foreground)]">{money(it.total_price)}</p>
|
||||
@ -441,8 +441,8 @@ export default function ProcurementPage() {
|
||||
)}
|
||||
|
||||
{detail?.approvals && detail.approvals.length > 0 && (
|
||||
<div className="app-surface mb-3 rounded-xl p-4">
|
||||
<p className="app-text-muted mb-3 text-xs font-medium uppercase tracking-wide">Согласования</p>
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">Согласования</p>
|
||||
<div className="space-y-2">
|
||||
{detail.approvals.map((a) => {
|
||||
const aSt = String(a.status).toLowerCase();
|
||||
@ -452,7 +452,7 @@ export default function ProcurementPage() {
|
||||
{icon}
|
||||
<span className="font-medium text-[var(--foreground)]">{displayUserName(a.approver, a.approver_name)}</span>
|
||||
<span className="app-text-muted">({a.step_label || `Этап ${a.priority}`})</span>
|
||||
{a.comment && <span className="app-text-muted ml-auto italic">«{a.comment}»</span>}
|
||||
{a.comment && <span className="app-text-wrap app-text-muted ml-auto max-w-full italic">«{a.comment}»</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -460,52 +460,59 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{isDraft && isAuthor && (
|
||||
<button type="button" onClick={() => handleSubmit(req.id)} disabled={busyKey === `submit-${req.id}`}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-60">
|
||||
<Send size={13} /> На согласование
|
||||
title="На согласование"
|
||||
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
|
||||
<Send size={14} />
|
||||
</button>
|
||||
)}
|
||||
{isDraft && isAuthor && (
|
||||
<button type="button" onClick={() => openEdit(req)} className="app-action-secondary inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium">
|
||||
<Pencil size={13} /> Редактировать
|
||||
<button type="button" onClick={() => openEdit(req)} title="Редактировать" className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg">
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
{isDraft && (isAuthor || canManage) && (
|
||||
<button type="button" onClick={() => handleDelete(req.id)} disabled={busyKey === `delete-${req.id}`}
|
||||
className="app-action-danger inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-60">
|
||||
<Trash2 size={13} /> Удалить
|
||||
title="Удалить"
|
||||
className="app-action-danger inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{isPending && !isAuthor && (
|
||||
<>
|
||||
<button type="button" onClick={() => handleApprove(req.id)} disabled={busyKey === `approve-${req.id}`}
|
||||
className="app-feedback-success inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-60">
|
||||
<ThumbsUp size={13} /> Одобрить
|
||||
title="Одобрить"
|
||||
className="app-feedback-success inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
|
||||
<ThumbsUp size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => handleReject(req.id)} disabled={busyKey === `reject-${req.id}`}
|
||||
className="app-action-danger inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-60">
|
||||
<ThumbsDown size={13} /> Отклонить
|
||||
title="Отклонить"
|
||||
className="app-action-danger inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
|
||||
<ThumbsDown size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isApproved && !req.executor && (
|
||||
<button type="button" onClick={() => handleStart(req.id)} disabled={busyKey === `start-${req.id}`}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-60">
|
||||
<Play size={13} /> Взять в работу
|
||||
title="Взять в работу"
|
||||
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
{isInProgress && isExecutor && (
|
||||
<button type="button" onClick={() => handleComplete(req.id)} disabled={busyKey === `complete-${req.id}`}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-60">
|
||||
<ClipboardCheck size={13} /> Завершить
|
||||
title="Завершить"
|
||||
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
|
||||
<ClipboardCheck size={14} />
|
||||
</button>
|
||||
)}
|
||||
{isAuthor && !isFinal(st) && st !== "draft" && (
|
||||
<button type="button" onClick={() => handleCancel(req.id)} disabled={busyKey === `cancel-${req.id}`}
|
||||
className="app-action-secondary inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-60">
|
||||
<XCircle size={13} /> Отменить
|
||||
title="Отменить"
|
||||
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
|
||||
<XCircle size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -519,7 +526,7 @@ export default function ProcurementPage() {
|
||||
{/* ── load more ── */}
|
||||
{nextPage && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button type="button" onClick={handleLoadMore} disabled={loadingMore} className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60">
|
||||
<button type="button" onClick={handleLoadMore} disabled={loadingMore} className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60">
|
||||
{loadingMore ? "Загружаем..." : "Загрузить ещё"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -709,10 +709,10 @@ export default function ProfilePage() {
|
||||
</span>
|
||||
) : null}
|
||||
{action.comment ? (
|
||||
<p className="app-text-muted mt-2 text-sm leading-6">
|
||||
{truncateText(
|
||||
action.comment,
|
||||
showAllActions ? 240 : 120,
|
||||
<p className="app-text-wrap app-text-muted mt-2 text-sm leading-6">
|
||||
{truncateText(
|
||||
action.comment,
|
||||
showAllActions ? 240 : 120,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@ -253,13 +253,13 @@ function RequestsPageContent() {
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={() => h.setDetailsRequest(item)} className="block w-full text-left">
|
||||
<h3 className={`${rowOpen ? "line-clamp-3 break-words" : "truncate"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}><span className="app-text-muted">{tl}:</span> <span className="text-[var(--foreground)]">{tt}</span></h3>
|
||||
<h3 className={`${rowOpen ? "app-text-wrap line-clamp-3" : "truncate"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}><span className="app-text-muted">{tl}:</span> <span className="text-[var(--foreground)]">{tt}</span></h3>
|
||||
</button>
|
||||
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"><span>Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}</span></div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right"><span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${st.className}`}>{st.label}</span></div>
|
||||
</div>
|
||||
{summary && <p className={`${rowOpen ? "line-clamp-10 break-words" : "line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}>{summary}</p>}
|
||||
{summary && <p className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}>{summary}</p>}
|
||||
<div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}>
|
||||
{isAuth && !h.isFinal(sk) && <button type="button" title="Отменить" onClick={() => h.handleCancel(item.id)} disabled={h.busyKey === `cancel-${item.id}`} className="app-action-secondary inline-flex items-center justify-center rounded-lg p-1.5 disabled:opacity-60"><Ban size={15} /></button>}
|
||||
<button type="button" title={`Комментарии (${item.comments_count ?? comments.length})`} onClick={() => h.toggleComments(item.id)} className="app-action-secondary relative inline-flex items-center justify-center rounded-lg p-1.5"><MessageSquare size={15} />{(item.comments_count ?? comments.length) > 0 && <span className="app-counter absolute -right-1.5 -top-1.5 flex h-4 min-w-4 px-1 text-[10px] font-bold">{item.comments_count ?? comments.length}</span>}</button>
|
||||
@ -289,7 +289,7 @@ function RequestsPageContent() {
|
||||
)}
|
||||
{commentsOpen && (
|
||||
<div className={rowOpen ? "app-surface mt-3 rounded-xl p-3" : "app-surface rounded-xl p-3"}>
|
||||
<div className="space-y-2">{comments.length === 0 ? <p className="app-text-muted text-xs">Комментариев пока нет</p> : comments.map((c) => (<div key={c.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]"><div className="mb-1 flex items-center justify-between gap-2"><span className="font-medium">{displayUserName(c.author)}</span><div className="flex items-center gap-2"><span className="app-text-muted">{formatDate(c.created_at)}</span>{Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser)) && <button type="button" onClick={() => h.handleDeleteComment(item.id, c.id)} className="app-action-danger rounded-md px-1.5 py-0.5">удалить</button>}</div></div><p className="text-[var(--foreground)]">{c.text}</p></div>))}</div>
|
||||
<div className="space-y-2">{comments.length === 0 ? <p className="app-text-muted text-xs">Комментариев пока нет</p> : comments.map((c) => (<div key={c.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]"><div className="mb-1 flex items-center justify-between gap-2"><span className="font-medium">{displayUserName(c.author)}</span><div className="flex items-center gap-2"><span className="app-text-muted">{formatDate(c.created_at)}</span>{Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser)) && <button type="button" onClick={() => h.handleDeleteComment(item.id, c.id)} className="app-action-danger rounded-md px-1.5 py-0.5">удалить</button>}</div></div><p className="app-text-wrap text-[var(--foreground)]">{c.text}</p></div>))}</div>
|
||||
<div className="mt-2 flex items-center gap-2"><input value={h.commentDrafts[item.id] || ""} onChange={(e) => h.setCommentDrafts((p) => ({ ...p, [item.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" /><button type="button" onClick={() => h.handleAddComment(item.id)} disabled={h.busyKey === `comment-${item.id}`} className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60">Отправить</button></div>
|
||||
</div>
|
||||
)}
|
||||
@ -316,7 +316,7 @@ function RequestsPageContent() {
|
||||
const dau = dr.attachment_url || dr.attachment || ""; const dan = dau ? decodeURIComponent(dau.split("/").pop() || "Вложение") : "";
|
||||
return (
|
||||
<div className="space-y-5 text-sm text-[var(--foreground)]">
|
||||
<div className="app-surface-muted rounded-xl p-4"><div className="space-y-3"><div className="flex flex-wrap items-center gap-2"><span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">{dtl}</span><span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${ds.className}`}>{ds.label}</span></div><h2 className="break-words text-lg font-semibold text-[var(--foreground)]">{dr.display_title || dr.title || "Без заголовка"}</h2>{(dr.comment || dr.description) ? <div className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--foreground)]">{dr.comment || dr.description}</div> : <p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>}</div></div>
|
||||
<div className="app-surface-muted rounded-xl p-4"><div className="space-y-3"><div className="flex flex-wrap items-center gap-2"><span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">{dtl}</span><span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${ds.className}`}>{ds.label}</span></div><h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">{dr.display_title || dr.title || "Без заголовка"}</h2>{(dr.comment || dr.description) ? <div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">{dr.comment || dr.description}</div> : <p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>}</div></div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p><div className="mt-3 space-y-2"><div><p className="app-text-muted text-xs">Период</p><p className="mt-1 text-sm text-[var(--foreground)]">{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}</p></div><div><p className="app-text-muted text-xs">Создано</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDate(dr.created_at) || "—"}</p></div><div><p className="app-text-muted text-xs">Обновлено</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDate(dr.updated_at) || "—"}</p></div></div></div>
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Участники</p><div className="mt-3 space-y-3"><div><p className="app-text-muted text-xs">Автор</p><div className="mt-2 flex flex-wrap gap-2">{da ? renderUserBadge(da as User, true) : <span className="app-text-muted text-sm">—</span>}</div></div><div><p className="app-text-muted text-xs">Решающий</p><div className="mt-2 flex flex-wrap gap-2">{dap ? renderUserBadge(dap as User, true) : <span className="app-text-muted text-sm">—</span>}</div></div></div></div>
|
||||
@ -324,7 +324,7 @@ function RequestsPageContent() {
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>{(dr.departments || []).length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{(dr.departments || []).map((id) => <span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">{h.departmentNameMap.get(Number(id)) || `Отдел #${id}`}</span>)}</div> : <p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>}</div>
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Получатели</p>{(dr.recipients || []).length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{(dr.recipients || []).map((r) => <div key={r.id}>{renderUserBadge(r, true)}</div>)}</div> : <p className="app-text-muted mt-3 text-sm">Получатели не указаны</p>}</div>
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">В копии</p>{(dr.cc_users || []).length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{(dr.cc_users || []).map((c) => <div key={c.id}>{renderUserBadge(c, true)}</div>)}</div> : <p className="app-text-muted mt-3 text-sm">Копия не указана</p>}</div>
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Вложение</p>{dau ? <div className="mt-3 flex flex-wrap items-center gap-3"><button type="button" onClick={() => h.setAttachmentPreview({ url: dau, name: dan })} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"><Paperclip size={15} /><span className="break-all">{dan}</span></button><a href={dau} target="_blank" rel="noreferrer" className="app-link-accent text-sm font-medium hover:underline">Открыть в новой вкладке</a></div> : <p className="app-text-muted mt-3 text-sm">Вложение отсутствует</p>}</div>
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Вложение</p>{dau ? <div className="mt-3 flex flex-wrap items-center gap-3"><button type="button" onClick={() => h.setAttachmentPreview({ url: dau, name: dan })} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"><Paperclip size={15} /><span className="app-text-wrap">{dan}</span></button><a href={dau} target="_blank" rel="noreferrer" className="app-link-accent text-sm font-medium hover:underline">Открыть в новой вкладке</a></div> : <p className="app-text-muted mt-3 text-sm">Вложение отсутствует</p>}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -361,7 +361,7 @@ export default function UserDetailPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
|
||||
<h1 className="app-text-wrap text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
|
||||
{fullName}
|
||||
</h1>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
@ -449,12 +449,12 @@ export default function UserDetailPage() {
|
||||
? "noreferrer"
|
||||
: undefined
|
||||
}
|
||||
className="app-text-muted mt-1 block truncate text-sm hover:text-[var(--accent-primary)]"
|
||||
className="app-text-wrap app-text-muted mt-1 block text-sm hover:text-[var(--accent-primary)]"
|
||||
>
|
||||
{contact.value}
|
||||
</a>
|
||||
) : (
|
||||
<p className="app-text-muted mt-1 truncate text-sm">
|
||||
<p className="app-text-wrap app-text-muted mt-1 text-sm">
|
||||
{contact.value}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -118,7 +118,7 @@ export function ViewDayEventsModal({
|
||||
|
||||
{/* Description preview */}
|
||||
{event.description && (
|
||||
<p className="app-text-muted mt-1.5 line-clamp-2 text-xs">
|
||||
<p className="app-text-wrap app-text-muted mt-1.5 line-clamp-2 text-xs">
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -193,7 +193,7 @@ export function ViewEventDetailsModal({
|
||||
<FileText size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="mb-1 text-sm font-medium text-[var(--foreground)]">Описание</p>
|
||||
<p className="app-text-muted whitespace-pre-wrap text-xs">
|
||||
<p className="app-text-wrap app-text-muted whitespace-pre-wrap text-xs">
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -227,7 +227,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="whitespace-pre-wrap text-sm text-[var(--foreground)]">{comment.text}</p>
|
||||
<p className="app-text-wrap whitespace-pre-wrap text-sm text-[var(--foreground)]">{comment.text}</p>
|
||||
{depth < 3 && (
|
||||
<button
|
||||
onClick={() => setReplyTo(comment.id)}
|
||||
|
||||
@ -280,7 +280,7 @@ export function DocumentDetailModal({
|
||||
<h3 className="app-text-muted mb-3 text-xs font-semibold uppercase tracking-wide">
|
||||
Описание
|
||||
</h3>
|
||||
<p className="app-text-muted text-sm leading-relaxed">
|
||||
<p className="app-text-wrap app-text-muted text-sm leading-relaxed">
|
||||
{document.description || "Описание отсутствует"}
|
||||
</p>
|
||||
</div>
|
||||
@ -543,7 +543,7 @@ export function DocumentDetailModal({
|
||||
<h3 className="app-text-muted mb-3 text-xs font-semibold uppercase tracking-wide">
|
||||
Описание
|
||||
</h3>
|
||||
<p className="app-text-muted text-sm leading-relaxed">
|
||||
<p className="app-text-wrap app-text-muted text-sm leading-relaxed">
|
||||
{document.description || "Описание отсутствует"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
|
||||
import { EmojiStyle, SuggestionMode, Theme } from "emoji-picker-react";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import emojiDataRu from "emoji-picker-react/dist/data/emojis-ru.js";
|
||||
|
||||
const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false });
|
||||
@ -14,17 +15,20 @@ type ReactionPickerModalProps = {
|
||||
};
|
||||
|
||||
export default function ReactionPickerModal({ onClose, onSelect }: ReactionPickerModalProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title="Выберите реакцию" size="sm" className="reaction-picker-modal">
|
||||
<div className="space-y-4" data-reaction-picker="true">
|
||||
<p className="text-sm text-gray-500">Быстрый способ отреагировать на сообщение без текста.</p>
|
||||
<p className="app-text-muted text-sm">Быстрый способ отреагировать на сообщение без текста.</p>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-gray-100 bg-white">
|
||||
<div className="reaction-picker-surface app-surface-muted overflow-hidden rounded-xl">
|
||||
<EmojiPicker
|
||||
className="reaction-emoji-picker"
|
||||
emojiData={emojiDataRu}
|
||||
onEmojiClick={(emoji) => onSelect(emoji.emoji)}
|
||||
searchPlaceholder="Поиск смайликов"
|
||||
theme={Theme.LIGHT}
|
||||
theme={resolvedTheme === "dark" ? Theme.DARK : Theme.LIGHT}
|
||||
emojiStyle={EmojiStyle.NATIVE}
|
||||
suggestedEmojisMode={SuggestionMode.FREQUENT}
|
||||
lazyLoadEmojis
|
||||
@ -36,4 +40,4 @@ export default function ReactionPickerModal({ onClose, onSelect }: ReactionPicke
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -231,7 +231,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
</div>
|
||||
|
||||
{/* title */}
|
||||
<h3 className="mb-2 break-all text-base font-bold text-[var(--foreground)]">
|
||||
<h3 className="app-text-wrap mb-2 text-base font-bold text-[var(--foreground)]">
|
||||
<span className="app-text-muted">{typeLabel}:</span> {req.display_title || req.title || "Без заголовка"}
|
||||
</h3>
|
||||
|
||||
@ -243,7 +243,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
|
||||
{/* description */}
|
||||
{summaryText && (
|
||||
<div className={`app-surface-muted rounded-lg p-3 text-sm leading-relaxed text-[var(--foreground)] break-all ${detailOpen ? "" : "line-clamp-3"}`}>
|
||||
<div className={`app-text-wrap app-surface-muted rounded-lg p-3 text-sm leading-relaxed text-[var(--foreground)] ${detailOpen ? "" : "line-clamp-3"}`}>
|
||||
{summaryText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -102,7 +102,7 @@ export default function EmployeeActionsTimeline({
|
||||
) : null}
|
||||
</div>
|
||||
{action.comment ? (
|
||||
<p className="app-text-muted mt-2 text-sm leading-6">
|
||||
<p className="app-text-wrap app-text-muted mt-2 text-sm leading-6">
|
||||
{action.comment}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@ -710,17 +710,29 @@ export function useEquipmentPage(user: User | null) {
|
||||
}
|
||||
}, [closeOperationModal, loadRowDetails, maintenanceForm, selectedEquipment]);
|
||||
|
||||
const handleOpenQr = useCallback(async (equipmentId: number) => {
|
||||
try {
|
||||
setBusyKey(`qr-${equipmentId}`);
|
||||
const blobUrl = await apiClient.getEquipmentQrCodeBlobUrl(equipmentId);
|
||||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
} catch (qrError) {
|
||||
setActionError(String((qrError as Error)?.message || "Не удалось получить QR-код"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
const openEquipmentById = useCallback(async (equipmentId: number, options?: { expand?: boolean }) => {
|
||||
if (options?.expand !== false) {
|
||||
setExpandedRows((previous) => ({ ...previous, [equipmentId]: true }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const existing = detailsMap[equipmentId] || items.find((item) => item.id === equipmentId);
|
||||
if (!existing) {
|
||||
try {
|
||||
const detail = (await apiClient.getEquipmentDetail(equipmentId)) as Equipment;
|
||||
setItems((previous) =>
|
||||
previous.some((item) => item.id === equipmentId)
|
||||
? previous
|
||||
: [detail, ...previous]
|
||||
);
|
||||
setDetailsMap((previous) => ({ ...previous, [equipmentId]: detail }));
|
||||
} catch (loadError) {
|
||||
setActionError(String((loadError as Error)?.message || "Не удалось открыть оборудование"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await loadRowDetails(equipmentId);
|
||||
}, [detailsMap, items, loadRowDetails]);
|
||||
|
||||
const toggleComments = useCallback(async (equipmentId: number) => {
|
||||
const isOpen = Boolean(expandedComments[equipmentId]);
|
||||
@ -820,10 +832,10 @@ export function useEquipmentPage(user: User | null) {
|
||||
handleDeleteComment,
|
||||
handleLoadMore,
|
||||
handleMaintenance,
|
||||
handleOpenQr,
|
||||
handleSave,
|
||||
handleTransfer,
|
||||
handleWriteOff,
|
||||
openEquipmentById,
|
||||
isCreateMode,
|
||||
isModalOpen,
|
||||
listMode,
|
||||
@ -865,4 +877,4 @@ export function useEquipmentPage(user: User | null) {
|
||||
toggleRow,
|
||||
writeOffReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,9 +90,18 @@ const getPaginatedCount = (response: unknown): number => {
|
||||
|
||||
const getReadableError = (error: unknown, fallback: string): string => {
|
||||
const raw = String((error as Error)?.message || fallback);
|
||||
const jsonStart = raw.indexOf("{");
|
||||
const payload = jsonStart >= 0 ? raw.slice(jsonStart) : raw;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
if (typeof parsed.error === "string" && parsed.error.trim()) {
|
||||
return parsed.error;
|
||||
}
|
||||
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
|
||||
return parsed.detail;
|
||||
}
|
||||
return Object.entries(parsed)
|
||||
.map(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
|
||||
@ -23,15 +23,6 @@ export function createEquipmentApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
getMaintenanceRecords: (params?: Record<string, string | number>) => request(`/api/v1/procurement/maintenance/${buildQuery(params)}`),
|
||||
getEquipmentCategoryTree: () => request('/api/v1/procurement/equipment-categories/tree/'),
|
||||
getEquipmentCategoryChildren: (categoryId: number) => request(`/api/v1/procurement/equipment-categories/${categoryId}/children/`),
|
||||
getEquipmentQrCodeBlobUrl: async (equipmentId: number): Promise<string> => {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(`/api/v1/procurement/equipment/${equipmentId}/qr_code/`, { method: 'GET', headers });
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
},
|
||||
getEquipmentComments: (equipmentId: number) => request(`/api/v1/procurement/equipment/${equipmentId}/comments/`),
|
||||
addEquipmentComment: (equipmentId: number, text: string) => request(`/api/v1/procurement/equipment/${equipmentId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
deleteEquipmentComment: (equipmentId: number, commentId: number): Promise<void> => request(`/api/v1/procurement/equipment/${equipmentId}/comments/${commentId}/`, { method: 'DELETE' }),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user