refactor: синхронизированы статусные бейджи во всех шаблонах закупок

- Обновлены иконки статусов в dashboard.html и request_list.html
- Все статусы теперь используют одинаковые иконки и стили
- Добавлены функции getStatusBadgeHTML() для динамического рендеринга
- Исправлена синтаксическая ошибка в loadRequests()
- Статусы: draft, pending, approved, rejected, in_progress, completed, cancelled
This commit is contained in:
admin i 2025-12-10 16:37:14 +03:00
parent 427f5ed22f
commit b787df45f6
22 changed files with 1094 additions and 517 deletions

View File

@ -0,0 +1 @@
# API модуля закупок

View File

@ -6,7 +6,7 @@ from rest_framework import permissions
from api.v1.permissions import has_dept_perm
from employees.constants import DeptPerm
from employees.models import Department, Employee, EmployeeDepartment
from employees.models import Department, EmployeeDepartment
class IsDepartmentHead(permissions.BasePermission):
@ -56,6 +56,8 @@ class CanCreateProcurementRequest(permissions.BasePermission):
"""Проверка права на создание заявки на закупку."""
def has_permission(self, request, view):
from employees.models import Employee
if not request.user or not request.user.is_authenticated:
return False

View File

@ -4,7 +4,7 @@
from rest_framework import serializers
from .models import (
from procurement.models import (
Approval,
Budget,
Equipment,
@ -344,6 +344,11 @@ class EquipmentListSerializer(serializers.ModelSerializer):
source='category.name',
read_only=True
)
category_icon = serializers.CharField(
source='category.icon',
read_only=True,
default='bi-box-seam'
)
department_name = serializers.CharField(
source='department.name',
read_only=True
@ -366,6 +371,7 @@ class EquipmentListSerializer(serializers.ModelSerializer):
'inventory_number',
'category',
'category_name',
'category_icon',
'status',
'status_display',
'department',
@ -380,6 +386,7 @@ class EquipmentListSerializer(serializers.ModelSerializer):
'id',
'inventory_number', # Генерируется автоматически
'category_name',
'category_icon',
'department_name',
'responsible_name',
'status_display',
@ -444,13 +451,15 @@ class EquipmentListSerializer(serializers.ModelSerializer):
).select_related('department', 'role')
for link in user_dept_links:
if has_dept_perm(user, link.department_id, DeptPerm.MANAGE_EQUIPMENT):
if has_dept_perm(
user, link.department_id, DeptPerm.MANAGE_EQUIPMENT
):
return 'scoped'
return None
def _get_user_allowed_departments(self, user, perm_level):
"""Возвращает отделы, в которых пользователь может создать оборудование."""
"""Возвращает отделы, где пользователь может создать оборудование."""
from employees.models import Department, EmployeeDepartment
if perm_level == 'full':
@ -507,7 +516,9 @@ class EquipmentListSerializer(serializers.ModelSerializer):
elif perm_level == 'dept_head':
# Начальник — только свои отделы
allowed_depts = self._get_user_allowed_departments(user, perm_level)
allowed_depts = self._get_user_allowed_departments(
user, perm_level
)
allowed_ids = [d.id for d in allowed_depts]
if department and department.id not in allowed_ids:
@ -528,7 +539,9 @@ class EquipmentListSerializer(serializers.ModelSerializer):
elif perm_level == 'scoped':
# Скоуп-право — только свой отдел, ответственный = начальник
allowed_depts = self._get_user_allowed_departments(user, perm_level)
allowed_depts = self._get_user_allowed_departments(
user, perm_level
)
allowed_ids = [d.id for d in allowed_depts]
if department and department.id not in allowed_ids:

View File

@ -2,7 +2,6 @@
URL конфигурация для API модуля закупок.
"""
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import (
@ -60,6 +59,4 @@ router.register(
basename='stats'
)
urlpatterns = [
path('', include(router.urls)),
]
urlpatterns = router.urls

View File

@ -12,8 +12,8 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from employees.models import Department
from .constants import ApprovalStatus, ProcurementStatus, EquipmentStatus
from .models import (
from procurement.constants import ApprovalStatus, ProcurementStatus, EquipmentStatus
from procurement.models import (
Approval,
Budget,
Equipment,
@ -24,7 +24,7 @@ from .models import (
ProcurementRequest,
Supplier,
)
from .services import QRCodeGenerator
from procurement.services import QRCodeGenerator
from .permissions import (
CanApproveProcurementRequest,
CanManageBudget,
@ -34,6 +34,7 @@ from .permissions import (
)
from .serializers import (
BudgetSerializer,
BudgetDetailSerializer,
EquipmentCategorySerializer,
EquipmentDetailSerializer,
EquipmentListSerializer,
@ -84,26 +85,66 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Фильтровать заявки в зависимости от роли пользователя."""
"""Фильтровать заявки в зависимости от роли пользователя и scope."""
from datetime import timedelta
from django.utils import timezone
queryset = super().get_queryset()
user = self.request.user
scope = self.request.query_params.get('scope', None)
period = self.request.query_params.get('period', None)
# Админы и пользователи с модельными правами видят все
if user.is_superuser or user.is_staff:
return queryset
# Обработка scope параметра
if scope == 'mine':
# Только мои заявки (где я автор)
queryset = queryset.filter(requestor=user)
elif scope == 'department':
# Заявки моего отдела
queryset = queryset.filter(department__in=user.departments.all())
elif scope == 'my_work':
# Заявки, которые я взял в работу (где я исполнитель)
queryset = queryset.filter(executor=user)
elif scope == 'available':
# Доступные заявки - со статусом "Согласовано"
queryset = queryset.filter(status='approved')
elif scope == 'all':
# Все заявки - применяем стандартную логику прав
pass
else:
# Если scope не указан, применяем стандартную логику
# Админы и пользователи с модельными правами видят все
if user.is_superuser or user.is_staff:
pass
# Пользователи с любыми модельными правами на заявки видят все
elif (user.has_perm('procurement.view_procurementrequest') or
user.has_perm('procurement.change_procurementrequest') or
user.has_perm('procurement.delete_procurementrequest')):
pass
else:
# Показываем: свои заявки + заявки отдела + где я approver
queryset = queryset.filter(
Q(requestor=user) |
Q(department__in=user.departments.all()) |
Q(approvals__approver=user)
)
# Пользователи с любыми модельными правами на заявки видят все
if (user.has_perm('procurement.view_procurementrequest') or
user.has_perm('procurement.change_procurementrequest') or
user.has_perm('procurement.delete_procurementrequest')):
return queryset
# Фильтрация по периоду
if period:
now = timezone.now()
if period == 'today':
start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
queryset = queryset.filter(created_at__gte=start_date)
elif period == 'week':
start_date = now - timedelta(days=7)
queryset = queryset.filter(created_at__gte=start_date)
elif period == 'month':
start_date = now - timedelta(days=30)
queryset = queryset.filter(created_at__gte=start_date)
elif period == 'quarter':
start_date = now - timedelta(days=90)
queryset = queryset.filter(created_at__gte=start_date)
# Показываем: свои заявки + заявки отдела + где я approver
return queryset.filter(
Q(requestor=user) |
Q(department__in=user.departments.all()) |
Q(approvals__approver=user)
).distinct()
return queryset.distinct()
def _check_budget_alert(self, procurement_request):
"""Проверяет бюджет и отправляет алерт если низкий остаток."""
@ -248,7 +289,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
f'ожидает вашего согласования. '
f'Сумма: {procurement_request.total_cost}₽'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
action_url=f'/procurement/orders/{procurement_request.id}/',
send_immediately=True,
)
@ -289,7 +330,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
f'{request.user.get_full_name()} одобрил '
f'заявку "{procurement_request.title}".'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
action_url=f'/procurement/orders/{procurement_request.id}/',
send_immediately=True,
)
@ -312,7 +353,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
f'Ваша заявка "{procurement_request.title}" '
f'была полностью одобрена. Можно приступать к закупке.'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
action_url=f'/procurement/orders/{procurement_request.id}/',
send_immediately=True,
)
@ -361,7 +402,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
f'заявку "{procurement_request.title}". '
f'Причина: {approval.comment}'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
action_url=f'/procurement/orders/{procurement_request.id}/',
send_immediately=True,
)
@ -472,7 +513,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
f'взята в работу пользователем '
f'{request.user.get_full_name()}.'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
action_url=f'/procurement/orders/{procurement_request.id}/',
send_immediately=True,
)
@ -489,7 +530,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
f'взята в работу пользователем '
f'{request.user.get_full_name()}.'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
action_url=f'/procurement/orders/{procurement_request.id}/',
send_immediately=True,
)
@ -535,7 +576,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
f'Заявка "{procurement_request.title}" '
f'успешно завершена.'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
action_url=f'/procurement/orders/{procurement_request.id}/',
send_immediately=True,
)
@ -618,7 +659,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
f'была отменена автором. '
f'Причина: {reason or "не указана"}'
),
action_url=f'/procurement/requests/{procurement_request.id}/',
action_url=f'/procurement/orders/{procurement_request.id}/',
send_immediately=True,
)
@ -860,6 +901,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
}
}, status=status.HTTP_200_OK)
class EquipmentCategoryViewSet(viewsets.ModelViewSet):
"""ViewSet для категорий оборудования."""
@ -1334,7 +1376,6 @@ class BudgetViewSet(viewsets.ModelViewSet):
def my_department(self, request):
"""Получить бюджет текущего квартала для отдела пользователя."""
from django.utils import timezone
from .serializers import BudgetDetailSerializer
user = request.user
now = timezone.now()
@ -1518,4 +1559,3 @@ class ProcurementStatsViewSet(viewsets.ViewSet):
})
return Response(result)

View File

@ -159,5 +159,10 @@ urlpatterns = [
get_poll_results,
name="poll_results"
),
# Procurement API
path(
"procurement/",
include("api.v1.procurement.urls", namespace="procurement")
),
path("", include(router.urls)),
]

View File

@ -15,12 +15,8 @@ urlpatterns = [
path("finance/", include("finance.urls", namespace="finance")),
path(
"procurement/",
include("procurement.urls_frontend", namespace="procurement")
include("procurement.urls_frontend", namespace="procurement_frontend")
),
path("api/", include(("api.urls", "api"), namespace="api")),
path(
"api/procurement/",
include("procurement.urls", namespace="procurement_api")
),
path("", include("feed.urls_front", namespace="feed")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -12,16 +12,16 @@ urlpatterns = [
# Дашборд
path('', views.dashboard, name='dashboard'),
# Заявки на закупку
path('requests/', views.request_list, name='request_list'),
path('requests/my/', views.my_requests, name='my_requests'),
# Заявки на закупку (orders - чтобы не путать с заявлениями requests)
path('orders/', views.request_list, name='request_list'),
path('orders/my/', views.my_requests, name='my_requests'),
path(
'requests/pending/',
'orders/pending/',
views.pending_approvals,
name='pending_approvals'
),
path('requests/create/', views.request_create, name='request_create'),
path('requests/<int:pk>/', views.request_detail, name='request_detail'),
path('orders/create/', views.request_create, name='request_create'),
path('orders/<int:pk>/', views.request_detail, name='request_detail'),
# Оборудование
path('equipment/', views.equipment_list, name='equipment_list'),

View File

@ -30,13 +30,30 @@ def request_list(request):
- /api/v1/departments/ — для фильтра по отделам
"""
scope = request.GET.get('scope', 'mine')
user = request.user
# Читаем фильтры из GET параметров
filters = {
'status': request.GET.get('status', ''),
'urgency': request.GET.get('urgency', ''),
'period': request.GET.get('period', ''),
}
# Определяем доступные представления
can_view_department = user.departments.exists()
can_view_all = (
user.is_staff or user.is_superuser or
user.has_perm('procurement.view_procurementrequest') or
user.has_perm('procurement.change_procurementrequest')
)
return render(request, 'procurement/request_list.html', {
'scope': scope,
'show_tabs': True,
'can_view_department': can_view_department,
'can_view_all': can_view_all,
'filters': filters,
})
@login_required
def my_requests(request):
"""Мои заявки — редирект на request_list."""
@ -51,24 +68,63 @@ def pending_approvals(request):
Данные загружаются через API:
- /api/procurement/requests/pending_approvals/
"""
user = request.user
# Определяем доступные представления
can_view_department = user.departments.exists()
can_view_all = (
user.is_staff or user.is_superuser or
user.has_perm('procurement.view_procurementrequest') or
user.has_perm('procurement.change_procurementrequest')
)
return render(request, 'procurement/request_list.html', {
'scope': 'pending',
'page_title': 'На согласовании',
'show_tabs': True,
'can_view_department': can_view_department,
'can_view_all': can_view_all,
})
@login_required
@login_required
def request_detail(request, pk):
"""Детальная страница заявки.
Данные загружаются через API:
- /api/procurement/requests/{pk}/
- /api/procurement/equipment/create-options/ — при создании оборудования
- /api/procurement/equipment-categories/ — категории
Загружает данные из БД и передаёт в шаблон.
"""
from django.shortcuts import get_object_or_404
from procurement.models import ProcurementRequest
request_obj = get_object_or_404(
ProcurementRequest.objects.select_related(
'department', 'requestor', 'executor'
).prefetch_related('items', 'approvals__approver'),
pk=pk
)
user = request.user
# Проверяем права на редактирование
can_edit = (
request_obj.requestor == user and
request_obj.status == 'draft'
)
# Проверяем права на согласование
can_approve = (
request_obj.approvals.filter(
approver=user,
status='pending'
).exists()
)
return render(request, 'procurement/request_detail.html', {
'request_id': pk,
'request_obj': request_obj,
'can_edit': can_edit,
'can_approve': can_approve,
})

View File

@ -235,12 +235,12 @@
color: var(--bs-danger);
}
.proc-status.ordered {
.proc-status.in_progress {
background: color-mix(in srgb, var(--bs-info) 15%, transparent);
color: var(--bs-info);
}
.proc-status.delivered {
.proc-status.completed {
background: color-mix(in srgb, var(--bs-primary) 15%, transparent);
color: var(--bs-primary);
}

View File

@ -33,8 +33,8 @@
<span>Отделы</span>
</a>
<a href="{% url 'requests:request_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'request_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'request_list' %}aria-current="page"{% endif %}>
class="nav-link {% if request.resolver_match.app_name == 'requests' %}active{% endif %}"
{% if request.resolver_match.app_name == 'requests' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-clipboard-check"></i></span>
<span>Заявления</span>
</a>

View File

@ -54,18 +54,30 @@
const urgency = document.getElementById('filterUrgency').value;
const period = document.getElementById('filterPeriod').value;
// Сохраняем scope если он есть
const scope = params.get('scope');
if (status) params.set('status', status); else params.delete('status');
if (urgency) params.set('urgency', urgency); else params.delete('urgency');
if (period) params.set('period', period); else params.delete('period');
// Восстанавливаем scope
if (scope) params.set('scope', scope);
window.location.search = params.toString();
});
document.getElementById('resetFilters')?.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
const scope = params.get('scope');
params.delete('status');
params.delete('urgency');
params.delete('period');
// Восстанавливаем scope
if (scope) params.set('scope', scope);
window.location.search = params.toString();
});
</script>

View File

@ -17,13 +17,13 @@
<span class="proc-status rejected">
<i class="bi-x-circle me-1"></i>Отклонено
</span>
{% elif status == 'ordered' %}
<span class="proc-status ordered">
{% elif status == 'in_progress' %}
<span class="proc-status in_progress">
<i class="bi-truck me-1"></i>Заказано
</span>
{% elif status == 'delivered' %}
<span class="proc-status delivered">
<i class="bi-box-seam me-1"></i>Получено
{% elif status == 'completed' %}
<span class="proc-status completed">
<i class="bi-check-all me-1"></i>Завершено
</span>
{% elif status == 'cancelled' %}
<span class="proc-status cancelled">

View File

@ -224,7 +224,7 @@
// === ЗАГРУЗКА БЮДЖЕТА ===
async function loadBudget() {
try {
const resp = await fetch(`/api/procurement/budgets/${budgetId}/`);
const resp = await fetch(`/api/v1/procurement/budgets/${budgetId}/`);
if (!resp.ok) {
if (resp.status === 404) {
showError('Бюджет не найден');
@ -307,8 +307,8 @@
};
const url = budgetId
? `/api/procurement/budgets/${budgetId}/`
: '/api/procurement/budgets/';
? `/api/v1/procurement/budgets/${budgetId}/`
: '/api/v1/procurement/budgets/';
const method = budgetId ? 'PATCH' : 'POST';

View File

@ -300,7 +300,7 @@
if (currentFilters.quarter) params.set('quarter', currentFilters.quarter);
if (currentFilters.department) params.set('department', currentFilters.department);
const resp = await fetch(`/api/procurement/budgets/?${params}`);
const resp = await fetch(`/api/v1/procurement/budgets/?${params}`);
if (!resp.ok) throw new Error('Ошибка загрузки');
const data = await resp.json();
@ -515,7 +515,7 @@
}
try {
const resp = await fetch('/api/procurement/budgets/create_next_quarter/', {
const resp = await fetch('/api/v1/procurement/budgets/create_next_quarter/', {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),

View File

@ -20,7 +20,7 @@
<i class="bi-hourglass-split"></i>
</div>
<div class="proc-stat-content">
<div class="proc-stat-value" id="statPending">{{ stats.pending|default:0 }}</div>
<div class="proc-stat-value" id="statPending">0</div>
<div class="proc-stat-label">На согласовании</div>
</div>
</div>
@ -30,7 +30,7 @@
<i class="bi-check-circle"></i>
</div>
<div class="proc-stat-content">
<div class="proc-stat-value" id="statApproved">{{ stats.approved|default:0 }}</div>
<div class="proc-stat-value" id="statApproved">0</div>
<div class="proc-stat-label">Одобрено</div>
</div>
</div>
@ -40,13 +40,7 @@
<i class="bi-wallet2"></i>
</div>
<div class="proc-stat-content">
<div class="proc-stat-value" id="statBudget">
{% if budget %}
{{ budget.remaining_percent|floatformat:0 }}%
{% else %}
—
{% endif %}
</div>
<div class="proc-stat-value" id="statBudget">—</div>
<div class="proc-stat-label">Бюджет отдела</div>
</div>
</div>
@ -56,7 +50,7 @@
<i class="bi-pc-display"></i>
</div>
<div class="proc-stat-content">
<div class="proc-stat-value" id="statEquipment">{{ stats.equipment|default:0 }}</div>
<div class="proc-stat-value" id="statEquipment">0</div>
<div class="proc-stat-label">Оборудование</div>
</div>
</div>
@ -94,8 +88,7 @@
</a>
</div>
{% if can_approve %}
<div class="col-md-6 col-lg-3">
<div class="col-md-6 col-lg-3" id="approvalsCard" style="display:none;">
<a href="{% url 'procurement_frontend:pending_approvals' %}" class="card text-decoration-none h-100">
<div class="card-body text-center py-4">
<i class="bi-clipboard-check text-warning fs-1 mb-2 d-block"></i>
@ -104,52 +97,8 @@
</div>
</a>
</div>
{% endif %}
</div>
{# ===== МОЙ БЮДЖЕТ (если есть) ===== #}
{% if budget %}
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi-pie-chart me-2"></i>Бюджет отдела на {{ budget.quarter }} кв. {{ budget.year }}
</h5>
</div>
<div class="card-body">
<div class="proc-budget-amounts">
<div class="proc-budget-amount">
<div class="proc-budget-value text-success">{{ budget.allocated_amount|floatformat:0 }} ₽</div>
<div class="proc-budget-label">Выделено</div>
</div>
<div class="proc-budget-amount">
<div class="proc-budget-value text-danger">{{ budget.spent_amount|floatformat:0 }} ₽</div>
<div class="proc-budget-label">Потрачено</div>
</div>
<div class="proc-budget-amount">
<div class="proc-budget-value text-warning">{{ budget.reserved_amount|floatformat:0 }} ₽</div>
<div class="proc-budget-label">Зарезервировано</div>
</div>
<div class="proc-budget-amount">
<div class="proc-budget-value text-primary">{{ budget.available_amount|floatformat:0 }} ₽</div>
<div class="proc-budget-label">Доступно</div>
</div>
</div>
<div class="proc-budget-progress">
{% with percent=budget.spent_percent %}
<div class="proc-budget-bar {% if percent > 80 %}danger{% elif percent > 60 %}warning{% else %}safe{% endif %}"
style="width: {{ percent|floatformat:0 }}%"></div>
{% endwith %}
</div>
<div class="text-end mt-2">
<small class="text-muted">
Использовано {{ budget.spent_percent|floatformat:0 }}% бюджета
</small>
</div>
</div>
</div>
{% endif %}
{# ===== ПОСЛЕДНИЕ ЗАЯВКИ ===== #}
<div class="card mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
@ -160,59 +109,11 @@
Все заявки <i class="bi-arrow-right"></i>
</a>
</div>
<div class="card-body p-0">
{% if recent_requests %}
<div class="proc-table-header d-none d-lg-block">
<div class="proc-table-row">
<div class="proc-cell"></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Заявка</h6></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Отдел</h6></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Сумма</h6></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Статус</h6></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Дата</h6></div>
</div>
</div>
{% for request in recent_requests %}
<div class="proc-request-item">
<div class="proc-urgency-icon {{ request.urgency }}">
{% if request.urgency == 'critical' %}
<i class="bi-exclamation-triangle-fill"></i>
{% elif request.urgency == 'high' %}
<i class="bi-arrow-up-circle"></i>
{% elif request.urgency == 'medium' %}
<i class="bi-dash-circle"></i>
{% else %}
<i class="bi-arrow-down-circle"></i>
{% endif %}
</div>
<div class="proc-request-info">
<div class="proc-request-title">
<a href="{% url 'procurement_frontend:request_detail' request.id %}">{{ request.title }}</a>
</div>
<div class="proc-request-desc">{{ request.description|truncatechars:50 }}</div>
</div>
<div class="proc-request-dept">{{ request.department.name }}</div>
<div class="proc-request-amount">{{ request.estimated_cost|floatformat:0 }} ₽</div>
<div class="proc-request-status">
{% include "procurement/_status_badge.html" with status=request.status %}
</div>
<div class="proc-request-date">
{{ request.created_at|date:"d.m.Y" }}
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi-inbox fs-1 d-block mb-2"></i>
<p class="mb-0">Заявок пока нет</p>
</div>
{% endif %}
<div class="card-body p-0" id="recentRequestsContainer">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm"></div>
<p class="mb-0 mt-2">Загрузка...</p>
</div>
</div>
</div>
@ -244,11 +145,8 @@
<div class="col-12">
<label class="form-label">Отдел <span class="text-danger">*</span></label>
<select name="department" class="form-select" required>
<select name="department" id="createDepartment" class="form-select" required>
<option value="">Выберите отдел...</option>
{% for dept in departments %}
<option value="{{ dept.id }}">{{ dept.name }}</option>
{% endfor %}
</select>
</div>
@ -311,6 +209,132 @@
{% block extra_js %}
<script>
// === ЗАГРУЗКА ДАННЫХ ПРИ СТАРТЕ ===
document.addEventListener('DOMContentLoaded', () => {
loadDashboardData();
loadDepartments();
});
async function loadDashboardData() {
try {
// Загрузка статистики
const statsResp = await fetch('/api/v1/procurement/stats/overview/');
if (statsResp.ok) {
const stats = await statsResp.json();
document.getElementById('statPending').textContent = stats.pending || 0;
document.getElementById('statApproved').textContent = stats.approved || 0;
document.getElementById('statEquipment').textContent = stats.equipment_count || 0;
}
// Загрузка бюджета
const budgetResp = await fetch('/api/v1/procurement/budgets/my-department/');
if (budgetResp.ok) {
const budget = await budgetResp.json();
if (budget && budget.remaining_percent !== undefined) {
document.getElementById('statBudget').textContent = Math.round(budget.remaining_percent) + '%';
}
}
// Загрузка последних заявок
const reqResp = await fetch('/api/v1/procurement/requests/?ordering=-created_at&page_size=5');
if (reqResp.ok) {
const data = await reqResp.json();
renderRecentRequests(data.results || data);
}
} catch (e) {
console.error('Error loading dashboard:', e);
}
}
// Функция для генерации HTML статуса (аналогично _status_badge.html)
function getStatusBadgeHTML(status) {
const statusConfig = {
draft: { icon: 'bi-pencil', text: 'Черновик', class: 'draft' },
pending: { icon: 'bi-hourglass-split', text: 'На согласовании', class: 'pending' },
approved: { icon: 'bi-check-circle', text: 'Одобрено', class: 'approved' },
rejected: { icon: 'bi-x-circle', text: 'Отклонено', class: 'rejected' },
in_progress: { icon: 'bi-truck', text: 'Заказано', class: 'in_progress' },
completed: { icon: 'bi-check-all', text: 'Завершено', class: 'completed' },
cancelled: { icon: 'bi-slash-circle', text: 'Отменено', class: 'cancelled' }
};
const config = statusConfig[status] || statusConfig.draft;
return `<span class="proc-status ${config.class}"><i class="${config.icon} me-1"></i>${config.text}</span>`;
}
function renderRecentRequests(requests) {
const container = document.getElementById('recentRequestsContainer');
if (!container) return;
if (!requests || !requests.length) {
container.innerHTML = '<div class="text-center py-4 text-muted"><i class="bi-inbox fs-1 d-block mb-2"></i><p>Заявок пока нет</p></div>';
return;
}
const urgencyIcons = {
critical: 'bi-exclamation-triangle-fill',
high: 'bi-arrow-up-circle',
medium: 'bi-dash-circle',
low: 'bi-arrow-down-circle'
};
let html = '<div class="proc-table-header d-none d-lg-block"><div class="proc-table-row">';
html += '<div class="proc-cell"></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Заявка</h6></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Отдел</h6></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Сумма</h6></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Статус</h6></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Дата</h6></div>';
html += '</div></div>';
requests.forEach(r => {
const urgIcon = urgencyIcons[r.urgency] || 'bi-dash-circle';
const deptName = r.department_name || r.department?.name || '—';
const cost = (r.total_cost || 0).toLocaleString('ru-RU');
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('ru-RU') : '—';
html += `<div class="proc-request-item">`;
html += `<div class="proc-urgency-icon ${r.urgency}"><i class="${urgIcon}"></i></div>`;
html += `<div class="proc-request-info">`;
html += `<div class="proc-request-title"><a href="/procurement/orders/${r.id}/">${escapeHtml(r.title)}</a></div>`;
html += `<div class="proc-request-desc">${escapeHtml((r.description || '').substring(0, 50))}</div>`;
html += `</div>`;
html += `<div class="proc-request-dept">${escapeHtml(deptName)}</div>`;
html += `<div class="proc-request-amount">${cost} ₽</div>`;
html += `<div class="proc-request-status">${getStatusBadgeHTML(r.status)}</div>`;
html += `<div class="proc-request-date">${date}</div>`;
html += `</div>`;
});
container.innerHTML = html;
}
async function loadDepartments() {
try {
const resp = await fetch('/api/v1/departments/');
if (resp.ok) {
const depts = await resp.json();
const select = document.getElementById('createDepartment');
if (select) {
depts.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name;
select.appendChild(opt);
});
}
}
} catch (e) {
console.error('Error loading departments:', e);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// === УПРАВЛЕНИЕ ПОЗИЦИЯМИ В ЗАЯВКЕ ===
let itemIndex = 0;
@ -418,7 +442,7 @@
data.items = items;
}
const resp = await fetch('/api/procurement/requests/', {
const resp = await fetch('/api/v1/procurement/requests/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -444,7 +468,7 @@
modal?.hide();
// Перейти к заявке
window.location.href = `/procurement/requests/${request.id}/`;
window.location.href = `/procurement/orders/${request.id}/`;
} catch (error) {
console.error('Error creating request:', error);
@ -457,7 +481,7 @@
// Автообновление статистики каждые 60 секунд
setInterval(async () => {
try {
const resp = await fetch('/api/procurement/stats/overview/');
const resp = await fetch('/api/v1/procurement/stats/overview/');
if (resp.ok) {
const data = await resp.json();
document.getElementById('statPending').textContent = data.pending || 0;

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ equipment.name }}{% endblock %}
{% block title %}Оборудование{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
@ -51,7 +51,7 @@
</div>
<div class="text-end">
<a href="/api/procurement/equipment/{{ equipment.id }}/qr_code/"
<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-код
@ -347,6 +347,43 @@
{% block extra_js %}
<script>
// Получаем ID оборудования из URL
const equipmentId = window.location.pathname.split('/').filter(x => x && !isNaN(x))[0];
// Загрузка данных при старте
document.addEventListener('DOMContentLoaded', () => {
if (equipmentId) {
loadEquipmentDetail();
}
});
async function loadEquipmentDetail() {
try {
const resp = await fetch(`/api/v1/procurement/equipment/${equipmentId}/`);
if (!resp.ok) {
throw new Error('Equipment not found');
}
const equipment = await resp.json();
document.title = equipment.name;
// Обновляем хлебные крошки
const breadcrumb = document.querySelector('.breadcrumb-item.active');
if (breadcrumb) {
breadcrumb.textContent = equipment.inventory_number;
}
} catch (e) {
console.error('Error loading equipment:', e);
document.querySelector('.container').innerHTML = `
<div class="alert alert-danger">
<i class="bi-exclamation-triangle me-2"></i>
Ошибка загрузки оборудования
</div>
`;
}
}
{% if can_manage %}
// Обслуживание
document.getElementById('maintenanceForm')?.addEventListener('submit', async (e) => {
@ -359,11 +396,11 @@
};
try {
const resp = await fetch('/api/procurement/equipment/{{ equipment.id }}/add_maintenance/', {
const resp = await fetch(`/api/v1/procurement/equipment/${equipmentId}/add_maintenance/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}'
},
body: JSON.stringify(data)
});
@ -388,7 +425,7 @@
};
try {
const resp = await fetch('/api/procurement/equipment/{{ equipment.id }}/transfer/', {
const resp = await fetch('/api/v1/procurement/equipment/{{ equipment.id }}/transfer/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -414,7 +451,7 @@
if (!reason) return;
try {
const resp = await fetch('/api/procurement/equipment/{{ equipment.id }}/write_off/', {
const resp = await fetch('/api/v1/procurement/equipment/{{ equipment.id }}/write_off/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@ -13,157 +13,15 @@
{# ===== ШАПКА СТРАНИЦЫ ===== #}
{% include "includes/components/page_header.html" with icon="bi-laptop" title="Оборудование" search_id="eqSearch" search_placeholder="Поиск по названию или инв. номеру..." has_filters=True filters_target="eqFilters" filters_template="procurement/_equipment_filters.html" create_as_card=True create_modal="equipmentCreateModal" create_text="Добавить оборудование" %}
{# ===== СЕТКА ОБОРУДОВАНИЯ ===== #}
<div class="proc-equipment-grid" id="equipmentGrid">
{% if equipment_list %}
{% for item in equipment_list %}
<div class="proc-equipment-card" data-id="{{ item.id }}">
<div class="proc-equipment-header">
<div class="proc-equipment-icon">
{% if item.category.name == 'Компьютеры' or 'ноутбук' in item.name|lower %}
<i class="bi-laptop"></i>
{% elif 'монитор' in item.name|lower %}
<i class="bi-display"></i>
{% elif 'принтер' in item.name|lower %}
<i class="bi-printer"></i>
{% elif 'телефон' in item.name|lower %}
<i class="bi-phone"></i>
{% elif 'сервер' in item.name|lower %}
<i class="bi-hdd-rack"></i>
{% else %}
<i class="bi-pc-display-horizontal"></i>
{% endif %}
</div>
<div class="proc-equipment-info">
<div class="proc-equipment-name">
<a href="{% url 'procurement_frontend:equipment_detail' item.id %}" class="text-decoration-none">
{{ item.name }}
</a>
</div>
<div class="proc-equipment-inv">{{ item.inventory_number }}</div>
</div>
<div class="dropdown">
<button class="btn btn-sm btn-link text-muted" type="button" data-bs-toggle="dropdown">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="{% url 'procurement_frontend:equipment_detail' item.id %}">
<i class="bi-eye me-2"></i>Просмотр
</a>
</li>
{% if can_manage %}
<li>
<a class="dropdown-item edit-equipment-btn" href="#" data-bs-toggle="modal" data-bs-target="#equipmentEditModal" data-id="{{ item.id }}">
<i class="bi-pencil me-2"></i>Редактировать
</a>
</li>
{% endif %}
<li>
<a class="dropdown-item" href="{% url 'api:v1:equipment-qr-code' item.id %}" target="_blank">
<i class="bi-qr-code me-2"></i>QR-код
</a>
</li>
{% if can_manage %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#transferModal" data-id="{{ item.id }}">
<i class="bi-arrow-left-right me-2"></i>Перевести
</a>
</li>
<li>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#maintenanceModal" data-id="{{ item.id }}">
<i class="bi-wrench me-2"></i>Обслуживание
</a>
</li>
{% endif %}
</ul>
</div>
</div>
<div class="proc-equipment-body">
<div class="proc-equipment-meta">
{% if item.category %}
<div class="proc-equipment-meta-item">
<i class="bi-tag"></i>
<span>{{ item.category.name }}</span>
</div>
{% endif %}
{% if item.department %}
<div class="proc-equipment-meta-item">
<i class="bi-building"></i>
<span>{{ item.department.name }}</span>
</div>
{% endif %}
{% if item.responsible_person %}
<div class="proc-equipment-meta-item">
<i class="bi-person"></i>
<span>{{ item.responsible_person.get_full_name }}</span>
</div>
{% endif %}
{% if item.location %}
<div class="proc-equipment-meta-item">
<i class="bi-geo-alt"></i>
<span>{{ item.location }}</span>
</div>
{% endif %}
</div>
</div>
<div class="proc-equipment-footer">
{% include "procurement/_equipment_status.html" with status=item.status %}
<div class="text-muted small">
{{ item.purchase_date|date:"d.m.Y" }}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="text-center py-5 text-muted">
<i class="bi-inbox fs-1 d-block mb-2"></i>
<p class="mb-0">Оборудование не найдено</p>
</div>
</div>
{% endif %}
{# ===== СПИСОК ОБОРУДОВАНИЯ ===== #}
<div id="equipmentGrid">
<div class="text-center py-5 w-100">
<div class="spinner-border text-primary"></div>
<p class="mt-2">Загрузка...</p>
</div>
</div>
{# Пагинация #}
{% if page_obj %}
<div class="d-flex justify-content-center mt-4">
<nav aria-label="Страницы">
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
<i class="bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item"><a class="page-link" href="?page={{ num }}">{{ num }}</a></li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
<i class="bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{# Модалки и формы ниже #}
{# ===== МОДАЛКА СОЗДАНИЯ ОБОРУДОВАНИЯ ===== #}
<div class="modal fade" id="equipmentCreateModal" tabindex="-1" aria-hidden="true">
@ -471,16 +329,310 @@
{% block extra_js %}
<script>
// === ЗАГРУЗКА ОБОРУДОВАНИЯ ===
document.addEventListener('DOMContentLoaded', () => {
loadEquipment();
});
async function loadEquipment() {
try {
const resp = await fetch('/api/v1/procurement/equipment/');
if (!resp.ok) throw new Error('Failed to load equipment');
const equipment = await resp.json();
renderEquipment(equipment.results || equipment);
} catch (e) {
console.error('Error loading equipment:', e);
document.getElementById('equipmentGrid').innerHTML = `
<div class="text-center py-5 text-danger w-100">
<i class="bi-exclamation-triangle fs-1 d-block mb-2"></i>
<p>Ошибка загрузки оборудования</p>
</div>
`;
}
}
function renderEquipment(items) {
const grid = document.getElementById('equipmentGrid');
if (!items || !items.length) {
grid.innerHTML = `
<div class="text-center py-5 text-muted w-100">
<i class="bi-inbox fs-1 d-block mb-2"></i>
<p class="mb-0">Оборудование не найдено</p>
</div>
`;
return;
}
// Группируем оборудование по названию
const groups = {};
items.forEach(item => {
const key = item.name;
if (!groups[key]) {
groups[key] = {
main: item,
items: [],
count: 0
};
}
groups[key].items.push(item);
groups[key].count++;
});
// Преобразуем в массив и определяем is_group
const equipmentGroups = Object.values(groups).map(g => ({
...g,
is_group: g.count > 1
}));
// Статусы
const statusMap = {
available: { label: 'Доступно', class: 'success' },
in_use: { label: 'В использовании', class: 'primary' },
maintenance: { label: 'На обслуживании', class: 'warning' },
retired: { label: 'Списано', class: 'secondary' }
};
// Заголовок таблицы
let html = `
<div class="card mb-4">
<div class="proc-table-header d-none d-lg-block">
<div class="proc-equipment-row">
<div class="proc-cell"><h6 class="mb-0 text-secondary">Оборудование</h6></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Кол-во</h6></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Категория</h6></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Отдел</h6></div>
<div class="proc-cell"><h6 class="mb-0 text-secondary">Статус</h6></div>
<div class="proc-cell"></div>
</div>
</div>
<div id="equipmentListContent">
`;
equipmentGroups.forEach(group => {
const main = group.main;
const icon = main.category_icon || 'bi-box-seam';
const statusInfo = statusMap[main.status] || { label: main.status_display || main.status, class: 'secondary' };
// Основная строка группы
html += `
<div class="proc-equipment-item ${group.is_group ? 'has-children' : ''}" data-id="${main.id}">
<div class="proc-equipment-info">
${group.is_group ? `
<button class="btn btn-sm btn-link p-0 me-1 proc-expand-btn" type="button"
data-bs-toggle="collapse" data-bs-target="#group-${main.id}" aria-expanded="false">
<i class="bi-chevron-right"></i>
</button>
` : ''}
<div class="proc-equipment-icon-sm">
<i class="${escapeHtml(icon)}"></i>
</div>
<div class="proc-equipment-details">
<div class="proc-equipment-name">
${group.is_group
? escapeHtml(main.name)
: `<a href="/procurement/equipment/${main.id}/">${escapeHtml(main.name)}</a>`
}
</div>
<div class="proc-equipment-inv">
${group.is_group
? `${group.count} единиц`
: escapeHtml(main.inventory_number)
}
</div>
</div>
</div>
<div class="proc-equipment-count">
<span class="badge ${group.count > 1 ? 'bg-primary' : 'bg-secondary'}">${group.count}</span>
</div>
<div class="proc-equipment-category">
${main.category_name ? escapeHtml(main.category_name) : '<span class="text-muted">—</span>'}
</div>
<div class="proc-equipment-dept">
${main.department_name ? escapeHtml(main.department_name) : '<span class="text-muted">—</span>'}
</div>
<div class="proc-equipment-status">
<span class="badge bg-${statusInfo.class}">${statusInfo.label}</span>
</div>
<div class="proc-equipment-actions">
${!group.is_group ? `
<div class="dropdown">
<button class="btn btn-sm btn-link text-muted p-0" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" style="position: absolute;">
<li>
<a class="dropdown-item" href="/procurement/equipment/${main.id}/">
<i class="bi-eye me-2"></i>Просмотр
</a>
</li>
<li>
<a class="dropdown-item edit-equipment-btn" href="#" data-bs-toggle="modal" data-bs-target="#equipmentEditModal" data-id="${main.id}">
<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}">
<i class="bi-arrow-left-right me-2"></i>Перевести
</a>
</li>
<li>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#maintenanceModal" data-id="${main.id}">
<i class="bi-wrench me-2"></i>Обслуживание
</a>
</li>
</ul>
</div>
` : ''}
</div>
</div>
`;
// Развёрнутый список единиц группы
if (group.is_group) {
html += `
<div class="collapse proc-equipment-group" id="group-${main.id}">
`;
group.items.forEach(item => {
const itemStatusInfo = statusMap[item.status] || { label: item.status_display || item.status, class: 'secondary' };
html += `
<div class="proc-equipment-subitem" data-id="${item.id}">
<div class="proc-equipment-info">
<div class="proc-equipment-details">
<div class="proc-equipment-name">
<a href="/procurement/equipment/${item.id}/">${escapeHtml(item.inventory_number)}</a>
</div>
<div class="proc-equipment-inv">
${item.responsible_name ? escapeHtml(item.responsible_name) : 'Не назначен'}
</div>
</div>
</div>
<div class="proc-equipment-count"></div>
<div class="proc-equipment-category"></div>
<div class="proc-equipment-dept"></div>
<div class="proc-equipment-status">
<span class="badge bg-${itemStatusInfo.class}">${itemStatusInfo.label}</span>
</div>
<div class="proc-equipment-actions">
<div class="dropdown">
<button class="btn btn-sm btn-link text-muted p-0" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" style="position: absolute;">
<li>
<a class="dropdown-item" href="/procurement/equipment/${item.id}/">
<i class="bi-eye me-2"></i>Просмотр
</a>
</li>
<li>
<a class="dropdown-item edit-equipment-btn" href="#" data-bs-toggle="modal" data-bs-target="#equipmentEditModal" data-id="${item.id}">
<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}">
<i class="bi-arrow-left-right me-2"></i>Перевести
</a>
</li>
<li>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#maintenanceModal" data-id="${item.id}">
<i class="bi-wrench me-2"></i>Обслуживание
</a>
</li>
</ul>
</div>
</div>
</div>
`;
});
html += `</div>`;
}
});
html += `
</div>
</div>
`;
grid.innerHTML = html;
// Инициализируем обработчики раскрытия групп после рендеринга
initExpandButtons();
}
function initExpandButtons() {
document.querySelectorAll('.proc-expand-btn').forEach(btn => {
const collapseEl = document.querySelector(btn.dataset.bsTarget);
if (collapseEl) {
collapseEl.addEventListener('show.bs.collapse', () => {
btn.setAttribute('aria-expanded', 'true');
});
collapseEl.addEventListener('hide.bs.collapse', () => {
btn.setAttribute('aria-expanded', 'false');
});
}
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Поиск по оборудованию
const searchInput = document.getElementById('eqSearch');
if (searchInput) {
searchInput.addEventListener('input', debounce((e) => {
const query = e.target.value.toLowerCase();
document.querySelectorAll('.proc-equipment-card').forEach(card => {
const name = card.querySelector('.proc-equipment-name')?.textContent.toLowerCase() || '';
const inv = card.querySelector('.proc-equipment-inv')?.textContent.toLowerCase() || '';
document.querySelectorAll('.proc-equipment-item').forEach(item => {
const name = item.querySelector('.proc-equipment-name')?.textContent.toLowerCase() || '';
const inv = item.querySelector('.proc-equipment-inv')?.textContent.toLowerCase() || '';
const match = name.includes(query) || inv.includes(query);
card.style.display = match ? '' : 'none';
item.style.display = match ? '' : 'none';
// Скрываем/показываем связанную группу
const groupId = item.dataset.id;
const groupEl = document.getElementById('group-' + groupId);
if (groupEl) {
groupEl.style.display = match ? '' : 'none';
}
});
}, 300));
}
@ -511,7 +663,7 @@
};
try {
const resp = await fetch(`/api/procurement/equipment/${id}/transfer/`, {
const resp = await fetch(`/api/v1/procurement/equipment/${id}/transfer/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -545,7 +697,7 @@
};
try {
const resp = await fetch(`/api/procurement/equipment/${id}/add_maintenance/`, {
const resp = await fetch(`/api/v1/procurement/equipment/${id}/add_maintenance/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -585,7 +737,7 @@
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Создание...';
try {
const resp = await fetch('/api/procurement/equipment/', {
const resp = await fetch('/api/v1/procurement/equipment/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -630,7 +782,7 @@
document.getElementById('editEquipmentId').value = equipmentId;
try {
const resp = await fetch(`/api/procurement/equipment/${equipmentId}/`);
const resp = await fetch(`/api/v1/procurement/equipment/${equipmentId}/`);
if (resp.ok) {
const eq = await resp.json();
@ -674,7 +826,7 @@
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Сохранение...';
try {
const resp = await fetch(`/api/procurement/equipment/${equipmentId}/`, {
const resp = await fetch(`/api/v1/procurement/equipment/${equipmentId}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',

View File

@ -2,8 +2,8 @@
Модал создания оборудования (включаемый шаблон)
Данные загружаются через API:
- /api/procurement/categories/ - категории
- /api/procurement/equipment/create-options/ - отделы и права
- /api/v1/procurement/categories/ - категории
- /api/v1/procurement/equipment/create-options/ - отделы и права
- /api/employees/users/ - пользователи (если нужен выбор)
Опциональный контекст для предзаполнения:

View File

@ -112,10 +112,10 @@
if (fromItemId) {
// Создание из позиции заявки - используем специальный endpoint
// Сначала генерируем инвентарный номер
const invResp = await fetch('/api/procurement/equipment/generate-inventory-number/');
const invResp = await fetch('/api/v1/procurement/equipment/generate-inventory-number/');
const invData = await invResp.json();
resp = await fetch(`/api/procurement/items/${fromItemId}/create_equipment/`, {
resp = await fetch(`/api/v1/procurement/items/${fromItemId}/create_equipment/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -152,7 +152,7 @@
} else {
// Обычное создание оборудования
for (let i = 0; i < quantity; i++) {
resp = await fetch('/api/procurement/equipment/', {
resp = await fetch('/api/v1/procurement/equipment/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Заявка #{{ request_obj.id }}{% endblock %}
{% block title %}Заявка{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
@ -11,15 +11,6 @@
{% block content %}
<div class="container">
{# ===== Хлебные крошки ===== #}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'procurement_frontend:dashboard' %}">Закупки</a></li>
<li class="breadcrumb-item"><a href="{% url 'procurement_frontend:request_list' %}">Заявки</a></li>
<li class="breadcrumb-item active" aria-current="page">#{{ request_obj.id }}</li>
</ol>
</nav>
{# ===== Заголовок ===== #}
<div class="card mb-4">
<div class="card-body">
@ -47,7 +38,7 @@
<div class="text-end">
{% include "procurement/_status_badge.html" with status=request_obj.status %}
<div class="mt-2 h4 mb-0">{{ request_obj.estimated_cost|floatformat:0 }} ₽</div>
<div class="mt-2 h4 mb-0">{{ request_obj.total_cost|floatformat:0 }} ₽</div>
</div>
</div>
</div>
@ -80,57 +71,72 @@
</button>
{% endif %}
</div>
<div class="card-body p-0">
{% if request_obj.items.exists %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Наименование</th>
<th class="text-center" style="width: 80px;">Кол-во</th>
<th class="text-end" style="width: 120px;">Цена</th>
<th class="text-end" style="width: 120px;">Сумма</th>
<th style="width: 60px;"></th>
</tr>
</thead>
<tbody>
{% for item in request_obj.items.all %}
<tr>
<td>
<div class="fw-medium">{{ item.name }}</div>
{% if item.specifications %}
<small class="text-muted">{{ item.specifications|truncatechars:50 }}</small>
{% endif %}
</td>
<td class="text-center">{{ item.quantity }}</td>
<td class="text-end">{{ item.unit_price|floatformat:0 }} ₽</td>
<td class="text-end fw-medium">{{ item.total_price|floatformat:0 }} ₽</td>
<td>
{% if can_edit %}
<button class="btn btn-sm btn-link text-danger p-0" title="Удалить">
<i class="bi-trash"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-end fw-bold">Итого:</td>
<td class="text-end fw-bold">{{ request_obj.total_amount|floatformat:0 }} ₽</td>
<td></td>
</tr>
</tfoot>
</table>
{% if request_obj.items.exists %}
<div class="proc-table-header d-none d-lg-block">
<div class="proc-table-row">
<div class="proc-cell" style="flex: 3;"><h6 class="mb-0 text-secondary">Наименование</h6></div>
<div class="proc-cell" style="flex: 1;"><h6 class="mb-0 text-secondary text-center">Кол-во</h6></div>
<div class="proc-cell" style="flex: 1;"><h6 class="mb-0 text-secondary text-end">Цена</h6></div>
<div class="proc-cell" style="flex: 1;"><h6 class="mb-0 text-secondary text-end">Сумма</h6></div>
{% if can_edit %}
<div class="proc-cell" style="width: 50px;"></div>
{% endif %}
</div>
{% else %}
</div>
<div class="card-body p-0">
{% for item in request_obj.items.all %}
<div class="proc-request-item">
<div class="proc-cell" style="flex: 3;">
<div class="fw-medium">{{ item.name }}</div>
{% if item.description %}
<small class="text-muted d-block mt-1">{{ item.description|truncatechars:80 }}</small>
{% endif %}
</div>
<div class="proc-cell text-center" style="flex: 1;">
<span class="d-lg-none text-muted small">Количество: </span>
{{ item.quantity }} {{ item.unit }}
</div>
<div class="proc-cell text-end" style="flex: 1;">
<span class="d-lg-none text-muted small">Цена: </span>
{{ item.estimated_unit_price|floatformat:0 }} ₽
</div>
<div class="proc-cell text-end" style="flex: 1;">
<span class="d-lg-none text-muted small">Сумма: </span>
<strong>{{ item.total_price|floatformat:0 }} ₽</strong>
</div>
{% if can_edit %}
<div class="proc-cell text-center" style="width: 50px;">
<button class="btn btn-sm btn-link text-danger p-0" title="Удалить" onclick="deleteItem({{ item.id }})">
<i class="bi-trash"></i>
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{# Итого #}
<div class="proc-table-header border-top">
<div class="proc-table-row">
<div class="proc-cell" style="flex: 3;"></div>
<div class="proc-cell" style="flex: 1;"></div>
<div class="proc-cell text-end" style="flex: 1;"><h6 class="mb-0 text-secondary">Итого:</h6></div>
<div class="proc-cell text-end" style="flex: 1;"><h6 class="mb-0 fw-bold">{{ request_obj.total_cost|floatformat:0 }} ₽</h6></div>
{% if can_edit %}
<div class="proc-cell" style="width: 50px;"></div>
{% endif %}
</div>
</div>
{% else %}
<div class="card-body">
<div class="text-center py-4 text-muted">
<i class="bi-inbox fs-3 d-block mb-2"></i>
<p class="mb-0">Позиции не добавлены</p>
</div>
{% endif %}
</div>
{% endif %}
</div>
{# История согласования #}
@ -155,8 +161,12 @@
<div>
<div class="fw-medium">{{ approval.approver.get_full_name }}</div>
<div class="text-muted small">
{% if approval.decided_at %}
{{ approval.decided_at|date:"d.m.Y H:i" }}
{% if approval.status == 'approved' %}
Одобрено
{% if approval.decided_at %} — {{ approval.decided_at|date:"d.m.Y H:i" }}{% endif %}
{% elif approval.status == 'rejected' %}
Отклонено
{% if approval.decided_at %} — {{ approval.decided_at|date:"d.m.Y H:i" }}{% endif %}
{% else %}
Ожидает решения
{% endif %}
@ -222,10 +232,14 @@
{% else %}🟢 Низкая{% endif %}
</span>
</li>
{% if request_obj.desired_date %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Желаемая дата</span>
<span>{{ request_obj.desired_date|date:"d.m.Y" }}</span>
<span class="text-muted">Статус</span>
<span>{% include "procurement/_status_badge.html" with status=request_obj.status %}</span>
</li>
{% if request_obj.executor %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Исполнитель</span>
<span>{{ request_obj.executor.get_full_name }}</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between">
@ -236,6 +250,24 @@
<span class="text-muted">Обновлено</span>
<span>{{ request_obj.updated_at|date:"d.m.Y H:i" }}</span>
</li>
{% if request_obj.submitted_at %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Отправлено на согласование</span>
<span>{{ request_obj.submitted_at|date:"d.m.Y H:i" }}</span>
</li>
{% endif %}
{% if request_obj.started_at %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Взято в работу</span>
<span>{{ request_obj.started_at|date:"d.m.Y H:i" }}</span>
</li>
{% endif %}
{% if request_obj.completed_at %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Завершено</span>
<span>{{ request_obj.completed_at|date:"d.m.Y H:i" }}</span>
</li>
{% endif %}
</ul>
</div>
</div>
@ -246,15 +278,68 @@
{% block extra_js %}
<script>
// Получаем ID заявки из URL
const requestId = window.location.pathname.split('/').filter(x => x && !isNaN(x))[0];
let requestData = null;
// Загрузка данных при старте
document.addEventListener('DOMContentLoaded', () => {
if (requestId) {
loadRequestDetail();
}
});
async function loadRequestDetail() {
try {
const resp = await fetch(`/api/v1/procurement/requests/${requestId}/`);
if (!resp.ok) {
throw new Error('Request not found');
}
requestData = await resp.json();
renderRequestDetail(requestData);
} catch (e) {
console.error('Error loading request:', e);
document.querySelector('.container').innerHTML = `
<div class="alert alert-danger">
<i class="bi-exclamation-triangle me-2"></i>
Ошибка загрузки заявки
</div>
`;
}
}
function renderRequestDetail(req) {
// Обновляем title страницы
document.title = `Заявка #${req.id}`;
// Рендерим контент (упрощённая версия - можно расширить)
// Основные элементы уже есть в шаблоне, просто обновляем их содержимое
// Обновляем иконку срочности
const urgencyIcon = document.querySelector('.proc-urgency-icon');
if (urgencyIcon) {
urgencyIcon.className = `proc-urgency-icon ${req.urgency}`;
const icons = {
critical: 'bi-exclamation-triangle-fill',
high: 'bi-arrow-up-circle',
medium: 'bi-dash-circle',
low: 'bi-arrow-down-circle'
};
urgencyIcon.innerHTML = `<i class="${icons[req.urgency] || 'bi-dash-circle'}"></i>`;
}
}
// Отправка на согласование
document.getElementById('submitRequest')?.addEventListener('click', async () => {
if (!confirm('Отправить заявку на согласование?')) return;
try {
const resp = await fetch('/api/procurement/requests/{{ request_obj.id }}/submit/', {
const resp = await fetch(`/api/v1/procurement/requests/${requestId}/submit/`, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}'
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}'
}
});

View File

@ -14,26 +14,50 @@
{% include "includes/components/page_header.html" with icon="bi-list-ul" title=page_title|default:"Заявки на закупку" search_id="procSearch" search_placeholder="Поиск по названию..." has_filters=True filters_target="procFilters" filters_template="procurement/_filters.html" create_as_card=True create_modal="procCreateModal" create_text="Создать заявку" %}
{# ===== Tabs для переключения области ===== #}
{% if can_view_all %}
{% if show_tabs %}
<div class="card mb-3">
<div class="card-body py-2">
<ul class="nav nav-pills nav-fill">
<ul class="nav nav-pills nav-fill" id="scopeTabs">
<li class="nav-item">
<a class="nav-link {% if scope == 'mine' %}active{% endif %}"
href="?scope=mine">
href="#"
data-scope="mine"
role="button">
<i class="bi-person-circle me-1"></i> Мои заявки
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if scope == 'my_work' %}active{% endif %}"
href="#"
data-scope="my_work"
role="button">
<i class="bi-briefcase me-1"></i> Взял в работу
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if scope == 'available' %}active{% endif %}"
href="#"
data-scope="available"
role="button">
<i class="bi-check2-circle me-1"></i> Доступные
</a>
</li>
{% if can_view_department %}
<li class="nav-item">
<a class="nav-link {% if scope == 'department' %}active{% endif %}"
href="?scope=department">
href="#"
data-scope="department"
role="button">
<i class="bi-building me-1"></i> Мой отдел
</a>
</li>
{% if user.is_staff %}
{% endif %}
{% if can_view_all %}
<li class="nav-item">
<a class="nav-link {% if scope == 'all' %}active{% endif %}"
href="?scope=all">
href="#"
data-scope="all"
role="button">
<i class="bi-globe me-1"></i> Все заявки
</a>
</li>
@ -57,94 +81,11 @@
</div>
<div id="procListContent">
{% if requests %}
{% for request in requests %}
<div class="proc-request-item" data-id="{{ request.id }}">
<div class="proc-urgency-icon {{ request.urgency }}">
{% if request.urgency == 'critical' %}
<i class="bi-exclamation-triangle-fill"></i>
{% elif request.urgency == 'high' %}
<i class="bi-arrow-up-circle"></i>
{% elif request.urgency == 'medium' %}
<i class="bi-dash-circle"></i>
{% else %}
<i class="bi-arrow-down-circle"></i>
{% endif %}
</div>
<div class="proc-request-info">
<div class="proc-request-title">
<a href="{% url 'procurement_frontend:request_detail' request.id %}">{{ request.title }}</a>
</div>
<div class="proc-request-desc">
{{ request.description|truncatechars:60 }}
{% if request.items.count > 0 %}
<span class="text-muted">({{ request.items.count }} поз.)</span>
{% endif %}
</div>
</div>
<div class="proc-request-dept">
<i class="bi-building text-muted me-1"></i>
{{ request.department.name }}
</div>
<div class="proc-request-amount">
{{ request.estimated_cost|floatformat:0 }} ₽
</div>
<div class="proc-request-status">
{% include "procurement/_status_badge.html" with status=request.status %}
</div>
<div class="proc-request-date">
{{ request.created_at|date:"d.m.Y" }}
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi-inbox fs-1 d-block mb-2"></i>
<p class="mb-2">Заявок не найдено</p>
<a href="#" data-bs-toggle="modal" data-bs-target="#procCreateModal" class="btn btn-primary btn-sm">
<i class="bi-plus-lg me-1"></i>Создать заявку
</a>
</div>
{% endif %}
<div class="text-center py-5 text-muted">
<div class="spinner-border text-primary"></div>
<p class="mt-2">Загрузка...</p>
</div>
</div>
{# Пагинация #}
{% if page_obj %}
<div class="card-footer">
<nav aria-label="Страницы">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&scope={{ scope }}">
<i class="bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item"><a class="page-link" href="?page={{ num }}&scope={{ scope }}">{{ num }}</a></li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}&scope={{ scope }}">
<i class="bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
{# ===== МОДАЛКА СОЗДАНИЯ ===== #}
@ -177,9 +118,7 @@
<label class="form-label">Отдел <span class="text-danger">*</span></label>
<select name="department" class="form-select" required>
<option value="">Выберите отдел...</option>
{% for dept in departments %}
<option value="{{ dept.id }}">{{ dept.name }}</option>
{% endfor %}
<!-- Загрузится через JS -->
</select>
</div>
@ -242,6 +181,223 @@
{% block extra_js %}
<script>
// === ЗАГРУЗКА СПИСКА ЗАЯВОК ===
let currentPage = 1;
let currentScope = '{{ scope|default:"mine" }}';
// Читаем фильтры из URL
function getCurrentFilters() {
const urlParams = new URLSearchParams(window.location.search);
return {
status: urlParams.get('status') || '',
urgency: urlParams.get('urgency') || '',
period: urlParams.get('period') || ''
};
}
document.addEventListener('DOMContentLoaded', () => {
loadRequests();
loadDepartmentsForCreate();
initScopeTabs();
});
// === ПЕРЕКЛЮЧЕНИЕ ТАБОВ БЕЗ ПЕРЕЗАГРУЗКИ ===
function initScopeTabs() {
const tabs = document.querySelectorAll('#scopeTabs .nav-link');
tabs.forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
const newScope = tab.dataset.scope;
// Обновляем активный таб
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Обновляем текущий scope и перезагружаем список
currentScope = newScope;
currentPage = 1;
loadRequests();
// Обновляем URL без перезагрузки страницы
const url = new URL(window.location);
url.searchParams.set('scope', newScope);
window.history.pushState({}, '', url);
});
});
}
async function loadRequests(page = 1) {
try {
const params = new URLSearchParams({
page: page,
page_size: 20,
ordering: '-created_at'
});
// Добавляем фильтр по scope
if (currentScope === 'mine') {
params.append('scope', 'mine');
} else if (currentScope === 'department') {
params.append('scope', 'department');
} else if (currentScope === 'my_work') {
params.append('scope', 'my_work');
} else if (currentScope === 'available') {
params.append('scope', 'available');
}
// Для 'all' не добавляем scope
// Добавляем фильтры из URL
const filters = getCurrentFilters();
if (filters.status) params.append('status', filters.status);
if (filters.urgency) params.append('urgency', filters.urgency);
if (filters.period) params.append('period', filters.period);
const resp = await fetch(`/api/v1/procurement/requests/?${params}`);
if (!resp.ok) throw new Error('Failed to load requests');
const data = await resp.json();
renderRequests(data.results || []);
renderPagination(data);
} catch (e) {
console.error('Error loading requests:', e);
document.getElementById('procListContent').innerHTML = `
<div class="text-center py-5 text-danger">
<i class="bi-exclamation-triangle fs-1 d-block mb-2"></i>
<p>Ошибка загрузки заявок</p>
</div>
`;
}
}
// Функция для генерации HTML статуса (аналогично _status_badge.html)
function getStatusBadgeHTML(status) {
const statusConfig = {
draft: { icon: 'bi-pencil', text: 'Черновик', class: 'draft' },
pending: { icon: 'bi-hourglass-split', text: 'На согласовании', class: 'pending' },
approved: { icon: 'bi-check-circle', text: 'Одобрено', class: 'approved' },
rejected: { icon: 'bi-x-circle', text: 'Отклонено', class: 'rejected' },
in_progress: { icon: 'bi-truck', text: 'Заказано', class: 'in_progress' },
completed: { icon: 'bi-check-all', text: 'Завершено', class: 'completed' },
cancelled: { icon: 'bi-slash-circle', text: 'Отменено', class: 'cancelled' }
};
const config = statusConfig[status] || statusConfig.draft;
return `<span class="proc-status ${config.class}"><i class="${config.icon} me-1"></i>${config.text}</span>`;
}
function renderRequests(requests) {
const container = document.getElementById('procListContent');
if (!requests || !requests.length) {
container.innerHTML = `
<div class="text-center py-5 text-muted">
<i class="bi-inbox fs-1 d-block mb-2"></i>
<p>Заявок пока нет</p>
</div>
`;
return;
}
const urgencyIcons = {
low: 'bi-arrow-down-circle',
medium: 'bi-dash-circle',
high: 'bi-arrow-up-circle',
critical: 'bi-exclamation-triangle-fill'
};
const html = requests.map(req => {
const urgIcon = urgencyIcons[req.urgency] || 'bi-dash-circle';
const date = new Date(req.created_at).toLocaleDateString('ru-RU');
const cost = req.estimated_cost ? `${parseFloat(req.estimated_cost).toLocaleString('ru-RU')} ₽` : '—';
const deptName = req.department_name || req.department?.name || '—';
return `
<div class="proc-request-item" data-id="${req.id}">
<div class="proc-urgency-icon ${req.urgency}">
<i class="${urgIcon}"></i>
</div>
<div class="proc-request-info">
<div class="proc-request-title">
<a href="/procurement/orders/${req.id}/">${escapeHtml(req.title)}</a>
</div>
<div class="proc-request-desc">${escapeHtml((req.description || '').substring(0, 60))}</div>
</div>
<div class="proc-request-dept">
<i class="bi-building text-muted me-1"></i>
${escapeHtml(deptName)}
</div>
<div class="proc-request-amount">
${cost}
</div>
<div class="proc-request-status">
${getStatusBadgeHTML(req.status)}
</div>
<div class="proc-request-date">
${date}
</div>
</div>
`;
}).join('');
container.innerHTML = html;
}
function renderPagination(data) {
// TODO: Добавить пагинацию если нужно
currentPage = data.page || 1;
}
async function loadDepartmentsForCreate() {
try {
const resp = await fetch('/api/v1/departments/');
if (resp.ok) {
const depts = await resp.json();
const select = document.querySelector('#procCreateModal select[name="department"]');
if (select) {
// Очистим существующие опции кроме placeholder
select.innerHTML = '<option value="">Выберите отдел...</option>';
depts.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name;
select.appendChild(opt);
});
}
}
} catch (e) {
console.error('Error loading departments:', e);
}
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// === УПРАВЛЕНИЕ ПОЗИЦИЯМИ В ЗАЯВКЕ (для request_list) ===
let itemIndexReq = 0;
@ -349,7 +505,7 @@
data.items = items;
}
const resp = await fetch('/api/procurement/requests/', {
const resp = await fetch('/api/v1/procurement/requests/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -375,7 +531,7 @@
modal?.hide();
// Перейти к заявке
window.location.href = `/procurement/requests/${request.id}/`;
window.location.href = `/procurement/orders/${request.id}/`;
} catch (error) {
console.error('Error creating request:', error);
@ -385,6 +541,7 @@
}
});
// Поиск по заявкам
const searchInput = document.getElementById('procSearch');
if (searchInput) {
@ -399,4 +556,4 @@
}, 300));
}
</script>
{% endblock %}
{% endblock %}