Corp/backend/templates/procurement/request_detail.html
Игорь Надеин ccbc8c5452 feat(procurement): полный жизненный цикл заявок
Добавлены 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
2025-12-09 16:46:21 +03:00

458 lines
18 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 %}Заявка #{{ 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 %}