refactor: синхронизированы статусные бейджи во всех шаблонах закупок
- Обновлены иконки статусов в dashboard.html и request_list.html - Все статусы теперь используют одинаковые иконки и стили - Добавлены функции getStatusBadgeHTML() для динамического рендеринга - Исправлена синтаксическая ошибка в loadRequests() - Статусы: draft, pending, approved, rejected, in_progress, completed, cancelled
This commit is contained in:
parent
427f5ed22f
commit
b787df45f6
1
backend/api/v1/procurement/__init__.py
Normal file
1
backend/api/v1/procurement/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# API модуля закупок
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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)),
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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/ - пользователи (если нужен выбор)
|
||||
|
||||
Опциональный контекст для предзаполнения:
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 }}'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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 %}
|
||||
Loading…
x
Reference in New Issue
Block a user