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 %} {% endblock extra_head %}
{% block content %} {% 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"> <form method="post" enctype="multipart/form-data" id="batch-form">
{% csrf_token %} {% csrf_token %}
@ -368,17 +378,20 @@ function initProductAutocomplete(lineEl) {
? `<span class="badge bg-success ms-1">${p.total_stock}</span>` ? `<span class="badge bg-success ms-1">${p.total_stock}</span>`
: `<span class="badge bg-secondary ms-1">0</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.innerHTML = `<strong>${escHtml(p.text)}</strong> <small class="text-muted">${escHtml(p.code || '')}</small>${stockBadge}`;
item.addEventListener('mousedown', e => { const selectProduct = (e) => {
e.preventDefault(); e.preventDefault();
hiddenInput.value = p.id; hiddenInput.value = p.id;
searchInput.value = p.text + (p.code ? ' (' + p.code + ')' : ''); searchInput.value = p.text + (p.code ? ' (' + p.code + ')' : '');
searchInput.classList.remove('is-invalid');
resultsBox.style.display = 'none'; resultsBox.style.display = 'none';
if (p.is_serialized) { if (p.is_serialized) {
fetchSerializedItems(lineEl, p.id); fetchSerializedItems(lineEl, p.id);
} else { } else {
resetSerializedSection(lineEl); resetSerializedSection(lineEl);
} }
}); };
item.addEventListener('pointerdown', selectProduct);
item.addEventListener('mousedown', selectProduct);
resultsBox.appendChild(item); resultsBox.appendChild(item);
}); });
} }
@ -389,7 +402,7 @@ function initProductAutocomplete(lineEl) {
}); });
searchInput.addEventListener('blur', () => { 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 ──────────────────── // ── Filtrage des personnes par entreprise ────────────────────
function filterPersonsByCompany(companyId) { function filterPersonsByCompany(companyId) {
const personSelect = document.getElementById('person_id'); 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 => { Array.from(personSelect.options).forEach(opt => {
if (!opt.value) return; // keep the blank option always if (!opt.value) return; // keep the blank option always
const allowedCompanies = (opt.dataset.companies || '').split(','); const allowedCompanies = (opt.dataset.companies || '').split(',');
@ -751,13 +765,15 @@ if (bpSearchInput) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'list-group-item list-group-item-action py-2 small'; 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.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(); e.preventDefault();
bpHiddenId.value = bp.id; bpHiddenId.value = bp.id;
bpSearchInput.value = bp.text; bpSearchInput.value = bp.text;
bpResults.style.display = 'none'; bpResults.style.display = 'none';
if (bpLoadBtn) bpLoadBtn.disabled = false; if (bpLoadBtn) bpLoadBtn.disabled = false;
}); };
item.addEventListener('pointerdown', selectBp);
item.addEventListener('mousedown', selectBp);
bpResults.appendChild(item); bpResults.appendChild(item);
}); });
} }
@ -767,7 +783,7 @@ if (bpSearchInput) {
}, 280); }, 280);
}); });
bpSearchInput.addEventListener('blur', () => { bpSearchInput.addEventListener('blur', () => {
setTimeout(() => { bpResults.style.display = 'none'; }, 150); setTimeout(() => { bpResults.style.display = 'none'; }, 250);
}); });
} }

View file

@ -42,6 +42,17 @@
</a> </a>
</div> </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"> <form method="post" enctype="multipart/form-data" id="batch-form">
{% csrf_token %} {% csrf_token %}

View file

@ -947,7 +947,13 @@ def _new_stock_batch_view(request, movement_type,
return redirect(cancel_url_name) return redirect(cancel_url_name)
products = Product.objects.select_related("category").order_by("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 from stock.permissions import user_can_administer_warehouse
for loc in locations: for loc in locations:
loc.can_administer = user_can_administer_warehouse(request.user, loc.warehouse) 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}) errors.append(_("Ligne %(n)s : produit invalide.") % {'n': i + 1})
continue 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: if errors:
for err in errors: for err in errors:
@ -1153,9 +1165,10 @@ def _new_stock_batch_view(request, movement_type,
reason=reason, reason=reason,
batch=batch, batch=batch,
) )
line_idx = line.get('line_index', 0)
# Articles tracés (numéros de série) # Articles tracés (numéros de série)
if product.is_serialized: 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_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 [] s_items = list(SerializedItem.objects.filter(pk__in=s_ids, product=product)) if s_ids else []
if s_items: if s_items:
@ -1165,7 +1178,7 @@ def _new_stock_batch_view(request, movement_type,
) )
# Nouveaux articles tracés (entrée uniquement) # 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 = [] new_items = []
if movement_type == 'in' and new_serials_str.strip(): if movement_type == 'in' and new_serials_str.strip():
for sn in [s.strip() for s in new_serials_str.split(',') if s.strip()]: for sn in [s.strip() for s in new_serials_str.split(',') if s.strip()]: