feat: add mobile views, stock management views, and preparation order templates
This commit is contained in:
parent
f429838a39
commit
25a64b824a
5 changed files with 710 additions and 650 deletions
|
|
@ -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,
|
||||
|
|
|
|||
677
loko/stock/templates/stock/_edit_preparation_order_modal.html
Normal file
677
loko/stock/templates/stock/_edit_preparation_order_modal.html
Normal 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 %}
|
||||
|
|
@ -260,10 +260,17 @@
|
|||
|
||||
<!-- 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 %}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue