490 lines
23 KiB
HTML
490 lines
23 KiB
HTML
{% extends "base.html" %}
|
||
{% load i18n %}
|
||
{% load static %}
|
||
{% load stock_custom_filters %}
|
||
|
||
{% block head %}
|
||
<link rel="stylesheet" href="{% static 'common/select2.min.css' %}" />
|
||
<link rel="stylesheet" href="{% static 'common/select2-bootstrap-5-theme.min.css' %}" />
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container mt-5">
|
||
<h1 class="h3 mb-3">{% if purchase_order %}{% translate "Modifier la commande" %} {{ purchase_order.code }}{% else %}{% translate "Nouvelle commande fournisseur" %}{% endif %}</h1>
|
||
<form method="post">
|
||
{% 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 %}
|
||
{% if field.is_hidden %}
|
||
{{ field }}
|
||
{% else %}
|
||
<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>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<span>{% translate "Articles" %}</span>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" id="add-item">{% translate "Ajouter un article" %}</button>
|
||
</div>
|
||
<div class="card-body" id="purchase-order-items-container">
|
||
{{ formset.management_form }}
|
||
{% for form in formset %}
|
||
<div class="row g-3 align-items-end purchase-order-item">
|
||
{{ form.id }}
|
||
{# Champ DELETE caché, activé par le bouton JS #}
|
||
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
|
||
{% for field in form.visible_fields %}
|
||
{% if field.name == 'DELETE' %}
|
||
<div class="d-none">{{ field }}</div>
|
||
{% elif field.html_name|slice:"-7:" == 'product' %}
|
||
<div class="col-md-3">
|
||
<label class="form-label">{% translate "Catégorie" %}</label>
|
||
<select class="form-select product-category-filter">
|
||
<option value="">-- {% translate "Toutes" %} --</option>
|
||
{% for cat in categories %}
|
||
<option value="{{ cat.id }}">{{ cat.name_fr }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||
{{ field|add_class:"form-select product-select" }}
|
||
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="col-md-3">
|
||
<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 %}
|
||
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% endfor %}
|
||
<div class="col-auto">
|
||
{% if form.instance.pk %}
|
||
<button type="button" class="btn btn-sm btn-outline-danger delete-item-btn mt-4" title="{% translate 'Supprimer' %}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
{% else %}
|
||
<button type="button" class="btn btn-sm btn-outline-danger remove-item-btn mt-4" title="{% translate 'Retirer' %}">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<hr class="my-1">
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
<template id="purchase-order-item-template">
|
||
<div class="row g-3 align-items-end purchase-order-item">
|
||
{% for field in formset.empty_form.visible_fields %}
|
||
{% if field.name == 'DELETE' %}
|
||
<div class="d-none">{{ field }}</div>
|
||
{% elif field.html_name|slice:"-7:" == 'product' %}
|
||
<div class="col-md-3">
|
||
<label class="form-label">{% translate "Catégorie" %}</label>
|
||
<select class="form-select product-category-filter">
|
||
<option value="">-- {% translate "Toutes" %} --</option>
|
||
{% for cat in categories %}
|
||
<option value="{{ cat.id }}">{{ cat.name_fr }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||
{{ field|add_class:"form-select product-select" }}
|
||
</div>
|
||
{% else %}
|
||
<div class="col-md-3">
|
||
<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 %}
|
||
</div>
|
||
{% endif %}
|
||
{% endfor %}
|
||
<div class="col-auto">
|
||
<button type="button" class="btn btn-sm btn-outline-danger remove-item-btn mt-4" title="{% translate 'Retirer' %}">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<hr class="my-1">
|
||
</template>
|
||
{% if purchase_order %}
|
||
<div class="card mt-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<span><i class="bi bi-paperclip"></i> {% translate "Documents" %}</span>
|
||
<label class="btn btn-sm btn-outline-secondary mb-0" for="doc-upload-input">
|
||
<i class="bi bi-upload"></i> {% translate "Ajouter" %}
|
||
</label>
|
||
<input type="file" id="doc-upload-input" multiple class="d-none" accept="*/*">
|
||
</div>
|
||
<div class="card-body">
|
||
{% if documents %}
|
||
<div class="row g-2" id="doc-items">
|
||
{% for doc in documents %}
|
||
<div class="col-md-3 col-sm-4 col-6" id="doc-item-{{ doc.id }}">
|
||
<div class="border rounded p-2 text-center">
|
||
{% if doc.is_image %}
|
||
<a href="{{ doc.url }}" target="_blank">
|
||
<img src="{{ doc.url }}" class="img-fluid rounded mb-1" style="max-height:80px;object-fit:cover;">
|
||
</a>
|
||
{% else %}
|
||
<a href="{{ doc.url }}" target="_blank" class="d-block mb-1">
|
||
<i class="bi bi-file-earmark fs-2 text-secondary"></i>
|
||
</a>
|
||
{% endif %}
|
||
<small class="d-block text-truncate" title="{{ doc.filename }}">{{ doc.filename }}</small>
|
||
<small class="text-muted">{{ doc.uploaded_at }}</small>
|
||
<button type="button" class="btn btn-sm btn-outline-danger mt-1 doc-delete-btn"
|
||
data-delete-url="{{ doc.delete_url }}" data-doc-id="{{ doc.id }}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted mb-0" id="no-docs-msg">{% translate "Aucun document associé." %}</p>
|
||
<div class="row g-2 d-none" id="doc-items"></div>
|
||
{% endif %}
|
||
<div id="doc-upload-progress" class="mt-2"></div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="mt-3 d-flex gap-2 justify-content-end">
|
||
<a href="{{ cancel_url }}" class="btn btn-secondary">{% 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 src="{% static 'common/select2.min.js' %}"></script>
|
||
{{ products_data|json_script:"products-data" }}
|
||
<script>
|
||
(function() {
|
||
const PRODUCTS = JSON.parse(document.getElementById('products-data').textContent);
|
||
|
||
function buildOptions(productSelect, catId) {
|
||
const currentVal = productSelect.value;
|
||
// Keep first empty option, replace the rest
|
||
while (productSelect.options.length > 1) productSelect.remove(1);
|
||
PRODUCTS.forEach(p => {
|
||
if (!catId || String(p.category_id) === catId) {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = p.code + ' - ' + p.name;
|
||
opt.dataset.category = p.category_id || '';
|
||
if (String(p.id) === currentVal) opt.selected = true;
|
||
productSelect.appendChild(opt);
|
||
}
|
||
});
|
||
}
|
||
|
||
function applySelect2(productSelect) {
|
||
$(productSelect).select2({
|
||
theme: 'bootstrap-5',
|
||
placeholder: '-- Sélectionner un produit --',
|
||
allowClear: true,
|
||
width: '100%',
|
||
});
|
||
}
|
||
|
||
function initRow(row) {
|
||
if (row.dataset.initialized) return;
|
||
row.dataset.initialized = '1';
|
||
const productSelect = row.querySelector('.product-select');
|
||
const catFilter = row.querySelector('.product-category-filter');
|
||
const locationSelect = row.querySelector('select[name$="-location"]');
|
||
if (!productSelect) return;
|
||
|
||
buildOptions(productSelect, catFilter ? catFilter.value : '');
|
||
applySelect2(productSelect);
|
||
|
||
if (catFilter) {
|
||
catFilter.addEventListener('change', function () {
|
||
$(productSelect).select2('destroy');
|
||
buildOptions(productSelect, this.value);
|
||
applySelect2(productSelect);
|
||
updateLocations();
|
||
});
|
||
}
|
||
|
||
function updateLocations() {
|
||
if (!locationSelect) return;
|
||
const pid = productSelect.value;
|
||
const product = PRODUCTS.find(p => String(p.id) === String(pid));
|
||
const allowedIds = product ? product.authorized_location_ids : null;
|
||
|
||
let visibleOptions = [];
|
||
|
||
Array.from(locationSelect.options).forEach(opt => {
|
||
if (!opt.value) return;
|
||
const optVal = parseInt(opt.value);
|
||
if (allowedIds && allowedIds.length > 0) {
|
||
if (allowedIds.includes(optVal)) {
|
||
opt.hidden = false;
|
||
opt.disabled = false;
|
||
visibleOptions.push(opt);
|
||
} else {
|
||
opt.hidden = true;
|
||
opt.disabled = true;
|
||
if (locationSelect.value === opt.value) {
|
||
locationSelect.value = '';
|
||
}
|
||
}
|
||
} else {
|
||
opt.hidden = false;
|
||
opt.disabled = false;
|
||
visibleOptions.push(opt);
|
||
}
|
||
});
|
||
|
||
if (visibleOptions.length === 1) {
|
||
locationSelect.value = visibleOptions[0].value;
|
||
}
|
||
}
|
||
|
||
// Auto-calcul quantité totale = num_articles × quantity_per_article
|
||
const numArticlesInput = row.querySelector('input[id$="-num_articles"]');
|
||
const qpaInput = row.querySelector('input[id$="-quantity_per_article"]');
|
||
const quantityInput = row.querySelector('input[id$="-quantity"]');
|
||
const unitBadge = document.createElement('span');
|
||
unitBadge.className = 'badge bg-secondary ms-1 unit-badge';
|
||
unitBadge.title = '{% translate "Unité du produit" %}';
|
||
|
||
// Afficher le badge unité à côté du champ quantité
|
||
if (quantityInput && quantityInput.parentElement) {
|
||
const labelEl = quantityInput.parentElement.querySelector('label');
|
||
if (labelEl) labelEl.appendChild(unitBadge);
|
||
}
|
||
|
||
function updateUnit() {
|
||
const pid = productSelect.value;
|
||
const product = PRODUCTS.find(p => String(p.id) === String(pid));
|
||
if (product && product.unit_display) {
|
||
unitBadge.textContent = product.unit_display;
|
||
unitBadge.style.display = '';
|
||
if (numArticlesInput) {
|
||
const numLabel = numArticlesInput.parentElement && numArticlesInput.parentElement.querySelector('label');
|
||
if (numLabel) numLabel.dataset.unit = product.unit_display;
|
||
}
|
||
if (qpaInput) {
|
||
const qpaLabel = qpaInput.parentElement && qpaInput.parentElement.querySelector('label');
|
||
if (qpaLabel) {
|
||
qpaLabel.textContent = `{% translate "Qté / article" %} (${product.unit_display})`;
|
||
}
|
||
}
|
||
} else {
|
||
unitBadge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function autoComputeQty() {
|
||
if (numArticlesInput && qpaInput && quantityInput) {
|
||
const n = parseFloat(numArticlesInput.value);
|
||
const q = parseFloat(qpaInput.value);
|
||
if (!isNaN(n) && !isNaN(q) && n > 0 && q > 0) {
|
||
quantityInput.value = Math.round(n * q);
|
||
quantityInput.readOnly = true;
|
||
quantityInput.classList.add('bg-light');
|
||
} else {
|
||
quantityInput.readOnly = false;
|
||
quantityInput.classList.remove('bg-light');
|
||
}
|
||
}
|
||
}
|
||
|
||
if (numArticlesInput) numArticlesInput.addEventListener('input', autoComputeQty);
|
||
if (qpaInput) qpaInput.addEventListener('input', autoComputeQty);
|
||
|
||
$(productSelect).on('change', function() {
|
||
updateUnit();
|
||
updateLocations();
|
||
|
||
const pid = productSelect.value;
|
||
const product = PRODUCTS.find(p => String(p.id) === String(pid));
|
||
if (product && product.category_id && catFilter) {
|
||
if (catFilter.value !== String(product.category_id)) {
|
||
catFilter.value = product.category_id;
|
||
setTimeout(() => {
|
||
$(productSelect).select2('destroy');
|
||
buildOptions(productSelect, catFilter.value);
|
||
applySelect2(productSelect);
|
||
}, 0);
|
||
}
|
||
}
|
||
});
|
||
updateUnit();
|
||
updateLocations();
|
||
autoComputeQty();
|
||
|
||
// Bouton supprimer (enregistré en DB)
|
||
const deleteBtn = row.querySelector('.delete-item-btn');
|
||
if (deleteBtn) {
|
||
deleteBtn.addEventListener('click', function () {
|
||
const deleteCheckbox = row.querySelector('input[type=checkbox][id$="-DELETE"]');
|
||
if (deleteCheckbox) {
|
||
deleteCheckbox.checked = true;
|
||
row.style.opacity = '0.4';
|
||
row.style.pointerEvents = 'none';
|
||
deleteBtn.disabled = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Bouton retirer (nouveau, pas encore en DB)
|
||
const removeBtn = row.querySelector('.remove-item-btn');
|
||
if (removeBtn) {
|
||
removeBtn.addEventListener('click', function () {
|
||
// Trouver le <hr> suivant et supprimer les deux
|
||
const next = row.nextElementSibling;
|
||
if (next && next.tagName === 'HR') next.remove();
|
||
row.remove();
|
||
// Décrémenter TOTAL_FORMS
|
||
const totalInput = document.querySelector('[name$="-TOTAL_FORMS"]');
|
||
if (totalInput) totalInput.value = parseInt(totalInput.value, 10) - 1;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Initialiser les lignes existantes
|
||
document.querySelectorAll('.purchase-order-item').forEach(initRow);
|
||
|
||
// Bouton "Ajouter un article"
|
||
const addButton = document.getElementById('add-item');
|
||
if (!addButton) return;
|
||
const formsetPrefix = '{{ formset.prefix }}';
|
||
const container = document.getElementById('purchase-order-items-container');
|
||
const templateHtml = document.getElementById('purchase-order-item-template').innerHTML.trim();
|
||
|
||
addButton.addEventListener('click', function () {
|
||
const totalFormsInput = document.querySelector(`[name="${formsetPrefix}-TOTAL_FORMS"]`);
|
||
const currentIndex = parseInt(totalFormsInput.value, 10);
|
||
const html = templateHtml.replace(/__prefix__/g, currentIndex);
|
||
const wrapper = document.createElement('div');
|
||
wrapper.innerHTML = html;
|
||
// Append all children
|
||
const children = Array.from(wrapper.childNodes);
|
||
children.forEach(child => container.appendChild(child));
|
||
// Find the new row (last .purchase-order-item not yet initialized)
|
||
const newRow = container.querySelector('.purchase-order-item:not([data-initialized])');
|
||
if (newRow) initRow(newRow);
|
||
totalFormsInput.value = currentIndex + 1;
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
{% if purchase_order %}
|
||
<script>
|
||
(function() {
|
||
const uploadInput = document.getElementById('doc-upload-input');
|
||
const uploadUrl = "{{ upload_url }}";
|
||
const csrfToken = "{{ csrf_token }}";
|
||
const docItems = document.getElementById('doc-items');
|
||
const noDocsMsg = document.getElementById('no-docs-msg');
|
||
const progressArea = document.getElementById('doc-upload-progress');
|
||
|
||
function addDocCard(doc) {
|
||
if (noDocsMsg) noDocsMsg.classList.add('d-none');
|
||
docItems.classList.remove('d-none');
|
||
const col = document.createElement('div');
|
||
col.className = 'col-md-3 col-sm-4 col-6';
|
||
col.id = 'doc-item-' + doc.id;
|
||
col.innerHTML = `<div class="border rounded p-2 text-center">
|
||
${doc.is_image
|
||
? `<a href="${doc.url}" target="_blank"><img src="${doc.url}" class="img-fluid rounded mb-1" style="max-height:80px;object-fit:cover;"></a>`
|
||
: `<a href="${doc.url}" target="_blank" class="d-block mb-1"><i class="bi bi-file-earmark fs-2 text-secondary"></i></a>`}
|
||
<small class="d-block text-truncate" title="${doc.filename}">${doc.filename}</small>
|
||
<small class="text-muted">${doc.uploaded_at}</small>
|
||
<button type="button" class="btn btn-sm btn-outline-danger mt-1 doc-delete-btn"
|
||
data-delete-url="${doc.delete_url}" data-doc-id="${doc.id}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</div>`;
|
||
docItems.appendChild(col);
|
||
}
|
||
|
||
if (uploadInput) {
|
||
uploadInput.addEventListener('change', function() {
|
||
const files = this.files;
|
||
if (!files.length) return;
|
||
progressArea.innerHTML = '<div class="spinner-border spinner-border-sm text-primary" role="status"></div> {% translate "Envoi en cours..." %}';
|
||
const formData = new FormData();
|
||
for (const f of files) formData.append('documents', f);
|
||
fetch(uploadUrl, {
|
||
method: 'POST',
|
||
headers: {'X-CSRFToken': csrfToken},
|
||
body: formData,
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
progressArea.innerHTML = '';
|
||
if (data.created) {
|
||
data.created.forEach(addDocCard);
|
||
} else {
|
||
progressArea.innerHTML = '<span class="text-danger">' + (data.error || '{% translate "Erreur" %}') + '</span>';
|
||
}
|
||
uploadInput.value = '';
|
||
})
|
||
.catch(() => {
|
||
progressArea.innerHTML = '<span class="text-danger">{% translate "Erreur réseau." %}</span>';
|
||
uploadInput.value = '';
|
||
});
|
||
});
|
||
}
|
||
|
||
document.getElementById('doc-items') && document.getElementById('doc-items').addEventListener('click', function(e) {
|
||
const btn = e.target.closest('.doc-delete-btn');
|
||
if (!btn) return;
|
||
if (!confirm('{% translate "Supprimer ce document ?" %}')) return;
|
||
const deleteUrl = btn.dataset.deleteUrl;
|
||
const docId = btn.dataset.docId;
|
||
fetch(deleteUrl, {
|
||
method: 'POST',
|
||
headers: {'X-CSRFToken': csrfToken},
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.deleted) {
|
||
const card = document.getElementById('doc-item-' + docId);
|
||
if (card) card.remove();
|
||
if (!docItems.children.length) {
|
||
docItems.classList.add('d-none');
|
||
if (noDocsMsg) noDocsMsg.classList.remove('d-none');
|
||
}
|
||
}
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
{% endif %}
|
||
|
||
{% endblock script %}
|