531 lines
23 KiB
HTML
531 lines
23 KiB
HTML
{% extends "base.html" %}
|
|
{% load static %}
|
|
{% load i18n %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid mt-3">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<!-- En-tête -->
|
|
<div class="mb-4">
|
|
<h3>
|
|
<i class="bi bi-cart-plus"></i>
|
|
{% translate "Créer un bon de commande" %}
|
|
</h3>
|
|
<p class="text-muted">
|
|
{% translate "Depuis le bon de préparation" %} #{{ preparation_order.order_number }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Produits manquants -->
|
|
{% if missing_products %}
|
|
<div class="card mb-3 shadow-sm">
|
|
<div class="card-header bg-warning text-dark">
|
|
<h5 class="mb-0">
|
|
<i class="bi bi-exclamation-triangle"></i>
|
|
{% translate "Produits manquants ou en stock insuffisant" %}
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>{% translate "Produit" %}</th>
|
|
<th>{% translate "Code" %}</th>
|
|
<th class="text-center">{% translate "Quantité nécessaire" %}</th>
|
|
<th class="text-center">{% translate "Stock disponible" %}</th>
|
|
<th class="text-center">{% translate "À commander" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for product_data in missing_products %}
|
|
<tr>
|
|
<td><strong>{{ product_data.product.name }}</strong></td>
|
|
<td><code>{{ product_data.product.code }}</code></td>
|
|
<td class="text-center">
|
|
<span class="badge bg-primary">{{ product_data.needed }} {{ product_data.product.unit }}</span>
|
|
</td>
|
|
<td class="text-center">
|
|
<span class="badge bg-danger">{{ product_data.available }} {{ product_data.product.unit }}</span>
|
|
</td>
|
|
<td class="text-center">
|
|
<span class="badge bg-warning text-dark">{{ product_data.to_order }} {{ product_data.product.unit }}</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="alert alert-success">
|
|
<i class="bi bi-check-circle"></i>
|
|
{% translate "Tous les produits sont en stock suffisant. Vous pouvez créer un bon de commande pour réapprovisionner." %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Formulaire de création de bon de commande -->
|
|
<div class="card shadow-sm">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="bi bi-file-earmark-text"></i>
|
|
{% translate "Informations du bon de commande" %}
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<form method="post" id="purchase-form">
|
|
{% csrf_token %}
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-md-4">
|
|
<label for="{{ form.supplier.id_for_label }}" class="form-label">
|
|
{{ form.supplier.label }}
|
|
</label>
|
|
{{ form.supplier }}
|
|
{{ form.supplier.errors }}
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label for="{{ form.warehouse.id_for_label }}" class="form-label">
|
|
{{ form.warehouse.label }}
|
|
</label>
|
|
{{ form.warehouse }}
|
|
{{ form.warehouse.errors }}
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label for="{{ form.status.id_for_label }}" class="form-label">
|
|
{{ form.status.label }}
|
|
</label>
|
|
{{ form.status }}
|
|
{{ form.status.errors }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section produits -->
|
|
<div class="card mb-3 border">
|
|
<div class="card-header bg-light">
|
|
<h6 class="mb-0">
|
|
<i class="bi bi-box-seam"></i> {% translate "Produits à commander" %}
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="products-container">
|
|
<p class="text-muted text-center" id="empty-message">
|
|
<i class="bi bi-inbox"></i> {% translate "Cliquez sur 'Ajouter produit manquant' ou ajoutez des produits manuellement" %}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
{% if missing_products %}
|
|
<button type="button" class="btn btn-warning" id="add-missing-btn">
|
|
<i class="bi bi-plus-circle"></i> {% translate "Ajouter tous les produits manquants" %}
|
|
</button>
|
|
{% endif %}
|
|
<button type="button" class="btn btn-primary" id="add-product-btn" data-bs-toggle="modal" data-bs-target="#addProductModal">
|
|
<i class="bi bi-plus-circle"></i> {% translate "Ajouter un produit" %}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Formset caché -->
|
|
{{ formset.management_form }}
|
|
<div id="formset-container"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="d-flex justify-content-between">
|
|
<a href="{% url 'stock:preparation_order_detail' preparation_order.pk %}" class="btn btn-secondary">
|
|
<i class="bi bi-arrow-left"></i> {% translate "Annuler" %}
|
|
</a>
|
|
<button type="submit" class="btn btn-success">
|
|
<i class="bi bi-check-circle"></i> {% translate "Créer le bon de commande" %}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal pour ajouter un produit -->
|
|
<div class="modal fade" id="addProductModal" tabindex="-1" aria-labelledby="addProductModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="addProductModalLabel">
|
|
<i class="bi bi-plus-circle"></i> {% translate "Ajouter un produit" %}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label for="product-search" class="form-label">
|
|
<i class="bi bi-search"></i> {% translate "Rechercher un produit" %}
|
|
</label>
|
|
<input type="text"
|
|
id="product-search"
|
|
class="form-control"
|
|
placeholder="{% translate "Tapez pour rechercher..." %}"
|
|
autocomplete="off">
|
|
<div id="search-results" class="list-group mt-2" style="display: none; max-height: 300px; overflow-y: auto;"></div>
|
|
</div>
|
|
|
|
<div class="mb-3" id="selected-product-info" style="display: none;">
|
|
<label class="form-label">{% translate "Produit sélectionné" %}</label>
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 id="selected-product-name"></h6>
|
|
<p class="mb-0"><small class="text-muted">Code: <span id="selected-product-code"></span></small></p>
|
|
<p class="mb-0"><small>Stock disponible: <span id="selected-product-stock" class="badge bg-info"></span></small></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="product-quantity" class="form-label">{% translate "Quantité" %}</label>
|
|
<input type="number" id="product-quantity" class="form-control" value="1" min="1">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
{% translate "Annuler" %}
|
|
</button>
|
|
<button type="button" class="btn btn-primary" id="confirm-add-product" disabled>
|
|
<i class="bi bi-check-circle"></i> {% translate "Ajouter" %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const productsApiUrl = "{% url 'stock:products_autocomplete_api' %}";
|
|
let selectedProduct = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const productsContainer = document.getElementById('products-container');
|
|
const emptyMessage = document.getElementById('empty-message');
|
|
const formsetContainer = document.getElementById('formset-container');
|
|
const managementForm = document.querySelector('[name="{{ formset.management_form.prefix }}-TOTAL_FORMS"]').closest('div');
|
|
const formPrefix = '{{ formset.prefix }}';
|
|
const warehouseSelect = document.querySelector('[name="warehouse"]');
|
|
|
|
let productsAdded = [];
|
|
let warehouseLocations = [];
|
|
|
|
const missingProducts = [
|
|
{% for product_data in missing_products %}
|
|
{
|
|
id: {{ product_data.product.id }},
|
|
name: "{{ product_data.product.name|escapejs }}",
|
|
code: "{{ product_data.product.code|escapejs }}",
|
|
to_order: {{ product_data.to_order }}
|
|
}{% if not forloop.last %},{% endif %}
|
|
{% endfor %}
|
|
];
|
|
|
|
// ============ Autocomplete pour la recherche de produits ============
|
|
const productSearch = document.getElementById('product-search');
|
|
const searchResults = document.getElementById('search-results');
|
|
const confirmAddBtn = document.getElementById('confirm-add-product');
|
|
const selectedProductInfo = document.getElementById('selected-product-info');
|
|
const productQuantityInput = document.getElementById('product-quantity');
|
|
|
|
let searchTimeout;
|
|
productSearch.addEventListener('input', function() {
|
|
clearTimeout(searchTimeout);
|
|
const query = this.value.trim();
|
|
|
|
if (query.length < 2) {
|
|
searchResults.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
searchTimeout = setTimeout(() => {
|
|
fetch(`${productsApiUrl}?q=${encodeURIComponent(query)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
searchResults.innerHTML = '';
|
|
|
|
if (data.results && data.results.length > 0) {
|
|
data.results.forEach(product => {
|
|
const item = document.createElement('a');
|
|
item.href = '#';
|
|
item.className = 'list-group-item list-group-item-action';
|
|
item.innerHTML = `
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<strong>${product.text}</strong><br>
|
|
<small class="text-muted">Code: ${product.code}</small>
|
|
</div>
|
|
<div class="text-end">
|
|
<span class="badge ${product.total_stock > 0 ? 'bg-success' : 'bg-danger'}">
|
|
Stock: ${product.total_stock}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
item.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
selectProduct(product);
|
|
});
|
|
searchResults.appendChild(item);
|
|
});
|
|
searchResults.style.display = 'block';
|
|
} else {
|
|
searchResults.innerHTML = '<div class="list-group-item text-muted">{% translate "Aucun produit trouvé" %}</div>';
|
|
searchResults.style.display = 'block';
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Erreur lors de la recherche:', error);
|
|
});
|
|
}, 300);
|
|
});
|
|
|
|
function selectProduct(product) {
|
|
selectedProduct = product;
|
|
productSearch.value = '';
|
|
searchResults.style.display = 'none';
|
|
|
|
document.getElementById('selected-product-name').textContent = product.text;
|
|
document.getElementById('selected-product-code').textContent = product.code;
|
|
document.getElementById('selected-product-stock').textContent = product.total_stock;
|
|
|
|
selectedProductInfo.style.display = 'block';
|
|
confirmAddBtn.disabled = false;
|
|
productQuantityInput.focus();
|
|
}
|
|
|
|
// Réinitialiser le modal quand il est fermé
|
|
const addProductModal = document.getElementById('addProductModal');
|
|
addProductModal.addEventListener('hidden.bs.modal', function() {
|
|
productSearch.value = '';
|
|
searchResults.style.display = 'none';
|
|
selectedProductInfo.style.display = 'none';
|
|
confirmAddBtn.disabled = true;
|
|
selectedProduct = null;
|
|
productQuantityInput.value = 1;
|
|
});
|
|
|
|
// Confirmer l'ajout du produit
|
|
confirmAddBtn.addEventListener('click', function() {
|
|
if (!selectedProduct) return;
|
|
|
|
const quantity = parseInt(productQuantityInput.value) || 1;
|
|
|
|
// Vérifier si le produit est déjà dans la liste
|
|
const existingIndex = productsAdded.findIndex(p => p.id === selectedProduct.id);
|
|
if (existingIndex !== -1) {
|
|
// Augmenter la quantité
|
|
productsAdded[existingIndex].quantity += quantity;
|
|
} else {
|
|
// Ajouter le nouveau produit
|
|
productsAdded.push({
|
|
id: selectedProduct.id,
|
|
name: selectedProduct.text,
|
|
code: selectedProduct.code,
|
|
quantity: quantity,
|
|
location: ''
|
|
});
|
|
}
|
|
|
|
renderProductsList();
|
|
|
|
// Fermer le modal
|
|
const modal = bootstrap.Modal.getInstance(addProductModal);
|
|
modal.hide();
|
|
});
|
|
|
|
// Fermer l'autocomplete si clic ailleurs
|
|
document.addEventListener('click', function(e) {
|
|
if (!productSearch.contains(e.target) && !searchResults.contains(e.target)) {
|
|
searchResults.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Charger les emplacements lorsque l'entrepôt change
|
|
function loadWarehouseLocations(warehouseId) {
|
|
if (!warehouseId) {
|
|
warehouseLocations = [];
|
|
updateAllLocationSelects();
|
|
return;
|
|
}
|
|
|
|
fetch(`/stock/warehouses/${warehouseId}/locations/api/`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
warehouseLocations = data;
|
|
updateAllLocationSelects();
|
|
})
|
|
.catch(error => {
|
|
console.error('Erreur lors du chargement des emplacements:', error);
|
|
warehouseLocations = [];
|
|
updateAllLocationSelects();
|
|
});
|
|
}
|
|
|
|
// Mettre à jour tous les selects d'emplacement
|
|
function updateAllLocationSelects() {
|
|
const defaultLocation = warehouseLocations.find(l => l.is_default);
|
|
const locationSelects = document.querySelectorAll('.location-select');
|
|
locationSelects.forEach(select => {
|
|
const currentValue = select.value;
|
|
select.innerHTML = '<option value="">{% translate "Sélectionner" %}</option>';
|
|
|
|
warehouseLocations.forEach(location => {
|
|
const option = document.createElement('option');
|
|
option.value = location.id;
|
|
option.textContent = location.name;
|
|
if (location.id.toString() === currentValue) {
|
|
option.selected = true;
|
|
}
|
|
select.appendChild(option);
|
|
});
|
|
// Pre-select the default location if no selection is already set
|
|
if (!currentValue && defaultLocation) {
|
|
select.value = defaultLocation.id;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Écouter les changements d'entrepôt
|
|
if (warehouseSelect) {
|
|
warehouseSelect.addEventListener('change', function() {
|
|
loadWarehouseLocations(this.value);
|
|
});
|
|
|
|
// Charger les emplacements de l'entrepôt initial si défini
|
|
if (warehouseSelect.value) {
|
|
loadWarehouseLocations(warehouseSelect.value);
|
|
}
|
|
}
|
|
|
|
// Ajouter tous les produits manquants
|
|
document.getElementById('add-missing-btn')?.addEventListener('click', function() {
|
|
missingProducts.forEach(product => {
|
|
const existing = productsAdded.find(p => p.id === product.id);
|
|
if (!existing) {
|
|
productsAdded.push({
|
|
id: product.id,
|
|
name: product.name,
|
|
code: product.code,
|
|
quantity: product.to_order,
|
|
location: ''
|
|
});
|
|
}
|
|
});
|
|
renderProductsList();
|
|
});
|
|
|
|
// Afficher la liste des produits
|
|
function renderProductsList() {
|
|
productsContainer.innerHTML = '';
|
|
|
|
if (productsAdded.length === 0) {
|
|
emptyMessage.style.display = 'block';
|
|
updateFormset();
|
|
return;
|
|
}
|
|
|
|
emptyMessage.style.display = 'none';
|
|
|
|
productsAdded.forEach((product, index) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'card mb-2';
|
|
|
|
let locationOptions = '<option value="">{% translate "Sélectionner" %}</option>';
|
|
const defaultLoc = warehouseLocations.find(l => l.is_default);
|
|
const effectiveLocation = product.location || (defaultLoc ? defaultLoc.id.toString() : '');
|
|
warehouseLocations.forEach(location => {
|
|
const selected = location.id.toString() === effectiveLocation ? 'selected' : '';
|
|
locationOptions += `<option value="${location.id}" ${selected}>${location.name}</option>`;
|
|
});
|
|
|
|
card.innerHTML = `
|
|
<div class="card-body">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-5">
|
|
<strong>${product.name}</strong><br>
|
|
<small class="text-muted">Code: ${product.code}</small>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">{% translate "Quantité" %}</label>
|
|
<input type="number" class="form-control" value="${product.quantity}"
|
|
min="1" onchange="updateQuantity(${index}, this.value)">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">{% translate "Emplacement" %}</label>
|
|
<select class="form-select location-select" onchange="updateLocation(${index}, this.value)">
|
|
${locationOptions}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 text-end">
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="removeProduct(${index})">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
productsContainer.appendChild(card);
|
|
});
|
|
|
|
updateFormset();
|
|
}
|
|
|
|
// Mettre à jour le formset
|
|
function updateFormset() {
|
|
formsetContainer.innerHTML = '';
|
|
const totalFormsInput = managementForm.querySelector('[id$="-TOTAL_FORMS"]');
|
|
totalFormsInput.value = productsAdded.length;
|
|
|
|
productsAdded.forEach((product, index) => {
|
|
const fields = ['product', 'quantity', 'location', 'id', 'DELETE'];
|
|
fields.forEach(field => {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = `${formPrefix}-${index}-${field}`;
|
|
if (field === 'product') input.value = product.id;
|
|
else if (field === 'quantity') input.value = product.quantity;
|
|
else if (field === 'location') input.value = product.location || '';
|
|
else input.value = '';
|
|
formsetContainer.appendChild(input);
|
|
});
|
|
});
|
|
}
|
|
|
|
window.updateQuantity = function(index, value) {
|
|
productsAdded[index].quantity = parseInt(value);
|
|
updateFormset();
|
|
};
|
|
|
|
window.updateLocation = function(index, value) {
|
|
productsAdded[index].location = value;
|
|
updateFormset();
|
|
};
|
|
|
|
window.removeProduct = function(index) {
|
|
productsAdded.splice(index, 1);
|
|
renderProductsList();
|
|
};
|
|
|
|
// Validation du formulaire
|
|
document.getElementById('purchase-form').addEventListener('submit', function(e) {
|
|
if (productsAdded.length === 0) {
|
|
e.preventDefault();
|
|
alert('{% translate "Veuillez ajouter au moins un produit" %}');
|
|
return false;
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.form-select, .form-control {
|
|
font-size: 0.9rem;
|
|
}
|
|
</style>
|
|
{% endblock content %}
|