Добавлены endpoints: - start_work: APPROVED → IN_PROGRESS - complete: IN_PROGRESS → COMPLETED - cancel: отмена заявки с любого статуса кроме завершённых - create_equipment: создание оборудования из позиции завершённой заявки Frontend: - Модальные окна approve/reject/cancel - POST handler для создания заявок - Django формы для заявок Тесты: 19 новых тестов lifecycle, всего 136 тестов procurement passed
458 lines
18 KiB
HTML
458 lines
18 KiB
HTML
{% extends "base.html" %}
|
||
{% load static %}
|
||
|
||
{% block title %}Заявка #{{ request_obj.id }}{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||
<link rel="stylesheet" href="{% static 'css/components/procurement.css' %}">
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
|
||
<div class="container">
|
||
{# ===== Хлебные крошки ===== #}
|
||
<nav aria-label="breadcrumb" class="mb-3">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="{% url 'procurement_frontend:dashboard' %}">Закупки</a></li>
|
||
<li class="breadcrumb-item"><a href="{% url 'procurement_frontend:request_list' %}">Заявки</a></li>
|
||
<li class="breadcrumb-item active" aria-current="page">#{{ request_obj.id }}</li>
|
||
</ol>
|
||
</nav>
|
||
|
||
{# ===== Заголовок ===== #}
|
||
<div class="card mb-4">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-start gap-3">
|
||
<div class="proc-urgency-icon {{ request_obj.urgency }}" style="width: 48px; height: 48px; font-size: 1.25rem;">
|
||
{% if request_obj.urgency == 'critical' %}
|
||
<i class="bi-exclamation-triangle-fill"></i>
|
||
{% elif request_obj.urgency == 'high' %}
|
||
<i class="bi-arrow-up-circle"></i>
|
||
{% elif request_obj.urgency == 'medium' %}
|
||
<i class="bi-dash-circle"></i>
|
||
{% else %}
|
||
<i class="bi-arrow-down-circle"></i>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="flex-grow-1">
|
||
<h3 class="mb-1">{{ request_obj.title }}</h3>
|
||
<div class="d-flex flex-wrap gap-3 text-muted small">
|
||
<span><i class="bi-building me-1"></i>{{ request_obj.department.name }}</span>
|
||
<span><i class="bi-person me-1"></i>{{ request_obj.requestor.get_full_name }}</span>
|
||
<span><i class="bi-calendar me-1"></i>{{ request_obj.created_at|date:"d.m.Y H:i" }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-end">
|
||
{% include "procurement/_status_badge.html" with status=request_obj.status %}
|
||
<div class="mt-2 h4 mb-0">{{ request_obj.estimated_cost|floatformat:0 }} ₽</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-4">
|
||
{# ===== Основная информация ===== #}
|
||
<div class="col-lg-8">
|
||
{# Описание #}
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h5 class="mb-0"><i class="bi-text-paragraph me-2"></i>Описание</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if request_obj.description %}
|
||
<p class="mb-0" style="white-space: pre-wrap;">{{ request_obj.description }}</p>
|
||
{% else %}
|
||
<p class="text-muted mb-0">Описание не указано</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{# Позиции заявки #}
|
||
<div class="card mb-4">
|
||
<div class="card-header d-flex align-items-center justify-content-between">
|
||
<h5 class="mb-0"><i class="bi-list-check me-2"></i>Позиции ({{ request_obj.items.count }})</h5>
|
||
{% if can_edit %}
|
||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addItemModal">
|
||
<i class="bi-plus-lg"></i> Добавить
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card-body p-0">
|
||
{% if request_obj.items.exists %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Наименование</th>
|
||
<th class="text-center" style="width: 80px;">Кол-во</th>
|
||
<th class="text-end" style="width: 120px;">Цена</th>
|
||
<th class="text-end" style="width: 120px;">Сумма</th>
|
||
<th style="width: 60px;"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in request_obj.items.all %}
|
||
<tr>
|
||
<td>
|
||
<div class="fw-medium">{{ item.name }}</div>
|
||
{% if item.specifications %}
|
||
<small class="text-muted">{{ item.specifications|truncatechars:50 }}</small>
|
||
{% endif %}
|
||
</td>
|
||
<td class="text-center">{{ item.quantity }}</td>
|
||
<td class="text-end">{{ item.unit_price|floatformat:0 }} ₽</td>
|
||
<td class="text-end fw-medium">{{ item.total_price|floatformat:0 }} ₽</td>
|
||
<td>
|
||
{% if can_edit %}
|
||
<button class="btn btn-sm btn-link text-danger p-0" title="Удалить">
|
||
<i class="bi-trash"></i>
|
||
</button>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
<tfoot class="table-light">
|
||
<tr>
|
||
<td colspan="3" class="text-end fw-bold">Итого:</td>
|
||
<td class="text-end fw-bold">{{ request_obj.total_amount|floatformat:0 }} ₽</td>
|
||
<td></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center py-4 text-muted">
|
||
<i class="bi-inbox fs-3 d-block mb-2"></i>
|
||
<p class="mb-0">Позиции не добавлены</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{# История согласования #}
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h5 class="mb-0"><i class="bi-clock-history me-2"></i>История согласования</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if request_obj.approvals.exists %}
|
||
<div class="timeline">
|
||
{% for approval in request_obj.approvals.all %}
|
||
<div class="d-flex gap-3 mb-3">
|
||
<div class="flex-shrink-0">
|
||
{% if approval.status == 'approved' %}
|
||
<span class="badge bg-success rounded-circle p-2"><i class="bi-check"></i></span>
|
||
{% elif approval.status == 'rejected' %}
|
||
<span class="badge bg-danger rounded-circle p-2"><i class="bi-x"></i></span>
|
||
{% else %}
|
||
<span class="badge bg-secondary rounded-circle p-2"><i class="bi-hourglass"></i></span>
|
||
{% endif %}
|
||
</div>
|
||
<div>
|
||
<div class="fw-medium">{{ approval.approver.get_full_name }}</div>
|
||
<div class="text-muted small">
|
||
{% if approval.decided_at %}
|
||
{{ approval.decided_at|date:"d.m.Y H:i" }}
|
||
{% else %}
|
||
Ожидает решения
|
||
{% endif %}
|
||
</div>
|
||
{% if approval.comment %}
|
||
<div class="mt-1 small">{{ approval.comment }}</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted mb-0">Согласования ещё не начаты</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# ===== Боковая панель ===== #}
|
||
<div class="col-lg-4">
|
||
{# Действия #}
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h5 class="mb-0"><i class="bi-gear me-2"></i>Действия</h5>
|
||
</div>
|
||
<div class="card-body d-grid gap-2">
|
||
{% if can_edit and request_obj.status == 'draft' %}
|
||
<button class="btn btn-primary" id="submitRequest">
|
||
<i class="bi-send me-2"></i>Отправить на согласование
|
||
</button>
|
||
<a href="#" class="btn btn-outline-secondary">
|
||
<i class="bi-pencil me-2"></i>Редактировать
|
||
</a>
|
||
{% endif %}
|
||
|
||
{% if can_approve and request_obj.status == 'pending' %}
|
||
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#approveModal">
|
||
<i class="bi-check-lg me-2"></i>Одобрить
|
||
</button>
|
||
<button class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">
|
||
<i class="bi-x-lg me-2"></i>Отклонить
|
||
</button>
|
||
{% endif %}
|
||
|
||
{% if request_obj.requestor == request.user and request_obj.status == 'approved' %}
|
||
<button class="btn btn-primary" id="startWorkBtn">
|
||
<i class="bi-play-fill me-2"></i>Начать работу
|
||
</button>
|
||
{% endif %}
|
||
|
||
{% if request_obj.requestor == request.user and request_obj.status == 'in_progress' %}
|
||
<button class="btn btn-success" id="completeBtn">
|
||
<i class="bi-check-circle me-2"></i>Завершить
|
||
</button>
|
||
{% endif %}
|
||
|
||
{% if request_obj.requestor == request.user and request_obj.status not in 'completed,cancelled' %}
|
||
<button class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#cancelModal">
|
||
<i class="bi-x-circle me-2"></i>Отменить заявку
|
||
</button>
|
||
{% endif %}
|
||
|
||
<a href="{% url 'procurement_frontend:request_list' %}" class="btn btn-outline-secondary">
|
||
<i class="bi-arrow-left me-2"></i>К списку
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{# Информация #}
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h5 class="mb-0"><i class="bi-info-circle me-2"></i>Информация</h5>
|
||
</div>
|
||
<ul class="list-group list-group-flush">
|
||
<li class="list-group-item d-flex justify-content-between">
|
||
<span class="text-muted">Срочность</span>
|
||
<span>
|
||
{% if request_obj.urgency == 'critical' %}🔴 Критическая
|
||
{% elif request_obj.urgency == 'high' %}🟠 Высокая
|
||
{% elif request_obj.urgency == 'medium' %}🟡 Средняя
|
||
{% else %}🟢 Низкая{% endif %}
|
||
</span>
|
||
</li>
|
||
{% if request_obj.desired_date %}
|
||
<li class="list-group-item d-flex justify-content-between">
|
||
<span class="text-muted">Желаемая дата</span>
|
||
<span>{{ request_obj.desired_date|date:"d.m.Y" }}</span>
|
||
</li>
|
||
{% endif %}
|
||
<li class="list-group-item d-flex justify-content-between">
|
||
<span class="text-muted">Создано</span>
|
||
<span>{{ request_obj.created_at|date:"d.m.Y H:i" }}</span>
|
||
</li>
|
||
<li class="list-group-item d-flex justify-content-between">
|
||
<span class="text-muted">Обновлено</span>
|
||
<span>{{ request_obj.updated_at|date:"d.m.Y H:i" }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{# ===== Модальные окна ===== #}
|
||
|
||
{# Модальное окно одобрения #}
|
||
{% if can_approve and request_obj.status == 'pending' %}
|
||
<div class="modal fade" id="approveModal" tabindex="-1" aria-labelledby="approveModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-success text-white">
|
||
<h5 class="modal-title" id="approveModalLabel">
|
||
<i class="bi-check-lg me-2"></i>Одобрить заявку
|
||
</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p>Вы собираетесь одобрить заявку <strong>"{{ request_obj.title }}"</strong></p>
|
||
<p>Сумма: <strong>{{ request_obj.estimated_cost|floatformat:2 }} ₽</strong></p>
|
||
<div class="mb-3">
|
||
<label for="approveComment" class="form-label">Комментарий (необязательно)</label>
|
||
<textarea class="form-control" id="approveComment" rows="3" placeholder="Ваш комментарий..."></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||
<button type="button" class="btn btn-success" id="confirmApprove">
|
||
<i class="bi-check-lg me-2"></i>Одобрить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# Модальное окно отклонения #}
|
||
<div class="modal fade" id="rejectModal" tabindex="-1" aria-labelledby="rejectModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-danger text-white">
|
||
<h5 class="modal-title" id="rejectModalLabel">
|
||
<i class="bi-x-lg me-2"></i>Отклонить заявку
|
||
</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p>Вы собираетесь отклонить заявку <strong>"{{ request_obj.title }}"</strong></p>
|
||
<div class="mb-3">
|
||
<label for="rejectComment" class="form-label">Причина отклонения <span class="text-danger">*</span></label>
|
||
<textarea class="form-control" id="rejectComment" rows="3" placeholder="Укажите причину отклонения..." required></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||
<button type="button" class="btn btn-danger" id="confirmReject">
|
||
<i class="bi-x-lg me-2"></i>Отклонить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# Модальное окно отмены заявки #}
|
||
{% if request_obj.requestor == request.user %}
|
||
<div class="modal fade" id="cancelModal" tabindex="-1" aria-labelledby="cancelModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-warning">
|
||
<h5 class="modal-title" id="cancelModalLabel">
|
||
<i class="bi-x-circle me-2"></i>Отменить заявку
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p>Вы собираетесь отменить заявку <strong>"{{ request_obj.title }}"</strong></p>
|
||
<p class="text-muted">Это действие нельзя отменить.</p>
|
||
<div class="mb-3">
|
||
<label for="cancelReason" class="form-label">Причина отмены</label>
|
||
<textarea class="form-control" id="cancelReason" rows="3" placeholder="Укажите причину отмены..."></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Назад</button>
|
||
<button type="button" class="btn btn-danger" id="confirmCancel">
|
||
<i class="bi-x-circle me-2"></i>Отменить заявку
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
const csrfToken = '{{ csrf_token }}';
|
||
const requestId = {{ request_obj.id }};
|
||
|
||
// Утилита для API вызовов
|
||
async function apiCall(endpoint, method = 'POST', data = null) {
|
||
const options = {
|
||
method,
|
||
headers: {
|
||
'X-CSRFToken': csrfToken,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
};
|
||
if (data) {
|
||
options.body = JSON.stringify(data);
|
||
}
|
||
|
||
const resp = await fetch(`/api/procurement/requests/${requestId}/${endpoint}/`, options);
|
||
if (!resp.ok) {
|
||
const err = await resp.json();
|
||
throw new Error(err.error || 'Ошибка запроса');
|
||
}
|
||
return resp.json();
|
||
}
|
||
|
||
// Отправка на согласование
|
||
document.getElementById('submitRequest')?.addEventListener('click', async () => {
|
||
if (!confirm('Отправить заявку на согласование?')) return;
|
||
|
||
try {
|
||
await apiCall('submit');
|
||
window.location.reload();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
});
|
||
|
||
// Одобрение
|
||
document.getElementById('confirmApprove')?.addEventListener('click', async () => {
|
||
const comment = document.getElementById('approveComment')?.value || '';
|
||
|
||
try {
|
||
await apiCall('approve', 'POST', { comment });
|
||
window.location.reload();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
});
|
||
|
||
// Отклонение
|
||
document.getElementById('confirmReject')?.addEventListener('click', async () => {
|
||
const comment = document.getElementById('rejectComment')?.value || '';
|
||
|
||
if (!comment.trim()) {
|
||
alert('Укажите причину отклонения');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await apiCall('reject', 'POST', { comment });
|
||
window.location.reload();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
});
|
||
|
||
// Начать работу
|
||
document.getElementById('startWorkBtn')?.addEventListener('click', async () => {
|
||
if (!confirm('Начать работу над заявкой?')) return;
|
||
|
||
try {
|
||
await apiCall('start_work');
|
||
window.location.reload();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
});
|
||
|
||
// Завершить
|
||
document.getElementById('completeBtn')?.addEventListener('click', async () => {
|
||
if (!confirm('Завершить заявку? Это действие нельзя отменить.')) return;
|
||
|
||
try {
|
||
await apiCall('complete');
|
||
window.location.reload();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
});
|
||
|
||
// Отмена заявки
|
||
document.getElementById('confirmCancel')?.addEventListener('click', async () => {
|
||
const reason = document.getElementById('cancelReason')?.value || '';
|
||
|
||
try {
|
||
await apiCall('cancel', 'POST', { reason });
|
||
window.location.reload();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
});
|
||
</script>
|
||
{% endblock %}
|