Игорь Надеин b787df45f6 refactor: синхронизированы статусные бейджи во всех шаблонах закупок
- Обновлены иконки статусов в dashboard.html и request_list.html
- Все статусы теперь используют одинаковые иконки и стили
- Добавлены функции getStatusBadgeHTML() для динамического рендеринга
- Исправлена синтаксическая ошибка в loadRequests()
- Статусы: draft, pending, approved, rejected, in_progress, completed, cancelled
2025-12-10 16:37:14 +03:00

497 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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_as_card=True create_modal="procCreateModal" 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">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">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">—</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">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>
<div class="col-md-6 col-lg-3" id="approvalsCard" style="display:none;">
<a href="{% url 'procurement_frontend:pending_approvals' %}" class="card text-decoration-none h-100">
<div class="card-body text-center py-4">
<i class="bi-clipboard-check text-warning fs-1 mb-2 d-block"></i>
<h6 class="mb-1">Согласование</h6>
<small class="text-muted">Ожидают решения</small>
</div>
</a>
</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-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" id="recentRequestsContainer">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm"></div>
<p class="mb-0 mt-2">Загрузка...</p>
</div>
</div>
</div>
{# ===== МОДАЛКА СОЗДАНИЯ ===== #}
<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">
{% 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-12">
<label class="form-label">Отдел <span class="text-danger">*</span></label>
<select name="department" id="createDepartment" class="form-select" required>
<option value="">Выберите отдел...</option>
</select>
</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 class="col-12">
<hr class="my-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Позиции (товары/услуги)</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="addItemBtn">
<i class="bi-plus-lg me-1"></i>Добавить позицию
</button>
</div>
<div id="itemsList" class="mb-2">
<!-- Позиции будут добавляться динамически -->
</div>
<div class="text-muted small">
<i class="bi-info-circle me-1"></i>
Можно добавить позиции сейчас или после создания заявки
</div>
</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>
// === ЗАГРУЗКА ДАННЫХ ПРИ СТАРТЕ ===
document.addEventListener('DOMContentLoaded', () => {
loadDashboardData();
loadDepartments();
});
async function loadDashboardData() {
try {
// Загрузка статистики
const statsResp = await fetch('/api/v1/procurement/stats/overview/');
if (statsResp.ok) {
const stats = await statsResp.json();
document.getElementById('statPending').textContent = stats.pending || 0;
document.getElementById('statApproved').textContent = stats.approved || 0;
document.getElementById('statEquipment').textContent = stats.equipment_count || 0;
}
// Загрузка бюджета
const budgetResp = await fetch('/api/v1/procurement/budgets/my-department/');
if (budgetResp.ok) {
const budget = await budgetResp.json();
if (budget && budget.remaining_percent !== undefined) {
document.getElementById('statBudget').textContent = Math.round(budget.remaining_percent) + '%';
}
}
// Загрузка последних заявок
const reqResp = await fetch('/api/v1/procurement/requests/?ordering=-created_at&page_size=5');
if (reqResp.ok) {
const data = await reqResp.json();
renderRecentRequests(data.results || data);
}
} catch (e) {
console.error('Error loading dashboard:', e);
}
}
// Функция для генерации HTML статуса (аналогично _status_badge.html)
function getStatusBadgeHTML(status) {
const statusConfig = {
draft: { icon: 'bi-pencil', text: 'Черновик', class: 'draft' },
pending: { icon: 'bi-hourglass-split', text: 'На согласовании', class: 'pending' },
approved: { icon: 'bi-check-circle', text: 'Одобрено', class: 'approved' },
rejected: { icon: 'bi-x-circle', text: 'Отклонено', class: 'rejected' },
in_progress: { icon: 'bi-truck', text: 'Заказано', class: 'in_progress' },
completed: { icon: 'bi-check-all', text: 'Завершено', class: 'completed' },
cancelled: { icon: 'bi-slash-circle', text: 'Отменено', class: 'cancelled' }
};
const config = statusConfig[status] || statusConfig.draft;
return `<span class="proc-status ${config.class}"><i class="${config.icon} me-1"></i>${config.text}</span>`;
}
function renderRecentRequests(requests) {
const container = document.getElementById('recentRequestsContainer');
if (!container) return;
if (!requests || !requests.length) {
container.innerHTML = '<div class="text-center py-4 text-muted"><i class="bi-inbox fs-1 d-block mb-2"></i><p>Заявок пока нет</p></div>';
return;
}
const urgencyIcons = {
critical: 'bi-exclamation-triangle-fill',
high: 'bi-arrow-up-circle',
medium: 'bi-dash-circle',
low: 'bi-arrow-down-circle'
};
let html = '<div class="proc-table-header d-none d-lg-block"><div class="proc-table-row">';
html += '<div class="proc-cell"></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Заявка</h6></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Отдел</h6></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Сумма</h6></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Статус</h6></div>';
html += '<div class="proc-cell"><h6 class="mb-0 text-secondary">Дата</h6></div>';
html += '</div></div>';
requests.forEach(r => {
const urgIcon = urgencyIcons[r.urgency] || 'bi-dash-circle';
const deptName = r.department_name || r.department?.name || '—';
const cost = (r.total_cost || 0).toLocaleString('ru-RU');
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('ru-RU') : '—';
html += `<div class="proc-request-item">`;
html += `<div class="proc-urgency-icon ${r.urgency}"><i class="${urgIcon}"></i></div>`;
html += `<div class="proc-request-info">`;
html += `<div class="proc-request-title"><a href="/procurement/orders/${r.id}/">${escapeHtml(r.title)}</a></div>`;
html += `<div class="proc-request-desc">${escapeHtml((r.description || '').substring(0, 50))}</div>`;
html += `</div>`;
html += `<div class="proc-request-dept">${escapeHtml(deptName)}</div>`;
html += `<div class="proc-request-amount">${cost} ₽</div>`;
html += `<div class="proc-request-status">${getStatusBadgeHTML(r.status)}</div>`;
html += `<div class="proc-request-date">${date}</div>`;
html += `</div>`;
});
container.innerHTML = html;
}
async function loadDepartments() {
try {
const resp = await fetch('/api/v1/departments/');
if (resp.ok) {
const depts = await resp.json();
const select = document.getElementById('createDepartment');
if (select) {
depts.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name;
select.appendChild(opt);
});
}
}
} catch (e) {
console.error('Error loading departments:', e);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// === УПРАВЛЕНИЕ ПОЗИЦИЯМИ В ЗАЯВКЕ ===
let itemIndex = 0;
// Функция добавления позиции
function addItem() {
itemIndex++;
const itemHtml = `
<div class="card mb-2 item-card" data-index="${itemIndex}">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="card-title mb-0">Позиция #${itemIndex}</h6>
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="removeItem(${itemIndex})">
<i class="bi-x-lg"></i>
</button>
</div>
<div class="row g-2">
<div class="col-12">
<input type="text" class="form-control form-control-sm item-name"
placeholder="Название товара/услуги *" required maxlength="255">
</div>
<div class="col-12">
<textarea class="form-control form-control-sm item-description" rows="2"
placeholder="Описание (опционально)" maxlength="1000"></textarea>
</div>
<div class="col-4">
<input type="number" class="form-control form-control-sm item-quantity"
placeholder="Количество *" min="1" step="1" value="1" required>
</div>
<div class="col-4">
<input type="text" class="form-control form-control-sm item-unit"
placeholder="Ед. изм." value="шт" maxlength="20">
</div>
<div class="col-4">
<div class="input-group input-group-sm">
<input type="number" class="form-control item-price"
placeholder="Цена" min="0" step="0.01">
<span class="input-group-text">₽</span>
</div>
</div>
</div>
</div>
</div>
`;
document.getElementById('itemsList').insertAdjacentHTML('beforeend', itemHtml);
}
// Функция удаления позиции
function removeItem(index) {
document.querySelector(`.item-card[data-index="${index}"]`)?.remove();
}
// Обработчик кнопки добавления позиции
document.getElementById('addItemBtn')?.addEventListener('click', addItem);
// Функция сбора данных позиций
function collectItems() {
const items = [];
document.querySelectorAll('.item-card').forEach(card => {
const name = card.querySelector('.item-name').value.trim();
if (name) {
items.push({
name: name,
description: card.querySelector('.item-description').value.trim() || '',
quantity: parseInt(card.querySelector('.item-quantity').value) || 1,
unit: card.querySelector('.item-unit').value.trim() || 'шт',
estimated_unit_price: parseFloat(card.querySelector('.item-price').value) || 0
});
}
});
return items;
}
// Обработка создания заявки
document.getElementById('procCreateForm')?.addEventListener('submit', async (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 = new FormData(form);
const items = collectItems();
const data = {
title: formData.get('title'),
description: formData.get('description') || '',
department: formData.get('department'),
urgency: formData.get('urgency'),
estimated_cost: formData.get('estimated_cost') || null,
desired_date: formData.get('desired_date') || null,
};
// Добавляем позиции, если есть
if (items.length > 0) {
data.items = items;
}
const resp = await fetch('/api/v1/procurement/requests/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
},
body: JSON.stringify(data)
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || JSON.stringify(err));
}
const request = await resp.json();
// Очистить форму и список позиций
form.reset();
document.getElementById('itemsList').innerHTML = '';
itemIndex = 0;
// Закрыть модалку
const modal = bootstrap.Modal.getInstance(document.querySelector('#procCreateModal'));
modal?.hide();
// Перейти к заявке
window.location.href = `/procurement/orders/${request.id}/`;
} catch (error) {
console.error('Error creating request:', error);
alert('Ошибка создания заявки: ' + error.message);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// Автообновление статистики каждые 60 секунд
setInterval(async () => {
try {
const resp = await fetch('/api/v1/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 %}