loko/streetup/stock/templates/stock/purchase_order_form.html
2026-07-22 14:48:40 +02:00

490 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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 %}