Corp/backend/templates/procurement/request_detail.html
Игорь Надеин 065a8851a1 feat: реализована полная функциональность создания и редактирования заявок
- Добавлены модальные окна для всех действий на странице деталей
  * Добавление позиции (#addItemModal)
  * Одобрение заявки (#approveModal)
  * Отклонение заявки (#rejectModal)
- Реализованы JavaScript обработчики для всех кнопок
  * deleteItem() - удаление позиции
  * saveItemBtn - сохранение новой позиции
  * confirmApproveBtn - одобрение с комментарием
  * confirmRejectBtn - отклонение с обязательным комментарием
- Исправлены API endpoints (items вместо request-items)
- Доработана форма создания/редактирования заявки
  * Удалены несуществующие поля (desired_date, estimated_cost)
  * Добавлен выбор отдела из API
  * Реализована AJAX отправка формы
  * Поддержка создания черновика и отправки на согласование
  * Поддержка редактирования существующих заявок (PATCH)
- Добавлен URL и view для редактирования заявок
- Кнопка "Редактировать" теперь ведёт на страницу редактирования
2025-12-10 16:46:47 +03:00

574 lines
23 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 %}
<div class="container">
{# ===== Заголовок ===== #}
<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.total_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>
{% if request_obj.items.exists %}
<div class="proc-table-header d-none d-lg-block">
<div class="proc-table-row">
<div class="proc-cell" style="flex: 3;"><h6 class="mb-0 text-secondary">Наименование</h6></div>
<div class="proc-cell" style="flex: 1;"><h6 class="mb-0 text-secondary text-center">Кол-во</h6></div>
<div class="proc-cell" style="flex: 1;"><h6 class="mb-0 text-secondary text-end">Цена</h6></div>
<div class="proc-cell" style="flex: 1;"><h6 class="mb-0 text-secondary text-end">Сумма</h6></div>
{% if can_edit %}
<div class="proc-cell" style="width: 50px;"></div>
{% endif %}
</div>
</div>
<div class="card-body p-0">
{% for item in request_obj.items.all %}
<div class="proc-request-item">
<div class="proc-cell" style="flex: 3;">
<div class="fw-medium">{{ item.name }}</div>
{% if item.description %}
<small class="text-muted d-block mt-1">{{ item.description|truncatechars:80 }}</small>
{% endif %}
</div>
<div class="proc-cell text-center" style="flex: 1;">
<span class="d-lg-none text-muted small">Количество: </span>
{{ item.quantity }} {{ item.unit }}
</div>
<div class="proc-cell text-end" style="flex: 1;">
<span class="d-lg-none text-muted small">Цена: </span>
{{ item.estimated_unit_price|floatformat:0 }} ₽
</div>
<div class="proc-cell text-end" style="flex: 1;">
<span class="d-lg-none text-muted small">Сумма: </span>
<strong>{{ item.total_price|floatformat:0 }} ₽</strong>
</div>
{% if can_edit %}
<div class="proc-cell text-center" style="width: 50px;">
<button class="btn btn-sm btn-link text-danger p-0" title="Удалить" onclick="deleteItem({{ item.id }})">
<i class="bi-trash"></i>
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{# Итого #}
<div class="proc-table-header border-top">
<div class="proc-table-row">
<div class="proc-cell" style="flex: 3;"></div>
<div class="proc-cell" style="flex: 1;"></div>
<div class="proc-cell text-end" style="flex: 1;"><h6 class="mb-0 text-secondary">Итого:</h6></div>
<div class="proc-cell text-end" style="flex: 1;"><h6 class="mb-0 fw-bold">{{ request_obj.total_cost|floatformat:0 }} ₽</h6></div>
{% if can_edit %}
<div class="proc-cell" style="width: 50px;"></div>
{% endif %}
</div>
</div>
{% else %}
<div class="card-body">
<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>
</div>
{% endif %}
</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.status == 'approved' %}
Одобрено
{% if approval.decided_at %} — {{ approval.decided_at|date:"d.m.Y H:i" }}{% endif %}
{% elif approval.status == 'rejected' %}
Отклонено
{% if approval.decided_at %} — {{ approval.decided_at|date:"d.m.Y H:i" }}{% endif %}
{% 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="{% url 'procurement_frontend:request_edit' request_obj.id %}" 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 %}
<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>
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Статус</span>
<span>{% include "procurement/_status_badge.html" with status=request_obj.status %}</span>
</li>
{% if request_obj.executor %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Исполнитель</span>
<span>{{ request_obj.executor.get_full_name }}</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>
{% if request_obj.submitted_at %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Отправлено на согласование</span>
<span>{{ request_obj.submitted_at|date:"d.m.Y H:i" }}</span>
</li>
{% endif %}
{% if request_obj.started_at %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Взято в работу</span>
<span>{{ request_obj.started_at|date:"d.m.Y H:i" }}</span>
</li>
{% endif %}
{% if request_obj.completed_at %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted">Завершено</span>
<span>{{ request_obj.completed_at|date:"d.m.Y H:i" }}</span>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{# ===== МОДАЛЬНЫЕ ОКНА ===== #}
{# Модальное окно добавления позиции #}
<div class="modal fade" id="addItemModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi-plus-lg me-2"></i>Добавить позицию</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addItemForm">
<div class="mb-3">
<label class="form-label">Наименование <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea class="form-control" name="description" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Количество <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="quantity" step="1" min="1" value="1" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Ед. изм. <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="unit" value="шт" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Цена <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="estimated_unit_price" step="0.01" min="0" required>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="saveItemBtn">Добавить</button>
</div>
</div>
</div>
</div>
{# Модальное окно одобрения #}
<div class="modal fade" id="approveModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi-check-lg me-2"></i>Одобрить заявку</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Вы уверены, что хотите одобрить эту заявку?</p>
<div class="mb-3">
<label 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="confirmApproveBtn">Одобрить</button>
</div>
</div>
</div>
</div>
{# Модальное окно отклонения #}
<div class="modal fade" id="rejectModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi-x-lg me-2"></i>Отклонить заявку</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Вы уверены, что хотите отклонить эту заявку?</p>
<div class="mb-3">
<label 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="confirmRejectBtn">Отклонить</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Получаем ID заявки из URL
const requestId = window.location.pathname.split('/').filter(x => x && !isNaN(x))[0];
let requestData = null;
// Загрузка данных при старте
document.addEventListener('DOMContentLoaded', () => {
if (requestId) {
loadRequestDetail();
}
});
async function loadRequestDetail() {
try {
const resp = await fetch(`/api/v1/procurement/requests/${requestId}/`);
if (!resp.ok) {
throw new Error('Request not found');
}
requestData = await resp.json();
renderRequestDetail(requestData);
} catch (e) {
console.error('Error loading request:', e);
document.querySelector('.container').innerHTML = `
<div class="alert alert-danger">
<i class="bi-exclamation-triangle me-2"></i>
Ошибка загрузки заявки
</div>
`;
}
}
function renderRequestDetail(req) {
// Обновляем title страницы
document.title = `Заявка #${req.id}`;
// Рендерим контент (упрощённая версия - можно расширить)
// Основные элементы уже есть в шаблоне, просто обновляем их содержимое
// Обновляем иконку срочности
const urgencyIcon = document.querySelector('.proc-urgency-icon');
if (urgencyIcon) {
urgencyIcon.className = `proc-urgency-icon ${req.urgency}`;
const icons = {
critical: 'bi-exclamation-triangle-fill',
high: 'bi-arrow-up-circle',
medium: 'bi-dash-circle',
low: 'bi-arrow-down-circle'
};
urgencyIcon.innerHTML = `<i class="${icons[req.urgency] || 'bi-dash-circle'}"></i>`;
}
}
// Отправка на согласование
document.getElementById('submitRequest')?.addEventListener('click', async () => {
if (!confirm('Отправить заявку на согласование?')) return;
try {
const resp = await fetch(`/api/v1/procurement/requests/${requestId}/submit/`, {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}'
}
});
if (resp.ok) {
window.location.reload();
} else {
const err = await resp.json();
alert(err.error || 'Ошибка отправки');
}
} catch (e) {
console.error(e);
alert('Ошибка сети');
}
});
// Добавление позиции
document.getElementById('saveItemBtn')?.addEventListener('click', async () => {
const form = document.getElementById('addItemForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form);
const data = {
name: formData.get('name'),
description: formData.get('description'),
quantity: parseFloat(formData.get('quantity')),
unit: formData.get('unit'),
estimated_unit_price: parseFloat(formData.get('estimated_unit_price')),
request: requestId
};
try {
const resp = await fetch('/api/v1/procurement/items/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}'
},
body: JSON.stringify(data)
});
if (resp.ok) {
bootstrap.Modal.getInstance(document.getElementById('addItemModal')).hide();
form.reset();
window.location.reload();
} else {
const err = await resp.json();
alert(err.error || 'Ошибка добавления позиции');
}
} catch (e) {
console.error(e);
alert('Ошибка сети');
}
});
// Удаление позиции
async function deleteItem(itemId) {
if (!confirm('Удалить эту позицию?')) return;
try {
const resp = await fetch(`/api/v1/procurement/items/${itemId}/`, {
method: 'DELETE',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}'
}
});
if (resp.ok) {
window.location.reload();
} else {
const err = await resp.json();
alert(err.error || 'Ошибка удаления');
}
} catch (e) {
console.error(e);
alert('Ошибка сети');
}
}
// Одобрение заявки
document.getElementById('confirmApproveBtn')?.addEventListener('click', async () => {
const comment = document.getElementById('approveComment').value;
try {
const resp = await fetch(`/api/v1/procurement/requests/${requestId}/approve/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}'
},
body: JSON.stringify({ comment })
});
if (resp.ok) {
bootstrap.Modal.getInstance(document.getElementById('approveModal')).hide();
window.location.reload();
} else {
const err = await resp.json();
alert(err.error || 'Ошибка одобрения');
}
} catch (e) {
console.error(e);
alert('Ошибка сети');
}
});
// Отклонение заявки
document.getElementById('confirmRejectBtn')?.addEventListener('click', async () => {
const comment = document.getElementById('rejectComment').value;
if (!comment.trim()) {
alert('Укажите причину отклонения');
return;
}
try {
const resp = await fetch(`/api/v1/procurement/requests/${requestId}/reject/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}'
},
body: JSON.stringify({ comment })
});
if (resp.ok) {
bootstrap.Modal.getInstance(document.getElementById('rejectModal')).hide();
window.location.reload();
} else {
const err = await resp.json();
alert(err.error || 'Ошибка отклонения');
}
} catch (e) {
console.error(e);
alert('Ошибка сети');
}
});
</script>
{% endblock %}