feat: add procurement frontend templates and styles
Stage 6 - Frontend Implementation: - Add procurement.css with iOS-style components - Add dashboard, request_list, request_detail, request_form templates - Add equipment_list, equipment_detail templates - Add status badges and filters partials - Add JS handlers for dashboard, requests and equipment - Add procurement link to sidebar navigation - Configure separate URL namespaces (procurement for API, procurement_frontend for views) All 66 tests passing.
This commit is contained in:
parent
3cd161d83c
commit
53a8f0e6b4
@ -13,10 +13,14 @@ urlpatterns = [
|
||||
path("notifications/", include("notifications.urls", namespace="notifications")),
|
||||
path("search/", include("search.urls", namespace="search")),
|
||||
path("finance/", include("finance.urls", namespace="finance")),
|
||||
path(
|
||||
"procurement/",
|
||||
include("procurement.urls_frontend", namespace="procurement")
|
||||
),
|
||||
path("api/", include(("api.urls", "api"), namespace="api")),
|
||||
path(
|
||||
"api/procurement/",
|
||||
include("procurement.urls", namespace="procurement")
|
||||
include("procurement.urls", namespace="procurement_api")
|
||||
),
|
||||
path("", include("feed.urls_front", namespace="feed")),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
33
backend/procurement/urls_frontend.py
Normal file
33
backend/procurement/urls_frontend.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""
|
||||
URL конфигурация для HTML страниц модуля закупок (Frontend).
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views_frontend as views
|
||||
|
||||
app_name = 'procurement_frontend'
|
||||
|
||||
urlpatterns = [
|
||||
# Дашборд
|
||||
path('', views.dashboard, name='dashboard'),
|
||||
|
||||
# Заявки на закупку
|
||||
path('requests/', views.request_list, name='request_list'),
|
||||
path('requests/my/', views.my_requests, name='my_requests'),
|
||||
path(
|
||||
'requests/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('equipment/', views.equipment_list, name='equipment_list'),
|
||||
path(
|
||||
'equipment/<int:pk>/',
|
||||
views.equipment_detail,
|
||||
name='equipment_detail'
|
||||
),
|
||||
]
|
||||
274
backend/procurement/views_frontend.py
Normal file
274
backend/procurement/views_frontend.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""
|
||||
HTML Views для модуля закупок (Frontend).
|
||||
"""
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.models import Department
|
||||
from .models import (
|
||||
Budget,
|
||||
Equipment,
|
||||
EquipmentCategory,
|
||||
ProcurementRequest,
|
||||
)
|
||||
from .constants import ProcurementStatus
|
||||
|
||||
|
||||
@login_required
|
||||
def dashboard(request):
|
||||
"""Главная страница модуля закупок."""
|
||||
user = request.user
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
# Получаем отделы пользователя
|
||||
user_departments = user.departments.filter(
|
||||
employeedepartment__is_active=True
|
||||
)
|
||||
|
||||
# Статистика
|
||||
stats = {
|
||||
'pending': ProcurementRequest.objects.filter(
|
||||
status=ProcurementStatus.PENDING
|
||||
).count(),
|
||||
'approved': ProcurementRequest.objects.filter(
|
||||
status=ProcurementStatus.APPROVED
|
||||
).count(),
|
||||
'equipment': Equipment.objects.count(),
|
||||
}
|
||||
|
||||
# Бюджет отдела (берём первый отдел пользователя)
|
||||
budget = None
|
||||
if user_departments.exists():
|
||||
budget = Budget.objects.filter(
|
||||
department=user_departments.first(),
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
).first()
|
||||
|
||||
# Последние заявки (мои или моего отдела)
|
||||
recent_requests = ProcurementRequest.objects.filter(
|
||||
department__in=user_departments
|
||||
).select_related(
|
||||
'department', 'requestor'
|
||||
).order_by('-created_at')[:5]
|
||||
|
||||
# Права на согласование
|
||||
can_approve = user.is_staff or user.headed_departments.exists()
|
||||
|
||||
context = {
|
||||
'stats': stats,
|
||||
'budget': budget,
|
||||
'recent_requests': recent_requests,
|
||||
'can_approve': can_approve,
|
||||
'create_url': 'procurement:request_create',
|
||||
}
|
||||
return render(request, 'procurement/dashboard.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def request_list(request):
|
||||
"""Список заявок на закупку."""
|
||||
user = request.user
|
||||
scope = request.GET.get('scope', 'mine')
|
||||
|
||||
# Базовый queryset
|
||||
queryset = ProcurementRequest.objects.select_related(
|
||||
'department', 'requestor'
|
||||
).prefetch_related('items')
|
||||
|
||||
# Фильтрация по области видимости
|
||||
if scope == 'mine':
|
||||
queryset = queryset.filter(requestor=user)
|
||||
page_title = 'Мои заявки'
|
||||
elif scope == 'department':
|
||||
user_departments = user.departments.filter(
|
||||
employeedepartment__is_active=True
|
||||
)
|
||||
queryset = queryset.filter(department__in=user_departments)
|
||||
page_title = 'Заявки отдела'
|
||||
elif scope == 'all' and user.is_staff:
|
||||
page_title = 'Все заявки'
|
||||
else:
|
||||
queryset = queryset.filter(requestor=user)
|
||||
page_title = 'Мои заявки'
|
||||
scope = 'mine'
|
||||
|
||||
# Фильтры
|
||||
filters = {}
|
||||
if status := request.GET.get('status'):
|
||||
queryset = queryset.filter(status=status)
|
||||
filters['status'] = status
|
||||
if urgency := request.GET.get('urgency'):
|
||||
queryset = queryset.filter(urgency=urgency)
|
||||
filters['urgency'] = urgency
|
||||
|
||||
# Сортировка
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
# Пагинация
|
||||
paginator = Paginator(queryset, 20)
|
||||
page_number = request.GET.get('page')
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
context = {
|
||||
'requests': page_obj,
|
||||
'page_obj': page_obj,
|
||||
'page_title': page_title,
|
||||
'scope': scope,
|
||||
'filters': filters,
|
||||
'can_view_all': user.is_staff or user.headed_departments.exists(),
|
||||
}
|
||||
return render(request, 'procurement/request_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def my_requests(request):
|
||||
"""Мои заявки."""
|
||||
return redirect('procurement:request_list', permanent=False)
|
||||
|
||||
|
||||
@login_required
|
||||
def pending_approvals(request):
|
||||
"""Заявки на согласовании (для руководителей)."""
|
||||
user = request.user
|
||||
|
||||
# Получаем отделы, которыми руководит пользователь
|
||||
headed_depts = user.headed_departments.all()
|
||||
|
||||
if not headed_depts.exists() and not user.is_staff:
|
||||
return redirect('procurement:dashboard')
|
||||
|
||||
queryset = ProcurementRequest.objects.filter(
|
||||
status=ProcurementStatus.PENDING
|
||||
).select_related(
|
||||
'department', 'requestor'
|
||||
)
|
||||
|
||||
# Для не-staff показываем только заявки своих отделов
|
||||
if not user.is_staff:
|
||||
queryset = queryset.filter(department__in=headed_depts)
|
||||
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
paginator = Paginator(queryset, 20)
|
||||
page_obj = paginator.get_page(request.GET.get('page'))
|
||||
|
||||
context = {
|
||||
'requests': page_obj,
|
||||
'page_obj': page_obj,
|
||||
'page_title': 'На согласовании',
|
||||
'scope': 'pending',
|
||||
'can_view_all': user.is_staff,
|
||||
}
|
||||
return render(request, 'procurement/request_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def request_detail(request, pk):
|
||||
"""Детальная страница заявки."""
|
||||
procurement_request = get_object_or_404(
|
||||
ProcurementRequest.objects.select_related(
|
||||
'department', 'requestor'
|
||||
).prefetch_related(
|
||||
'items__supplier',
|
||||
'approvals__approver'
|
||||
),
|
||||
pk=pk
|
||||
)
|
||||
|
||||
context = {
|
||||
'request_obj': procurement_request,
|
||||
'can_edit': (
|
||||
procurement_request.requestor == request.user and
|
||||
procurement_request.status == ProcurementStatus.DRAFT
|
||||
),
|
||||
'can_approve': (
|
||||
request.user.is_staff or
|
||||
request.user.headed_departments.filter(
|
||||
pk=procurement_request.department_id
|
||||
).exists()
|
||||
),
|
||||
}
|
||||
return render(request, 'procurement/request_detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def request_create(request):
|
||||
"""Страница/обработка создания заявки."""
|
||||
if request.method == 'POST':
|
||||
# Обработка формы - для простоты перенаправляем на API
|
||||
# В реальности можно обработать здесь или использовать Django Forms
|
||||
pass
|
||||
|
||||
context = {}
|
||||
return render(request, 'procurement/request_form.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def equipment_list(request):
|
||||
"""Список оборудования."""
|
||||
queryset = Equipment.objects.select_related(
|
||||
'category', 'department', 'responsible_person'
|
||||
)
|
||||
|
||||
# Фильтры
|
||||
filters = {}
|
||||
if category := request.GET.get('category'):
|
||||
queryset = queryset.filter(category_id=category)
|
||||
filters['category'] = category
|
||||
if status := request.GET.get('status'):
|
||||
queryset = queryset.filter(status=status)
|
||||
filters['status'] = status
|
||||
if department := request.GET.get('department'):
|
||||
queryset = queryset.filter(department_id=department)
|
||||
filters['department'] = department
|
||||
|
||||
# Поиск
|
||||
if search := request.GET.get('q'):
|
||||
queryset = queryset.filter(
|
||||
name__icontains=search
|
||||
) | queryset.filter(
|
||||
inventory_number__icontains=search
|
||||
)
|
||||
|
||||
queryset = queryset.order_by('-purchase_date')
|
||||
|
||||
# Пагинация
|
||||
paginator = Paginator(queryset, 24)
|
||||
page_obj = paginator.get_page(request.GET.get('page'))
|
||||
|
||||
context = {
|
||||
'equipment_list': page_obj,
|
||||
'page_obj': page_obj,
|
||||
'filters': filters,
|
||||
'categories': EquipmentCategory.objects.all(),
|
||||
'departments': Department.objects.all(),
|
||||
'can_manage': request.user.is_staff,
|
||||
}
|
||||
return render(request, 'procurement/equipment_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def equipment_detail(request, pk):
|
||||
"""Детальная страница оборудования."""
|
||||
equipment = get_object_or_404(
|
||||
Equipment.objects.select_related(
|
||||
'category', 'department', 'responsible_person'
|
||||
).prefetch_related(
|
||||
'maintenance_records', 'transfer_logs'
|
||||
),
|
||||
pk=pk
|
||||
)
|
||||
|
||||
context = {
|
||||
'equipment': equipment,
|
||||
'can_manage': (
|
||||
request.user.is_staff or
|
||||
equipment.responsible_person == request.user
|
||||
),
|
||||
}
|
||||
return render(request, 'procurement/equipment_detail.html', context)
|
||||
504
backend/static/css/components/procurement.css
Normal file
504
backend/static/css/components/procurement.css
Normal file
@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Procurement Module Styles (procurement.css)
|
||||
*
|
||||
* Стили для модуля закупок и инвентаря.
|
||||
* Использует CSS переменные из variables.css и компоненты из card-list.css
|
||||
*/
|
||||
|
||||
/* ===== Dashboard Cards (Статистика) ===== */
|
||||
.proc-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.proc-stat-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--feed-radius, 18px);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
transition: box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.proc-stat-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.proc-stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proc-stat-icon.pending {
|
||||
background: color-mix(in srgb, var(--bs-warning) 15%, transparent);
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.proc-stat-icon.approved {
|
||||
background: color-mix(in srgb, var(--bs-success) 15%, transparent);
|
||||
color: var(--bs-success);
|
||||
}
|
||||
|
||||
.proc-stat-icon.budget {
|
||||
background: color-mix(in srgb, var(--bs-primary) 15%, transparent);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.proc-stat-icon.equipment {
|
||||
background: color-mix(in srgb, var(--bs-info) 15%, transparent);
|
||||
color: var(--bs-info);
|
||||
}
|
||||
|
||||
.proc-stat-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.proc-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.proc-stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ===== Request List Table ===== */
|
||||
.proc-table-header {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.proc-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr 140px 100px 120px 100px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.proc-cell {
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.proc-cell h6 {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ===== Request Item ===== */
|
||||
.proc-request-item {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr 140px 100px 120px 100px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.proc-request-item:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.proc-request-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Иконка срочности */
|
||||
.proc-urgency-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.proc-urgency-icon.low {
|
||||
background: color-mix(in srgb, var(--bs-secondary) 15%, transparent);
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
.proc-urgency-icon.medium {
|
||||
background: color-mix(in srgb, var(--bs-info) 15%, transparent);
|
||||
color: var(--bs-info);
|
||||
}
|
||||
|
||||
.proc-urgency-icon.high {
|
||||
background: color-mix(in srgb, var(--bs-warning) 15%, transparent);
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.proc-urgency-icon.critical {
|
||||
background: color-mix(in srgb, var(--bs-danger) 15%, transparent);
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
|
||||
/* Информация о заявке */
|
||||
.proc-request-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.proc-request-title {
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.proc-request-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.proc-request-title a:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.proc-request-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-secondary-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Отдел */
|
||||
.proc-request-dept {
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Сумма */
|
||||
.proc-request-amount {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Дата */
|
||||
.proc-request-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
/* ===== Status Badges ===== */
|
||||
.proc-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.proc-status.draft {
|
||||
background: color-mix(in srgb, var(--bs-secondary) 15%, transparent);
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
.proc-status.pending {
|
||||
background: color-mix(in srgb, var(--bs-warning) 15%, transparent);
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.proc-status.approved {
|
||||
background: color-mix(in srgb, var(--bs-success) 15%, transparent);
|
||||
color: var(--bs-success);
|
||||
}
|
||||
|
||||
.proc-status.rejected {
|
||||
background: color-mix(in srgb, var(--bs-danger) 15%, transparent);
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
|
||||
.proc-status.ordered {
|
||||
background: color-mix(in srgb, var(--bs-info) 15%, transparent);
|
||||
color: var(--bs-info);
|
||||
}
|
||||
|
||||
.proc-status.delivered {
|
||||
background: color-mix(in srgb, var(--bs-primary) 15%, transparent);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.proc-status.cancelled {
|
||||
background: color-mix(in srgb, var(--bs-dark) 10%, transparent);
|
||||
color: var(--bs-dark);
|
||||
}
|
||||
|
||||
/* ===== Equipment Grid ===== */
|
||||
.proc-equipment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.proc-equipment-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--feed-radius, 18px);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.proc-equipment-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.proc-equipment-header {
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.proc-equipment-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bs-primary) 10%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--bs-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proc-equipment-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.proc-equipment-name {
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.proc-equipment-inv {
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--bs-font-monospace);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.proc-equipment-body {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.proc-equipment-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.proc-equipment-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.proc-equipment-meta-item i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.proc-equipment-footer {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Equipment Status */
|
||||
.proc-eq-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.proc-eq-status.available {
|
||||
background: color-mix(in srgb, var(--bs-success) 15%, transparent);
|
||||
color: var(--bs-success);
|
||||
}
|
||||
|
||||
.proc-eq-status.in_use {
|
||||
background: color-mix(in srgb, var(--bs-primary) 15%, transparent);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.proc-eq-status.maintenance {
|
||||
background: color-mix(in srgb, var(--bs-warning) 15%, transparent);
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.proc-eq-status.repair {
|
||||
background: color-mix(in srgb, var(--bs-info) 15%, transparent);
|
||||
color: var(--bs-info);
|
||||
}
|
||||
|
||||
.proc-eq-status.retired {
|
||||
background: color-mix(in srgb, var(--bs-secondary) 15%, transparent);
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
/* ===== Budget Card ===== */
|
||||
.proc-budget-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--feed-radius, 18px);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.proc-budget-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.proc-budget-title {
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.proc-budget-period {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.proc-budget-amounts {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.proc-budget-amount {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.proc-budget-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.proc-budget-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.proc-budget-progress {
|
||||
height: 8px;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.proc-budget-bar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.proc-budget-bar.safe {
|
||||
background: var(--bs-success);
|
||||
}
|
||||
|
||||
.proc-budget-bar.warning {
|
||||
background: var(--bs-warning);
|
||||
}
|
||||
|
||||
.proc-budget-bar.danger {
|
||||
background: var(--bs-danger);
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
@media (max-width: 991.98px) {
|
||||
.proc-table-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.proc-table-row,
|
||||
.proc-request-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
|
||||
.proc-request-item {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.proc-cell.proc-cell-icon,
|
||||
.proc-urgency-icon {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.proc-request-info {
|
||||
order: 2;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.proc-request-status,
|
||||
.proc-status {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.proc-request-dept,
|
||||
.proc-request-amount,
|
||||
.proc-request-date,
|
||||
.proc-request-actions {
|
||||
order: 4;
|
||||
width: 100%;
|
||||
font-size: 0.8rem;
|
||||
padding-left: calc(36px + var(--space-2));
|
||||
}
|
||||
|
||||
.proc-stats {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.proc-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.proc-equipment-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
391
backend/static/js/modules/equipmentListHandler.js
Normal file
391
backend/static/js/modules/equipmentListHandler.js
Normal file
@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Equipment List Handler
|
||||
* Обработчик для списка оборудования
|
||||
*/
|
||||
|
||||
export class EquipmentListHandler {
|
||||
constructor(options = {}) {
|
||||
this.apiBase = options.apiBase || '/api/procurement';
|
||||
this.container = options.container || '#equipmentList';
|
||||
this.filterForm = options.filterForm || '#filterForm';
|
||||
this.searchInput = options.searchInput || '#searchInput';
|
||||
|
||||
this.currentPage = 1;
|
||||
this.pageSize = 24;
|
||||
this.filters = {};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadEquipment();
|
||||
this.loadCategories();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Поиск
|
||||
const searchInput = document.querySelector(this.searchInput);
|
||||
if (searchInput) {
|
||||
let debounceTimer;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
this.filters.search = e.target.value;
|
||||
this.currentPage = 1;
|
||||
this.loadEquipment();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтры
|
||||
const filterForm = document.querySelector(this.filterForm);
|
||||
if (filterForm) {
|
||||
filterForm.addEventListener('change', () => {
|
||||
this.applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
// Кнопки быстрых фильтров по статусу
|
||||
document.querySelectorAll('[data-status-filter]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('[data-status-filter]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const status = btn.dataset.statusFilter;
|
||||
if (status === 'all') {
|
||||
delete this.filters.status;
|
||||
} else {
|
||||
this.filters.status = status;
|
||||
}
|
||||
this.currentPage = 1;
|
||||
this.loadEquipment();
|
||||
});
|
||||
});
|
||||
|
||||
// Переключение вида
|
||||
document.querySelectorAll('[data-view]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('[data-view]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const view = btn.dataset.view;
|
||||
const container = document.querySelector(this.container);
|
||||
if (container) {
|
||||
container.classList.toggle('list-view', view === 'list');
|
||||
container.classList.toggle('grid-view', view === 'grid');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters() {
|
||||
const form = document.querySelector(this.filterForm);
|
||||
if (!form) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
this.filters = {};
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
this.filters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPage = 1;
|
||||
this.loadEquipment();
|
||||
}
|
||||
|
||||
async loadCategories() {
|
||||
try {
|
||||
const resp = await fetch(`${this.apiBase}/categories/`);
|
||||
if (!resp.ok) return;
|
||||
|
||||
const categories = await resp.json();
|
||||
const select = document.querySelector('#categoryFilter');
|
||||
|
||||
if (select && categories.length) {
|
||||
select.innerHTML = '<option value="">Все категории</option>' +
|
||||
categories.map(cat => `<option value="${cat.id}">${this.escapeHtml(cat.name)}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadEquipment() {
|
||||
const container = document.querySelector(this.container);
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5 col-12">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
let url = `${this.apiBase}/equipment/?page=${this.currentPage}&page_size=${this.pageSize}`;
|
||||
|
||||
Object.entries(this.filters).forEach(([key, value]) => {
|
||||
url += `&${key}=${encodeURIComponent(value)}`;
|
||||
});
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Failed to load equipment');
|
||||
|
||||
const data = await resp.json();
|
||||
const equipment = data.results || data;
|
||||
|
||||
this.renderEquipment(equipment, data.count || equipment.length);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading equipment:', error);
|
||||
container.innerHTML = `
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi-exclamation-triangle me-2"></i>
|
||||
Ошибка загрузки оборудования
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
renderEquipment(equipment, totalCount) {
|
||||
const container = document.querySelector(this.container);
|
||||
if (!container) return;
|
||||
|
||||
// Сохраняем классы вида
|
||||
const isListView = container.classList.contains('list-view');
|
||||
|
||||
if (equipment.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="col-12 text-center py-5 text-muted">
|
||||
<i class="bi-inbox fs-1 d-block mb-3"></i>
|
||||
<h5>Оборудование не найдено</h5>
|
||||
<p class="mb-0">Попробуйте изменить параметры поиска</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = equipment.map(eq => this.renderEquipmentCard(eq)).join('');
|
||||
|
||||
if (isListView) {
|
||||
container.classList.add('list-view');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (totalCount > this.pageSize) {
|
||||
const paginationWrapper = document.createElement('div');
|
||||
paginationWrapper.className = 'col-12';
|
||||
paginationWrapper.innerHTML = this.renderPagination(totalCount);
|
||||
container.appendChild(paginationWrapper);
|
||||
this.bindPaginationEvents();
|
||||
}
|
||||
|
||||
// QR modals
|
||||
this.bindQRModals();
|
||||
}
|
||||
|
||||
renderEquipmentCard(eq) {
|
||||
const icon = this.getEquipmentIcon(eq.name, eq.category_name);
|
||||
|
||||
return `
|
||||
<div class="col-md-6 col-lg-4 col-xl-3">
|
||||
<div class="proc-equipment-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="proc-equipment-icon mb-3">
|
||||
${icon}
|
||||
</div>
|
||||
<h6 class="mb-1">${this.escapeHtml(eq.name)}</h6>
|
||||
<div class="mb-2">
|
||||
<code class="small">${eq.inventory_number}</code>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="proc-eq-status ${this.getStatusClass(eq.status)}">
|
||||
${this.getStatusLabel(eq.status)}
|
||||
</span>
|
||||
<small class="text-muted">${eq.category_name || ''}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/procurement/equipment/${eq.id}/" class="btn btn-outline-primary btn-sm flex-grow-1">
|
||||
<i class="bi-eye me-1"></i>Подробнее
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-qr-url="/api/procurement/equipment/${eq.id}/qr_code/"
|
||||
data-qr-name="${this.escapeHtml(eq.name)}"
|
||||
title="QR-код">
|
||||
<i class="bi-qr-code"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getEquipmentIcon(name, category) {
|
||||
const text = (name + ' ' + (category || '')).toLowerCase();
|
||||
|
||||
if (text.includes('ноутбук') || text.includes('laptop')) {
|
||||
return '<i class="bi-laptop"></i>';
|
||||
} else if (text.includes('монитор') || text.includes('display')) {
|
||||
return '<i class="bi-display"></i>';
|
||||
} else if (text.includes('принтер') || text.includes('printer')) {
|
||||
return '<i class="bi-printer"></i>';
|
||||
} else if (text.includes('телефон') || text.includes('phone')) {
|
||||
return '<i class="bi-phone"></i>';
|
||||
} else if (text.includes('сервер') || text.includes('server')) {
|
||||
return '<i class="bi-hdd-rack"></i>';
|
||||
} else if (text.includes('клавиатур') || text.includes('keyboard')) {
|
||||
return '<i class="bi-keyboard"></i>';
|
||||
} else if (text.includes('мышь') || text.includes('mouse')) {
|
||||
return '<i class="bi-mouse"></i>';
|
||||
} else if (text.includes('роутер') || text.includes('router') || text.includes('свитч') || text.includes('switch')) {
|
||||
return '<i class="bi-router"></i>';
|
||||
}
|
||||
return '<i class="bi-pc-display-horizontal"></i>';
|
||||
}
|
||||
|
||||
bindQRModals() {
|
||||
document.querySelectorAll('[data-qr-url]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const url = btn.dataset.qrUrl;
|
||||
const name = btn.dataset.qrName;
|
||||
this.showQRModal(url, name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showQRModal(qrUrl, name) {
|
||||
// Удаляем существующую модалку
|
||||
document.querySelector('#qrModal')?.remove();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'qrModal';
|
||||
modal.className = 'modal fade';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">QR-код</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img src="${qrUrl}" alt="QR Code" class="img-fluid mb-2" style="max-width: 200px;">
|
||||
<p class="small text-muted mb-0">${name}</p>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center">
|
||||
<a href="${qrUrl}" download class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi-download me-1"></i>Скачать
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
bsModal.show();
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', () => modal.remove());
|
||||
}
|
||||
|
||||
renderPagination(totalCount) {
|
||||
const totalPages = Math.ceil(totalCount / this.pageSize);
|
||||
let html = '<nav class="mt-4"><ul class="pagination justify-content-center">';
|
||||
|
||||
// Previous
|
||||
html += `
|
||||
<li class="page-item ${this.currentPage === 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" data-page="${this.currentPage - 1}">
|
||||
<i class="bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
// Pages
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
|
||||
html += `
|
||||
<li class="page-item ${i === this.currentPage ? 'active' : ''}">
|
||||
<a class="page-link" href="#" data-page="${i}">${i}</a>
|
||||
</li>
|
||||
`;
|
||||
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
|
||||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||||
}
|
||||
}
|
||||
|
||||
// Next
|
||||
html += `
|
||||
<li class="page-item ${this.currentPage === totalPages ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" data-page="${this.currentPage + 1}">
|
||||
<i class="bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
html += '</ul></nav>';
|
||||
return html;
|
||||
}
|
||||
|
||||
bindPaginationEvents() {
|
||||
document.querySelectorAll('.pagination .page-link[data-page]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const page = parseInt(link.dataset.page);
|
||||
if (page && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.loadEquipment();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Вспомогательные методы
|
||||
*/
|
||||
escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getStatusClass(status) {
|
||||
const classes = {
|
||||
'in_stock': 'in-stock',
|
||||
'in_use': 'in-use',
|
||||
'maintenance': 'maintenance',
|
||||
'retired': 'retired'
|
||||
};
|
||||
return classes[status] || '';
|
||||
}
|
||||
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'in_stock': 'На складе',
|
||||
'in_use': 'В использовании',
|
||||
'maintenance': 'Обслуживание',
|
||||
'retired': 'Списано'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическая инициализация
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.querySelector('#equipmentList')) {
|
||||
window.equipmentList = new EquipmentListHandler();
|
||||
}
|
||||
});
|
||||
|
||||
export default EquipmentListHandler;
|
||||
245
backend/static/js/modules/procurementDashboardHandler.js
Normal file
245
backend/static/js/modules/procurementDashboardHandler.js
Normal file
@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Procurement Dashboard Handler
|
||||
* Обработчик для главной страницы модуля закупок
|
||||
*/
|
||||
|
||||
export class ProcurementDashboardHandler {
|
||||
constructor(options = {}) {
|
||||
this.apiBase = options.apiBase || '/api/procurement';
|
||||
this.statsContainer = options.statsContainer || '#procDashboardStats';
|
||||
this.recentRequestsContainer = options.recentRequestsContainer || '#recentRequests';
|
||||
this.recentEquipmentContainer = options.recentEquipmentContainer || '#recentEquipment';
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadRecentRequests(),
|
||||
this.loadRecentEquipment()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка статистики
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const [requestsResp, equipmentResp] = await Promise.all([
|
||||
fetch(`${this.apiBase}/requests/?page_size=1000`),
|
||||
fetch(`${this.apiBase}/equipment/?page_size=1000`)
|
||||
]);
|
||||
|
||||
if (!requestsResp.ok || !equipmentResp.ok) return;
|
||||
|
||||
const requestsData = await requestsResp.json();
|
||||
const equipmentData = await equipmentResp.json();
|
||||
|
||||
const requests = requestsData.results || requestsData;
|
||||
const equipment = equipmentData.results || equipmentData;
|
||||
|
||||
// Подсчёт статистики
|
||||
const stats = {
|
||||
totalRequests: requests.length,
|
||||
pendingRequests: requests.filter(r => r.status === 'pending_approval').length,
|
||||
approvedRequests: requests.filter(r => ['approved', 'ordered', 'delivered'].includes(r.status)).length,
|
||||
totalEquipment: equipment.length,
|
||||
inUseEquipment: equipment.filter(e => e.status === 'in_use').length,
|
||||
totalBudget: requests.reduce((sum, r) => sum + parseFloat(r.total_cost || 0), 0)
|
||||
};
|
||||
|
||||
this.updateStatsDisplay(stats);
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление отображения статистики
|
||||
*/
|
||||
updateStatsDisplay(stats) {
|
||||
const container = document.querySelector(this.statsContainer);
|
||||
if (!container) return;
|
||||
|
||||
const statsElements = {
|
||||
'totalRequests': stats.totalRequests,
|
||||
'pendingRequests': stats.pendingRequests,
|
||||
'approvedRequests': stats.approvedRequests,
|
||||
'totalEquipment': stats.totalEquipment,
|
||||
'inUseEquipment': stats.inUseEquipment,
|
||||
'totalBudget': this.formatCurrency(stats.totalBudget)
|
||||
};
|
||||
|
||||
Object.entries(statsElements).forEach(([id, value]) => {
|
||||
const el = container.querySelector(`[data-stat="${id}"]`);
|
||||
if (el) el.textContent = value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка последних заявок
|
||||
*/
|
||||
async loadRecentRequests() {
|
||||
const container = document.querySelector(this.recentRequestsContainer);
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${this.apiBase}/requests/?ordering=-created_at&page_size=5`);
|
||||
if (!resp.ok) return;
|
||||
|
||||
const data = await resp.json();
|
||||
const requests = data.results || data;
|
||||
|
||||
if (requests.length === 0) {
|
||||
container.innerHTML = `
|
||||
<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>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = requests.map(req => `
|
||||
<a href="/procurement/requests/${req.id}/" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="mb-1">${this.escapeHtml(req.title)}</h6>
|
||||
<small class="text-muted">
|
||||
${req.requester_name || 'Неизвестно'} •
|
||||
${this.formatDate(req.created_at)}
|
||||
</small>
|
||||
</div>
|
||||
<span class="proc-status ${this.getStatusClass(req.status)}">
|
||||
${this.getStatusLabel(req.status)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
`).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading recent requests:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка последнего оборудования
|
||||
*/
|
||||
async loadRecentEquipment() {
|
||||
const container = document.querySelector(this.recentEquipmentContainer);
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${this.apiBase}/equipment/?ordering=-id&page_size=5`);
|
||||
if (!resp.ok) return;
|
||||
|
||||
const data = await resp.json();
|
||||
const equipment = data.results || data;
|
||||
|
||||
if (equipment.length === 0) {
|
||||
container.innerHTML = `
|
||||
<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>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = equipment.map(eq => `
|
||||
<a href="/procurement/equipment/${eq.id}/" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="mb-1">${this.escapeHtml(eq.name)}</h6>
|
||||
<small class="text-muted">
|
||||
<code>${eq.inventory_number}</code> •
|
||||
${eq.category_name || 'Без категории'}
|
||||
</small>
|
||||
</div>
|
||||
<span class="proc-eq-status ${this.getEquipmentStatusClass(eq.status)}">
|
||||
${this.getEquipmentStatusLabel(eq.status)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
`).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading recent equipment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вспомогательные методы
|
||||
*/
|
||||
formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getStatusClass(status) {
|
||||
const classes = {
|
||||
'draft': 'draft',
|
||||
'pending_approval': 'pending',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
'ordered': 'ordered',
|
||||
'delivered': 'delivered'
|
||||
};
|
||||
return classes[status] || '';
|
||||
}
|
||||
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'draft': 'Черновик',
|
||||
'pending_approval': 'На согласовании',
|
||||
'approved': 'Одобрена',
|
||||
'rejected': 'Отклонена',
|
||||
'ordered': 'Заказано',
|
||||
'delivered': 'Получено'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
getEquipmentStatusClass(status) {
|
||||
const classes = {
|
||||
'in_stock': 'in-stock',
|
||||
'in_use': 'in-use',
|
||||
'maintenance': 'maintenance',
|
||||
'retired': 'retired'
|
||||
};
|
||||
return classes[status] || '';
|
||||
}
|
||||
|
||||
getEquipmentStatusLabel(status) {
|
||||
const labels = {
|
||||
'in_stock': 'На складе',
|
||||
'in_use': 'В использовании',
|
||||
'maintenance': 'Обслуживание',
|
||||
'retired': 'Списано'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическая инициализация
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.querySelector('#procDashboardStats, #recentRequests, #recentEquipment')) {
|
||||
window.procurementDashboard = new ProcurementDashboardHandler();
|
||||
}
|
||||
});
|
||||
|
||||
export default ProcurementDashboardHandler;
|
||||
402
backend/static/js/modules/procurementRequestListHandler.js
Normal file
402
backend/static/js/modules/procurementRequestListHandler.js
Normal file
@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Procurement Request List Handler
|
||||
* Обработчик для списка заявок на закупку
|
||||
*/
|
||||
|
||||
export class ProcurementRequestListHandler {
|
||||
constructor(options = {}) {
|
||||
this.apiBase = options.apiBase || '/api/procurement';
|
||||
this.container = options.container || '#requestsList';
|
||||
this.filterForm = options.filterForm || '#filterForm';
|
||||
this.searchInput = options.searchInput || '#searchInput';
|
||||
this.createForm = options.createForm || '#createRequestForm';
|
||||
this.scope = options.scope || 'all'; // 'all', 'my', 'pending'
|
||||
|
||||
this.currentPage = 1;
|
||||
this.pageSize = 20;
|
||||
this.filters = {};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadRequests();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Поиск
|
||||
const searchInput = document.querySelector(this.searchInput);
|
||||
if (searchInput) {
|
||||
let debounceTimer;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
this.filters.search = e.target.value;
|
||||
this.currentPage = 1;
|
||||
this.loadRequests();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтры
|
||||
const filterForm = document.querySelector(this.filterForm);
|
||||
if (filterForm) {
|
||||
filterForm.addEventListener('change', () => {
|
||||
this.applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
// Табы scope
|
||||
document.querySelectorAll('[data-scope]').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('[data-scope]').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
this.scope = tab.dataset.scope;
|
||||
this.currentPage = 1;
|
||||
this.loadRequests();
|
||||
});
|
||||
});
|
||||
|
||||
// Форма создания заявки
|
||||
const createForm = document.querySelector(this.createForm);
|
||||
if (createForm) {
|
||||
createForm.addEventListener('submit', (e) => this.handleCreate(e));
|
||||
}
|
||||
|
||||
// Кнопка добавления позиции
|
||||
document.querySelector('#addItemBtn')?.addEventListener('click', () => {
|
||||
this.addItemRow();
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters() {
|
||||
const form = document.querySelector(this.filterForm);
|
||||
if (!form) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
this.filters = {};
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
this.filters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPage = 1;
|
||||
this.loadRequests();
|
||||
}
|
||||
|
||||
async loadRequests() {
|
||||
const container = document.querySelector(this.container);
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
let url = `${this.apiBase}/requests/?page=${this.currentPage}&page_size=${this.pageSize}`;
|
||||
|
||||
// Scope filters
|
||||
if (this.scope === 'my') {
|
||||
url += '&requester=me';
|
||||
} else if (this.scope === 'pending') {
|
||||
url += '&status=pending_approval';
|
||||
}
|
||||
|
||||
// Additional filters
|
||||
Object.entries(this.filters).forEach(([key, value]) => {
|
||||
url += `&${key}=${encodeURIComponent(value)}`;
|
||||
});
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Failed to load requests');
|
||||
|
||||
const data = await resp.json();
|
||||
const requests = data.results || data;
|
||||
|
||||
this.renderRequests(requests, data.count || requests.length);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading requests:', error);
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi-exclamation-triangle me-2"></i>
|
||||
Ошибка загрузки заявок
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
renderRequests(requests, totalCount) {
|
||||
const container = document.querySelector(this.container);
|
||||
if (!container) return;
|
||||
|
||||
if (requests.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi-inbox fs-1 d-block mb-3"></i>
|
||||
<h5>Заявок не найдено</h5>
|
||||
<p class="mb-0">Создайте новую заявку на закупку</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = requests.map(req => this.renderRequestCard(req)).join('');
|
||||
|
||||
// Pagination
|
||||
if (totalCount > this.pageSize) {
|
||||
container.innerHTML += this.renderPagination(totalCount);
|
||||
this.bindPaginationEvents();
|
||||
}
|
||||
}
|
||||
|
||||
renderRequestCard(req) {
|
||||
const urgencyClass = req.is_urgent ? 'border-danger' : '';
|
||||
const urgencyBadge = req.is_urgent ?
|
||||
'<span class="badge bg-danger me-2"><i class="bi-lightning-fill"></i> Срочно</span>' : '';
|
||||
|
||||
return `
|
||||
<a href="/procurement/requests/${req.id}/" class="proc-request-card ${urgencyClass}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mb-1">
|
||||
${urgencyBadge}
|
||||
${this.escapeHtml(req.title)}
|
||||
</h5>
|
||||
<p class="text-muted mb-2 small">
|
||||
<i class="bi-person me-1"></i>${req.requester_name || 'Неизвестно'}
|
||||
<span class="mx-2">•</span>
|
||||
<i class="bi-calendar me-1"></i>${this.formatDate(req.created_at)}
|
||||
${req.items_count ? `<span class="mx-2">•</span><i class="bi-list me-1"></i>${req.items_count} поз.` : ''}
|
||||
</p>
|
||||
${req.description ? `<p class="mb-0 small text-truncate" style="max-width: 400px;">${this.escapeHtml(req.description)}</p>` : ''}
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="proc-status ${this.getStatusClass(req.status)} mb-2 d-inline-block">
|
||||
${this.getStatusLabel(req.status)}
|
||||
</span>
|
||||
${req.total_cost ? `<div class="fw-bold">${this.formatCurrency(req.total_cost)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
renderPagination(totalCount) {
|
||||
const totalPages = Math.ceil(totalCount / this.pageSize);
|
||||
let html = '<nav class="mt-4"><ul class="pagination justify-content-center">';
|
||||
|
||||
// Previous
|
||||
html += `
|
||||
<li class="page-item ${this.currentPage === 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" data-page="${this.currentPage - 1}">
|
||||
<i class="bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
// Pages
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
|
||||
html += `
|
||||
<li class="page-item ${i === this.currentPage ? 'active' : ''}">
|
||||
<a class="page-link" href="#" data-page="${i}">${i}</a>
|
||||
</li>
|
||||
`;
|
||||
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
|
||||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||||
}
|
||||
}
|
||||
|
||||
// Next
|
||||
html += `
|
||||
<li class="page-item ${this.currentPage === totalPages ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" data-page="${this.currentPage + 1}">
|
||||
<i class="bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
html += '</ul></nav>';
|
||||
return html;
|
||||
}
|
||||
|
||||
bindPaginationEvents() {
|
||||
document.querySelectorAll('.pagination .page-link[data-page]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const page = parseInt(link.dataset.page);
|
||||
if (page && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.loadRequests();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleCreate(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Сохранение...';
|
||||
|
||||
try {
|
||||
// Собираем данные
|
||||
const formData = {
|
||||
title: form.querySelector('[name="title"]').value,
|
||||
description: form.querySelector('[name="description"]')?.value || '',
|
||||
is_urgent: form.querySelector('[name="is_urgent"]')?.checked || false,
|
||||
items: this.collectItems(form)
|
||||
};
|
||||
|
||||
const resp = await fetch(`${this.apiBase}/requests/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.detail || JSON.stringify(err));
|
||||
}
|
||||
|
||||
const request = await resp.json();
|
||||
|
||||
// Закрыть модалку и перейти к заявке
|
||||
const modal = bootstrap.Modal.getInstance(document.querySelector('#createModal'));
|
||||
modal?.hide();
|
||||
|
||||
window.location.href = `/procurement/requests/${request.id}/`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating request:', error);
|
||||
alert('Ошибка создания заявки: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
collectItems(form) {
|
||||
const items = [];
|
||||
form.querySelectorAll('.item-row').forEach(row => {
|
||||
const name = row.querySelector('[name="item_name"]')?.value;
|
||||
const quantity = parseInt(row.querySelector('[name="item_quantity"]')?.value) || 1;
|
||||
const price = parseFloat(row.querySelector('[name="item_price"]')?.value) || 0;
|
||||
|
||||
if (name) {
|
||||
items.push({ name, quantity, estimated_price: price });
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
addItemRow() {
|
||||
const container = document.querySelector('#itemsContainer');
|
||||
if (!container) return;
|
||||
|
||||
const index = container.querySelectorAll('.item-row').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'item-row row g-2 mb-2';
|
||||
row.innerHTML = `
|
||||
<div class="col-6">
|
||||
<input type="text" name="item_name" class="form-control form-control-sm"
|
||||
placeholder="Наименование" required>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<input type="number" name="item_quantity" class="form-control form-control-sm"
|
||||
value="1" min="1" placeholder="Кол-во">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<input type="number" name="item_price" class="form-control form-control-sm"
|
||||
min="0" step="0.01" placeholder="Цена">
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm w-100" onclick="this.closest('.item-row').remove()">
|
||||
<i class="bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вспомогательные методы
|
||||
*/
|
||||
getCsrfToken() {
|
||||
return document.querySelector('[name="csrfmiddlewaretoken"]')?.value ||
|
||||
document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getStatusClass(status) {
|
||||
const classes = {
|
||||
'draft': 'draft',
|
||||
'pending_approval': 'pending',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
'ordered': 'ordered',
|
||||
'delivered': 'delivered'
|
||||
};
|
||||
return classes[status] || '';
|
||||
}
|
||||
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'draft': 'Черновик',
|
||||
'pending_approval': 'На согласовании',
|
||||
'approved': 'Одобрена',
|
||||
'rejected': 'Отклонена',
|
||||
'ordered': 'Заказано',
|
||||
'delivered': 'Получено'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическая инициализация
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.querySelector('#requestsList')) {
|
||||
const scopeTab = document.querySelector('[data-scope].active');
|
||||
window.procurementRequestList = new ProcurementRequestListHandler({
|
||||
scope: scopeTab?.dataset.scope || 'all'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default ProcurementRequestListHandler;
|
||||
@ -69,6 +69,13 @@
|
||||
<span>Финансы</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'procurement_frontend:dashboard' %}"
|
||||
class="nav-link {% if request.resolver_match.app_name == 'procurement_frontend' %}active{% endif %}"
|
||||
{% if request.resolver_match.app_name == 'procurement_frontend' %}aria-current="page"{% endif %}>
|
||||
<span class="side-icon"><i class="bi-cart3"></i></span>
|
||||
<span>Закупки</span>
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3 small">
|
||||
|
||||
67
backend/templates/procurement/_equipment_filters.html
Normal file
67
backend/templates/procurement/_equipment_filters.html
Normal file
@ -0,0 +1,67 @@
|
||||
{# Фильтры для списка оборудования #}
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">Категория</label>
|
||||
<select name="category" class="form-select form-select-sm" id="filterCategory">
|
||||
<option value="">Все категории</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if filters.category == cat.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">Статус</label>
|
||||
<select name="status" class="form-select form-select-sm" id="filterStatus">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="available" {% if filters.status == 'available' %}selected{% endif %}>Доступно</option>
|
||||
<option value="in_use" {% if filters.status == 'in_use' %}selected{% endif %}>В использовании</option>
|
||||
<option value="maintenance" {% if filters.status == 'maintenance' %}selected{% endif %}>На ТО</option>
|
||||
<option value="repair" {% if filters.status == 'repair' %}selected{% endif %}>В ремонте</option>
|
||||
<option value="retired" {% if filters.status == 'retired' %}selected{% endif %}>Списано</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">Отдел</label>
|
||||
<select name="department" class="form-select form-select-sm" id="filterDepartment">
|
||||
<option value="">Все отделы</option>
|
||||
{% for dept in departments %}
|
||||
<option value="{{ dept.id }}" {% if filters.department == dept.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ dept.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex align-items-end gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm flex-grow-1" id="applyEqFilters">
|
||||
<i class="bi-funnel me-1"></i>Применить
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="resetEqFilters">
|
||||
<i class="bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('applyEqFilters')?.addEventListener('click', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const category = document.getElementById('filterCategory').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const department = document.getElementById('filterDepartment').value;
|
||||
|
||||
if (category) params.set('category', category); else params.delete('category');
|
||||
if (status) params.set('status', status); else params.delete('status');
|
||||
if (department) params.set('department', department); else params.delete('department');
|
||||
|
||||
window.location.search = params.toString();
|
||||
});
|
||||
|
||||
document.getElementById('resetEqFilters')?.addEventListener('click', () => {
|
||||
window.location.search = '';
|
||||
});
|
||||
</script>
|
||||
29
backend/templates/procurement/_equipment_status.html
Normal file
29
backend/templates/procurement/_equipment_status.html
Normal file
@ -0,0 +1,29 @@
|
||||
{# Бейдж статуса оборудования #}
|
||||
|
||||
{% if status == 'available' %}
|
||||
<span class="proc-eq-status available">
|
||||
<i class="bi-check-circle me-1"></i>Доступно
|
||||
</span>
|
||||
{% elif status == 'in_use' %}
|
||||
<span class="proc-eq-status in_use">
|
||||
<i class="bi-person-check me-1"></i>В использовании
|
||||
</span>
|
||||
{% elif status == 'maintenance' %}
|
||||
<span class="proc-eq-status maintenance">
|
||||
<i class="bi-tools me-1"></i>На ТО
|
||||
</span>
|
||||
{% elif status == 'repair' %}
|
||||
<span class="proc-eq-status repair">
|
||||
<i class="bi-wrench me-1"></i>В ремонте
|
||||
</span>
|
||||
{% elif status == 'retired' %}
|
||||
<span class="proc-eq-status retired">
|
||||
<i class="bi-x-circle me-1"></i>Списано
|
||||
</span>
|
||||
{% elif status == 'lost' %}
|
||||
<span class="proc-eq-status retired">
|
||||
<i class="bi-question-circle me-1"></i>Утеряно
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="proc-eq-status">{{ status }}</span>
|
||||
{% endif %}
|
||||
71
backend/templates/procurement/_filters.html
Normal file
71
backend/templates/procurement/_filters.html
Normal file
@ -0,0 +1,71 @@
|
||||
{# Фильтры для списка заявок на закупку #}
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">Статус</label>
|
||||
<select name="status" class="form-select form-select-sm" id="filterStatus">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="draft" {% if filters.status == 'draft' %}selected{% endif %}>Черновик</option>
|
||||
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>На согласовании</option>
|
||||
<option value="approved" {% if filters.status == 'approved' %}selected{% endif %}>Одобрено</option>
|
||||
<option value="rejected" {% if filters.status == 'rejected' %}selected{% endif %}>Отклонено</option>
|
||||
<option value="ordered" {% if filters.status == 'ordered' %}selected{% endif %}>Заказано</option>
|
||||
<option value="delivered" {% if filters.status == 'delivered' %}selected{% endif %}>Получено</option>
|
||||
<option value="cancelled" {% if filters.status == 'cancelled' %}selected{% endif %}>Отменено</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">Срочность</label>
|
||||
<select name="urgency" class="form-select form-select-sm" id="filterUrgency">
|
||||
<option value="">Любая</option>
|
||||
<option value="low" {% if filters.urgency == 'low' %}selected{% endif %}>Низкая</option>
|
||||
<option value="medium" {% if filters.urgency == 'medium' %}selected{% endif %}>Средняя</option>
|
||||
<option value="high" {% if filters.urgency == 'high' %}selected{% endif %}>Высокая</option>
|
||||
<option value="critical" {% if filters.urgency == 'critical' %}selected{% endif %}>Критическая</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">Период</label>
|
||||
<select name="period" class="form-select form-select-sm" id="filterPeriod">
|
||||
<option value="">Всё время</option>
|
||||
<option value="today" {% if filters.period == 'today' %}selected{% endif %}>Сегодня</option>
|
||||
<option value="week" {% if filters.period == 'week' %}selected{% endif %}>За неделю</option>
|
||||
<option value="month" {% if filters.period == 'month' %}selected{% endif %}>За месяц</option>
|
||||
<option value="quarter" {% if filters.period == 'quarter' %}selected{% endif %}>За квартал</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex align-items-end gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm flex-grow-1" id="applyFilters">
|
||||
<i class="bi-funnel me-1"></i>Применить
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="resetFilters">
|
||||
<i class="bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('applyFilters')?.addEventListener('click', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const urgency = document.getElementById('filterUrgency').value;
|
||||
const period = document.getElementById('filterPeriod').value;
|
||||
|
||||
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');
|
||||
|
||||
window.location.search = params.toString();
|
||||
});
|
||||
|
||||
document.getElementById('resetFilters')?.addEventListener('click', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('status');
|
||||
params.delete('urgency');
|
||||
params.delete('period');
|
||||
window.location.search = params.toString();
|
||||
});
|
||||
</script>
|
||||
34
backend/templates/procurement/_status_badge.html
Normal file
34
backend/templates/procurement/_status_badge.html
Normal file
@ -0,0 +1,34 @@
|
||||
{# Бейдж статуса заявки на закупку #}
|
||||
{% load i18n %}
|
||||
|
||||
{% if status == 'draft' %}
|
||||
<span class="proc-status draft">
|
||||
<i class="bi-pencil me-1"></i>Черновик
|
||||
</span>
|
||||
{% elif status == 'pending' %}
|
||||
<span class="proc-status pending">
|
||||
<i class="bi-hourglass-split me-1"></i>На согласовании
|
||||
</span>
|
||||
{% elif status == 'approved' %}
|
||||
<span class="proc-status approved">
|
||||
<i class="bi-check-circle me-1"></i>Одобрено
|
||||
</span>
|
||||
{% elif status == 'rejected' %}
|
||||
<span class="proc-status rejected">
|
||||
<i class="bi-x-circle me-1"></i>Отклонено
|
||||
</span>
|
||||
{% elif status == 'ordered' %}
|
||||
<span class="proc-status ordered">
|
||||
<i class="bi-truck me-1"></i>Заказано
|
||||
</span>
|
||||
{% elif status == 'delivered' %}
|
||||
<span class="proc-status delivered">
|
||||
<i class="bi-box-seam me-1"></i>Получено
|
||||
</span>
|
||||
{% elif status == 'cancelled' %}
|
||||
<span class="proc-status cancelled">
|
||||
<i class="bi-slash-circle me-1"></i>Отменено
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="proc-status draft">{{ status }}</span>
|
||||
{% endif %}
|
||||
238
backend/templates/procurement/dashboard.html
Normal file
238
backend/templates/procurement/dashboard.html
Normal file
@ -0,0 +1,238 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Закупки{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# ===== ШАПКА СТРАНИЦЫ ===== #}
|
||||
{% include "includes/components/page_header.html" with icon="bi-cart3" title="Закупки" no_search=True create_url=create_url create_text="Новая заявка" %}
|
||||
|
||||
{# ===== СТАТИСТИКА ===== #}
|
||||
<div class="proc-stats">
|
||||
<div class="proc-stat-card">
|
||||
<div class="proc-stat-icon pending">
|
||||
<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-label">На согласовании</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proc-stat-card">
|
||||
<div class="proc-stat-icon approved">
|
||||
<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-label">Одобрено</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proc-stat-card">
|
||||
<div class="proc-stat-icon budget">
|
||||
<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-label">Бюджет отдела</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proc-stat-card">
|
||||
<div class="proc-stat-icon equipment">
|
||||
<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-label">Оборудование</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== БЫСТРЫЕ ДЕЙСТВИЯ ===== #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<a href="{% url 'procurement_frontend:request_list' %}" class="card text-decoration-none h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<i class="bi-list-ul text-primary fs-1 mb-2 d-block"></i>
|
||||
<h6 class="mb-1">Заявки</h6>
|
||||
<small class="text-muted">Все заявки на закупку</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<a href="{% url 'procurement_frontend:my_requests' %}" class="card text-decoration-none h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<i class="bi-person-circle text-info fs-1 mb-2 d-block"></i>
|
||||
<h6 class="mb-1">Мои заявки</h6>
|
||||
<small class="text-muted">Созданные мной</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<a href="{% url 'procurement_frontend:equipment_list' %}" class="card text-decoration-none h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<i class="bi-laptop text-success fs-1 mb-2 d-block"></i>
|
||||
<h6 class="mb-1">Оборудование</h6>
|
||||
<small class="text-muted">Каталог инвентаря</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if can_approve %}
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<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>
|
||||
<h6 class="mb-1">Согласование</h6>
|
||||
<small class="text-muted">Ожидают решения</small>
|
||||
</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">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi-clock-history me-2"></i>Последние заявки
|
||||
</h5>
|
||||
<a href="{% url 'procurement_frontend:request_list' %}" class="btn btn-sm btn-outline-primary">
|
||||
Все заявки <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>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Автообновление статистики каждые 60 секунд
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/procurement/stats/overview/');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
document.getElementById('statPending').textContent = data.pending || 0;
|
||||
document.getElementById('statApproved').textContent = data.approved || 0;
|
||||
document.getElementById('statEquipment').textContent = data.equipment || 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Stats update failed:', e);
|
||||
}
|
||||
}, 60000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
438
backend/templates/procurement/equipment_detail.html
Normal file
438
backend/templates/procurement/equipment_detail.html
Normal file
@ -0,0 +1,438 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ equipment.name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% 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:equipment_list' %}">Оборудование</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ equipment.inventory_number }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row g-4">
|
||||
{# ===== Основная информация ===== #}
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="proc-equipment-icon" style="width: 64px; height: 64px; font-size: 2rem;">
|
||||
{% if 'ноутбук' in equipment.name|lower or 'laptop' in equipment.name|lower %}
|
||||
<i class="bi-laptop"></i>
|
||||
{% elif 'монитор' in equipment.name|lower or 'display' in equipment.name|lower %}
|
||||
<i class="bi-display"></i>
|
||||
{% elif 'принтер' in equipment.name|lower or 'printer' in equipment.name|lower %}
|
||||
<i class="bi-printer"></i>
|
||||
{% elif 'телефон' in equipment.name|lower or 'phone' in equipment.name|lower %}
|
||||
<i class="bi-phone"></i>
|
||||
{% elif 'сервер' in equipment.name|lower or 'server' in equipment.name|lower %}
|
||||
<i class="bi-hdd-rack"></i>
|
||||
{% else %}
|
||||
<i class="bi-pc-display-horizontal"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<h3 class="mb-1">{{ equipment.name }}</h3>
|
||||
<div class="d-flex flex-wrap gap-3 align-items-center">
|
||||
<code class="fs-6">{{ equipment.inventory_number }}</code>
|
||||
{% include "procurement/_equipment_status.html" with status=equipment.status %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<a href="/api/procurement/equipment/{{ equipment.id }}/qr_code/"
|
||||
target="_blank"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi-qr-code me-1"></i>QR-код
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Характеристики #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-list-ul me-2"></i>Характеристики</h5>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% if equipment.category %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Категория</span>
|
||||
<span>{{ equipment.category.name }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if equipment.serial_number %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Серийный номер</span>
|
||||
<code>{{ equipment.serial_number }}</code>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if equipment.model %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Модель</span>
|
||||
<span>{{ equipment.model }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if equipment.manufacturer %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Производитель</span>
|
||||
<span>{{ equipment.manufacturer }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if equipment.location %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Расположение</span>
|
||||
<span>{{ equipment.location }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Описание #}
|
||||
{% if equipment.notes %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-text-paragraph me-2"></i>Примечания</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0" style="white-space: pre-wrap;">{{ equipment.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# История обслуживания #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<h5 class="mb-0"><i class="bi-wrench me-2"></i>История обслуживания</h5>
|
||||
{% if can_manage %}
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#maintenanceModal">
|
||||
<i class="bi-plus-lg"></i> Добавить
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if equipment.maintenance_records.exists %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тип</th>
|
||||
<th>Описание</th>
|
||||
<th class="text-end">Стоимость</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in equipment.maintenance_records.all %}
|
||||
<tr>
|
||||
<td>{{ record.date|date:"d.m.Y" }}</td>
|
||||
<td>{{ record.get_type_display }}</td>
|
||||
<td>{{ record.description|truncatechars:50 }}</td>
|
||||
<td class="text-end">
|
||||
{% if record.cost %}{{ record.cost|floatformat:0 }} ₽{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{# История передач #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-arrow-left-right me-2"></i>История передач</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if equipment.transfer_logs.exists %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Откуда</th>
|
||||
<th>Куда</th>
|
||||
<th>Причина</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in equipment.transfer_logs.all %}
|
||||
<tr>
|
||||
<td>{{ log.created_at|date:"d.m.Y" }}</td>
|
||||
<td>
|
||||
{% if log.from_department %}{{ log.from_department.name }}{% endif %}
|
||||
{% if log.from_person %}({{ log.from_person.get_full_name }}){% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.to_department %}{{ log.to_department.name }}{% endif %}
|
||||
{% if log.to_person %}({{ log.to_person.get_full_name }}){% endif %}
|
||||
</td>
|
||||
<td>{{ log.reason|truncatechars:40 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== Боковая панель ===== #}
|
||||
<div class="col-lg-4">
|
||||
{# Действия #}
|
||||
{% if can_manage %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-gear me-2"></i>Действия</h5>
|
||||
</div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#transferModal">
|
||||
<i class="bi-arrow-left-right me-2"></i>Перевести
|
||||
</button>
|
||||
<button class="btn btn-outline-warning" data-bs-toggle="modal" data-bs-target="#maintenanceModal">
|
||||
<i class="bi-wrench me-2"></i>Обслуживание
|
||||
</button>
|
||||
{% if equipment.status != 'retired' %}
|
||||
<button class="btn btn-outline-danger" id="writeOffBtn">
|
||||
<i class="bi-x-circle me-2"></i>Списать
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Информация о владении #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-person-badge me-2"></i>Владение</h5>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Отдел</span>
|
||||
<span>{{ equipment.department.name|default:"—" }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Ответственный</span>
|
||||
<span>{{ equipment.responsible_person.get_full_name|default:"—" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Финансовая информация #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-wallet2 me-2"></i>Финансы</h5>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Дата покупки</span>
|
||||
<span>{{ equipment.purchase_date|date:"d.m.Y" }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Стоимость</span>
|
||||
<span class="fw-bold">{{ equipment.purchase_cost|floatformat:0 }} ₽</span>
|
||||
</li>
|
||||
{% if equipment.warranty_until %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Гарантия до</span>
|
||||
<span {% if equipment.is_under_warranty %}class="text-success"{% else %}class="text-danger"{% endif %}>
|
||||
{{ equipment.warranty_until|date:"d.m.Y" }}
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== Модалки ===== #}
|
||||
{% if can_manage %}
|
||||
{# Модалка обслуживания #}
|
||||
<div class="modal fade" id="maintenanceModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form class="modal-content" id="maintenanceForm">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi-wrench me-2"></i>Запись об обслуживании</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Тип обслуживания</label>
|
||||
<select name="type" class="form-select" required>
|
||||
<option value="inspection">Осмотр/проверка</option>
|
||||
<option value="maintenance">Плановое ТО</option>
|
||||
<option value="repair">Ремонт</option>
|
||||
<option value="upgrade">Модернизация</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание работ</label>
|
||||
<textarea name="description" class="form-control" rows="3" required></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Стоимость</label>
|
||||
<div class="input-group">
|
||||
<input type="number" name="cost" class="form-control" min="0" step="0.01">
|
||||
<span class="input-group-text">₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Модалка перевода #}
|
||||
<div class="modal fade" id="transferModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form class="modal-content" id="transferForm">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi-arrow-left-right me-2"></i>Перевод оборудования</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">В отдел</label>
|
||||
<select name="to_department" class="form-select" id="transferDepartment">
|
||||
<option value="">Без изменений</option>
|
||||
{# Заполняется через API #}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Причина</label>
|
||||
<textarea name="reason" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Перевести</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
{% if can_manage %}
|
||||
// Обслуживание
|
||||
document.getElementById('maintenanceForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
type: form.querySelector('[name="type"]').value,
|
||||
description: form.querySelector('[name="description"]').value,
|
||||
cost: form.querySelector('[name="cost"]').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/procurement/equipment/{{ equipment.id }}/add_maintenance/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Ошибка сохранения');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
});
|
||||
|
||||
// Перевод
|
||||
document.getElementById('transferForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
to_department: form.querySelector('[name="to_department"]').value || null,
|
||||
reason: form.querySelector('[name="reason"]').value
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/procurement/equipment/{{ equipment.id }}/transfer/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert(err.error || 'Ошибка перевода');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
});
|
||||
|
||||
// Списание
|
||||
document.getElementById('writeOffBtn')?.addEventListener('click', async () => {
|
||||
const reason = prompt('Укажите причину списания:');
|
||||
if (!reason) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/procurement/equipment/{{ equipment.id }}/write_off/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert(err.error || 'Ошибка списания');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
351
backend/templates/procurement/equipment_list.html
Normal file
351
backend/templates/procurement/equipment_list.html
Normal file
@ -0,0 +1,351 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Оборудование{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# ===== ШАПКА СТРАНИЦЫ ===== #}
|
||||
{% 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" %}
|
||||
|
||||
{# ===== СЕТКА ОБОРУДОВАНИЯ ===== #}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{# Пагинация #}
|
||||
{% 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="transferModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form class="modal-content" id="transferForm">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="equipment_id" id="transferEquipmentId">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi-arrow-left-right me-2"></i>Перевод оборудования
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">В отдел</label>
|
||||
<select name="to_department" class="form-select" id="transferDepartment">
|
||||
<option value="">Без изменений</option>
|
||||
{% for dept in departments %}
|
||||
<option value="{{ dept.id }}">{{ dept.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Новый ответственный</label>
|
||||
<select name="to_person" class="form-select" id="transferPerson">
|
||||
<option value="">Без изменений</option>
|
||||
{# Загружается динамически #}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Причина перевода</label>
|
||||
<textarea name="reason" class="form-control" rows="2" placeholder="Укажите причину..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi-check-lg me-1"></i>Перевести
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== МОДАЛКА ОБСЛУЖИВАНИЯ ===== #}
|
||||
<div class="modal fade" id="maintenanceModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form class="modal-content" id="maintenanceForm">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="equipment_id" id="maintenanceEquipmentId">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi-wrench me-2"></i>Запись об обслуживании
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Тип обслуживания</label>
|
||||
<select name="type" class="form-select" required>
|
||||
<option value="inspection">Осмотр/проверка</option>
|
||||
<option value="maintenance">Плановое ТО</option>
|
||||
<option value="repair">Ремонт</option>
|
||||
<option value="upgrade">Модернизация</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание работ</label>
|
||||
<textarea name="description" class="form-control" rows="3"
|
||||
placeholder="Опишите выполненные работы..." required></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Стоимость</label>
|
||||
<div class="input-group">
|
||||
<input type="number" name="cost" class="form-control" placeholder="0" min="0" step="0.01">
|
||||
<span class="input-group-text">₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi-check-lg me-1"></i>Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Поиск по оборудованию
|
||||
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() || '';
|
||||
const match = name.includes(query) || inv.includes(query);
|
||||
card.style.display = match ? '' : 'none';
|
||||
});
|
||||
}, 300));
|
||||
}
|
||||
|
||||
// Модалка перевода - заполняем ID
|
||||
document.getElementById('transferModal')?.addEventListener('show.bs.modal', (e) => {
|
||||
const btn = e.relatedTarget;
|
||||
const id = btn?.dataset.id;
|
||||
if (id) document.getElementById('transferEquipmentId').value = id;
|
||||
});
|
||||
|
||||
// Модалка обслуживания - заполняем ID
|
||||
document.getElementById('maintenanceModal')?.addEventListener('show.bs.modal', (e) => {
|
||||
const btn = e.relatedTarget;
|
||||
const id = btn?.dataset.id;
|
||||
if (id) document.getElementById('maintenanceEquipmentId').value = id;
|
||||
});
|
||||
|
||||
// Отправка формы перевода
|
||||
document.getElementById('transferForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const id = form.querySelector('#transferEquipmentId').value;
|
||||
const data = {
|
||||
to_department: form.querySelector('#transferDepartment').value || null,
|
||||
to_person: form.querySelector('#transferPerson').value || null,
|
||||
reason: form.querySelector('[name="reason"]').value
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/procurement/equipment/${id}/transfer/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': form.querySelector('[name="csrfmiddlewaretoken"]').value
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('transferModal')).hide();
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert(err.error || 'Ошибка перевода');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
});
|
||||
|
||||
// Отправка формы обслуживания
|
||||
document.getElementById('maintenanceForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const id = form.querySelector('#maintenanceEquipmentId').value;
|
||||
const data = {
|
||||
type: form.querySelector('[name="type"]').value,
|
||||
description: form.querySelector('[name="description"]').value,
|
||||
cost: form.querySelector('[name="cost"]').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/procurement/equipment/${id}/add_maintenance/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': form.querySelector('[name="csrfmiddlewaretoken"]').value
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('maintenanceModal')).hide();
|
||||
alert('Запись добавлена');
|
||||
form.reset();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert(err.error || 'Ошибка сохранения');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
273
backend/templates/procurement/request_detail.html
Normal file
273
backend/templates/procurement/request_detail.html
Normal file
@ -0,0 +1,273 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Заявка #{{ request_obj.id }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="proc-urgency-icon {{ request_obj.urgency }}" style="width: 48px; height: 48px; font-size: 1.25rem;">
|
||||
{% if request_obj.urgency == 'critical' %}
|
||||
<i class="bi-exclamation-triangle-fill"></i>
|
||||
{% elif request_obj.urgency == 'high' %}
|
||||
<i class="bi-arrow-up-circle"></i>
|
||||
{% elif request_obj.urgency == 'medium' %}
|
||||
<i class="bi-dash-circle"></i>
|
||||
{% else %}
|
||||
<i class="bi-arrow-down-circle"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<h3 class="mb-1">{{ request_obj.title }}</h3>
|
||||
<div class="d-flex flex-wrap gap-3 text-muted small">
|
||||
<span><i class="bi-building me-1"></i>{{ request_obj.department.name }}</span>
|
||||
<span><i class="bi-person me-1"></i>{{ request_obj.requestor.get_full_name }}</span>
|
||||
<span><i class="bi-calendar me-1"></i>{{ request_obj.created_at|date:"d.m.Y H:i" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{# ===== Основная информация ===== #}
|
||||
<div class="col-lg-8">
|
||||
{# Описание #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-text-paragraph me-2"></i>Описание</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if request_obj.description %}
|
||||
<p class="mb-0" style="white-space: pre-wrap;">{{ request_obj.description }}</p>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Описание не указано</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Позиции заявки #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<h5 class="mb-0"><i class="bi-list-check me-2"></i>Позиции ({{ request_obj.items.count }})</h5>
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addItemModal">
|
||||
<i class="bi-plus-lg"></i> Добавить
|
||||
</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>
|
||||
</div>
|
||||
{% else %}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{# История согласования #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-clock-history me-2"></i>История согласования</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if request_obj.approvals.exists %}
|
||||
<div class="timeline">
|
||||
{% for approval in request_obj.approvals.all %}
|
||||
<div class="d-flex gap-3 mb-3">
|
||||
<div class="flex-shrink-0">
|
||||
{% if approval.status == 'approved' %}
|
||||
<span class="badge bg-success rounded-circle p-2"><i class="bi-check"></i></span>
|
||||
{% elif approval.status == 'rejected' %}
|
||||
<span class="badge bg-danger rounded-circle p-2"><i class="bi-x"></i></span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary rounded-circle p-2"><i class="bi-hourglass"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<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" }}
|
||||
{% else %}
|
||||
Ожидает решения
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if approval.comment %}
|
||||
<div class="mt-1 small">{{ approval.comment }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Согласования ещё не начаты</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== Боковая панель ===== #}
|
||||
<div class="col-lg-4">
|
||||
{# Действия #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-gear me-2"></i>Действия</h5>
|
||||
</div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
{% if can_edit and request_obj.status == 'draft' %}
|
||||
<button class="btn btn-primary" id="submitRequest">
|
||||
<i class="bi-send me-2"></i>Отправить на согласование
|
||||
</button>
|
||||
<a href="#" class="btn btn-outline-secondary">
|
||||
<i class="bi-pencil me-2"></i>Редактировать
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_approve and request_obj.status == 'pending' %}
|
||||
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#approveModal">
|
||||
<i class="bi-check-lg me-2"></i>Одобрить
|
||||
</button>
|
||||
<button class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">
|
||||
<i class="bi-x-lg me-2"></i>Отклонить
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'procurement_frontend:request_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi-arrow-left me-2"></i>К списку
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Информация #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-info-circle me-2"></i>Информация</h5>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Срочность</span>
|
||||
<span>
|
||||
{% if request_obj.urgency == 'critical' %}🔴 Критическая
|
||||
{% elif request_obj.urgency == 'high' %}🟠 Высокая
|
||||
{% elif request_obj.urgency == 'medium' %}🟡 Средняя
|
||||
{% 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>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Создано</span>
|
||||
<span>{{ request_obj.created_at|date:"d.m.Y H:i" }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">Обновлено</span>
|
||||
<span>{{ request_obj.updated_at|date:"d.m.Y H:i" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Отправка на согласование
|
||||
document.getElementById('submitRequest')?.addEventListener('click', async () => {
|
||||
if (!confirm('Отправить заявку на согласование?')) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/procurement/requests/{{ request_obj.id }}/submit/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
}
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert(err.error || 'Ошибка отправки');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
103
backend/templates/procurement/request_form.html
Normal file
103
backend/templates/procurement/request_form.html
Normal file
@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Новая заявка{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% 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">Новая заявка</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi-cart-plus me-2"></i>Новая заявка на закупку
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{% url 'procurement_frontend:request_create' %}" id="requestForm">
|
||||
{% csrf_token %}
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Название заявки <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control"
|
||||
placeholder="Например: Закупка ноутбуков для нового сотрудника"
|
||||
required maxlength="255">
|
||||
<div class="form-text">Кратко опишите суть заявки</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Обоснование / описание</label>
|
||||
<textarea name="description" class="form-control" rows="4"
|
||||
placeholder="Опишите необходимость закупки, для каких целей..."
|
||||
maxlength="2000"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Срочность <span class="text-danger">*</span></label>
|
||||
<select name="urgency" class="form-select" required>
|
||||
<option value="low">🟢 Низкая — до 30 дней</option>
|
||||
<option value="medium" selected>🟡 Средняя — до 14 дней</option>
|
||||
<option value="high">🟠 Высокая — до 7 дней</option>
|
||||
<option value="critical">🔴 Критическая — до 3 дней</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Желаемая дата получения</label>
|
||||
<input type="date" name="desired_date" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Ориентировочная сумма</label>
|
||||
<div class="input-group">
|
||||
<input type="number" name="estimated_cost" class="form-control"
|
||||
placeholder="0" min="0" step="0.01">
|
||||
<span class="input-group-text">₽</span>
|
||||
</div>
|
||||
<div class="form-text">Можно указать позже</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer d-flex gap-2 justify-content-between">
|
||||
<a href="{% url 'procurement_frontend:request_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi-arrow-left me-1"></i>Отмена
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" name="action" value="draft" class="btn btn-outline-primary">
|
||||
<i class="bi-save me-1"></i>Сохранить черновик
|
||||
</button>
|
||||
<button type="submit" name="action" value="submit" class="btn btn-primary">
|
||||
<i class="bi-send me-1"></i>Создать и отправить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<i class="bi-lightbulb me-2"></i>
|
||||
<strong>Подсказка:</strong> После создания заявки вы сможете добавить детальные позиции
|
||||
(товары с количеством и ценами) на странице заявки.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
235
backend/templates/procurement/request_list.html
Normal file
235
backend/templates/procurement/request_list.html
Normal file
@ -0,0 +1,235 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Заявки на закупку{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# ===== ШАПКА СТРАНИЦЫ ===== #}
|
||||
{% 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_modal="procCreateModal" create_text="Новая заявка" %}
|
||||
|
||||
{# ===== Tabs для переключения области ===== #}
|
||||
{% if can_view_all %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<ul class="nav nav-pills nav-fill">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if scope == 'mine' %}active{% endif %}"
|
||||
href="?scope=mine">
|
||||
<i class="bi-person-circle me-1"></i> Мои заявки
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if scope == 'department' %}active{% endif %}"
|
||||
href="?scope=department">
|
||||
<i class="bi-building me-1"></i> Мой отдел
|
||||
</a>
|
||||
</li>
|
||||
{% if user.is_staff %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if scope == 'all' %}active{% endif %}"
|
||||
href="?scope=all">
|
||||
<i class="bi-globe me-1"></i> Все заявки
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== СПИСОК ЗАЯВОК ===== #}
|
||||
<div class="card mb-4">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{# Пагинация #}
|
||||
{% 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>
|
||||
|
||||
{# ===== МОДАЛКА СОЗДАНИЯ ===== #}
|
||||
<div class="modal fade" id="procCreateModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<form class="modal-content" id="procCreateForm" method="POST" action="{% url 'procurement_frontend:request_create' %}">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi-cart-plus me-2"></i>Новая заявка на закупку
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Название заявки <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control"
|
||||
placeholder="Например: Закупка ноутбуков для отдела" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Описание / обоснование</label>
|
||||
<textarea name="description" class="form-control" rows="3"
|
||||
placeholder="Опишите необходимость закупки..." maxlength="2000"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Срочность <span class="text-danger">*</span></label>
|
||||
<select name="urgency" class="form-select" required>
|
||||
<option value="low">🟢 Низкая</option>
|
||||
<option value="medium" selected>🟡 Средняя</option>
|
||||
<option value="high">🟠 Высокая</option>
|
||||
<option value="critical">🔴 Критическая</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Ориентировочная сумма</label>
|
||||
<div class="input-group">
|
||||
<input type="number" name="estimated_cost" class="form-control"
|
||||
placeholder="0" min="0" step="0.01">
|
||||
<span class="input-group-text">₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Желаемая дата получения</label>
|
||||
<input type="date" name="desired_date" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<i class="bi-info-circle me-2"></i>
|
||||
После создания вы сможете добавить позиции (товары) в заявку.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi-check-lg me-1"></i>Создать заявку
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Поиск по заявкам
|
||||
const searchInput = document.getElementById('procSearch');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', debounce((e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.proc-request-item').forEach(item => {
|
||||
const title = item.querySelector('.proc-request-title')?.textContent.toLowerCase() || '';
|
||||
const desc = item.querySelector('.proc-request-desc')?.textContent.toLowerCase() || '';
|
||||
const match = title.includes(query) || desc.includes(query);
|
||||
item.style.display = match ? '' : 'none';
|
||||
});
|
||||
}, 300));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user