diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index 61f5f754..309795d7 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -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) diff --git a/backend/procurement/urls_frontend.py b/backend/procurement/urls_frontend.py new file mode 100644 index 00000000..7159f557 --- /dev/null +++ b/backend/procurement/urls_frontend.py @@ -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//', views.request_detail, name='request_detail'), + + # Оборудование + path('equipment/', views.equipment_list, name='equipment_list'), + path( + 'equipment//', + views.equipment_detail, + name='equipment_detail' + ), +] diff --git a/backend/procurement/views_frontend.py b/backend/procurement/views_frontend.py new file mode 100644 index 00000000..48948506 --- /dev/null +++ b/backend/procurement/views_frontend.py @@ -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) diff --git a/backend/static/css/components/procurement.css b/backend/static/css/components/procurement.css new file mode 100644 index 00000000..3980845d --- /dev/null +++ b/backend/static/css/components/procurement.css @@ -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; + } +} diff --git a/backend/static/js/modules/equipmentListHandler.js b/backend/static/js/modules/equipmentListHandler.js new file mode 100644 index 00000000..ee091579 --- /dev/null +++ b/backend/static/js/modules/equipmentListHandler.js @@ -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 = '' + + categories.map(cat => ``).join(''); + } + } catch (error) { + console.error('Error loading categories:', error); + } + } + + async loadEquipment() { + const container = document.querySelector(this.container); + if (!container) return; + + container.innerHTML = ` +
+
+ Загрузка... +
+
+ `; + + 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 = ` +
+
+ + Ошибка загрузки оборудования +
+
+ `; + } + } + + 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 = ` +
+ +
Оборудование не найдено
+

Попробуйте изменить параметры поиска

+
+ `; + 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 ` +
+
+
+
+ ${icon} +
+
${this.escapeHtml(eq.name)}
+
+ ${eq.inventory_number} +
+
+ + ${this.getStatusLabel(eq.status)} + + ${eq.category_name || ''} +
+
+ +
+
+ `; + } + + getEquipmentIcon(name, category) { + const text = (name + ' ' + (category || '')).toLowerCase(); + + if (text.includes('ноутбук') || text.includes('laptop')) { + return ''; + } else if (text.includes('монитор') || text.includes('display')) { + return ''; + } else if (text.includes('принтер') || text.includes('printer')) { + return ''; + } else if (text.includes('телефон') || text.includes('phone')) { + return ''; + } else if (text.includes('сервер') || text.includes('server')) { + return ''; + } else if (text.includes('клавиатур') || text.includes('keyboard')) { + return ''; + } else if (text.includes('мышь') || text.includes('mouse')) { + return ''; + } else if (text.includes('роутер') || text.includes('router') || text.includes('свитч') || text.includes('switch')) { + return ''; + } + return ''; + } + + 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 = ` + + `; + + 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 = ''; + 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; diff --git a/backend/static/js/modules/procurementDashboardHandler.js b/backend/static/js/modules/procurementDashboardHandler.js new file mode 100644 index 00000000..edaa9d90 --- /dev/null +++ b/backend/static/js/modules/procurementDashboardHandler.js @@ -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 = ` +
+ +

Заявок пока нет

+
+ `; + return; + } + + container.innerHTML = requests.map(req => ` + +
+
+
${this.escapeHtml(req.title)}
+ + ${req.requester_name || 'Неизвестно'} • + ${this.formatDate(req.created_at)} + +
+ + ${this.getStatusLabel(req.status)} + +
+
+ `).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 = ` +
+ +

Оборудования пока нет

+
+ `; + return; + } + + container.innerHTML = equipment.map(eq => ` + +
+
+
${this.escapeHtml(eq.name)}
+ + ${eq.inventory_number} • + ${eq.category_name || 'Без категории'} + +
+ + ${this.getEquipmentStatusLabel(eq.status)} + +
+
+ `).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; diff --git a/backend/static/js/modules/procurementRequestListHandler.js b/backend/static/js/modules/procurementRequestListHandler.js new file mode 100644 index 00000000..3cf39de4 --- /dev/null +++ b/backend/static/js/modules/procurementRequestListHandler.js @@ -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 = ` +
+
+ Загрузка... +
+
+ `; + + 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 = ` +
+ + Ошибка загрузки заявок +
+ `; + } + } + + renderRequests(requests, totalCount) { + const container = document.querySelector(this.container); + if (!container) return; + + if (requests.length === 0) { + container.innerHTML = ` +
+ +
Заявок не найдено
+

Создайте новую заявку на закупку

+
+ `; + 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 ? + ' Срочно' : ''; + + return ` + +
+
+
+
+ ${urgencyBadge} + ${this.escapeHtml(req.title)} +
+

+ ${req.requester_name || 'Неизвестно'} + • + ${this.formatDate(req.created_at)} + ${req.items_count ? `•${req.items_count} поз.` : ''} +

+ ${req.description ? `

${this.escapeHtml(req.description)}

` : ''} +
+
+ + ${this.getStatusLabel(req.status)} + + ${req.total_cost ? `
${this.formatCurrency(req.total_cost)}
` : ''} +
+
+
+
+ `; + } + + renderPagination(totalCount) { + const totalPages = Math.ceil(totalCount / this.pageSize); + let html = ''; + 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 = 'Сохранение...'; + + 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 = ` +
+ +
+
+ +
+
+ +
+
+ +
+ `; + 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; diff --git a/backend/templates/includes/sidebar.html b/backend/templates/includes/sidebar.html index 05bbcb9c..f0c0037d 100644 --- a/backend/templates/includes/sidebar.html +++ b/backend/templates/includes/sidebar.html @@ -69,6 +69,13 @@ Финансы + + + Закупки + +
diff --git a/backend/templates/procurement/_equipment_filters.html b/backend/templates/procurement/_equipment_filters.html new file mode 100644 index 00000000..f38b3134 --- /dev/null +++ b/backend/templates/procurement/_equipment_filters.html @@ -0,0 +1,67 @@ +{# Фильтры для списка оборудования #} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + diff --git a/backend/templates/procurement/_equipment_status.html b/backend/templates/procurement/_equipment_status.html new file mode 100644 index 00000000..1d470afa --- /dev/null +++ b/backend/templates/procurement/_equipment_status.html @@ -0,0 +1,29 @@ +{# Бейдж статуса оборудования #} + +{% if status == 'available' %} + + Доступно + +{% elif status == 'in_use' %} + + В использовании + +{% elif status == 'maintenance' %} + + На ТО + +{% elif status == 'repair' %} + + В ремонте + +{% elif status == 'retired' %} + + Списано + +{% elif status == 'lost' %} + + Утеряно + +{% else %} + {{ status }} +{% endif %} diff --git a/backend/templates/procurement/_filters.html b/backend/templates/procurement/_filters.html new file mode 100644 index 00000000..b6007dd1 --- /dev/null +++ b/backend/templates/procurement/_filters.html @@ -0,0 +1,71 @@ +{# Фильтры для списка заявок на закупку #} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + diff --git a/backend/templates/procurement/_status_badge.html b/backend/templates/procurement/_status_badge.html new file mode 100644 index 00000000..63649fa3 --- /dev/null +++ b/backend/templates/procurement/_status_badge.html @@ -0,0 +1,34 @@ +{# Бейдж статуса заявки на закупку #} +{% load i18n %} + +{% if status == 'draft' %} + + Черновик + +{% elif status == 'pending' %} + + На согласовании + +{% elif status == 'approved' %} + + Одобрено + +{% elif status == 'rejected' %} + + Отклонено + +{% elif status == 'ordered' %} + + Заказано + +{% elif status == 'delivered' %} + + Получено + +{% elif status == 'cancelled' %} + + Отменено + +{% else %} + {{ status }} +{% endif %} diff --git a/backend/templates/procurement/dashboard.html b/backend/templates/procurement/dashboard.html new file mode 100644 index 00000000..ccb5823f --- /dev/null +++ b/backend/templates/procurement/dashboard.html @@ -0,0 +1,238 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Закупки{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} + + {# ===== ШАПКА СТРАНИЦЫ ===== #} + {% include "includes/components/page_header.html" with icon="bi-cart3" title="Закупки" no_search=True create_url=create_url create_text="Новая заявка" %} + + {# ===== СТАТИСТИКА ===== #} +
+
+
+ +
+
+
{{ stats.pending|default:0 }}
+
На согласовании
+
+
+ +
+
+ +
+
+
{{ stats.approved|default:0 }}
+
Одобрено
+
+
+ +
+
+ +
+
+
+ {% if budget %} + {{ budget.remaining_percent|floatformat:0 }}% + {% else %} + — + {% endif %} +
+
Бюджет отдела
+
+
+ +
+
+ +
+
+
{{ stats.equipment|default:0 }}
+
Оборудование
+
+
+
+ + {# ===== БЫСТРЫЕ ДЕЙСТВИЯ ===== #} + + + {# ===== МОЙ БЮДЖЕТ (если есть) ===== #} + {% if budget %} +
+
+
+ Бюджет отдела на {{ budget.quarter }} кв. {{ budget.year }} +
+
+
+
+
+
{{ budget.allocated_amount|floatformat:0 }} ₽
+
Выделено
+
+
+
{{ budget.spent_amount|floatformat:0 }} ₽
+
Потрачено
+
+
+
{{ budget.reserved_amount|floatformat:0 }} ₽
+
Зарезервировано
+
+
+
{{ budget.available_amount|floatformat:0 }} ₽
+
Доступно
+
+
+ +
+ {% with percent=budget.spent_percent %} +
+ {% endwith %} +
+
+ + Использовано {{ budget.spent_percent|floatformat:0 }}% бюджета + +
+
+
+ {% endif %} + + {# ===== ПОСЛЕДНИЕ ЗАЯВКИ ===== #} +
+
+
+ Последние заявки +
+ + Все заявки + +
+
+ {% if recent_requests %} +
+
+
+
Заявка
+
Отдел
+
Сумма
+
Статус
+
Дата
+
+
+ + {% for request in recent_requests %} +
+
+ {% if request.urgency == 'critical' %} + + {% elif request.urgency == 'high' %} + + {% elif request.urgency == 'medium' %} + + {% else %} + + {% endif %} +
+ +
+ +
{{ request.description|truncatechars:50 }}
+
+ +
{{ request.department.name }}
+ +
{{ request.estimated_cost|floatformat:0 }} ₽
+ +
+ {% include "procurement/_status_badge.html" with status=request.status %} +
+ +
+ {{ request.created_at|date:"d.m.Y" }} +
+
+ {% endfor %} + {% else %} +
+ +

Заявок пока нет

+
+ {% endif %} +
+
+ +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/procurement/equipment_detail.html b/backend/templates/procurement/equipment_detail.html new file mode 100644 index 00000000..d2b89c7f --- /dev/null +++ b/backend/templates/procurement/equipment_detail.html @@ -0,0 +1,438 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ equipment.name }}{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} + +
+ {# ===== Хлебные крошки ===== #} + + +
+ {# ===== Основная информация ===== #} +
+
+
+
+
+ {% if 'ноутбук' in equipment.name|lower or 'laptop' in equipment.name|lower %} + + {% elif 'монитор' in equipment.name|lower or 'display' in equipment.name|lower %} + + {% elif 'принтер' in equipment.name|lower or 'printer' in equipment.name|lower %} + + {% elif 'телефон' in equipment.name|lower or 'phone' in equipment.name|lower %} + + {% elif 'сервер' in equipment.name|lower or 'server' in equipment.name|lower %} + + {% else %} + + {% endif %} +
+ +
+

{{ equipment.name }}

+
+ {{ equipment.inventory_number }} + {% include "procurement/_equipment_status.html" with status=equipment.status %} +
+
+ + +
+
+
+ + {# Характеристики #} +
+
+
Характеристики
+
+
    + {% if equipment.category %} +
  • + Категория + {{ equipment.category.name }} +
  • + {% endif %} + {% if equipment.serial_number %} +
  • + Серийный номер + {{ equipment.serial_number }} +
  • + {% endif %} + {% if equipment.model %} +
  • + Модель + {{ equipment.model }} +
  • + {% endif %} + {% if equipment.manufacturer %} +
  • + Производитель + {{ equipment.manufacturer }} +
  • + {% endif %} + {% if equipment.location %} +
  • + Расположение + {{ equipment.location }} +
  • + {% endif %} +
+
+ + {# Описание #} + {% if equipment.notes %} +
+
+
Примечания
+
+
+

{{ equipment.notes }}

+
+
+ {% endif %} + + {# История обслуживания #} +
+
+
История обслуживания
+ {% if can_manage %} + + {% endif %} +
+
+ {% if equipment.maintenance_records.exists %} +
+ + + + + + + + + + + {% for record in equipment.maintenance_records.all %} + + + + + + + {% endfor %} + +
ДатаТипОписаниеСтоимость
{{ record.date|date:"d.m.Y" }}{{ record.get_type_display }}{{ record.description|truncatechars:50 }} + {% if record.cost %}{{ record.cost|floatformat:0 }} ₽{% else %}—{% endif %} +
+
+ {% else %} +
+ +

Записей об обслуживании нет

+
+ {% endif %} +
+
+ + {# История передач #} +
+
+
История передач
+
+
+ {% if equipment.transfer_logs.exists %} +
+ + + + + + + + + + + {% for log in equipment.transfer_logs.all %} + + + + + + + {% endfor %} + +
ДатаОткудаКудаПричина
{{ log.created_at|date:"d.m.Y" }} + {% if log.from_department %}{{ log.from_department.name }}{% endif %} + {% if log.from_person %}({{ log.from_person.get_full_name }}){% endif %} + + {% if log.to_department %}{{ log.to_department.name }}{% endif %} + {% if log.to_person %}({{ log.to_person.get_full_name }}){% endif %} + {{ log.reason|truncatechars:40 }}
+
+ {% else %} +
+ +

Передач не было

+
+ {% endif %} +
+
+
+ + {# ===== Боковая панель ===== #} +
+ {# Действия #} + {% if can_manage %} +
+
+
Действия
+
+
+ + + {% if equipment.status != 'retired' %} + + {% endif %} +
+
+ {% endif %} + + {# Информация о владении #} +
+
+
Владение
+
+
    +
  • + Отдел + {{ equipment.department.name|default:"—" }} +
  • +
  • + Ответственный + {{ equipment.responsible_person.get_full_name|default:"—" }} +
  • +
+
+ + {# Финансовая информация #} +
+
+
Финансы
+
+
    +
  • + Дата покупки + {{ equipment.purchase_date|date:"d.m.Y" }} +
  • +
  • + Стоимость + {{ equipment.purchase_cost|floatformat:0 }} ₽ +
  • + {% if equipment.warranty_until %} +
  • + Гарантия до + + {{ equipment.warranty_until|date:"d.m.Y" }} + +
  • + {% endif %} +
+
+
+
+
+ +{# ===== Модалки ===== #} +{% if can_manage %} +{# Модалка обслуживания #} + + +{# Модалка перевода #} + +{% endif %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/procurement/equipment_list.html b/backend/templates/procurement/equipment_list.html new file mode 100644 index 00000000..7679a439 --- /dev/null +++ b/backend/templates/procurement/equipment_list.html @@ -0,0 +1,351 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Оборудование{% endblock %} + +{% block extra_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" %} + + {# ===== СЕТКА ОБОРУДОВАНИЯ ===== #} +
+ {% if equipment_list %} + {% for item in equipment_list %} +
+
+
+ {% if item.category.name == 'Компьютеры' or 'ноутбук' in item.name|lower %} + + {% elif 'монитор' in item.name|lower %} + + {% elif 'принтер' in item.name|lower %} + + {% elif 'телефон' in item.name|lower %} + + {% elif 'сервер' in item.name|lower %} + + {% else %} + + {% endif %} +
+
+ +
{{ item.inventory_number }}
+
+ +
+ +
+
+ {% if item.category %} +
+ + {{ item.category.name }} +
+ {% endif %} + + {% if item.department %} +
+ + {{ item.department.name }} +
+ {% endif %} + + {% if item.responsible_person %} +
+ + {{ item.responsible_person.get_full_name }} +
+ {% endif %} + + {% if item.location %} +
+ + {{ item.location }} +
+ {% endif %} +
+
+ + +
+ {% endfor %} + {% else %} +
+
+ +

Оборудование не найдено

+
+
+ {% endif %} +
+ + {# Пагинация #} + {% if page_obj %} +
+ +
+ {% endif %} + +{# ===== МОДАЛКА ПЕРЕВОДА ===== #} + + +{# ===== МОДАЛКА ОБСЛУЖИВАНИЯ ===== #} + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/procurement/request_detail.html b/backend/templates/procurement/request_detail.html new file mode 100644 index 00000000..e4f1107b --- /dev/null +++ b/backend/templates/procurement/request_detail.html @@ -0,0 +1,273 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Заявка #{{ request_obj.id }}{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} + +
+ {# ===== Хлебные крошки ===== #} + + + {# ===== Заголовок ===== #} +
+
+
+
+ {% if request_obj.urgency == 'critical' %} + + {% elif request_obj.urgency == 'high' %} + + {% elif request_obj.urgency == 'medium' %} + + {% else %} + + {% endif %} +
+ +
+

{{ request_obj.title }}

+
+ {{ request_obj.department.name }} + {{ request_obj.requestor.get_full_name }} + {{ request_obj.created_at|date:"d.m.Y H:i" }} +
+
+ +
+ {% include "procurement/_status_badge.html" with status=request_obj.status %} +
{{ request_obj.estimated_cost|floatformat:0 }} ₽
+
+
+
+
+ +
+ {# ===== Основная информация ===== #} +
+ {# Описание #} +
+
+
Описание
+
+
+ {% if request_obj.description %} +

{{ request_obj.description }}

+ {% else %} +

Описание не указано

+ {% endif %} +
+
+ + {# Позиции заявки #} +
+
+
Позиции ({{ request_obj.items.count }})
+ {% if can_edit %} + + {% endif %} +
+
+ {% if request_obj.items.exists %} +
+ + + + + + + + + + + + {% for item in request_obj.items.all %} + + + + + + + + {% endfor %} + + + + + + + + +
НаименованиеКол-воЦенаСумма
+
{{ item.name }}
+ {% if item.specifications %} + {{ item.specifications|truncatechars:50 }} + {% endif %} +
{{ item.quantity }}{{ item.unit_price|floatformat:0 }} ₽{{ item.total_price|floatformat:0 }} ₽ + {% if can_edit %} + + {% endif %} +
Итого:{{ request_obj.total_amount|floatformat:0 }} ₽
+
+ {% else %} +
+ +

Позиции не добавлены

+
+ {% endif %} +
+
+ + {# История согласования #} +
+
+
История согласования
+
+
+ {% if request_obj.approvals.exists %} +
+ {% for approval in request_obj.approvals.all %} +
+
+ {% if approval.status == 'approved' %} + + {% elif approval.status == 'rejected' %} + + {% else %} + + {% endif %} +
+
+
{{ approval.approver.get_full_name }}
+
+ {% if approval.decided_at %} + {{ approval.decided_at|date:"d.m.Y H:i" }} + {% else %} + Ожидает решения + {% endif %} +
+ {% if approval.comment %} +
{{ approval.comment }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

Согласования ещё не начаты

+ {% endif %} +
+
+
+ + {# ===== Боковая панель ===== #} +
+ {# Действия #} +
+
+
Действия
+
+
+ {% if can_edit and request_obj.status == 'draft' %} + + + Редактировать + + {% endif %} + + {% if can_approve and request_obj.status == 'pending' %} + + + {% endif %} + + + К списку + +
+
+ + {# Информация #} +
+
+
Информация
+
+
    +
  • + Срочность + + {% if request_obj.urgency == 'critical' %}🔴 Критическая + {% elif request_obj.urgency == 'high' %}🟠 Высокая + {% elif request_obj.urgency == 'medium' %}🟡 Средняя + {% else %}🟢 Низкая{% endif %} + +
  • + {% if request_obj.desired_date %} +
  • + Желаемая дата + {{ request_obj.desired_date|date:"d.m.Y" }} +
  • + {% endif %} +
  • + Создано + {{ request_obj.created_at|date:"d.m.Y H:i" }} +
  • +
  • + Обновлено + {{ request_obj.updated_at|date:"d.m.Y H:i" }} +
  • +
+
+
+
+
+ +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/procurement/request_form.html b/backend/templates/procurement/request_form.html new file mode 100644 index 00000000..5ecc7527 --- /dev/null +++ b/backend/templates/procurement/request_form.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Новая заявка{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} + +
+ {# ===== Хлебные крошки ===== #} + + +
+
+
+
+

+ Новая заявка на закупку +

+
+ +
+ {% csrf_token %} +
+
+
+ + +
Кратко опишите суть заявки
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + ₽ +
+
Можно указать позже
+
+
+
+ + +
+
+ +
+ + Подсказка: После создания заявки вы сможете добавить детальные позиции + (товары с количеством и ценами) на странице заявки. +
+
+
+
+ +{% endblock %} diff --git a/backend/templates/procurement/request_list.html b/backend/templates/procurement/request_list.html new file mode 100644 index 00000000..be005e07 --- /dev/null +++ b/backend/templates/procurement/request_list.html @@ -0,0 +1,235 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Заявки на закупку{% endblock %} + +{% block extra_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 %} +
+
+ +
+
+ {% endif %} + + {# ===== СПИСОК ЗАЯВОК ===== #} +
+
+
+
+
Заявка
+
Отдел
+
Сумма
+
Статус
+
Дата
+
+
+ +
+ {% if requests %} + {% for request in requests %} +
+
+ {% if request.urgency == 'critical' %} + + {% elif request.urgency == 'high' %} + + {% elif request.urgency == 'medium' %} + + {% else %} + + {% endif %} +
+ +
+ +
+ {{ request.description|truncatechars:60 }} + {% if request.items.count > 0 %} + ({{ request.items.count }} поз.) + {% endif %} +
+
+ +
+ + {{ request.department.name }} +
+ +
+ {{ request.estimated_cost|floatformat:0 }} ₽ +
+ +
+ {% include "procurement/_status_badge.html" with status=request.status %} +
+ +
+ {{ request.created_at|date:"d.m.Y" }} +
+
+ {% endfor %} + {% else %} +
+ +

Заявок не найдено

+ + Создать заявку + +
+ {% endif %} +
+ + {# Пагинация #} + {% if page_obj %} + + {% endif %} +
+ +{# ===== МОДАЛКА СОЗДАНИЯ ===== #} + + +{% endblock %} + +{% block extra_js %} + +{% endblock %}