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:
parent
b787df45f6
commit
065a8851a1
@ -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'),
|
||||
|
||||
@ -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):
|
||||
"""Список оборудования.
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user