- Обновлены иконки статусов в dashboard.html и request_list.html - Все статусы теперь используют одинаковые иконки и стили - Добавлены функции getStatusBadgeHTML() для динамического рендеринга - Исправлена синтаксическая ошибка в loadRequests() - Статусы: draft, pending, approved, rejected, in_progress, completed, cancelled
497 lines
20 KiB
HTML
497 lines
20 KiB
HTML
{% 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 %}
|