feat: add mobile views, stock management views, and preparation order templates

This commit is contained in:
Karl Determe 2026-09-29 13:55:26 +02:00
parent f429838a39
commit 25a64b824a
5 changed files with 710 additions and 650 deletions

View file

@ -965,7 +965,7 @@ def stock_purchase_order_detail_mobile(request, pk):
def stock_preparation_order_detail_mobile(request, pk):
"""Mobile detail page for a preparation order (bon de préparation)."""
from django.shortcuts import get_object_or_404
from stock.models import PreparationOrder, Stock
from stock.models import PreparationOrder, Stock, ProductCategory
from stock.permissions import get_user_accessible_warehouses
from common.models import UserConfig
@ -1120,6 +1120,7 @@ def stock_preparation_order_detail_mobile(request, pk):
'preparation_order': preparation_order,
'items_with_stock': items_with_stock,
'other_items': other_items_with_info,
'categories': list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')),
'can_manage': can_manage,
'can_edit': can_edit,
'can_cancel': can_cancel,

View file

@ -0,0 +1,677 @@
{% load i18n stock_custom_filters %}
{% if can_edit %}
<style>
@media (min-width: 576px) {
#editPreparationOrderModal .modal-dialog {
height: 90vh;
max-height: 90vh;
}
}
@media (max-width: 575.98px) {
#editPreparationOrderModal .modal-dialog {
margin: 0;
max-width: 100%;
height: 100%;
}
#editPreparationOrderModal .modal-content {
border-radius: 0;
height: 100%;
}
}
</style>
<!-- Modale de modification du bon de préparation (Desktop & Mobile) -->
<div class="modal fade" id="editPreparationOrderModal" tabindex="-1" aria-labelledby="editPreparationOrderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-fullscreen-sm-down">
<div class="modal-content h-100 d-flex flex-column">
<div class="modal-header bg-warning flex-shrink-0">
<h5 class="modal-title" id="editPreparationOrderModalLabel">
<i class="bi bi-pencil-square me-2"></i>{% translate "Modifier le bon de préparation" %} #{{ preparation_order.order_number }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body flex-grow-1 d-flex flex-column" style="overflow-y:auto;-webkit-overflow-scrolling:touch;min-height:0;">
<!-- Ajouter un produit -->
<h6 class="mb-2 flex-shrink-0"><i class="bi bi-plus-circle me-1"></i>{% translate "Ajouter un produit" %}</h6>
<div class="row g-2 align-items-end flex-shrink-0">
<div class="col-md-6 col-12">
<label class="form-label small mb-1">{% translate "Produit" %}</label>
<input type="text" id="addProductSearch" class="form-control" placeholder="{% translate 'Rechercher un produit...' %}" autocomplete="off">
<input type="hidden" id="addProductId">
<div id="addProductDropdown" class="list-group shadow" style="position:fixed;z-index:9999;max-height:220px;overflow-y:auto;display:none;width:auto;"></div>
</div>
<div class="col-md-3 col-6">
<label class="form-label small mb-1">{% translate "Quantité" %}</label>
<input type="number" id="addProductQty" class="form-control" min="1" value="1">
</div>
<div class="col-md-3 col-6">
<button type="button" class="btn btn-primary w-100" id="addProductBtn">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter" %}
</button>
</div>
</div>
<div id="editErrorAlert" class="alert alert-danger mt-3 mb-0 flex-shrink-0" style="display:none;"></div>
<hr class="flex-shrink-0 my-3">
<!-- Articles existants -->
<h6 class="mb-2 flex-shrink-0"><i class="bi bi-list-ul me-1"></i>{% translate "Articles" %}</h6>
<div class="table-responsive flex-grow-1" style="overflow-y:auto;min-height:120px;">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Produit" %}</th>
<th>{% translate "Code" %}</th>
<th class="text-center" style="width:140px">{% translate "Quantité" %}</th>
<th style="width:50px"></th>
</tr>
</thead>
<tbody id="editItemsBody">
<tr id="editLoadingRow">
<td colspan="4" class="text-center text-muted py-3">
<div class="spinner-border spinner-border-sm me-2"></div>{% translate "Chargement..." %}
</td>
</tr>
</tbody>
</table>
</div>
<hr class="flex-shrink-0 my-3">
<!-- Autres produits -->
<h6 class="mb-2 flex-shrink-0"><i class="bi bi-pencil-square me-1"></i>{% translate "Autres produits" %}</h6>
<div class="row g-2 align-items-end flex-shrink-0 mb-2">
<div class="col-md-4 col-12">
<label class="form-label small mb-1">{% translate "Description" %}</label>
<input type="text" id="addOtherDesc" class="form-control form-control-sm" placeholder="{% translate 'Description...' %}">
</div>
<div class="col-md-2 col-4">
<label class="form-label small mb-1">{% translate "Quantité" %}</label>
<input type="number" id="addOtherQty" class="form-control form-control-sm" min="1" value="1">
</div>
<div class="col-md-3 col-8">
<label class="form-label small mb-1">{% translate "Catégorie" %}</label>
<select id="addOtherCategory" class="form-select form-select-sm">
<option value="">— {% translate "Choisir..." %} —</option>
{% for cat in categories %}
<option value="{{ cat.id }}" data-code="{{ cat.code }}">{{ cat.name_fr }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 col-12" id="addOtherSignWrapper" style="display:none;">
<label class="form-label small mb-1">{% translate "Type (Sign.)" %}</label>
<input type="text" id="addOtherSignType" class="form-control form-control-sm" placeholder="{% translate 'Code ou nom...' %}" autocomplete="off">
<input type="hidden" id="addOtherSignTypeId">
<div id="addOtherSignDropdown" class="list-group shadow" style="position:fixed;z-index:9999;max-height:200px;overflow-y:auto;display:none;width:auto;"></div>
</div>
</div>
<div class="mb-2 flex-shrink-0">
<button type="button" class="btn btn-sm btn-warning" id="addOtherBtn">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter la ligne" %}
</button>
</div>
<div class="table-responsive flex-grow-1" style="overflow-y:auto;min-height:100px;">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Description" %}</th>
<th>{% translate "Catégorie" %}</th>
<th>{% translate "Type signalisation" %}</th>
<th class="text-center" style="width:130px">{% translate "Quantité" %}</th>
<th style="width:50px"></th>
</tr>
</thead>
<tbody id="editOtherItemsBody">
<tr id="editOtherLoadingRow">
<td colspan="5" class="text-center text-muted py-2">
<div class="spinner-border spinner-border-sm me-2"></div>{% translate "Chargement..." %}
</td>
</tr>
</tbody>
</table>
</div>
<hr class="flex-shrink-0 my-3">
<!-- Documents joints -->
<h6 class="mb-2 flex-shrink-0"><i class="bi bi-paperclip me-1"></i>{% translate "Documents joints" %}</h6>
<div id="editDocsList" class="flex-shrink-0 mb-2">
{% for doc in preparation_order.documents.all %}
{% private_file_url doc 'file' as edit_modal_doc_url %}
<div class="d-flex align-items-center gap-2 border rounded p-1 mb-1" id="edit-doc-{{ doc.pk }}">
<i class="bi {% if doc.is_image %}bi-file-earmark-image text-info{% else %}bi-file-earmark text-secondary{% endif %} fs-5 flex-shrink-0"></i>
<a href="{{ edit_modal_doc_url }}" target="_blank" class="small flex-grow-1 text-truncate">{{ doc.filename }}</a>
<small class="text-muted text-nowrap">{{ doc.uploaded_at|date:"d/m/Y H:i" }}</small>
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-1 btn-delete-doc flex-shrink-0"
data-doc-id="{{ doc.pk }}"
data-delete-url="{% url 'stock:preparation_order_document_delete' doc.pk %}"
title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</div>
{% empty %}
<p class="text-muted fst-italic small mb-1" id="editDocsEmpty">{% translate "Aucun document joint." %}</p>
{% endfor %}
</div>
<div class="d-flex align-items-center gap-2 flex-shrink-0 mt-1">
<label for="editDocInput" class="btn btn-sm btn-outline-secondary mb-0">
<i class="bi bi-paperclip me-1"></i>{% translate "Ajouter un document..." %}
</label>
<input type="file" id="editDocInput" multiple class="d-none"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.odt,.ods,.txt">
<span id="editDocUploading" style="display:none;" class="text-muted small">
<div class="spinner-border spinner-border-sm me-1"></div>{% translate "Envoi en cours..." %}
</span>
</div>
<div id="editDocError" class="alert alert-danger mt-2 py-1 flex-shrink-0" style="display:none;"></div>
</div>
<div class="modal-footer flex-shrink-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="closeEditModal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
<script>
(function () {
const EDIT_URL = '{% url "stock:preparation_order_edit" preparation_order.pk %}';
const SEARCH_URL = '{% url "stock:products_autocomplete_api" %}';
const SIGN_TYPES_URL = '{% url "stock:sign_panel_types_api" %}';
const DOC_UPLOAD_URL = '{% url "stock:preparation_order_document_upload" preparation_order.pk %}';
const CSRF = '{{ csrf_token }}';
// IDs des catégories signalisation (transmis depuis le contexte Django)
const signalisationIds = new Set([{% for cat in categories %}{% if 'signalisation' in cat.code|lower or 'signalisation' in cat.name_fr|lower %}{{ cat.id }},{% endif %}{% endfor %}]);
let hasChanges = false;
function showError(msg) {
const el = document.getElementById('editErrorAlert');
if (el) {
el.textContent = msg;
el.style.display = 'block';
}
}
function hideError() {
const el = document.getElementById('editErrorAlert');
if (el) el.style.display = 'none';
}
function markChanged() {
hasChanges = true;
}
function buildRow(item) {
const locked = item.has_production_order && !item.can_edit;
const lockBadge = item.has_production_order
? (locked
? `<span class="badge bg-danger ms-1" title="Bon de confection ${item.production_order_code} non modifiable (${item.production_order_status})"><i class="bi bi-lock-fill"></i> ${item.production_order_code}</span>`
: `<span class="badge bg-warning text-dark ms-1" title="Bon de confection ${item.production_order_code} (à traiter)"><i class="bi bi-tools"></i> ${item.production_order_code}</span>`)
: '';
return `<tr id="edit-row-${item.id}" class="${locked ? 'table-secondary' : ''}">
<td>${item.product_name} ${lockBadge}</td>
<td><code>${item.product_code}</code></td>
<td class="text-center">
<input type="number" class="form-control form-control-sm qty-input mx-auto text-center" data-item-id="${item.id}" value="${item.quantity}" min="1" style="max-width:90px" ${locked ? 'disabled' : ''}>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger btn-remove" data-item-id="${item.id}" title="${locked ? 'Suppression impossible : bon de confection en cours' : 'Supprimer'}" ${locked ? 'disabled' : ''}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`;
}
function buildOtherRow(oi) {
const locked = oi.has_production_order && !oi.can_edit;
const lockBadge = oi.has_production_order
? (locked
? `<span class="badge bg-danger ms-1" title="Bon de confection ${oi.production_order_code} non modifiable (${oi.production_order_status})"><i class="bi bi-lock-fill"></i> ${oi.production_order_code}</span>`
: `<span class="badge bg-warning text-dark ms-1" title="Bon de confection ${oi.production_order_code} (à traiter)"><i class="bi bi-tools"></i> ${oi.production_order_code}</span>`)
: '';
const catLabel = oi.category_name ? `<span class="badge bg-secondary">${oi.category_name}</span>` : '<span class="text-muted">—</span>';
const signLabel = oi.sign_panel_type_text || '—';
return `<tr id="edit-other-row-${oi.id}" class="${locked ? 'table-secondary' : ''}">
<td>${oi.description} ${lockBadge}</td>
<td>${catLabel}</td>
<td><small class="text-muted">${signLabel}</small></td>
<td class="text-center">
<input type="number" class="form-control form-control-sm other-qty-input mx-auto text-center" data-other-id="${oi.id}" value="${oi.quantity}" min="1" style="max-width:80px" ${locked ? 'disabled' : ''}>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger btn-remove-other" data-other-id="${oi.id}" title="${locked ? 'Suppression impossible : bon de confection en cours' : 'Supprimer'}" ${locked ? 'disabled' : ''}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`;
}
function loadItems() {
const tbody = document.getElementById('editItemsBody');
const otherBody = document.getElementById('editOtherItemsBody');
if (tbody) {
tbody.innerHTML = '<tr id="editLoadingRow"><td colspan="4" class="text-center text-muted py-3"><div class="spinner-border spinner-border-sm me-2"></div></td></tr>';
}
if (otherBody) {
otherBody.innerHTML = '<tr id="editOtherLoadingRow"><td colspan="5" class="text-center text-muted py-2"><div class="spinner-border spinner-border-sm me-2"></div></td></tr>';
}
fetch(EDIT_URL, {headers: {'X-Requested-With': 'XMLHttpRequest'}})
.then(r => r.json())
.then(data => {
if (tbody) {
tbody.innerHTML = (data.items && data.items.length)
? data.items.map(buildRow).join('')
: '<tr><td colspan="4" class="text-center text-muted py-3">{% translate "Aucun article" %}</td></tr>';
}
if (otherBody) {
otherBody.innerHTML = (data.other_items && data.other_items.length)
? data.other_items.map(buildOtherRow).join('')
: '<tr><td colspan="5" class="text-center text-muted py-2">{% translate "Aucune ligne" %}</td></tr>';
}
});
}
function apiPost(payload) {
return fetch(EDIT_URL, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': CSRF},
body: JSON.stringify(payload),
}).then(r => r.json());
}
const modalEl = document.getElementById('editPreparationOrderModal');
modalEl?.addEventListener('show.bs.modal', function () {
hideError();
hasChanges = false;
loadItems();
});
function reloadIfNeeded() {
if (hasChanges) {
const url = new URL(window.location.href);
url.searchParams.delete('open_edit');
window.location.href = url.pathname + (url.search ? url.search : '');
}
}
document.getElementById('closeEditModal')?.addEventListener('click', reloadIfNeeded);
modalEl?.addEventListener('hidden.bs.modal', reloadIfNeeded);
// Auto-open modal if ?open_edit=1 in URL
if (new URLSearchParams(window.location.search).get('open_edit') === '1') {
if (modalEl && typeof bootstrap !== 'undefined') {
const m = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
m.show();
try {
const cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete('open_edit');
window.history.replaceState({}, '', cleanUrl.pathname + (cleanUrl.search ? cleanUrl.search : ''));
} catch (e) {}
}
}
const modalBody = modalEl?.querySelector('.modal-body');
modalBody?.addEventListener('scroll', function () {
if (dropdown && dropdown.style.display !== 'none') positionDropdown();
if (signDropdown && signDropdown.style.display !== 'none') positionSignDropdown();
});
window.addEventListener('resize', function () {
if (dropdown && dropdown.style.display !== 'none') positionDropdown();
if (signDropdown && signDropdown.style.display !== 'none') positionSignDropdown();
});
// ---- Auto-sauvegarde des quantités au changement (Ajax) ----
document.getElementById('editItemsBody')?.addEventListener('change', function (e) {
const input = e.target.closest('.qty-input');
if (!input) return;
hideError();
const itemId = input.dataset.itemId;
const qty = parseInt(input.value);
if (!qty || qty < 1) { showError('{% translate "La quantité doit être supérieure à 0." %}'); return; }
apiPost({action: 'update_quantity', item_id: itemId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
input.value = data.quantity;
input.classList.add('is-valid');
setTimeout(() => input.classList.remove('is-valid'), 1200);
markChanged();
});
});
document.getElementById('editOtherItemsBody')?.addEventListener('change', function (e) {
const input = e.target.closest('.other-qty-input');
if (!input) return;
hideError();
const oiId = input.dataset.otherId;
const qty = parseInt(input.value);
if (!qty || qty < 1) { showError('{% translate "La quantité doit être supérieure à 0." %}'); return; }
apiPost({action: 'update_other_item', item_id: oiId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
input.value = data.quantity;
input.classList.add('is-valid');
setTimeout(() => input.classList.remove('is-valid'), 1200);
markChanged();
});
});
// ---- Suppression d'un article catalogue ----
document.getElementById('editItemsBody')?.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-remove');
if (!btn || btn.disabled) return;
const itemId = btn.dataset.itemId;
if (!confirm('{% translate "Supprimer cet article du bon ?" %}')) return;
hideError();
btn.disabled = true;
apiPost({action: 'remove_item', item_id: itemId}).then(data => {
if (data.error) { showError(data.error); btn.disabled = false; return; }
const row = document.getElementById(`edit-row-${itemId}`);
if (row) row.remove();
const tbody = document.getElementById('editItemsBody');
if (!tbody.querySelector('tr[id^="edit-row-"]')) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">{% translate "Aucun article" %}</td></tr>';
}
markChanged();
});
});
// ---- Suppression d'un autre produit ----
document.getElementById('editOtherItemsBody')?.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-remove-other');
if (!btn || btn.disabled) return;
const oiId = btn.dataset.otherId;
if (!confirm('{% translate "Supprimer cette ligne ?" %}')) return;
hideError();
btn.disabled = true;
apiPost({action: 'remove_other_item', item_id: oiId}).then(data => {
if (data.error) { showError(data.error); btn.disabled = false; return; }
const row = document.getElementById(`edit-other-row-${oiId}`);
if (row) row.remove();
const otherBody = document.getElementById('editOtherItemsBody');
if (!otherBody.querySelector('tr[id^="edit-other-row-"]')) {
otherBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-2">{% translate "Aucune ligne" %}</td></tr>';
}
markChanged();
});
});
// ---- Catégorie -> affichage sélecteur type signalisation ----
const categorySelect = document.getElementById('addOtherCategory');
const signWrapper = document.getElementById('addOtherSignWrapper');
const signTypeInput = document.getElementById('addOtherSignType');
const signDropdown = document.getElementById('addOtherSignDropdown');
let signTypeTimeout;
function positionSignDropdown() {
if (!signTypeInput || !signDropdown) return;
const rect = signTypeInput.getBoundingClientRect();
signDropdown.style.left = rect.left + 'px';
signDropdown.style.top = (rect.bottom + 2) + 'px';
signDropdown.style.width = Math.min(rect.width, window.innerWidth - rect.left - 10) + 'px';
}
categorySelect?.addEventListener('change', function () {
const catId = parseInt(this.value);
if (signalisationIds.has(catId)) {
signWrapper.style.display = '';
} else {
signWrapper.style.display = 'none';
if (signTypeInput) signTypeInput.value = '';
const idInput = document.getElementById('addOtherSignTypeId');
if (idInput) idInput.value = '';
if (signDropdown) signDropdown.style.display = 'none';
}
});
signTypeInput?.addEventListener('input', function () {
clearTimeout(signTypeTimeout);
const idInput = document.getElementById('addOtherSignTypeId');
if (idInput) idInput.value = '';
const q = this.value.trim();
if (q.length < 1) { if (signDropdown) signDropdown.style.display = 'none'; return; }
signTypeTimeout = setTimeout(() => {
fetch(`${SIGN_TYPES_URL}?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(data => {
const results = data.results || [];
if (signDropdown) {
signDropdown.innerHTML = results.map(pt =>
`<a href="#" class="list-group-item list-group-item-action py-1" data-id="${pt.id}" data-text="${pt.text}">
<strong>${pt.code}</strong> — <small class="text-muted">${pt.name}</small>
</a>`
).join('') || '<div class="list-group-item text-muted small">{% translate "Aucun type trouvé" %}</div>';
if (results.length) {
positionSignDropdown();
signDropdown.style.display = 'block';
} else {
signDropdown.style.display = 'none';
}
}
});
}, 250);
});
signDropdown?.addEventListener('click', function (e) {
const item = e.target.closest('a[data-id]');
if (!item) return;
e.preventDefault();
const idInput = document.getElementById('addOtherSignTypeId');
if (idInput) idInput.value = item.dataset.id;
if (signTypeInput) signTypeInput.value = item.dataset.text;
signDropdown.style.display = 'none';
});
document.addEventListener('click', function (e) {
if (!e.target.closest('#addOtherSignType') && !e.target.closest('#addOtherSignDropdown')) {
if (signDropdown) signDropdown.style.display = 'none';
}
});
// ---- Add other item ----
document.getElementById('addOtherBtn')?.addEventListener('click', function () {
hideError();
const desc = (document.getElementById('addOtherDesc').value || '').trim();
const qty = parseInt(document.getElementById('addOtherQty').value) || 1;
const categoryId = document.getElementById('addOtherCategory').value || null;
const signTypeId = document.getElementById('addOtherSignTypeId')?.value || null;
if (!desc) { showError('{% translate "Veuillez saisir une description." %}'); return; }
apiPost({
action: 'add_other_item',
description: desc,
quantity: qty,
category_id: categoryId,
sign_panel_type_id: signTypeId,
}).then(data => {
if (data.error) { showError(data.error); return; }
const otherBody = document.getElementById('editOtherItemsBody');
if (otherBody && otherBody.querySelector('td[colspan]')) otherBody.innerHTML = '';
if (otherBody) otherBody.insertAdjacentHTML('beforeend', buildOtherRow(data));
document.getElementById('addOtherDesc').value = '';
document.getElementById('addOtherQty').value = 1;
document.getElementById('addOtherCategory').value = '';
document.getElementById('addOtherSignWrapper').style.display = 'none';
if (document.getElementById('addOtherSignType')) document.getElementById('addOtherSignType').value = '';
if (document.getElementById('addOtherSignTypeId')) document.getElementById('addOtherSignTypeId').value = '';
markChanged();
});
});
// ---- Autocomplete produit ----
const searchInput = document.getElementById('addProductSearch');
const dropdown = document.getElementById('addProductDropdown');
const productIdInput = document.getElementById('addProductId');
let searchTimeout;
function positionDropdown() {
if (!searchInput || !dropdown) return;
const rect = searchInput.getBoundingClientRect();
dropdown.style.left = rect.left + 'px';
dropdown.style.top = (rect.bottom + 2) + 'px';
dropdown.style.width = Math.min(rect.width, window.innerWidth - rect.left - 10) + 'px';
}
searchInput?.addEventListener('input', function () {
clearTimeout(searchTimeout);
if (productIdInput) productIdInput.value = '';
const q = this.value.trim();
if (q.length < 2) { if (dropdown) dropdown.style.display = 'none'; return; }
searchTimeout = setTimeout(() => {
fetch(`${SEARCH_URL}?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(data => {
const results = data.results || data;
if (dropdown) {
dropdown.innerHTML = results.map(p =>
`<a href="#" class="list-group-item list-group-item-action py-1" data-id="${p.id}" data-name="${p.text}" data-code="${p.code || ''}">
<strong>${p.text}</strong>${p.code ? ' <small class="text-muted">('+p.code+')</small>' : ''}
</a>`
).join('');
if (results.length) {
positionDropdown();
dropdown.style.display = 'block';
} else {
dropdown.style.display = 'none';
}
}
});
}, 250);
});
dropdown?.addEventListener('click', function (e) {
const item = e.target.closest('a[data-id]');
if (!item) return;
e.preventDefault();
if (productIdInput) productIdInput.value = item.dataset.id;
if (searchInput) searchInput.value = item.dataset.name + (item.dataset.code ? ' (' + item.dataset.code + ')' : '');
dropdown.style.display = 'none';
});
document.addEventListener('click', function (e) {
if (!e.target.closest('#addProductSearch') && !e.target.closest('#addProductDropdown')) {
if (dropdown) dropdown.style.display = 'none';
}
});
document.getElementById('addProductBtn')?.addEventListener('click', function () {
hideError();
const pId = productIdInput ? productIdInput.value : '';
const qty = parseInt(document.getElementById('addProductQty').value) || 1;
if (!pId) { showError('{% translate "Veuillez sélectionner un produit dans la liste." %}'); return; }
apiPost({action: 'add_item', product_id: pId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
const tbody = document.getElementById('editItemsBody');
if (!tbody) return;
const existingRow = document.getElementById(`edit-row-${data.id}`);
if (existingRow) {
const qtyInput = existingRow.querySelector('.qty-input');
if (qtyInput) {
qtyInput.value = data.quantity;
qtyInput.classList.add('is-valid');
setTimeout(() => qtyInput.classList.remove('is-valid'), 1200);
}
} else {
if (tbody.querySelector('td[colspan]')) tbody.innerHTML = '';
tbody.insertAdjacentHTML('beforeend', buildRow(data));
}
if (searchInput) searchInput.value = '';
if (productIdInput) productIdInput.value = '';
document.getElementById('addProductQty').value = 1;
markChanged();
});
});
// ---- Documents ----
function buildDocRow(doc) {
const icon = doc.is_image ? 'bi-file-earmark-image text-info' : 'bi-file-earmark text-secondary';
return `<div class="d-flex align-items-center gap-2 border rounded p-1 mb-1" id="edit-doc-${doc.id}">
<i class="bi ${icon} fs-5 flex-shrink-0"></i>
<a href="${doc.url}" target="_blank" class="small flex-grow-1 text-truncate">${doc.filename}</a>
<small class="text-muted text-nowrap">${doc.uploaded_at}</small>
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-1 btn-delete-doc flex-shrink-0"
data-doc-id="${doc.id}"
data-delete-url="${doc.delete_url}"
title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</div>`;
}
document.getElementById('editDocInput')?.addEventListener('change', function () {
const files = this.files;
if (!files || !files.length) return;
const uploading = document.getElementById('editDocUploading');
const errorEl = document.getElementById('editDocError');
if (errorEl) errorEl.style.display = 'none';
if (uploading) uploading.style.display = '';
this.disabled = true;
const fd = new FormData();
Array.from(files).forEach(f => fd.append('documents', f));
fetch(DOC_UPLOAD_URL, {
method: 'POST',
headers: {'X-CSRFToken': CSRF},
body: fd,
})
.then(r => r.json())
.then(data => {
if (uploading) uploading.style.display = 'none';
if (data.error) {
if (errorEl) {
errorEl.textContent = data.error;
errorEl.style.display = '';
}
return;
}
const list = document.getElementById('editDocsList');
const empty = document.getElementById('editDocsEmpty');
if (empty) empty.remove();
if (list) {
data.created.forEach(doc => {
list.insertAdjacentHTML('beforeend', buildDocRow(doc));
});
}
markChanged();
})
.catch(() => {
if (uploading) uploading.style.display = 'none';
{% translate "Erreur lors de l\'envoi." as error_msg %}
if (errorEl) {
errorEl.textContent = '{{ error_msg|escapejs }}';
errorEl.style.display = '';
}
})
.finally(() => { this.disabled = false; this.value = ''; });
});
document.getElementById('editDocsList')?.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-delete-doc');
if (!btn) return;
if (!confirm('{% translate "Supprimer ce document ?" %}')) return;
const docId = btn.dataset.docId;
const deleteUrl = btn.dataset.deleteUrl;
fetch(deleteUrl, {
method: 'POST',
headers: {'X-CSRFToken': CSRF},
})
.then(r => r.json())
.then(data => {
if (data.error) {
const errorEl = document.getElementById('editDocError');
if (errorEl) {
errorEl.textContent = data.error;
errorEl.style.display = '';
}
return;
}
const row = document.getElementById(`edit-doc-${docId}`);
if (row) row.remove();
const list = document.getElementById('editDocsList');
if (list && !list.querySelector('[id^="edit-doc-"]')) {
list.insertAdjacentHTML('afterbegin',
'<p class="text-muted fst-italic small mb-1" id="editDocsEmpty">{% translate "Aucun document joint." %}</p>');
}
markChanged();
});
});
})();
</script>
{% endif %}

View file

@ -260,9 +260,16 @@
<!-- Produits du stock -->
{% if items_with_stock %}
<div class="section-title mt-2">
<div class="d-flex justify-content-between align-items-center mt-2 mb-1">
<div class="section-title mb-0">
<i class="bi bi-box-seam"></i>{% translate "Produits demandés" %}
<span class="badge bg-secondary ms-1" style="font-size:0.7rem;">{{ items_with_stock|length }}</span>
</div>
{% if can_edit %}
<button type="button" class="btn btn-sm btn-outline-warning" data-bs-toggle="modal" data-bs-target="#editPreparationOrderModal">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier" %}
</button>
{% endif %}
</div>
{% for item_data in items_with_stock %}
<div class="product-card {% if not item_data.is_sufficient and preparation_order.status != 'processed' and preparation_order.status != 'cancelled' %}insufficient{% endif %}">
@ -481,9 +488,9 @@
<i class="bi bi-check2-circle me-1"></i>{% translate "Signaler comme traité" %}
</button>
{% if can_edit %}
<a href="{% url 'stock:preparation_order_detail' preparation_order.pk %}?open_edit=1" class="btn btn-outline-warning">
<button type="button" class="btn btn-outline-warning" data-bs-toggle="modal" data-bs-target="#editPreparationOrderModal">
<i class="bi bi-pencil-square me-1"></i>{% translate "Modifier les articles" %}
</a>
</button>
{% endif %}
{% if can_manage_purchase %}
<a href="{% url 'stock:preparation_order_create_purchase' preparation_order.pk %}" class="btn btn-primary">
@ -501,9 +508,9 @@
{% endif %}
{% elif preparation_order.status == 'processed' %}
{% if can_edit %}
<a href="{% url 'stock:preparation_order_detail' preparation_order.pk %}?open_edit=1" class="btn btn-outline-primary">
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editPreparationOrderModal">
<i class="bi bi-pencil-square me-1"></i>{% translate "Modifier le bon" %}
</a>
</button>
{% endif %}
{% if has_pending_stock_adjustments %}
<a href="{% url 'stock:preparation_order_process' preparation_order.pk %}" class="btn btn-warning">
@ -518,6 +525,7 @@
<form method="post" action="{% url 'stock:preparation_order_reopen' preparation_order.pk %}"
onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir réouvrir ce bon de préparation ?" %}');">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'mobile:stock_preparation_order_detail_mobile' preparation_order.pk %}">
<button type="submit" class="btn btn-outline-warning w-100">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Réouvrir le bon" %}
</button>
@ -544,6 +552,7 @@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Retour" %}</button>
<form method="post" action="{% url 'stock:preparation_order_cancel' preparation_order.pk %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'mobile:stock_preparation_order_detail_mobile' preparation_order.pk %}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-x-circle me-1"></i>{% translate "Confirmer" %}
</button>
@ -1270,4 +1279,6 @@ document.addEventListener('DOMContentLoaded', function () {
});
})();
</script>
{% include "stock/_edit_preparation_order_modal.html" %}
{% endblock content %}

View file

@ -858,644 +858,7 @@
</div>
</div>
{% if can_edit %}
<!-- Modale de modification du bon de préparation -->
<div class="modal fade" id="editPreparationOrderModal" tabindex="-1" aria-labelledby="editPreparationOrderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl" style="height:90vh;max-height:90vh;">
<div class="modal-content h-100 d-flex flex-column">
<div class="modal-header bg-warning flex-shrink-0">
<h5 class="modal-title" id="editPreparationOrderModalLabel">
<i class="bi bi-pencil-square me-2"></i>{% translate "Modifier le bon de préparation" %} #{{ preparation_order.order_number }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body flex-grow-1 d-flex flex-column" style="overflow:visible;min-height:0;">
<!-- Ajouter un produit -->
<h6 class="mb-3 flex-shrink-0"><i class="bi bi-plus-circle me-1"></i>{% translate "Ajouter un produit" %}</h6>
<div class="row g-2 align-items-end flex-shrink-0">
<div class="col-md-6">
<label class="form-label">{% translate "Produit" %}</label>
<input type="text" id="addProductSearch" class="form-control" placeholder="{% translate 'Rechercher un produit...' %}" autocomplete="off">
<input type="hidden" id="addProductId">
<div id="addProductDropdown" class="list-group shadow" style="position:fixed;z-index:9999;max-height:220px;overflow-y:auto;display:none;width:auto;"></div>
</div>
<div class="col-md-3">
<label class="form-label">{% translate "Quantité" %}</label>
<input type="number" id="addProductQty" class="form-control" min="1" value="1">
</div>
<div class="col-md-3">
<button type="button" class="btn btn-primary w-100" id="addProductBtn">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter" %}
</button>
</div>
</div>
<div id="editErrorAlert" class="alert alert-danger mt-3 mb-0 flex-shrink-0" style="display:none;"></div>
<hr class="flex-shrink-0">
<!-- Articles existants -->
<h6 class="mb-3 flex-shrink-0"><i class="bi bi-list-ul me-1"></i>{% translate "Articles" %}</h6>
<div class="table-responsive flex-grow-1" style="overflow-y:auto;min-height:0;">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>{% translate "Produit" %}</th>
<th>{% translate "Code" %}</th>
<th class="text-center" style="width:160px">{% translate "Quantité" %}</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody id="editItemsBody">
<tr id="editLoadingRow">
<td colspan="4" class="text-center text-muted py-3">
<div class="spinner-border spinner-border-sm me-2"></div>{% translate "Chargement..." %}
</td>
</tr>
</tbody>
</table>
</div>
<hr class="flex-shrink-0 mt-3">
<!-- Autres produits -->
<h6 class="mb-2 flex-shrink-0"><i class="bi bi-pencil-square me-1"></i>{% translate "Autres produits" %}</h6>
<!-- Formulaire d'ajout -->
<div class="row g-2 align-items-end flex-shrink-0 mb-2">
<div class="col-md-4">
<label class="form-label small">{% translate "Description" %}</label>
<input type="text" id="addOtherDesc" class="form-control form-control-sm" placeholder="{% translate 'Description...' %}">
</div>
<div class="col-md-2">
<label class="form-label small">{% translate "Quantité" %}</label>
<input type="number" id="addOtherQty" class="form-control form-control-sm" min="1" value="1">
</div>
<div class="col-md-3">
<label class="form-label small">{% translate "Catégorie" %}</label>
<select id="addOtherCategory" class="form-select form-select-sm">
<option value="">— {% translate "Choisir..." %} —</option>
{% for cat in categories %}
<option value="{{ cat.id }}" data-code="{{ cat.code }}">{{ cat.name_fr }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3" id="addOtherSignWrapper" style="display:none;">
<label class="form-label small">{% translate "Type (Sign.)" %}</label>
<input type="text" id="addOtherSignType" class="form-control form-control-sm" placeholder="{% translate 'Code ou nom...' %}" autocomplete="off">
<input type="hidden" id="addOtherSignTypeId">
<div id="addOtherSignDropdown" class="list-group shadow" style="position:fixed;z-index:9999;max-height:200px;overflow-y:auto;display:none;width:auto;"></div>
</div>
</div>
<div class="mb-2 flex-shrink-0">
<button type="button" class="btn btn-sm btn-warning" id="addOtherBtn">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter la ligne" %}
</button>
</div>
<!-- Table autres items -->
<div class="table-responsive flex-shrink-0" style="max-height:220px;overflow-y:auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-warning">
<tr>
<th>{% translate "Description" %}</th>
<th>{% translate "Catégorie" %}</th>
<th>{% translate "Type (Sign.)" %}</th>
<th class="text-center" style="width:120px">{% translate "Quantité" %}</th>
<th style="width:50px"></th>
</tr>
</thead>
<tbody id="editOtherItemsBody">
<tr id="editOtherLoadingRow">
<td colspan="5" class="text-center text-muted py-2">
<div class="spinner-border spinner-border-sm me-2"></div>
</td>
</tr>
</tbody>
</table>
</div>
<hr class="flex-shrink-0 mt-3">
<!-- Documents -->
<h6 class="mb-2 flex-shrink-0"><i class="bi bi-paperclip me-1"></i>{% translate "Documents" %}</h6>
<div id="editDocsList" class="flex-shrink-0" style="max-height:150px;overflow-y:auto;">
{% for doc in preparation_order.documents.all %}
{% private_file_url doc 'file' as edit_modal_doc_url %}
<div class="d-flex align-items-center gap-2 border rounded p-1 mb-1" id="edit-doc-{{ doc.pk }}">
<i class="bi {% if doc.is_image %}bi-file-earmark-image text-info{% else %}bi-file-earmark text-secondary{% endif %} fs-5 flex-shrink-0"></i>
<a href="{{ edit_modal_doc_url }}" target="_blank" class="small flex-grow-1 text-truncate">{{ doc.filename }}</a>
<small class="text-muted text-nowrap">{{ doc.uploaded_at|date:"d/m/Y H:i" }}</small>
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-1 btn-delete-doc flex-shrink-0"
data-doc-id="{{ doc.pk }}"
data-delete-url="{% url 'stock:preparation_order_document_delete' doc.pk %}"
title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</div>
{% empty %}
<p class="text-muted fst-italic small mb-1" id="editDocsEmpty">{% translate "Aucun document joint." %}</p>
{% endfor %}
</div>
<div class="d-flex align-items-center gap-2 flex-shrink-0 mt-1">
<label for="editDocInput" class="btn btn-sm btn-outline-secondary mb-0">
<i class="bi bi-paperclip me-1"></i>{% translate "Ajouter un document..." %}
</label>
<input type="file" id="editDocInput" multiple class="d-none"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.odt,.ods,.txt">
<span id="editDocUploading" style="display:none;" class="text-muted small">
<div class="spinner-border spinner-border-sm me-1"></div>{% translate "Envoi en cours..." %}
</span>
</div>
<div id="editDocError" class="alert alert-danger mt-2 py-1 flex-shrink-0" style="display:none;"></div>
</div>
<div class="modal-footer flex-shrink-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="closeEditModal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
<script>
(function () {
const EDIT_URL = '{% url "stock:preparation_order_edit" preparation_order.pk %}';
const SEARCH_URL = '{% url "stock:products_autocomplete_api" %}';
const SIGN_TYPES_URL = '{% url "stock:sign_panel_types_api" %}';
const DOC_UPLOAD_URL = '{% url "stock:preparation_order_document_upload" preparation_order.pk %}';
const CSRF = '{{ csrf_token }}';
// IDs des catégories signalisation (transmis depuis le contexte Django)
const signalisationIds = new Set([{% for cat in categories %}{% if 'signalisation' in cat.code|lower or 'signalisation' in cat.name_fr|lower %}{{ cat.id }},{% endif %}{% endfor %}]);
let hasChanges = false;
function showError(msg) {
const el = document.getElementById('editErrorAlert');
el.textContent = msg;
el.style.display = 'block';
}
function hideError() {
document.getElementById('editErrorAlert').style.display = 'none';
}
function markChanged() {
hasChanges = true;
}
function buildRow(item) {
const locked = item.has_production_order && !item.can_edit;
const lockBadge = item.has_production_order
? (locked
? `<span class="badge bg-danger ms-1" title="Bon de confection ${item.production_order_code} non modifiable (${item.production_order_status})"><i class="bi bi-lock-fill"></i> ${item.production_order_code}</span>`
: `<span class="badge bg-warning text-dark ms-1" title="Bon de confection ${item.production_order_code} (à traiter)"><i class="bi bi-tools"></i> ${item.production_order_code}</span>`)
: '';
return `<tr id="edit-row-${item.id}" class="${locked ? 'table-secondary' : ''}">
<td>${item.product_name} ${lockBadge}</td>
<td><code>${item.product_code}</code></td>
<td class="text-center">
<input type="number" class="form-control form-control-sm qty-input mx-auto text-center" data-item-id="${item.id}" value="${item.quantity}" min="1" style="max-width:90px" ${locked ? 'disabled' : ''}>
</td>
<td>
<button class="btn btn-sm btn-outline-danger btn-remove" data-item-id="${item.id}" title="${locked ? 'Suppression impossible : bon de confection en cours' : 'Supprimer'}" ${locked ? 'disabled' : ''}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`;
}
function buildOtherRow(oi) {
const locked = oi.has_production_order && !oi.can_edit;
const lockBadge = oi.has_production_order
? (locked
? `<span class="badge bg-danger ms-1" title="Bon de confection ${oi.production_order_code} non modifiable (${oi.production_order_status})"><i class="bi bi-lock-fill"></i> ${oi.production_order_code}</span>`
: `<span class="badge bg-warning text-dark ms-1" title="Bon de confection ${oi.production_order_code} (à traiter)"><i class="bi bi-tools"></i> ${oi.production_order_code}</span>`)
: '';
const catLabel = oi.category_name ? `<span class="badge bg-secondary">${oi.category_name}</span>` : '<span class="text-muted">—</span>';
const signLabel = oi.sign_panel_type_text || '—';
return `<tr id="edit-other-row-${oi.id}" class="${locked ? 'table-secondary' : ''}">
<td>${oi.description} ${lockBadge}</td>
<td>${catLabel}</td>
<td><small class="text-muted">${signLabel}</small></td>
<td class="text-center">
<input type="number" class="form-control form-control-sm other-qty-input mx-auto text-center" data-other-id="${oi.id}" value="${oi.quantity}" min="1" style="max-width:80px" ${locked ? 'disabled' : ''}>
</td>
<td>
<button class="btn btn-sm btn-outline-danger btn-remove-other" data-other-id="${oi.id}" title="${locked ? 'Suppression impossible : bon de confection en cours' : 'Supprimer'}" ${locked ? 'disabled' : ''}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`;
}
function loadItems() {
document.getElementById('editItemsBody').innerHTML =
'<tr id="editLoadingRow"><td colspan="4" class="text-center text-muted py-3"><div class="spinner-border spinner-border-sm me-2"></div></td></tr>';
document.getElementById('editOtherItemsBody').innerHTML =
'<tr id="editOtherLoadingRow"><td colspan="5" class="text-center text-muted py-2"><div class="spinner-border spinner-border-sm me-2"></div></td></tr>';
fetch(EDIT_URL, {headers: {'X-Requested-With': 'XMLHttpRequest'}})
.then(r => r.json())
.then(data => {
const tbody = document.getElementById('editItemsBody');
tbody.innerHTML = data.items.length
? data.items.map(buildRow).join('')
: '<tr><td colspan="4" class="text-center text-muted py-3">{% translate "Aucun article" %}</td></tr>';
const otherBody = document.getElementById('editOtherItemsBody');
otherBody.innerHTML = (data.other_items && data.other_items.length)
? data.other_items.map(buildOtherRow).join('')
: '<tr><td colspan="5" class="text-center text-muted py-2">{% translate "Aucune ligne" %}</td></tr>';
});
}
function apiPost(payload) {
return fetch(EDIT_URL, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': CSRF},
body: JSON.stringify(payload),
}).then(r => r.json());
}
document.getElementById('editPreparationOrderModal')?.addEventListener('show.bs.modal', function () {
hideError();
hasChanges = false;
loadItems();
});
function reloadIfNeeded() {
if (hasChanges) {
const url = new URL(window.location.href);
url.searchParams.delete('open_edit');
window.location.href = url.pathname + (url.search ? url.search : '');
}
}
document.getElementById('closeEditModal')?.addEventListener('click', reloadIfNeeded);
document.getElementById('editPreparationOrderModal')?.addEventListener('hidden.bs.modal', reloadIfNeeded);
// ---- Auto-sauvegarde des quantités au changement (Ajax) ----
document.getElementById('editItemsBody')?.addEventListener('change', function (e) {
const input = e.target.closest('.qty-input');
if (!input) return;
hideError();
const itemId = input.dataset.itemId;
const qty = parseInt(input.value);
if (!qty || qty < 1) { showError('{% translate "La quantité doit être supérieure à 0." %}'); return; }
apiPost({action: 'update_quantity', item_id: itemId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
input.value = data.quantity;
input.classList.add('is-valid');
setTimeout(() => input.classList.remove('is-valid'), 1200);
markChanged();
});
});
document.getElementById('editOtherItemsBody')?.addEventListener('change', function (e) {
const input = e.target.closest('.other-qty-input');
if (!input) return;
hideError();
const otherId = input.dataset.otherId;
const qty = parseInt(input.value);
if (!qty || qty < 1) { showError('{% translate "La quantité doit être supérieure à 0." %}'); return; }
apiPost({action: 'update_other_item', item_id: otherId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
input.value = data.quantity;
input.classList.add('is-valid');
setTimeout(() => input.classList.remove('is-valid'), 1200);
markChanged();
});
});
// ---- Suppressions d'articles dans la modale ----
document.getElementById('editItemsBody')?.addEventListener('click', function (e) {
hideError();
const removeBtn = e.target.closest('.btn-remove');
if (removeBtn) {
const itemId = removeBtn.dataset.itemId;
if (!confirm('{% translate "Supprimer cet article du bon ?" %}')) return;
apiPost({action: 'remove_item', item_id: itemId}).then(data => {
if (data.error) { showError(data.error); return; }
document.getElementById(`edit-row-${itemId}`)?.remove();
markChanged();
const tbody = document.getElementById('editItemsBody');
if (!tbody.querySelector('tr[id^="edit-row-"]')) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">{% translate "Aucun article" %}</td></tr>';
}
});
}
});
document.getElementById('editOtherItemsBody')?.addEventListener('click', function (e) {
hideError();
const removeBtn = e.target.closest('.btn-remove-other');
if (removeBtn) {
const otherId = removeBtn.dataset.otherId;
if (!confirm('{% translate "Supprimer cette ligne ?" %}')) return;
apiPost({action: 'remove_other_item', item_id: otherId}).then(data => {
if (data.error) { showError(data.error); return; }
document.getElementById(`edit-other-row-${otherId}`)?.remove();
markChanged();
const otherBody = document.getElementById('editOtherItemsBody');
if (!otherBody.querySelector('tr[id^="edit-other-row-"]')) {
otherBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-2">{% translate "Aucune ligne" %}</td></tr>';
}
});
}
});
// ---- Bascule interactive l'état "Préparé" depuis le tableau principal ----
document.addEventListener('change', function (e) {
const toggle = e.target.closest('.prepare-toggle-input');
if (!toggle) return;
const itemId = toggle.dataset.itemId;
const isOther = toggle.dataset.isOther === 'true';
const url = isOther
? `/stock/preparation-order-other-items/${itemId}/toggle-prepared/`
: `/stock/preparation-order-items/${itemId}/toggle-prepared/`;
fetch(url, {
method: 'POST',
headers: {'X-CSRFToken': CSRF, 'X-Requested-With': 'XMLHttpRequest'},
})
.then(r => r.json())
.then(data => {
if (!data.success) {
toggle.checked = !toggle.checked;
alert(data.error || 'Erreur lors de la mise à jour');
}
})
.catch(() => {
toggle.checked = !toggle.checked;
});
});
// ---- Ouverture automatique de la modale post-réouverture ----
document.addEventListener('DOMContentLoaded', function () {
const url = new URL(window.location.href);
if (url.searchParams.get('open_edit') === '1') {
const editModal = document.getElementById('editPreparationOrderModal');
if (editModal) {
const modalInstance = bootstrap.Modal.getOrCreateInstance(editModal);
modalInstance.show();
}
// Nettoyer l'URL du navigateur sans recharger la page
url.searchParams.delete('open_edit');
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : ''));
}
});
// ---- Catégorie signalisation toggle ----
document.getElementById('addOtherCategory')?.addEventListener('change', function () {
const catId = parseInt(this.value) || 0;
const wrapper = document.getElementById('addOtherSignWrapper');
if (signalisationIds.has(catId)) {
wrapper.style.display = '';
} else {
wrapper.style.display = 'none';
document.getElementById('addOtherSignType').value = '';
document.getElementById('addOtherSignTypeId').value = '';
document.getElementById('addOtherSignDropdown').style.display = 'none';
}
});
// ---- Autocomplete sign types ----
let signTypeTimeout;
const signTypeInput = document.getElementById('addOtherSignType');
const signDropdown = document.getElementById('addOtherSignDropdown');
function positionSignDropdown() {
const rect = signTypeInput.getBoundingClientRect();
signDropdown.style.left = rect.left + 'px';
signDropdown.style.top = (rect.bottom + 2) + 'px';
signDropdown.style.width = rect.width + 'px';
}
signTypeInput?.addEventListener('input', function () {
clearTimeout(signTypeTimeout);
document.getElementById('addOtherSignTypeId').value = '';
const q = this.value.trim();
if (q.length < 1) { signDropdown.style.display = 'none'; return; }
signTypeTimeout = setTimeout(() => {
fetch(`${SIGN_TYPES_URL}?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(data => {
const results = data.results || [];
signDropdown.innerHTML = results.map(pt =>
`<a href="#" class="list-group-item list-group-item-action py-1" data-id="${pt.id}" data-text="${pt.text}">
<strong>${pt.code}</strong> — <small class="text-muted">${pt.name}</small>
</a>`
).join('') || '<div class="list-group-item text-muted small">{% translate "Aucun type trouvé" %}</div>';
if (results.length) {
positionSignDropdown();
signDropdown.style.display = 'block';
} else {
signDropdown.style.display = 'none';
}
});
}, 250);
});
signDropdown?.addEventListener('click', function (e) {
const item = e.target.closest('a[data-id]');
if (!item) return;
e.preventDefault();
document.getElementById('addOtherSignTypeId').value = item.dataset.id;
signTypeInput.value = item.dataset.text;
signDropdown.style.display = 'none';
});
document.addEventListener('click', function (e) {
if (!e.target.closest('#addOtherSignType') && !e.target.closest('#addOtherSignDropdown')) {
if (signDropdown) signDropdown.style.display = 'none';
}
});
// ---- Add other item ----
document.getElementById('addOtherBtn')?.addEventListener('click', function () {
hideError();
const desc = (document.getElementById('addOtherDesc').value || '').trim();
const qty = parseInt(document.getElementById('addOtherQty').value) || 1;
const categoryId = document.getElementById('addOtherCategory').value || null;
const signTypeId = document.getElementById('addOtherSignTypeId').value || null;
if (!desc) { showError('{% translate "Veuillez saisir une description." %}'); return; }
apiPost({
action: 'add_other_item',
description: desc,
quantity: qty,
category_id: categoryId,
sign_panel_type_id: signTypeId,
}).then(data => {
if (data.error) { showError(data.error); return; }
const otherBody = document.getElementById('editOtherItemsBody');
if (otherBody.querySelector('td[colspan]')) otherBody.innerHTML = '';
otherBody.insertAdjacentHTML('beforeend', buildOtherRow(data));
document.getElementById('addOtherDesc').value = '';
document.getElementById('addOtherQty').value = 1;
document.getElementById('addOtherCategory').value = '';
document.getElementById('addOtherSignWrapper').style.display = 'none';
document.getElementById('addOtherSignType').value = '';
document.getElementById('addOtherSignTypeId').value = '';
markChanged();
});
});
// ---- Autocomplete produit ----
const searchInput = document.getElementById('addProductSearch');
const dropdown = document.getElementById('addProductDropdown');
const productIdInput = document.getElementById('addProductId');
let searchTimeout;
function positionDropdown() {
const rect = searchInput.getBoundingClientRect();
dropdown.style.left = rect.left + 'px';
dropdown.style.top = (rect.bottom + 2) + 'px';
dropdown.style.width = rect.width + 'px';
}
searchInput?.addEventListener('input', function () {
clearTimeout(searchTimeout);
productIdInput.value = '';
const q = this.value.trim();
if (q.length < 2) { dropdown.style.display = 'none'; return; }
searchTimeout = setTimeout(() => {
fetch(`${SEARCH_URL}?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(data => {
const results = data.results || data;
dropdown.innerHTML = results.map(p =>
`<a href="#" class="list-group-item list-group-item-action py-1" data-id="${p.id}" data-name="${p.text}" data-code="${p.code || ''}">
<strong>${p.text}</strong>${p.code ? ' <small class="text-muted">('+p.code+')</small>' : ''}
</a>`
).join('');
if (results.length) {
positionDropdown();
dropdown.style.display = 'block';
} else {
dropdown.style.display = 'none';
}
});
}, 250);
});
dropdown?.addEventListener('click', function (e) {
const item = e.target.closest('a[data-id]');
if (!item) return;
e.preventDefault();
productIdInput.value = item.dataset.id;
searchInput.value = item.dataset.name + (item.dataset.code ? ' (' + item.dataset.code + ')' : '');
dropdown.style.display = 'none';
});
document.addEventListener('click', function (e) {
if (!e.target.closest('#addProductSearch') && !e.target.closest('#addProductDropdown')) {
if (dropdown) dropdown.style.display = 'none';
}
});
document.getElementById('addProductBtn')?.addEventListener('click', function () {
hideError();
const productId = productIdInput.value;
const qty = parseInt(document.getElementById('addProductQty').value) || 1;
if (!productId) { showError('{% translate "Veuillez sélectionner un produit dans la liste." %}'); return; }
apiPost({action: 'add_item', product_id: productId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
const tbody = document.getElementById('editItemsBody');
if (tbody.querySelector('td[colspan]')) tbody.innerHTML = '';
const existing = document.getElementById(`edit-row-${data.id}`);
if (existing) {
existing.querySelector('.qty-input').value = data.quantity;
} else {
tbody.insertAdjacentHTML('beforeend', buildRow(data));
}
searchInput.value = '';
productIdInput.value = '';
document.getElementById('addProductQty').value = 1;
markChanged();
});
});
// ---- Documents ----
function buildDocRow(doc) {
const iconClass = doc.is_image ? 'bi-file-earmark-image text-info' : 'bi-file-earmark text-secondary';
return `<div class="d-flex align-items-center gap-2 border rounded p-1 mb-1" id="edit-doc-${doc.id}">
<i class="bi ${iconClass} fs-5 flex-shrink-0"></i>
<a href="${doc.url}" target="_blank" class="small flex-grow-1 text-truncate">${doc.filename}</a>
<small class="text-muted text-nowrap">${doc.uploaded_at}</small>
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-1 btn-delete-doc flex-shrink-0"
data-doc-id="${doc.id}"
data-delete-url="${doc.delete_url}"
title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</div>`;
}
document.getElementById('editDocInput')?.addEventListener('change', function () {
const files = this.files;
if (!files || !files.length) return;
const uploading = document.getElementById('editDocUploading');
const errorEl = document.getElementById('editDocError');
errorEl.style.display = 'none';
uploading.style.display = '';
this.disabled = true;
const fd = new FormData();
Array.from(files).forEach(f => fd.append('documents', f));
fetch(DOC_UPLOAD_URL, {
method: 'POST',
headers: {'X-CSRFToken': CSRF},
body: fd,
})
.then(r => r.json())
.then(data => {
uploading.style.display = 'none';
if (data.error) {
errorEl.textContent = data.error;
errorEl.style.display = '';
return;
}
const list = document.getElementById('editDocsList');
const empty = document.getElementById('editDocsEmpty');
if (empty) empty.remove();
data.created.forEach(doc => {
list.insertAdjacentHTML('beforeend', buildDocRow(doc));
});
markChanged();
})
.catch(() => {
uploading.style.display = 'none';
{% translate "Erreur lors de l\'envoi." as error_msg %}
errorEl.textContent = '{{ error_msg|escapejs }}';
errorEl.style.display = '';
})
.finally(() => { this.disabled = false; this.value = ''; });
});
document.getElementById('editDocsList')?.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-delete-doc');
if (!btn) return;
if (!confirm('{% translate "Supprimer ce document ?" %}')) return;
const docId = btn.dataset.docId;
const deleteUrl = btn.dataset.deleteUrl;
fetch(deleteUrl, {
method: 'POST',
headers: {'X-CSRFToken': CSRF},
})
.then(r => r.json())
.then(data => {
if (data.error) {
const errorEl = document.getElementById('editDocError');
errorEl.textContent = data.error;
errorEl.style.display = '';
return;
}
const row = document.getElementById(`edit-doc-${docId}`);
if (row) row.remove();
const list = document.getElementById('editDocsList');
if (!list.querySelector('[id^="edit-doc-"]')) {
list.insertAdjacentHTML('afterbegin',
'<p class="text-muted fst-italic small mb-1" id="editDocsEmpty">{% translate "Aucun document joint." %}</p>');
}
markChanged();
});
});
})();
</script>
{% endif %}
{% include "stock/_edit_preparation_order_modal.html" %}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- Documents attachés -->

View file

@ -4142,7 +4142,12 @@ def preparation_order_cancel(request, pk):
else:
messages.error(request, _("Ce bon ne peut pas être annulé."))
return redirect('stock:preparation_order_detail', pk=pk)
is_mobile = 'mobile' in request.META.get('HTTP_REFERER', '') or 'mobile' in request.POST.get('next', '')
detail_url_name = 'mobile:stock_preparation_order_detail_mobile' if is_mobile else 'stock:preparation_order_detail'
next_url = request.POST.get('next')
if next_url:
return redirect(next_url)
return redirect(detail_url_name, pk=pk)
@require_POST
@ -4150,10 +4155,13 @@ def preparation_order_reopen(request, pk):
"""
Réouvre un bon de préparation traité pour permettre sa modification (statut : to_process).
"""
is_mobile = 'mobile' in request.META.get('HTTP_REFERER', '') or 'mobile' in request.POST.get('next', '')
detail_url_name = 'mobile:stock_preparation_order_detail_mobile' if is_mobile else 'stock:preparation_order_detail'
from stock.permissions import user_can_manage_preparation_orders
if not user_can_manage_preparation_orders(request.user):
messages.error(request, _("Vous n'avez pas la permission de réouvrir des bons de préparation."))
return redirect('stock:preparation_order_detail', pk=pk)
return redirect(detail_url_name, pk=pk)
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
@ -4166,11 +4174,11 @@ def preparation_order_reopen(request, pk):
details={'status': 'to_process'},
)
messages.success(request, _("Le bon de préparation #{} a été réouvert (statut : À traiter).").format(preparation_order.order_number))
return redirect(f"{reverse('stock:preparation_order_detail', args=[pk])}?open_edit=1")
return redirect(f"{reverse(detail_url_name, args=[pk])}?open_edit=1")
else:
messages.error(request, _("Ce bon ne peut être réouvert que s'il est au statut Traité."))
return redirect('stock:preparation_order_detail', pk=pk)
return redirect(detail_url_name, pk=pk)