282 lines
14 KiB
HTML
282 lines
14 KiB
HTML
{% extends "base.html" %}
|
||
{% load i18n %}
|
||
{% load stock_custom_filters %}
|
||
|
||
{% block content %}
|
||
<div class="container mt-5">
|
||
<h1 class="h3 mb-3">{% if edit_mode %}{% translate "Modifier l'inventaire" %} #{{ inventory.id }}{% else %}{% translate "Nouvel inventaire" %}{% endif %}</h1>
|
||
<form method="post" id="inventory-form">
|
||
{% csrf_token %}
|
||
<div class="card mb-3">
|
||
<div class="card-header">{% translate "Informations" %}</div>
|
||
<div class="card-body">
|
||
{{ form.non_field_errors }}
|
||
<div class="row g-3">
|
||
{% for field in form %}
|
||
<div class="col-md-4">
|
||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||
{% if field.field.widget.input_type == 'select' %}
|
||
{{ field|add_class:"form-select" }}
|
||
{% else %}
|
||
{{ field|add_class:"form-control" }}
|
||
{% endif %}
|
||
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
|
||
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<span>
|
||
{% translate "Articles" %}
|
||
<span id="items-count-badge" class="badge bg-secondary ms-1 d-none"></span>
|
||
</span>
|
||
<div class="d-flex gap-2 align-items-center">
|
||
<span id="prefill-status" class="text-muted small fst-italic"></span>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" id="add-item">
|
||
<i class="bi bi-plus-circle"></i> {% translate "Ajouter un article" %}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
{{ formset.management_form }}
|
||
<table class="table table-sm mb-0 align-middle" id="inventory-items-table">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>{% translate "Produit" %}</th>
|
||
<th style="width:140px">{% translate "Quantité attendue" %}</th>
|
||
<th style="width:140px">{% translate "Quantité comptée" %}</th>
|
||
<th style="min-width:180px">{% translate "Commentaire" %}</th>
|
||
{% if can_validate %}
|
||
<th style="width:48px"></th>
|
||
{% endif %}
|
||
</tr>
|
||
</thead>
|
||
<tbody id="inventory-items-container">
|
||
{% for item_form in formset %}
|
||
<tr class="inventory-item">
|
||
{% for hidden in item_form.hidden_fields %}{{ hidden }}{% endfor %}
|
||
<td>
|
||
{% if edit_mode %}
|
||
{% if item_form.instance.pk %}
|
||
{# Produit fixé, déjà rendu en champ caché via hidden_fields #}
|
||
<span class="fw-semibold">{{ item_form.instance.product.name }}</span>
|
||
{% endif %}
|
||
{% else %}
|
||
{% with field=item_form.product %}
|
||
{{ field|add_class:"form-select form-select-sm" }}
|
||
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||
{% endwith %}
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% with field=item_form.expected_quantity %}
|
||
{{ field|add_class:"form-control form-control-sm" }}
|
||
{% endwith %}
|
||
</td>
|
||
<td>
|
||
{% with field=item_form.counted_quantity %}
|
||
{{ field|add_class:"form-control form-control-sm" }}
|
||
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||
{% endwith %}
|
||
</td>
|
||
<td>
|
||
{% with field=item_form.comment %}
|
||
{{ field|add_class:"form-control form-control-sm" }}
|
||
{% endwith %}
|
||
</td>
|
||
{% if can_validate %}
|
||
<td class="text-end">
|
||
<button type="button" class="btn btn-sm btn-outline-danger remove-item" title="{% translate 'Supprimer' %}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</td>
|
||
{% endif %}
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
<div id="empty-state" class="text-center text-muted py-4 {% if formset.forms %}d-none{% endif %}">
|
||
<i class="bi bi-box-seam fs-3 d-block mb-2 opacity-50"></i>
|
||
{% translate "Sélectionnez un emplacement pour charger automatiquement les produits en stock." %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-3 text-end">
|
||
<a href="{% if edit_mode %}{% url 'stock:inventory_detail' inventory.pk %}{% else %}{% url 'stock:stock_movements' %}{% endif %}" class="btn btn-secondary me-2">{% translate "Annuler" %}</a>
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="bi bi-floppy"></i> {% translate "Enregistrer" %}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
{% endblock content %}
|
||
|
||
{% block script %}
|
||
<script>
|
||
(function () {
|
||
const PREFILL_URL = "{% url 'stock:inventory_prefill_api' %}";
|
||
const FORMSET_PREFIX = '{{ formset.prefix }}';
|
||
const PRODUCTS = {{ products_json|safe }};
|
||
const canValidate = {{ can_validate|yesno:"true,false" }};
|
||
const container = document.getElementById('inventory-items-container');
|
||
const emptyState = document.getElementById('empty-state');
|
||
const countBadge = document.getElementById('items-count-badge');
|
||
const prefillStatus = document.getElementById('prefill-status');
|
||
|
||
// ── Gestion TOTAL_FORMS ────────────────────────────────────────────────
|
||
function getTotalForms() {
|
||
return parseInt(document.querySelector(`[name="${FORMSET_PREFIX}-TOTAL_FORMS"]`).value, 10);
|
||
}
|
||
function setTotalForms(n) {
|
||
document.querySelector(`[name="${FORMSET_PREFIX}-TOTAL_FORMS"]`).value = n;
|
||
updateCountBadge();
|
||
}
|
||
function updateCountBadge() {
|
||
const n = container.querySelectorAll('tr.inventory-item').length;
|
||
if (n > 0) {
|
||
countBadge.textContent = n;
|
||
countBadge.classList.remove('d-none');
|
||
} else {
|
||
countBadge.classList.add('d-none');
|
||
}
|
||
emptyState.classList.toggle('d-none', n > 0);
|
||
}
|
||
|
||
// ── Construction d'une ligne de formset (auto-remplie) ────────────────
|
||
function buildAutoRow(index, productId, productName, productCode, expectedQty) {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'inventory-item';
|
||
tr.innerHTML = `
|
||
<td>
|
||
<input type="hidden" name="${FORMSET_PREFIX}-${index}-id" value="">
|
||
<input type="hidden" name="${FORMSET_PREFIX}-${index}-product" value="${productId}">
|
||
<span class="fw-semibold">${productCode} – ${productName}</span>
|
||
</td>
|
||
<td>
|
||
<input type="number" name="${FORMSET_PREFIX}-${index}-expected_quantity"
|
||
value="${expectedQty}" class="form-control form-control-sm" readonly>
|
||
</td>
|
||
<td>
|
||
<input type="number" name="${FORMSET_PREFIX}-${index}-counted_quantity"
|
||
value="${expectedQty}" class="form-control form-control-sm" min="0" required>
|
||
</td>
|
||
<td>
|
||
<textarea name="${FORMSET_PREFIX}-${index}-comment"
|
||
class="form-control form-control-sm" rows="1"
|
||
placeholder="Commentaire optionnel…"></textarea>
|
||
</td>
|
||
${canValidate ? `
|
||
<td class="text-end">
|
||
<button type="button" class="btn btn-sm btn-outline-danger remove-item" title="Supprimer">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</td>` : ''}`;
|
||
return tr;
|
||
}
|
||
|
||
// ── Construction d'une ligne manuelle (avec select produit) ───────────
|
||
function buildManualRow(index) {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'inventory-item';
|
||
let options = '<option value="">-- Sélectionner --</option>';
|
||
PRODUCTS.forEach(p => {
|
||
options += `<option value="${p.id}">${p.code} – ${p.name}</option>`;
|
||
});
|
||
tr.innerHTML = `
|
||
<td>
|
||
<input type="hidden" name="${FORMSET_PREFIX}-${index}-id" value="">
|
||
<select name="${FORMSET_PREFIX}-${index}-product" class="form-select form-select-sm" required>
|
||
${options}
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<input type="number" name="${FORMSET_PREFIX}-${index}-expected_quantity"
|
||
value="" class="form-control form-control-sm" min="0">
|
||
</td>
|
||
<td>
|
||
<input type="number" name="${FORMSET_PREFIX}-${index}-counted_quantity"
|
||
value="" class="form-control form-control-sm" min="0" required>
|
||
</td>
|
||
<td>
|
||
<textarea name="${FORMSET_PREFIX}-${index}-comment"
|
||
class="form-control form-control-sm" rows="1"
|
||
placeholder="Commentaire optionnel…"></textarea>
|
||
</td>
|
||
${canValidate ? `
|
||
<td class="text-end">
|
||
<button type="button" class="btn btn-sm btn-outline-danger remove-item" title="Supprimer">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</td>` : ''}`;
|
||
return tr;
|
||
}
|
||
|
||
// ── Chargement automatique via API ─────────────────────────────────────
|
||
function prefillFromLocation(locationId) {
|
||
if (!locationId) return;
|
||
prefillStatus.textContent = '{% translate "Chargement…" %}';
|
||
fetch(`${PREFILL_URL}?location_id=${locationId}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
container.innerHTML = '';
|
||
if (!data.stocks || data.stocks.length === 0) {
|
||
setTotalForms(0);
|
||
prefillStatus.textContent = '{% translate "Aucun produit en stock à cet emplacement." %}';
|
||
return;
|
||
}
|
||
data.stocks.forEach((s, i) => {
|
||
container.appendChild(buildAutoRow(i, s.product_id, s.product_name, s.product_code, s.quantity));
|
||
});
|
||
setTotalForms(data.stocks.length);
|
||
prefillStatus.textContent = `{% translate "Chargement automatique :" %} ${data.stocks.length} {% translate "produit(s)." %}`;
|
||
})
|
||
.catch(() => {
|
||
prefillStatus.textContent = '{% translate "Erreur lors du chargement." %}';
|
||
});
|
||
}
|
||
|
||
// ── Écoute le changement de warehouse_location ─────────────────────────
|
||
const locationSelect = document.getElementById('id_warehouse_location');
|
||
if (locationSelect) {
|
||
locationSelect.addEventListener('change', function () {
|
||
if (this.value) {
|
||
prefillFromLocation(this.value);
|
||
} else {
|
||
container.innerHTML = '';
|
||
setTotalForms(0);
|
||
prefillStatus.textContent = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Bouton "Ajouter un article" (ligne manuelle) ───────────────────────
|
||
document.getElementById('add-item').addEventListener('click', function () {
|
||
const index = getTotalForms();
|
||
container.appendChild(buildManualRow(index));
|
||
setTotalForms(index + 1);
|
||
});
|
||
|
||
// ── Suppression d'une ligne ────────────────────────────────────────────
|
||
container.addEventListener('click', function (e) {
|
||
const btn = e.target.closest('.remove-item');
|
||
if (!btn) return;
|
||
btn.closest('tr').remove();
|
||
// Renuméroter les indices
|
||
container.querySelectorAll('tr.inventory-item').forEach((row, i) => {
|
||
row.querySelectorAll('[name]').forEach(el => {
|
||
el.name = el.name.replace(new RegExp(`${FORMSET_PREFIX}-\\d+-`), `${FORMSET_PREFIX}-${i}-`);
|
||
});
|
||
});
|
||
setTotalForms(container.querySelectorAll('tr.inventory-item').length);
|
||
});
|
||
|
||
// Init badge au chargement
|
||
updateCountBadge();
|
||
})();
|
||
</script>
|
||
{% endblock script %}
|