Интегрирован компонент поиска товаров в форму списания

- В WriteOffCreateView добавлена передача категорий и тегов в контекст
- Шаблон writeoff_form.html обновлен с использованием product_search_picker
- Автоматическая фильтрация партий по выбранному товару
- Отображение информации о выбранном товаре с фото
- Улучшенный UX при выборе товара для списания
- Подключены CSS и JS компонента поиска товаров
This commit is contained in:
2025-12-10 23:36:58 +03:00
parent f8808c6ba0
commit 865cdbbb8b
2 changed files with 251 additions and 85 deletions

View File

@@ -1,107 +1,156 @@
{% extends 'inventory/base_inventory_minimal.html' %} {% extends 'inventory/base_inventory_minimal.html' %}
{% load inventory_filters %} {% load static inventory_filters %}
{% block inventory_title %}{% if form.instance.pk %}Редактирование списания{% else %}Новое списание{% endif %}{% endblock %} {% block inventory_title %}{% if form.instance.pk %}Редактирование списания{% else %}Новое списание{% endif %}{% endblock %}
{% block breadcrumb_current %}Списания{% endblock %} {% block breadcrumb_current %}Списания{% endblock %}
{% block inventory_content %} {% block inventory_content %}
<div class="card"> <!-- CSS для компонента -->
<div class="card-header"><h4 class="mb-0">{% if form.instance.pk %}Редактирование{% else %}Создание{% endif %} списания</h4></div> <link rel="stylesheet" href="{% static 'products/css/product-search-picker.css' %}">
<div class="card-body">
<!-- Ошибки формы --> <div class="row">
{% if form.non_field_errors %} <!-- Левая колонка: поиск товаров -->
<div class="alert alert-danger alert-dismissible fade show" role="alert"> <div class="col-lg-6 mb-4">
<strong><i class="bi bi-exclamation-triangle"></i> Ошибка:</strong> {% include 'products/components/product_search_picker.html' with container_id='writeoff-product-picker' title='Найти товар для списания' filter_in_stock_only=True categories=categories tags=tags add_button_text='Выбрать товар' multi_select=False content_height='350px' %}
{% for error in form.non_field_errors %} </div>
<div>{{ error }}</div>
{% endfor %} <!-- Правая колонка: форма списания -->
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-clipboard-minus text-danger"></i>
{% if form.instance.pk %}Редактирование{% else %}Создание{% endif %} списания
</h5>
</div>
<div class="card-body">
<!-- Ошибки формы -->
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong><i class="bi bi-exclamation-triangle"></i> Ошибка:</strong>
{% for error in form.non_field_errors %}
<div>{{ error }}</div>
{% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<!-- Выбранный товар -->
<div id="selected-product-info" class="alert alert-info mb-3" style="display: none;">
<div class="d-flex align-items-center">
<img id="selected-product-photo" src="" alt="" class="rounded me-3" style="width: 50px; height: 50px; object-fit: cover; display: none;">
<div class="flex-grow-1">
<strong id="selected-product-name"></strong>
<div class="small text-muted" id="selected-product-sku"></div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-selected-product">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<form method="post" novalidate id="writeoff-form">
{% csrf_token %}
<!-- Скрытое поле для хранения выбранного product_id -->
<input type="hidden" id="selected-product-id" name="selected_product_id" value="">
<!-- Поле Партия -->
<div class="mb-3">
<label class="form-label">{{ form.batch.label }} <span class="text-danger">*</span></label>
{{ form.batch }}
{% if form.batch.errors %}
<div class="invalid-feedback d-block">{{ form.batch.errors.0 }}</div>
{% endif %}
<small class="text-muted" id="batch-hint">
<i class="bi bi-info-circle"></i> Сначала выберите товар слева, затем здесь появятся его партии
</small>
<!-- Информация об остатке партии -->
<div id="batch-info" class="mt-2 p-2 bg-light border rounded" style="display:none;">
<small class="text-muted">
Остаток в партии: <strong id="batch-quantity">0</strong> шт
</small>
</div>
</div>
<!-- Поле Количество -->
<div class="mb-3">
<label class="form-label">{{ form.quantity.label }} <span class="text-danger">*</span></label>
{{ form.quantity|smart_quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">{{ form.quantity.errors.0 }}</div>
{% endif %}
<small class="text-muted d-block mt-1">
Введите количество товара для списания
</small>
<!-- Предупреждение о превышении остатка -->
<div id="quantity-warning" class="alert alert-warning mt-2" style="display:none;">
<i class="bi bi-exclamation-circle"></i>
<strong>Внимание!</strong> Вы пытаетесь списать <strong id="warning-qty">0</strong> шт,
а в партии только <strong id="warning-batch">0</strong> шт.
Недостаток: <strong id="warning-shortage" class="text-danger">0</strong> шт.
</div>
</div>
<!-- Поле Причина -->
<div class="mb-3">
<label class="form-label">{{ form.reason.label }}</label>
{{ form.reason }}
{% if form.reason.errors %}
<div class="invalid-feedback d-block">{{ form.reason.errors.0 }}</div>
{% endif %}
</div>
<!-- Поле Номер документа -->
<div class="mb-3">
<label class="form-label">{{ form.document_number.label }}</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="invalid-feedback d-block">{{ form.document_number.errors.0 }}</div>
{% endif %}
</div>
<!-- Поле Примечания -->
<div class="mb-3">
<label class="form-label">{{ form.notes.label }}</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">{{ form.notes.errors.0 }}</div>
{% endif %}
</div>
<!-- Кнопки действия -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-check-circle"></i> Сохранить
</button>
<a href="{% url 'inventory:writeoff-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
</div>
</form>
</div>
</div> </div>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<!-- Поле Партия -->
<div class="mb-3">
<label class="form-label">{{ form.batch.label }} <span class="text-danger">*</span></label>
{{ form.batch }}
{% if form.batch.errors %}
<div class="invalid-feedback d-block">{{ form.batch.errors.0 }}</div>
{% endif %}
<!-- Информация об остатке партии -->
<div id="batch-info" class="mt-2 p-2 bg-light border rounded" style="display:none;">
<small class="text-muted">
Остаток в партии: <strong id="batch-quantity">0</strong> шт
</small>
</div>
</div>
<!-- Поле Количество -->
<div class="mb-3">
<label class="form-label">{{ form.quantity.label }} <span class="text-danger">*</span></label>
{{ form.quantity|smart_quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">{{ form.quantity.errors.0 }}</div>
{% endif %}
<small class="text-muted d-block mt-1">
Введите количество товара для списания
</small>
<!-- Предупреждение о превышении остатка -->
<div id="quantity-warning" class="alert alert-warning mt-2" style="display:none;">
<i class="bi bi-exclamation-circle"></i>
<strong>Внимание!</strong> Вы пытаетесь списать <strong id="warning-qty">0</strong> шт,
а в партии только <strong id="warning-batch">0</strong> шт.
Недостаток: <strong id="warning-shortage" class="text-danger">0</strong> шт.
</div>
</div>
<!-- Поле Причина -->
<div class="mb-3">
<label class="form-label">{{ form.reason.label }}</label>
{{ form.reason }}
{% if form.reason.errors %}
<div class="invalid-feedback d-block">{{ form.reason.errors.0 }}</div>
{% endif %}
</div>
<!-- Поле Номер документа -->
<div class="mb-3">
<label class="form-label">{{ form.document_number.label }}</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="invalid-feedback d-block">{{ form.document_number.errors.0 }}</div>
{% endif %}
</div>
<!-- Поле Примечания -->
<div class="mb-3">
<label class="form-label">{{ form.notes.label }}</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">{{ form.notes.errors.0 }}</div>
{% endif %}
</div>
<!-- Кнопки действия -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" id="submit-btn"><i class="bi bi-check-circle"></i> Сохранить</button>
<a href="{% url 'inventory:writeoff-list' %}" class="btn btn-secondary"><i class="bi bi-x-circle"></i> Отмена</a>
</div>
</form>
</div> </div>
</div> </div>
<style> <style>
select, textarea, input { select, textarea, input[type="text"], input[type="number"] {
width: 100%; width: 100%;
} }
.invalid-feedback { .invalid-feedback {
color: #dc3545; color: #dc3545;
font-size: 0.875em; font-size: 0.875em;
} }
#selected-product-info {
border-left: 4px solid #0d6efd;
}
</style> </style>
<!-- JS для компонента -->
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Элементы формы
const batchSelect = document.querySelector('#id_batch'); const batchSelect = document.querySelector('#id_batch');
const quantityInput = document.querySelector('#id_quantity'); const quantityInput = document.querySelector('#id_quantity');
const batchInfo = document.getElementById('batch-info'); const batchInfo = document.getElementById('batch-info');
@@ -110,6 +159,116 @@ document.addEventListener('DOMContentLoaded', function() {
const warningQty = document.getElementById('warning-qty'); const warningQty = document.getElementById('warning-qty');
const warningBatch = document.getElementById('warning-batch'); const warningBatch = document.getElementById('warning-batch');
const warningShortage = document.getElementById('warning-shortage'); const warningShortage = document.getElementById('warning-shortage');
const batchHint = document.getElementById('batch-hint');
// Элементы отображения выбранного товара
const selectedProductInfo = document.getElementById('selected-product-info');
const selectedProductName = document.getElementById('selected-product-name');
const selectedProductSku = document.getElementById('selected-product-sku');
const selectedProductPhoto = document.getElementById('selected-product-photo');
const selectedProductIdInput = document.getElementById('selected-product-id');
const clearSelectedBtn = document.getElementById('clear-selected-product');
// Сохраняем все опции партий для фильтрации
const allBatchOptions = Array.from(batchSelect.options).map(opt => ({
value: opt.value,
text: opt.text,
productName: opt.text.split(' на ')[0] // Извлекаем название товара
}));
// Инициализация компонента поиска товаров
const picker = ProductSearchPicker.init('#writeoff-product-picker', {
onAddSelected: function(products, instance) {
if (products.length > 0) {
selectProduct(products[0]);
instance.clearSelection();
}
}
});
// Функция выбора товара
function selectProduct(product) {
const productId = String(product.id).replace('product_', '');
const productName = product.text || product.name || '';
// Показываем информацию о выбранном товаре
selectedProductName.textContent = productName;
selectedProductSku.textContent = product.sku || '';
selectedProductIdInput.value = productId;
if (product.photo_url) {
selectedProductPhoto.src = product.photo_url;
selectedProductPhoto.style.display = 'block';
} else {
selectedProductPhoto.style.display = 'none';
}
selectedProductInfo.style.display = 'block';
// Фильтруем партии по выбранному товару
filterBatchesByProduct(productName);
// Скрываем подсказку
batchHint.style.display = 'none';
}
// Функция очистки выбора товара
function clearSelectedProduct() {
selectedProductInfo.style.display = 'none';
selectedProductName.textContent = '';
selectedProductSku.textContent = '';
selectedProductPhoto.style.display = 'none';
selectedProductIdInput.value = '';
// Показываем все партии
showAllBatches();
// Показываем подсказку
batchHint.style.display = 'block';
}
// Фильтрация партий по названию товара
function filterBatchesByProduct(productName) {
// Очищаем select
batchSelect.innerHTML = '<option value="">---------</option>';
// Добавляем только партии выбранного товара
const normalizedName = productName.toLowerCase().trim();
allBatchOptions.forEach(function(opt) {
if (opt.value && opt.productName.toLowerCase().trim().includes(normalizedName.split(' (')[0])) {
const option = document.createElement('option');
option.value = opt.value;
option.text = opt.text;
batchSelect.appendChild(option);
}
});
// Если найдена только одна партия, выбираем её автоматически
if (batchSelect.options.length === 2) {
batchSelect.selectedIndex = 1;
updateBatchInfo();
}
}
// Показать все партии
function showAllBatches() {
batchSelect.innerHTML = '<option value="">---------</option>';
allBatchOptions.forEach(function(opt) {
if (opt.value) {
const option = document.createElement('option');
option.value = opt.value;
option.text = opt.text;
batchSelect.appendChild(option);
}
});
}
// Очистка выбора товара
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
// ===== Существующая логика для партий =====
// Функция для получения остатка партии // Функция для получения остатка партии
function getBatchQuantity() { function getBatchQuantity() {

View File

@@ -9,6 +9,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages from django.contrib import messages
from ..models import WriteOff from ..models import WriteOff
from ..forms import WriteOffForm from ..forms import WriteOffForm
from products.models import ProductCategory, ProductTag
class WriteOffListView(LoginRequiredMixin, ListView): class WriteOffListView(LoginRequiredMixin, ListView):
@@ -27,6 +28,12 @@ class WriteOffCreateView(LoginRequiredMixin, CreateView):
template_name = 'inventory/writeoff/writeoff_form.html' template_name = 'inventory/writeoff/writeoff_form.html'
success_url = reverse_lazy('inventory:writeoff-list') success_url = reverse_lazy('inventory:writeoff-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
return context
def form_valid(self, form): def form_valid(self, form):
messages.success(self.request, f'Списание товара успешно создано.') messages.success(self.request, f'Списание товара успешно создано.')
return super().form_valid(form) return super().form_valid(form)