feat: реализована полная функциональность создания и редактирования заявок

- Добавлены модальные окна для всех действий на странице деталей
  * Добавление позиции (#addItemModal)
  * Одобрение заявки (#approveModal)
  * Отклонение заявки (#rejectModal)
- Реализованы JavaScript обработчики для всех кнопок
  * deleteItem() - удаление позиции
  * saveItemBtn - сохранение новой позиции
  * confirmApproveBtn - одобрение с комментарием
  * confirmRejectBtn - отклонение с обязательным комментарием
- Исправлены API endpoints (items вместо request-items)
- Доработана форма создания/редактирования заявки
  * Удалены несуществующие поля (desired_date, estimated_cost)
  * Добавлен выбор отдела из API
  * Реализована AJAX отправка формы
  * Поддержка создания черновика и отправки на согласование
  * Поддержка редактирования существующих заявок (PATCH)
- Добавлен URL и view для редактирования заявок
- Кнопка "Редактировать" теперь ведёт на страницу редактирования
This commit is contained in:
admin i 2025-12-10 16:46:47 +03:00
parent b787df45f6
commit 065a8851a1
4 changed files with 425 additions and 17 deletions

View File

@ -22,6 +22,7 @@ urlpatterns = [
),
path('orders/create/', views.request_create, name='request_create'),
path('orders/<int:pk>/', views.request_detail, name='request_detail'),
path('orders/<int:pk>/edit/', views.request_edit, name='request_edit'),
# Оборудование
path('equipment/', views.equipment_list, name='equipment_list'),

View File

@ -143,6 +143,22 @@ def request_create(request):
})
@login_required
def request_edit(request, pk):
"""Страница редактирования заявки.
Данные загружаются через API:
- /api/v1/procurement/requests/{pk}/ — данные заявки
- /api/v1/departments/ — список отделов
Обновление через:
- PATCH /api/v1/procurement/requests/{pk}/
"""
return render(request, 'procurement/request_form.html', {
'page_title': 'Редактирование заявки',
'request_id': pk,
})
@login_required
def equipment_list(request):
"""Список оборудования.

View File

@ -197,7 +197,7 @@
<button class="btn btn-primary" id="submitRequest">
<i class="bi-send me-2"></i>Отправить на согласование
</button>
<a href="#" class="btn btn-outline-secondary">
<a href="{% url 'procurement_frontend:request_edit' request_obj.id %}" class="btn btn-outline-secondary">
<i class="bi-pencil me-2"></i>Редактировать
</a>
{% endif %}
@ -274,6 +274,96 @@
</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 %}
@ -354,5 +444,130 @@
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 %}

View File

@ -29,7 +29,7 @@
</h4>
</div>
<form method="POST" action="{% url 'procurement_frontend:request_create' %}" id="requestForm">
<form method="POST" id="requestForm">
{% csrf_token %}
<div class="card-body">
<div class="row g-3">
@ -48,6 +48,13 @@
maxlength="2000"></textarea>
</div>
<div class="col-md-6">
<label class="form-label">Отдел <span class="text-danger">*</span></label>
<select name="department" class="form-select" required id="departmentSelect">
<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>
@ -57,21 +64,6 @@
<option value="critical">🔴 Критическая — до 3 дней</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Желаемая дата получения</label>
<input type="date" name="desired_date" class="form-control">
</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 class="form-text">Можно указать позже</div>
</div>
</div>
</div>
@ -101,3 +93,187 @@
</div>
{% endblock %}
{% block extra_js %}
<script>
const requestId = {{ request_id|default:'null' }};
let isEdit = requestId !== null;
// Загрузка данных при старте
document.addEventListener('DOMContentLoaded', async () => {
await loadDepartments();
if (isEdit) {
await loadRequestData();
}
});
async function loadDepartments() {
try {
const resp = await fetch('/api/v1/departments/');
if (resp.ok) {
const departments = await resp.json();
const select = document.getElementById('departmentSelect');
select.innerHTML = '<option value="">Выберите отдел...</option>';
departments.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name;
// Автовыбор отдела текущего пользователя, если он один
if (!isEdit && departments.length === 1) {
option.selected = true;
}
select.appendChild(option);
});
}
} catch (e) {
console.error('Error loading departments:', e);
}
}
async function loadRequestData() {
try {
const resp = await fetch(`/api/v1/procurement/requests/${requestId}/`);
if (!resp.ok) {
throw new Error('Не удалось загрузить заявку');
}
const request = await resp.json();
// Заполняем форму
document.querySelector('[name="title"]').value = request.title || '';
document.querySelector('[name="description"]').value = request.description || '';
document.querySelector('[name="urgency"]').value = request.urgency || 'medium';
// Отдел выбираем после загрузки списка
const deptSelect = document.getElementById('departmentSelect');
if (request.department) {
const deptId = typeof request.department === 'object' ? request.department.id : request.department;
deptSelect.value = deptId;
}
// Обновляем заголовок
document.querySelector('.card-header h4').innerHTML = '<i class="bi-pencil me-2"></i>Редактирование заявки #' + requestId;
document.querySelector('.breadcrumb-item.active').textContent = 'Редактирование заявки';
// Скрываем кнопку "Создать и отправить" для редактирования
const submitBtn = document.querySelector('[name="action"][value="submit"]');
if (submitBtn) {
submitBtn.remove();
}
// Меняем текст кнопки сохранения
const draftBtn = document.querySelector('[name="action"][value="draft"]');
if (draftBtn) {
draftBtn.innerHTML = '<i class="bi-save me-1"></i>Сохранить изменения';
}
} catch (e) {
console.error('Error loading request:', e);
alert('Ошибка загрузки заявки: ' + e.message);
}
}
// Обработка отправки формы
document.getElementById('requestForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const submitBtn = e.submitter;
const action = submitBtn?.value || 'draft';
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// Блокируем кнопки
const buttons = form.querySelectorAll('button[type="submit"]');
buttons.forEach(btn => btn.disabled = true);
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Сохранение...';
try {
const formData = new FormData(form);
const data = {
title: formData.get('title'),
description: formData.get('description'),
department: parseInt(formData.get('department')),
urgency: formData.get('urgency')
};
let request;
if (isEdit) {
// Обновляем существующую заявку
const resp = await fetch(`/api/v1/procurement/requests/${requestId}/`, {
method: 'PATCH',
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.error || JSON.stringify(err));
}
request = await resp.json();
} else {
// Создаём новую заявку
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.error || JSON.stringify(err));
}
request = await resp.json();
// Если нужно сразу отправить на согласование
if (action === 'submit') {
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Отправка...';
const submitResp = await fetch(`/api/v1/procurement/requests/${request.id}/submit/`, {
method: 'POST',
headers: {
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
}
});
if (!submitResp.ok) {
const err = await submitResp.json();
// Заявка создана, но не отправлена - перенаправляем на детали
alert('Заявка создана, но не удалось отправить на согласование: ' + (err.error || 'Неизвестная ошибка'));
}
}
}
// Перенаправляем на страницу заявки
window.location.href = `/procurement/orders/${request.id}/`;
} catch (error) {
console.error('Error:', error);
alert('Ошибка: ' + error.message);
// Разблокируем кнопки
buttons.forEach(btn => btn.disabled = false);
submitBtn.innerHTML = originalText;
}
});
</script>
{% endblock %}