feat(ui): improve product search and document info section UI

- Collapse incoming document info by default with toggle animation
- Add inline cost price editing in incoming document items
- Make product search picker more compact (smaller inputs, reduced padding)
- Display new inventory lines at the top of the table
- Update product search picker styling for better visual hierarchy
This commit is contained in:
2026-01-26 22:58:20 +03:00
parent 32bc0d2c39
commit 67ad0e50ee
4 changed files with 261 additions and 250 deletions

View File

@@ -394,9 +394,9 @@
const emptyMessage = document.getElementById('empty-lines-message');
if (emptyMessage) emptyMessage.remove();
// Добавляем новую строку
// Добавляем новую строку в начало таблицы
const newRow = self.createLineRow(data.line);
tbody.appendChild(newRow);
tbody.insertBefore(newRow, tbody.firstChild);
// Включаем кнопку завершения
const completeBtn = document.getElementById('complete-inventory-btn');

View File

@@ -20,19 +20,22 @@
<div class="row g-3">
<!-- Основной контент - одна колонка -->
<div class="col-12">
<!-- Информация о документе -->
<!-- Информация о документе - свернута по умолчанию -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
{% if document.status == 'draft' %}
<span class="badge bg-warning text-dark ms-2">Черновик</span>
{% elif document.status == 'confirmed' %}
<span class="badge bg-success ms-2">Проведён</span>
{% elif document.status == 'cancelled' %}
<span class="badge bg-secondary ms-2">Отменён</span>
{% endif %}
</h5>
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
<button class="btn btn-outline-primary btn-sm d-flex align-items-center gap-2" type="button" data-bs-toggle="collapse" data-bs-target="#document-info-collapse" aria-expanded="false" aria-controls="document-info-collapse">
<i class="bi bi-chevron-down" id="document-info-collapse-icon"></i>
<span>
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
{% if document.status == 'draft' %}
<span class="badge bg-warning text-dark ms-2">Черновик</span>
{% elif document.status == 'confirmed' %}
<span class="badge bg-success ms-2">Проведён</span>
{% elif document.status == 'cancelled' %}
<span class="badge bg-secondary ms-2">Отменён</span>
{% endif %}
</span>
</button>
{% if document.can_edit %}
<div class="btn-group">
<form method="post" action="{% url 'inventory:incoming-confirm' document.pk %}" class="d-inline">
@@ -50,65 +53,67 @@
</div>
{% endif %}
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3">
<p class="text-muted small mb-1">Склад</p>
<p class="fw-semibold">{{ document.warehouse.name }}</p>
<div class="collapse" id="document-info-collapse">
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3">
<p class="text-muted small mb-1">Склад</p>
<p class="fw-semibold">{{ document.warehouse.name }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Дата документа</p>
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Тип поступления</p>
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Создан</p>
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
</div>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Дата документа</p>
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Тип поступления</p>
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
</div>
<div class="col-md-3">
<p class="text-muted small mb-1">Создан</p>
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
</div>
</div>
{% if document.supplier_name %}
<div class="mb-3">
<p class="text-muted small mb-1">Поставщик</p>
<p class="fw-semibold">{{ document.supplier_name }}</p>
</div>
{% endif %}
{% if document.notes %}
<div class="mb-3">
<p class="text-muted small mb-1">Примечания</p>
<p>{{ document.notes }}</p>
</div>
{% endif %}
{% if document.confirmed_at %}
<div class="row">
<div class="col-md-6">
<p class="text-muted small mb-1">Проведён</p>
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
{% if document.supplier_name %}
<div class="mb-3">
<p class="text-muted small mb-1">Поставщик</p>
<p class="fw-semibold">{{ document.supplier_name }}</p>
</div>
<div class="col-md-6">
<p class="text-muted small mb-1">Провёл</p>
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
{% endif %}
{% if document.notes %}
<div class="mb-3">
<p class="text-muted small mb-1">Примечания</p>
<p>{{ document.notes }}</p>
</div>
{% endif %}
{% if document.confirmed_at %}
<div class="row">
<div class="col-md-6">
<p class="text-muted small mb-1">Проведён</p>
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
</div>
<div class="col-md-6">
<p class="text-muted small mb-1">Провёл</p>
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- Добавление позиции -->
{% if document.can_edit %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3">
<div class="card-header bg-light py-1">
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить позицию в документ</h6>
</div>
<div class="card-body">
<!-- Компонент поиска товаров -->
<div class="mb-3">
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар для поступления' warehouse_id=document.warehouse.id filter_in_stock_only=False skip_stock_filter=True categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
<div class="card-body p-2">
<!-- Компонент поиска товаров - компактный -->
<div class="mb-2">
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар...' warehouse_id=document.warehouse.id filter_in_stock_only=False skip_stock_filter=True categories=categories tags=tags add_button_text='Выбрать' content_height='150px' %}
</div>
<!-- Информация о выбранном товаре -->
@@ -217,11 +222,19 @@
{% endif %}
</td>
<td class="px-3 py-2 text-end" style="width: 120px;">
<span class="item-cost-price-display">{{ item.cost_price|floatformat:2 }}</span>
{% if document.can_edit %}
<input type="number" class="form-control form-control-sm item-cost-price-input"
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
style="display: none; width: 100px; text-align: right; margin-left: auto;">
<span class="editable-cost-price"
data-item-id="{{ item.id }}"
data-current-value="{{ item.cost_price }}"
title="Закупочная цена (клик для редактирования)"
style="cursor: pointer;">
{{ item.cost_price|floatformat:2 }}
</span>
<input type="number" class="form-control form-control-sm item-cost-price-input"
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
style="display: none; width: 100px; text-align: right; margin-left: auto;">
{% else %}
<span>{{ item.cost_price|floatformat:2 }}</span>
{% endif %}
</td>
<td class="px-3 py-2 text-end" style="width: 120px;">
@@ -271,24 +284,13 @@
</td>
{% if document.can_edit %}
<td class="px-3 py-2 text-end" style="width: 100px;">
<div class="btn-group btn-group-sm item-action-buttons">
<button type="button" class="btn btn-outline-primary btn-edit-item" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-danger"
onclick="if(confirm('Удалить позицию?')) document.getElementById('delete-form-{{ item.id }}').submit();"
title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="btn-group btn-group-sm item-edit-buttons" style="display: none;">
<button type="button" class="btn btn-success btn-save-item" title="Сохранить">
<i class="bi bi-check-lg"></i>
</button>
<button type="button" class="btn btn-secondary btn-cancel-edit" title="Отменить">
<i class="bi bi-x-lg"></i>
</button>
</div>
<form id="delete-form-{{ item.id }}" method="post"
action="{% url 'inventory:incoming-remove-item' document.pk item.pk %}"
style="display: none;">
@@ -351,6 +353,22 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Анимация для иконки сворачивания/разворачивания информации о документе
const documentInfoCollapse = document.getElementById('document-info-collapse');
const documentInfoCollapseIcon = document.getElementById('document-info-collapse-icon');
if (documentInfoCollapse && documentInfoCollapseIcon) {
documentInfoCollapse.addEventListener('show.bs.collapse', function() {
documentInfoCollapseIcon.classList.remove('bi-chevron-down');
documentInfoCollapseIcon.classList.add('bi-chevron-up');
});
documentInfoCollapse.addEventListener('hide.bs.collapse', function() {
documentInfoCollapseIcon.classList.remove('bi-chevron-up');
documentInfoCollapseIcon.classList.add('bi-chevron-down');
});
}
// Функция выбора товара
function selectProduct(product) {
const productId = String(product.id).replace('product_', '');
@@ -416,160 +434,10 @@ document.addEventListener('DOMContentLoaded', function() {
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
}
// ============================================
// Inline редактирование позиций в таблице
// ============================================
// Хранилище оригинальных значений при редактировании
const originalValues = {};
// Обработчики для кнопок редактирования
document.querySelectorAll('.btn-edit-item').forEach(btn => {
btn.addEventListener('click', function() {
const row = this.closest('tr');
const itemId = row.dataset.itemId;
// Сохраняем оригинальные значения
originalValues[itemId] = {
quantity: row.querySelector('.item-quantity-input').value,
cost_price: row.querySelector('.item-cost-price-input').value,
notes: row.querySelector('.item-notes-input').value
};
// Переключаем в режим редактирования
toggleEditMode(row, true);
});
});
// Обработчики для кнопок сохранения
document.querySelectorAll('.btn-save-item').forEach(btn => {
btn.addEventListener('click', function() {
const row = this.closest('tr');
const itemId = row.dataset.itemId;
saveItemChanges(itemId, row);
});
});
// Обработчики для кнопок отмены
document.querySelectorAll('.btn-cancel-edit').forEach(btn => {
btn.addEventListener('click', function() {
const row = this.closest('tr');
const itemId = row.dataset.itemId;
// Восстанавливаем оригинальные значения
if (originalValues[itemId]) {
row.querySelector('.item-quantity-input').value = originalValues[itemId].quantity;
row.querySelector('.item-cost-price-input').value = originalValues[itemId].cost_price;
row.querySelector('.item-notes-input').value = originalValues[itemId].notes;
}
// Выходим из режима редактирования
toggleEditMode(row, false);
});
});
/**
* Переключение режима редактирования строки
*/
function toggleEditMode(row, isEditing) {
// Переключаем видимость полей отображения/ввода
row.querySelectorAll('.item-quantity-display, .item-cost-price-display, .item-notes-display').forEach(el => {
el.style.display = isEditing ? 'none' : '';
});
row.querySelectorAll('.item-quantity-input, .item-cost-price-input, .item-notes-input').forEach(el => {
el.style.display = isEditing ? '' : 'none';
});
// Переключаем видимость кнопок
row.querySelector('.item-action-buttons').style.display = isEditing ? 'none' : '';
row.querySelector('.item-edit-buttons').style.display = isEditing ? '' : 'none';
// Фокус на поле количества при входе в режим редактирования
if (isEditing) {
const qtyInput = row.querySelector('.item-quantity-input');
if (qtyInput) {
qtyInput.focus();
qtyInput.select();
}
}
}
/**
* Сохранение изменений позиции
*/
function saveItemChanges(itemId, row) {
const quantity = row.querySelector('.item-quantity-input').value;
const costPrice = row.querySelector('.item-cost-price-input').value;
const notes = row.querySelector('.item-notes-input').value;
// Валидация
if (!quantity || parseFloat(quantity) <= 0) {
alert('Количество должно быть больше нуля');
return;
}
if (!costPrice || parseFloat(costPrice) < 0) {
alert('Закупочная цена не может быть отрицательной');
return;
}
// Отправляем на сервер
const formData = new FormData();
formData.append('quantity', quantity);
formData.append('cost_price', costPrice);
formData.append('notes', notes);
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
// Блокируем кнопки во время сохранения
const saveBtn = row.querySelector('.btn-save-item');
const cancelBtn = row.querySelector('.btn-cancel-edit');
saveBtn.disabled = true;
cancelBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Обновляем отображение
let formattedQty = parseFloat(quantity);
if (formattedQty === Math.floor(formattedQty)) {
formattedQty = Math.floor(formattedQty).toString();
} else {
formattedQty = formattedQty.toString().replace('.', ',');
}
row.querySelector('.item-quantity-display').textContent = formattedQty;
row.querySelector('.item-cost-price-display').textContent = parseFloat(costPrice).toFixed(2);
row.querySelector('.item-notes-display').textContent = notes || '-';
// Пересчитываем сумму
const totalCost = (parseFloat(quantity) * parseFloat(costPrice)).toFixed(2);
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
// Выходим из режима редактирования
toggleEditMode(row, false);
} else {
alert('Ошибка: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка при сохранении');
})
.finally(() => {
saveBtn.disabled = false;
cancelBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
});
}
// ============================================
// Inline редактирование количества
// Inline редактирование количества и цены
// ============================================
function initInlineQuantityEdit() {
@@ -708,14 +576,146 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
function initInlineCostPriceEdit() {
// Проверяем, есть ли на странице редактируемые цены
const editableCostPrices = document.querySelectorAll('.editable-cost-price');
if (editableCostPrices.length === 0) {
return; // Нет элементов для редактирования
}
// Обработчик клика на редактируемую цену
document.addEventListener('click', function(e) {
const costPriceSpan = e.target.closest('.editable-cost-price');
if (!costPriceSpan) return;
// Предотвращаем повторное срабатывание, если уже редактируем
if (costPriceSpan.querySelector('input')) return;
const itemId = costPriceSpan.dataset.itemId;
const currentValue = costPriceSpan.dataset.currentValue;
// Сохраняем оригинальный HTML
const originalHTML = costPriceSpan.innerHTML;
// Создаем input для редактирования
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.style.width = '100px';
input.style.textAlign = 'right';
input.value = parseFloat(currentValue).toFixed(2);
input.step = '0.01';
input.min = '0';
input.placeholder = 'Цена';
// Заменяем содержимое на input
costPriceSpan.innerHTML = '';
costPriceSpan.appendChild(input);
input.focus();
input.select();
// Функция сохранения
const saveCostPrice = async () => {
let newValue = input.value.trim();
// Валидация
if (!newValue || parseFloat(newValue) < 0) {
alert('Закупочная цена не может быть отрицательной');
costPriceSpan.innerHTML = originalHTML;
return;
}
// Проверяем, изменилось ли значение
if (parseFloat(newValue) === parseFloat(currentValue)) {
// Значение не изменилось
costPriceSpan.innerHTML = originalHTML;
return;
}
// Показываем загрузку
input.disabled = true;
input.style.opacity = '0.5';
try {
// Получаем текущие значения других полей
const row = costPriceSpan.closest('tr');
const quantity = row.querySelector('.item-quantity-input').value;
const notes = row.querySelector('.item-notes-input').value;
const response = await fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
},
body: new URLSearchParams({
quantity: quantity,
cost_price: newValue,
notes: notes
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data.success) {
// Обновляем отображение
const formattedPrice = parseFloat(newValue).toFixed(2);
costPriceSpan.textContent = formattedPrice;
costPriceSpan.dataset.currentValue = newValue;
// Пересчитываем сумму
const totalCost = (parseFloat(quantity) * parseFloat(newValue)).toFixed(2);
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
// Обновляем итого
updateTotals();
} else {
alert(data.error || 'Ошибка при обновлении цены');
costPriceSpan.innerHTML = originalHTML;
}
} catch (error) {
console.error('Error:', error);
alert('Ошибка сети при обновлении цены');
costPriceSpan.innerHTML = originalHTML;
}
};
// Функция отмены
const cancelEdit = () => {
costPriceSpan.innerHTML = originalHTML;
};
// Enter - сохранить
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
saveCostPrice();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
});
// Потеря фокуса - сохранить
input.addEventListener('blur', function() {
setTimeout(saveCostPrice, 100);
});
});
}
// Функция обновления итоговых сумм
function updateTotals() {
// Можно реализовать пересчет итогов, если нужно
// Пока оставим как есть, так как сервер возвращает обновленные данные
}
// Инициализация inline редактирования количества
// Инициализация inline редактирования
initInlineQuantityEdit();
initInlineCostPriceEdit();
});
</script>
@@ -745,6 +745,17 @@ document.addEventListener('DOMContentLoaded', function() {
color: #0d6efd !important;
text-decoration: underline;
}
/* Стили для редактируемой цены */
.editable-cost-price {
cursor: pointer;
transition: color 0.2s ease;
}
.editable-cost-price:hover {
color: #0d6efd !important;
text-decoration: underline;
}
</style>
{% endblock %}

View File

@@ -124,14 +124,14 @@
<h5>Строки инвентаризации</h5>
<!-- Компонент поиска товаров (только если не завершена) -->
<!-- Компонент поиска товаров - открыт по умолчанию, компактный -->
{% if inventory.status != 'completed' %}
<div class="card border-primary mb-4" id="product-search-section">
<div class="card-header bg-light">
<div class="card-header bg-light py-1">
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить товар в инвентаризацию</h6>
</div>
<div class="card-body">
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара для инвентаризации...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить товар' content_height='250px' skip_stock_filter=True %}
<div class="card-body p-2">
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить' content_height='150px' skip_stock_filter=True %}
</div>
</div>
{% endif %}