refactor(ui): stabilize equipment deep links and qr flow

This commit is contained in:
lizerk 2026-04-12 16:09:38 +03:00
parent 1552bbe84b
commit e3ba6ea925
32 changed files with 1059 additions and 437 deletions

View File

@ -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):
"""История переводов оборудования."""

View File

@ -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

View File

@ -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 (планируется)
## Разработка

View File

@ -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:

View File

@ -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

View File

@ -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">';

View File

@ -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>

View File

@ -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}">

View File

@ -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'
# ==============================================================================
# ТЕСТЫ КАТЕГОРИЙ
# ==============================================================================

View File

@ -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
):

View File

@ -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) ФИЛЬТРЫ

View File

@ -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."""

View File

@ -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",

View File

@ -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",

View File

@ -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>
)}

View File

@ -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>
);

View File

@ -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);

View File

@ -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" />

View File

@ -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>

View File

@ -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}

View File

@ -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>
);
})()}

View File

@ -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>
)}

View File

@ -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>
)}

View File

@ -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>

View File

@ -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)}

View File

@ -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>

View File

@ -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>
);
}
}

View File

@ -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>
)}

View File

@ -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}

View File

@ -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,
};
}
}

View File

@ -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)) {

View File

@ -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' }),