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:
admin i 2025-12-09 12:03:58 +03:00
parent 3cd161d83c
commit 53a8f0e6b4
18 changed files with 3700 additions and 1 deletions

View File

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

View 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'
),
]

View 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)

View 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;
}
}

View 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;

View 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;

View 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;

View File

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

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

View 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 %}

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

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}