feat: add views and templates for stock batch creation on desktop and mobile

This commit is contained in:
Karl Determe 2026-09-29 12:42:27 +02:00
parent 8b13236a26
commit ca4d1b3c1b
3 changed files with 51 additions and 11 deletions

View file

@ -38,6 +38,16 @@
{% endblock extra_head %}
{% block content %}
{% if messages %}
<div class="mb-3">
{% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% elif message.tags %}{{ message.tags }}{% else %}info{% endif %} alert-dismissible fade show small" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" id="batch-form">
{% csrf_token %}
@ -368,17 +378,20 @@ function initProductAutocomplete(lineEl) {
? `<span class="badge bg-success ms-1">${p.total_stock}</span>`
: `<span class="badge bg-secondary ms-1">0</span>`;
item.innerHTML = `<strong>${escHtml(p.text)}</strong> <small class="text-muted">${escHtml(p.code || '')}</small>${stockBadge}`;
item.addEventListener('mousedown', e => {
const selectProduct = (e) => {
e.preventDefault();
hiddenInput.value = p.id;
searchInput.value = p.text + (p.code ? ' (' + p.code + ')' : '');
searchInput.classList.remove('is-invalid');
resultsBox.style.display = 'none';
if (p.is_serialized) {
fetchSerializedItems(lineEl, p.id);
} else {
resetSerializedSection(lineEl);
}
});
};
item.addEventListener('pointerdown', selectProduct);
item.addEventListener('mousedown', selectProduct);
resultsBox.appendChild(item);
});
}
@ -389,7 +402,7 @@ function initProductAutocomplete(lineEl) {
});
searchInput.addEventListener('blur', () => {
setTimeout(() => { resultsBox.style.display = 'none'; }, 150);
setTimeout(() => { resultsBox.style.display = 'none'; }, 250);
});
}
@ -694,7 +707,8 @@ function onCompanyInputChange() {
// ── Filtrage des personnes par entreprise ────────────────────
function filterPersonsByCompany(companyId) {
const personSelect = document.getElementById('person_id');
const currentVal = personSelect.value;
if (!personSelect) return;
const currentOpt = personSelect.selectedIndex >= 0 ? personSelect.options[personSelect.selectedIndex] : null;
Array.from(personSelect.options).forEach(opt => {
if (!opt.value) return; // keep the blank option always
const allowedCompanies = (opt.dataset.companies || '').split(',');
@ -751,13 +765,15 @@ if (bpSearchInput) {
const item = document.createElement('div');
item.className = 'list-group-item list-group-item-action py-2 small';
item.innerHTML = `<strong>${escHtml(bp.text)}</strong> <span class="badge bg-secondary ms-1">${escHtml(bp.status)}</span>`;
item.addEventListener('mousedown', e => {
const selectBp = (e) => {
e.preventDefault();
bpHiddenId.value = bp.id;
bpSearchInput.value = bp.text;
bpResults.style.display = 'none';
if (bpLoadBtn) bpLoadBtn.disabled = false;
});
};
item.addEventListener('pointerdown', selectBp);
item.addEventListener('mousedown', selectBp);
bpResults.appendChild(item);
});
}
@ -767,7 +783,7 @@ if (bpSearchInput) {
}, 280);
});
bpSearchInput.addEventListener('blur', () => {
setTimeout(() => { bpResults.style.display = 'none'; }, 150);
setTimeout(() => { bpResults.style.display = 'none'; }, 250);
});
}

View file

@ -42,6 +42,17 @@
</a>
</div>
{% if messages %}
<div class="mb-3">
{% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% elif message.tags %}{{ message.tags }}{% else %}info{% endif %} alert-dismissible fade show small" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" id="batch-form">
{% csrf_token %}

View file

@ -947,7 +947,13 @@ def _new_stock_batch_view(request, movement_type,
return redirect(cancel_url_name)
products = Product.objects.select_related("category").order_by("name")
locations = list(WarehouseLocation.objects.select_related("warehouse").order_by("warehouse__name", "name"))
locations = list(
filter_locations_by_permission(
WarehouseLocation.objects.select_related("warehouse"),
request.user,
can_manage=True
).order_by("warehouse__name", "name")
)
from stock.permissions import user_can_administer_warehouse
for loc in locations:
loc.can_administer = user_can_administer_warehouse(request.user, loc.warehouse)
@ -1102,7 +1108,13 @@ def _new_stock_batch_view(request, movement_type,
errors.append(_("Ligne %(n)s : produit invalide.") % {'n': i + 1})
continue
lines.append({'product_id': pid, 'qty': qty, 'src': src, 'dst': dst})
lines.append({
'product_id': pid,
'qty': qty,
'src': src,
'dst': dst,
'line_index': i,
})
if errors:
for err in errors:
@ -1153,9 +1165,10 @@ def _new_stock_batch_view(request, movement_type,
reason=reason,
batch=batch,
)
line_idx = line.get('line_index', 0)
# Articles tracés (numéros de série)
if product.is_serialized:
ids_str = serialized_ids_list[i] if i < len(serialized_ids_list) else ''
ids_str = serialized_ids_list[line_idx] if line_idx < len(serialized_ids_list) else ''
s_ids = [int(x) for x in ids_str.split(',') if x.strip().isdigit()]
s_items = list(SerializedItem.objects.filter(pk__in=s_ids, product=product)) if s_ids else []
if s_items:
@ -1165,7 +1178,7 @@ def _new_stock_batch_view(request, movement_type,
)
# Nouveaux articles tracés (entrée uniquement)
new_serials_str = serialized_new_serials_list[i] if i < len(serialized_new_serials_list) else ''
new_serials_str = serialized_new_serials_list[line_idx] if line_idx < len(serialized_new_serials_list) else ''
new_items = []
if movement_type == 'in' and new_serials_str.strip():
for sn in [s.strip() for s in new_serials_str.split(',') if s.strip()]: