Compare commits

...

2 commits

Author SHA1 Message Date
ea02b12ea5 feat(stock): add product search by code and label in purchase order PDF import 2026-10-07 11:58:17 +02:00
b307791b90 feat(contracts): add global permissions to view contract posts and prices on UserConfig
- Add can_view_all_posts and can_view_all_prices boolean fields to UserConfig
- Add migration 0020 with automatic data migration for Legal Office users
- Update permissions helpers, UserConfigAdmin, copy_user_config and intervention detail view
- Add unit tests for global posts and prices permissions
2026-10-07 11:57:57 +02:00
13 changed files with 1097 additions and 196 deletions

View file

@ -620,7 +620,7 @@ class UserContractAccessAdmin(admin.ModelAdmin):
@admin.register(UserConfig) @admin.register(UserConfig)
class UserConfigAdmin(admin.ModelAdmin): class UserConfigAdmin(admin.ModelAdmin):
list_display = ("user", "display_contracts", "display_thematics", "display_views", "is_intern", "display_roles", "company", "display_member_teams", "duplicate_button") list_display = ("user", "display_contracts", "display_thematics", "display_views", "is_intern", "display_roles", "company", "display_member_teams", "duplicate_button")
list_filter = ("thematics", "roles", "company", "user__companymember__teams", "contracts", "accessible_views", "auto_logout_hours") list_filter = ("thematics", "roles", "company", "user__companymember__teams", "contracts", "can_view_all_posts", "can_view_all_prices", "accessible_views", "auto_logout_hours")
search_fields = ("user__username", "user__first_name", "user__last_name", "contracts__contract_number") search_fields = ("user__username", "user__first_name", "user__last_name", "contracts__contract_number")
inlines = [UserThematicsInline, UserContractAccessInline, UserWarehouseAccessInline, UserWarehouseLocationAccessInline, UserCompanyTeamAccessInline, UserAssetTypeAccessInline, UserAssetAccessInline] inlines = [UserThematicsInline, UserContractAccessInline, UserWarehouseAccessInline, UserWarehouseLocationAccessInline, UserCompanyTeamAccessInline, UserAssetTypeAccessInline, UserAssetAccessInline]
filter_horizontal = ("linked_configs", "accessible_views") filter_horizontal = ("linked_configs", "accessible_views")
@ -637,6 +637,10 @@ class UserConfigAdmin(admin.ModelAdmin):
'fields': ('accessible_views',), 'fields': ('accessible_views',),
'description': "Configurer les vues auxquelles l'utilisateur a accès." 'description': "Configurer les vues auxquelles l'utilisateur a accès."
}), }),
('Permissions globales / Contrats', {
'fields': ('can_view_all_posts', 'can_view_all_prices'),
'description': "Permet de consulter les prestations et/ou les prix sur tous les contrats sans configuration contractuelle individuelle."
}),
('eSign / Signalisation', { ('eSign / Signalisation', {
'fields': ('esign_can_edit', 'esign_can_manage_rcp', 'esign_can_validate_rcp', 'esign_organizations'), 'fields': ('esign_can_edit', 'esign_can_manage_rcp', 'esign_can_validate_rcp', 'esign_organizations'),
'description': "Permissions spécifiques pour l'application eSign / Signalisation." 'description': "Permissions spécifiques pour l'application eSign / Signalisation."

View file

@ -113,6 +113,8 @@ class Command(BaseCommand):
'hide_intervention_map_by_default': cfg.hide_intervention_map_by_default, 'hide_intervention_map_by_default': cfg.hide_intervention_map_by_default,
'auto_logout_hours': cfg.auto_logout_hours, 'auto_logout_hours': cfg.auto_logout_hours,
'can_add_unique_tag_documents': cfg.can_add_unique_tag_documents, 'can_add_unique_tag_documents': cfg.can_add_unique_tag_documents,
'can_view_all_posts': cfg.can_view_all_posts,
'can_view_all_prices': cfg.can_view_all_prices,
'roles': list(cfg.roles.values_list('name', flat=True)), 'roles': list(cfg.roles.values_list('name', flat=True)),
'accessible_views': list(cfg.accessible_views.values_list('code', flat=True)), 'accessible_views': list(cfg.accessible_views.values_list('code', flat=True)),
'linked_configs': list(cfg.linked_configs.values_list('user__username', flat=True)), 'linked_configs': list(cfg.linked_configs.values_list('user__username', flat=True)),

View file

@ -199,6 +199,8 @@ class Command(BaseCommand):
'hide_intervention_map_by_default': entry.get('hide_intervention_map_by_default', False), 'hide_intervention_map_by_default': entry.get('hide_intervention_map_by_default', False),
'auto_logout_hours': entry.get('auto_logout_hours'), 'auto_logout_hours': entry.get('auto_logout_hours'),
'can_add_unique_tag_documents': entry.get('can_add_unique_tag_documents', False), 'can_add_unique_tag_documents': entry.get('can_add_unique_tag_documents', False),
'can_view_all_posts': entry.get('can_view_all_posts', False),
'can_view_all_prices': entry.get('can_view_all_prices', False),
'default_thematic': default_thematic, 'default_thematic': default_thematic,
'company': company_obj, 'company': company_obj,
} }

View file

@ -0,0 +1,39 @@
# Generated by Django 6.0.7 on 2026-10-07 09:41
from django.db import migrations, models
def set_legal_office_posts_and_prices(apps, schema_editor):
UserConfig = apps.get_model('common', 'UserConfig')
Company = apps.get_model('contracts', 'Company')
legal_companies = Company.objects.filter(name__icontains="Legal Office")
if legal_companies.exists():
UserConfig.objects.filter(company__in=legal_companies).update(
can_view_all_posts=True,
can_view_all_prices=True,
)
class Migration(migrations.Migration):
dependencies = [
('common', '0019_userthematics_can_edit_management_zones'),
('contracts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='userconfig',
name='can_view_all_posts',
field=models.BooleanField(default=False, help_text="Permet à l'utilisateur de consulter les postes/prestations sur l'ensemble des contrats sans nécessiter d'accès contractuel explicite.", verbose_name='Peut voir toutes les prestations'),
),
migrations.AddField(
model_name='userconfig',
name='can_view_all_prices',
field=models.BooleanField(default=False, help_text="Permet à l'utilisateur de consulter les prix des postes sur l'ensemble des contrats sans nécessiter d'accès contractuel explicite.", verbose_name='Peut voir tous les prix'),
),
migrations.RunPython(
set_legal_office_posts_and_prices,
reverse_code=migrations.RunPython.noop,
),
]

View file

@ -305,6 +305,16 @@ class UserConfig(models.Model):
verbose_name="Peut ajouter des documents avec tags uniques", verbose_name="Peut ajouter des documents avec tags uniques",
help_text="Permet aux utilisateurs externes d'ajouter/modifier des documents avec des tags uniques (CME, PIS, As-Built, etc.).", help_text="Permet aux utilisateurs externes d'ajouter/modifier des documents avec des tags uniques (CME, PIS, As-Built, etc.).",
) )
can_view_all_posts = models.BooleanField(
default=False,
verbose_name=_("Peut voir toutes les prestations"),
help_text=_("Permet à l'utilisateur de consulter les postes/prestations sur l'ensemble des contrats sans nécessiter d'accès contractuel explicite."),
)
can_view_all_prices = models.BooleanField(
default=False,
verbose_name=_("Peut voir tous les prix"),
help_text=_("Permet à l'utilisateur de consulter les prix des postes sur l'ensemble des contrats sans nécessiter d'accès contractuel explicite."),
)
esign_can_edit = models.BooleanField( esign_can_edit = models.BooleanField(
default=False, default=False,
verbose_name="eSign : Peut éditer", verbose_name="eSign : Peut éditer",

View file

@ -168,8 +168,10 @@ def copy_user_config(source_user_or_config: Any, target_user_or_config: Any, cop
dict(source_config.calendar_preferences) if source_config.calendar_preferences is not None else None dict(source_config.calendar_preferences) if source_config.calendar_preferences is not None else None
) )
# Permissions spécifiques (eSign, documents, panoramax, transport exceptionnel) # Permissions spécifiques (eSign, documents, panoramax, transport exceptionnel, contrats)
target_config.can_add_unique_tag_documents = source_config.can_add_unique_tag_documents target_config.can_add_unique_tag_documents = source_config.can_add_unique_tag_documents
target_config.can_view_all_posts = source_config.can_view_all_posts
target_config.can_view_all_prices = source_config.can_view_all_prices
target_config.esign_can_edit = source_config.esign_can_edit target_config.esign_can_edit = source_config.esign_can_edit
target_config.esign_can_manage_rcp = source_config.esign_can_manage_rcp target_config.esign_can_manage_rcp = source_config.esign_can_manage_rcp
target_config.esign_can_validate_rcp = source_config.esign_can_validate_rcp target_config.esign_can_validate_rcp = source_config.esign_can_validate_rcp

View file

@ -90,11 +90,17 @@ def user_has_access_to_contract(user, contract_id, for_assets=False):
def user_can_view_contract_prices(user, contract_id): def user_can_view_contract_prices(user, contract_id):
"""Vrai si l'utilisateur est admin ou dispose du droit can_view_prices sur le contrat.""" """Vrai si l'utilisateur est admin ou dispose du droit can_view_prices sur le contrat."""
if getattr(user, 'is_superuser', False):
return True
try: try:
config = user.config config = user.config
except AttributeError: except AttributeError:
return False return False
if getattr(config, 'can_view_all_prices', False):
return True
user_roles = set(config.roles.values_list("name", flat=True)) user_roles = set(config.roles.values_list("name", flat=True))
if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS): if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS):
return False return False
@ -102,9 +108,10 @@ def user_can_view_contract_prices(user, contract_id):
if "admin" in user_roles or "top_manager" in user_roles: if "admin" in user_roles or "top_manager" in user_roles:
return True return True
cid = getattr(contract_id, 'id', contract_id)
return UserContractAccess.objects.filter( return UserContractAccess.objects.filter(
user_config=config, user_config=config,
contract_id=contract_id, contract_id=cid,
can_view_prices=True can_view_prices=True
).exists() ).exists()
@ -209,11 +216,17 @@ def user_has_access_to_contract_posts(user, contract_id):
""" """
Vérifie que l'utilisateur a accès aux postes du contrat. Vérifie que l'utilisateur a accès aux postes du contrat.
""" """
if getattr(user, 'is_superuser', False):
return True
try: try:
config = user.config config = user.config
except AttributeError: except AttributeError:
return False return False
if getattr(config, 'can_view_all_posts', False):
return True
user_roles = set(config.roles.values_list("name", flat=True)) user_roles = set(config.roles.values_list("name", flat=True))
if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS): if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS):
return False return False
@ -221,9 +234,10 @@ def user_has_access_to_contract_posts(user, contract_id):
if "admin" in user_roles or "top_manager" in user_roles: if "admin" in user_roles or "top_manager" in user_roles:
return True return True
cid = getattr(contract_id, 'id', contract_id)
return UserContractAccess.objects.filter( return UserContractAccess.objects.filter(
user_config=config, user_config=config,
contract_id=contract_id, contract_id=cid,
can_view_posts=True can_view_posts=True
).exists() ).exists()

View file

@ -723,3 +723,37 @@ class ContractGeojsonEndpointTests(TestCase):
self.assertEqual(len(data["features"]), 0) self.assertEqual(len(data["features"]), 0)
class GlobalPostsAndPricesPermissionsTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="viewer_user", password="password123")
self.role_viewer, _ = Role.objects.get_or_create(name="viewer")
self.config = UserConfig.objects.create(
user=self.user,
can_view_all_posts=False,
can_view_all_prices=False,
)
self.config.roles.add(self.role_viewer)
self.contract = Contract.objects.create(
contract_number="CTR-TEST-001",
description="Test Contract"
)
def test_default_viewer_cannot_view_posts_or_prices(self):
from contracts.permissions import user_can_view_contract_prices, user_has_access_to_contract_posts
self.assertFalse(user_can_view_contract_prices(self.user, self.contract.id))
self.assertFalse(user_has_access_to_contract_posts(self.user, self.contract.id))
def test_viewer_with_can_view_all_posts(self):
from contracts.permissions import user_has_access_to_contract_posts
self.config.can_view_all_posts = True
self.config.save()
self.assertTrue(user_has_access_to_contract_posts(self.user, self.contract.id))
def test_viewer_with_can_view_all_prices(self):
from contracts.permissions import user_can_view_contract_prices
self.config.can_view_all_prices = True
self.config.save()
self.assertTrue(user_can_view_contract_prices(self.user, self.contract.id))

View file

@ -220,6 +220,9 @@ class ControlQuantityInlineFormSet(BaseInlineFormSet):
qs = Contract.objects.none() qs = Contract.objects.none()
if user and hasattr(user, "config"): if user and hasattr(user, "config"):
if getattr(user.config, 'can_view_all_posts', False):
qs = Contract.objects.all().order_by("contract_number")
else:
allowed_ids = UserContractAccess.objects.filter( allowed_ids = UserContractAccess.objects.filter(
user_config=user.config, can_view_posts=True user_config=user.config, can_view_posts=True
).values_list("contract_id", flat=True) ).values_list("contract_id", flat=True)

View file

@ -181,7 +181,7 @@ def intervention_detail(request, intervention_id):
can_check_intervention = True can_check_intervention = True
# Filter the positions associated with this intervention # Filter the positions associated with this intervention
if user_contract_access and user_contract_access.can_view_posts: if getattr(user_config, 'can_view_all_posts', False) or (user_contract_access and user_contract_access.can_view_posts):
can_view_posts = True can_view_posts = True
contract_posts = list( contract_posts = list(
InterventionContractPost.objects.filter(intervention=intervention) InterventionContractPost.objects.filter(intervention=intervention)
@ -702,7 +702,10 @@ def intervention_detail(request, intervention_id):
"contract_posts": contract_posts, "contract_posts": contract_posts,
"total_general": total_general, "total_general": total_general,
"total_validated": total_validated, "total_validated": total_validated,
'can_view_prices': user_contract_access.can_view_prices if user_contract_access else False, 'can_view_prices': (
getattr(user_config, 'can_view_all_prices', False)
or (user_contract_access.can_view_prices if user_contract_access else False)
),
'geom_geojson': geom_geojson, 'geom_geojson': geom_geojson,
'allowed_transitions': json.dumps(allowed_transitions), 'allowed_transitions': json.dumps(allowed_transitions),
'add_note_form': InterventionNoteForm(), 'add_note_form': InterventionNoteForm(),

View file

@ -72,6 +72,30 @@
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin: 0 -0.75rem -0.75rem -0.75rem; margin: 0 -0.75rem -0.75rem -0.75rem;
} }
.product-search-result-item {
cursor: pointer;
transition: background-color 0.15s ease-in-out;
}
.product-search-result-item:active {
background-color: #eef5ff;
}
.keyword-chip {
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.55rem;
border-radius: 50rem;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
color: #495057;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.keyword-chip:active {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
</style> </style>
{% endblock extra_head %} {% endblock extra_head %}
@ -358,14 +382,28 @@
</div> </div>
{% endif %} {% endif %}
<select name="item_{{ idx }}_product_id" id="item_{{ idx }}_product_id" class="form-select form-select-sm" data-idx="{{ idx }}" onchange="handleMobileProductChange({{ idx }})"> <div class="input-group input-group-sm">
<select name="item_{{ idx }}_product_id"
id="item_{{ idx }}_product_id"
class="form-select form-select-sm"
data-idx="{{ idx }}"
data-item-ref="{{ item.reference|default:'' }}"
data-item-name="{{ item.name|default:'' }}"
onchange="handleMobileProductChange({{ idx }})">
<option value="">{% translate "— Choisir un produit du catalogue —" %}</option> <option value="">{% translate "— Choisir un produit du catalogue —" %}</option>
{% for p in products %} {% for p in products %}
<option value="{{ p.id }}" {% if item.matched_product_id == p.id %}selected{% endif %}> <option value="{{ p.id }}" {% if item.matched_product_id == p.id %}selected{% endif %}>
{{ p.name }} ({{ p.code }}) {{ p.code }} — {{ p.name }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<button type="button"
class="btn btn-outline-secondary"
onclick="openProductSearchModalMobile({{ idx }})"
title="{% translate 'Rechercher un produit (code ou libellé)' %}">
<i class="bi bi-search"></i>
</button>
</div>
</div> </div>
<!-- Quantité, Prix unitaire, Emplacement --> <!-- Quantité, Prix unitaire, Emplacement -->
@ -416,10 +454,64 @@
</div> </div>
</div> </div>
</form> </form>
<!-- Modal Recherche Mobile de Produit Catalogue -->
<div class="modal fade" id="productSearchModalMobile" tabindex="-1" aria-labelledby="productSearchModalMobileLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content shadow border-0">
<div class="modal-header bg-light py-2 px-3 border-bottom">
<div>
<h6 class="modal-title fw-bold mb-0 text-dark" id="productSearchModalMobileLabel">
<i class="bi bi-search text-primary me-2"></i>{% translate "Rechercher un produit" %}
</h6>
<div class="text-muted small mt-1" id="mobile-modal-target-item-info"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-3">
<div class="input-group mb-2">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text"
id="mobileModalProductSearchInput"
class="form-control border-start-0 border-end-0 ps-0"
placeholder="{% translate 'Code, nom, réf. ou SKU...' %}"
autocomplete="off">
<button class="btn btn-outline-secondary border-start-0" type="button" id="mobileModalProductSearchClear" title="{% translate 'Effacer' %}">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="d-flex align-items-center flex-wrap gap-1 mb-2" id="mobileModalQuickKeywordsContainer">
<span class="small text-muted me-1" style="font-size: 0.75rem;"><i class="bi bi-lightbulb text-warning me-1"></i>{% translate "Suggestions :" %}</span>
<div id="mobileModalQuickKeywordsList" class="d-inline-flex flex-wrap gap-1"></div>
</div>
<div id="mobileModalSearchLoading" class="text-center py-4 d-none">
<div class="spinner-border text-primary spinner-border-sm me-2" role="status"></div>
<span class="text-muted small">{% translate "Recherche en cours..." %}</span>
</div>
<div id="mobileModalSearchEmpty" class="text-center py-4 text-muted d-none">
<i class="bi bi-inbox fs-2 d-block mb-1 text-secondary opacity-50"></i>
{% translate "Aucun produit correspondant trouvé." %}
</div>
<div id="mobileModalSearchResults" class="list-group list-group-flush border rounded-3 overflow-auto" style="max-height: 350px;">
</div>
</div>
<div class="modal-footer py-2 px-3 bg-light d-flex justify-content-between">
<span class="small text-muted" id="mobileModalResultsCount"></span>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endif %} {% endif %}
<script> <script>
const PRODUCT_LOCATIONS_MAP = {{ product_locations_json|default:'{}'|safe }}; const PRODUCT_LOCATIONS_MAP = {{ product_locations_json|default:'{}'|safe }};
const PRODUCTS_AUTOCOMPLETE_URL = "{% url 'stock:products_autocomplete_api' %}";
let currentMobileModalTargetIdx = null;
let mobileSearchDebounceTimer = null;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// ── GESTION DE L'UPLOAD EN ÉTAPE 1 ── // ── GESTION DE L'UPLOAD EN ÉTAPE 1 ──
@ -466,10 +558,219 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
// ── GESTION DE LA RECHERCHE PRODUIT EN MODAL ──
const mobileModalInput = document.getElementById('mobileModalProductSearchInput');
const mobileModalClear = document.getElementById('mobileModalProductSearchClear');
if (mobileModalInput) {
mobileModalInput.addEventListener('input', function() {
clearTimeout(mobileSearchDebounceTimer);
mobileSearchDebounceTimer = setTimeout(() => {
runMobileModalSearch(mobileModalInput.value);
}, 250);
});
mobileModalInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(mobileSearchDebounceTimer);
runMobileModalSearch(mobileModalInput.value);
}
});
}
if (mobileModalClear && mobileModalInput) {
mobileModalClear.addEventListener('click', function() {
mobileModalInput.value = '';
mobileModalInput.focus();
runMobileModalSearch('');
});
}
const mobileModalEl = document.getElementById('productSearchModalMobile');
if (mobileModalEl) {
mobileModalEl.addEventListener('shown.bs.modal', function() {
if (mobileModalInput) {
mobileModalInput.focus();
mobileModalInput.select();
}
});
}
// ── GESTION DE LA REVUE EN ÉTAPE 2 ── // ── GESTION DE LA REVUE EN ÉTAPE 2 ──
updateMobileTotals(); updateMobileTotals();
}); });
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function openProductSearchModalMobile(idx) {
currentMobileModalTargetIdx = idx;
const select = document.getElementById('item_' + idx + '_product_id');
const itemRef = select ? (select.getAttribute('data-item-ref') || '') : '';
const itemName = select ? (select.getAttribute('data-item-name') || '') : '';
const infoEl = document.getElementById('mobile-modal-target-item-info');
if (infoEl) {
let html = '<span class="badge bg-secondary me-1">#' + (idx + 1) + '</span>';
if (itemRef) {
html += '<span class="badge bg-light text-dark border font-monospace me-1">' + escapeHtml(itemRef) + '</span> ';
}
html += '<span class="fw-semibold text-dark">' + escapeHtml(itemName) + '</span>';
infoEl.innerHTML = html;
}
const quickList = document.getElementById('mobileModalQuickKeywordsList');
if (quickList) {
quickList.innerHTML = '';
const keywords = [];
if (itemRef && itemRef.trim().length > 1) {
keywords.push(itemRef.trim());
}
const stopWords = new Set(['pour', 'avec', 'sans', 'dans', 'type', 'prix', 'par', 'les', 'des', 'une', 'sur']);
const words = itemName.split(/[\s,;\/\-—\(\)\+]+/).filter(w => {
const clean = w.trim().toLowerCase();
return clean.length >= 3 && !stopWords.has(clean);
});
words.forEach(w => {
if (keywords.length < 4 && !keywords.some(k => k.toLowerCase() === w.toLowerCase())) {
keywords.push(w);
}
});
keywords.forEach(kw => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'keyword-chip';
btn.textContent = kw;
btn.onclick = function() {
const input = document.getElementById('mobileModalProductSearchInput');
if (input) {
input.value = kw;
runMobileModalSearch(kw);
}
};
quickList.appendChild(btn);
});
}
const input = document.getElementById('mobileModalProductSearchInput');
let initialQuery = '';
if (itemRef && itemRef.trim().length > 1) {
initialQuery = itemRef.trim();
} else {
const parts = itemName.split(/[\s,;\/\-—\(\)\+]+/).filter(w => w.trim().length >= 3);
if (parts.length > 0) {
initialQuery = parts.slice(0, 2).join(' ');
}
}
if (input) {
input.value = initialQuery;
}
const modalEl = document.getElementById('productSearchModalMobile');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
runMobileModalSearch(initialQuery);
}
function runMobileModalSearch(query) {
const loading = document.getElementById('mobileModalSearchLoading');
const empty = document.getElementById('mobileModalSearchEmpty');
const resultsContainer = document.getElementById('mobileModalSearchResults');
const countEl = document.getElementById('mobileModalResultsCount');
if (loading) loading.classList.remove('d-none');
if (empty) empty.classList.add('d-none');
if (resultsContainer) resultsContainer.innerHTML = '';
if (countEl) countEl.textContent = '';
const url = PRODUCTS_AUTOCOMPLETE_URL + '?q=' + encodeURIComponent(query ? query.trim() : '');
fetch(url)
.then(response => {
if (!response.ok) throw new Error('Erreur réseau');
return response.json();
})
.then(data => {
if (loading) loading.classList.add('d-none');
const items = data.results || [];
if (items.length === 0) {
if (empty) empty.classList.remove('d-none');
if (countEl) countEl.textContent = '0 ' + "{% translate 'résultat' %}";
return;
}
if (countEl) {
countEl.textContent = items.length + ' ' + (items.length > 1 ? "{% translate 'résultats' %}" : "{% translate 'résultat' %}");
}
items.forEach(p => {
const itemDiv = document.createElement('div');
itemDiv.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center p-2 product-search-result-item';
const stockBadgeClass = (p.total_stock > 0) ? 'bg-success-subtle text-success border border-success-subtle' : 'bg-secondary-subtle text-muted border';
itemDiv.innerHTML = `
<div class="me-2 text-truncate">
<div class="d-flex align-items-center gap-1 mb-1 flex-wrap">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle font-monospace fw-bold">${escapeHtml(p.code)}</span>
${p.supplier_reference ? `<span class="badge bg-light text-dark border font-monospace small"><i class="bi bi-tag me-1"></i>${escapeHtml(p.supplier_reference)}</span>` : ''}
<span class="badge ${stockBadgeClass} small">
<i class="bi bi-box-seam me-1"></i>${p.total_stock} ${escapeHtml(p.unit_display || '')}
</span>
</div>
<div class="fw-semibold text-dark text-truncate small">${escapeHtml(p.text)}</div>
</div>
<div>
<button type="button" class="btn btn-sm btn-success px-2 text-nowrap">
<i class="bi bi-check-lg me-1"></i>{% translate "Associer" %}
</button>
</div>
`;
itemDiv.onclick = function() {
applyMobileProductAssociation(p.id, p.code, p.text);
};
resultsContainer.appendChild(itemDiv);
});
})
.catch(() => {
if (loading) loading.classList.add('d-none');
if (resultsContainer) {
resultsContainer.innerHTML = '<div class="p-3 text-center text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>' + "{% translate 'Erreur lors de la recherche.' %}" + '</div>';
}
});
}
function applyMobileProductAssociation(productId, productCode, productName) {
if (currentMobileModalTargetIdx === null) return;
const idx = currentMobileModalTargetIdx;
const select = document.getElementById('item_' + idx + '_product_id');
if (!select) return;
let existingOpt = select.querySelector('option[value="' + productId + '"]');
if (!existingOpt) {
const optText = productCode + ' — ' + productName;
const newOpt = new Option(optText, productId, true, true);
select.appendChild(newOpt);
}
select.value = productId;
handleMobileProductChange(idx);
const modalEl = document.getElementById('productSearchModalMobile');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
}
function toggleMobileSupplierChoice() { function toggleMobileSupplierChoice() {
const isNew = document.getElementById('sup_choice_new') && document.getElementById('sup_choice_new').checked; const isNew = document.getElementById('sup_choice_new') && document.getElementById('sup_choice_new').checked;
const boxCreate = document.getElementById('box-create-supplier'); const boxCreate = document.getElementById('box-create-supplier');

View file

@ -3,6 +3,88 @@
{% block title %}{% translate "Créer un bon de commande depuis une offre (PDF, Scan, Photo)" %} — Loko{% endblock %} {% block title %}{% translate "Créer un bon de commande depuis une offre (PDF, Scan, Photo)" %} — Loko{% endblock %}
{% block head %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'common/select2.min.css' %}">
<link rel="stylesheet" href="{% static 'common/select2-bootstrap-5-theme.min.css' %}">
<style>
.select2-container--bootstrap-5 .select2-selection--single {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
min-height: calc(1.5em + 0.5rem + 2px);
}
.select2-container--bootstrap-5 .select2-selection--single .select2-selection__rendered {
font-size: 0.875rem;
line-height: 1.5;
color: #212529;
padding-left: 0;
}
.select2-container--bootstrap-5 .select2-dropdown .select2-results__options .select2-results__option {
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
}
.select2-container--bootstrap-5 .select2-dropdown .select2-search .select2-search__field {
font-size: 0.85rem;
padding: 0.3rem 0.5rem;
}
.table td .input-group {
flex-wrap: nowrap !important;
}
.table td .input-group .select2-container--bootstrap-5 {
flex: 1 1 auto;
width: 1% !important;
}
.product-select-warning + .select2-container--bootstrap-5 .select2-selection {
border-color: #ffc107 !important;
background-color: #fffdf5;
}
.product-select-invalid + .select2-container--bootstrap-5 .select2-selection {
border-color: #dc3545 !important;
background-color: #fff8f8;
}
#items-review-table th.col-correspondence,
#items-review-table td.col-correspondence {
min-width: 340px;
}
.btn-search-product {
border-color: #ced4da;
background-color: #f8f9fa;
color: #495057;
transition: all 0.15s ease-in-out;
}
.btn-search-product:hover {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
.product-search-result-item {
cursor: pointer;
transition: background-color 0.15s ease-in-out;
}
.product-search-result-item:hover {
background-color: #f1f7ff;
}
.keyword-chip {
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.55rem;
border-radius: 50rem;
transition: all 0.15s ease-in-out;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
color: #495057;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.keyword-chip:hover {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<!-- Fil d'Ariane & Titre --> <!-- Fil d'Ariane & Titre -->
@ -341,7 +423,7 @@
<input type="checkbox" id="master-select-checkbox" class="form-check-input" checked onchange="toggleSelectAllItems(this.checked)"> <input type="checkbox" id="master-select-checkbox" class="form-check-input" checked onchange="toggleSelectAllItems(this.checked)">
</th> </th>
<th style="min-width: 250px;">{% translate "Référence & Désignation fournisseur" %}</th> <th style="min-width: 250px;">{% translate "Référence & Désignation fournisseur" %}</th>
<th style="min-width: 320px;">{% translate "Correspondance Produit Stock" %}</th> <th class="col-correspondence" style="min-width: 340px;">{% translate "Correspondance Produit Stock" %}</th>
<th style="width: 100px;" class="text-end">{% translate "Quantité" %}</th> <th style="width: 100px;" class="text-end">{% translate "Quantité" %}</th>
<th style="width: 120px;" class="text-end">{% translate "Prix net HT" %}</th> <th style="width: 120px;" class="text-end">{% translate "Prix net HT" %}</th>
<th style="width: 90px;" class="text-end">{% translate "TVA" %}</th> <th style="width: 90px;" class="text-end">{% translate "TVA" %}</th>
@ -383,8 +465,8 @@
</td> </td>
<!-- Rapprochement Catalogue Produit (Exclusif aux produits existants) --> <!-- Rapprochement Catalogue Produit (Exclusif aux produits existants) -->
<td> <td class="col-correspondence">
<div class="mb-1 d-flex align-items-center gap-1"> <div class="mb-1 d-flex align-items-center gap-1" id="item_{{ idx }}_badge_container">
{% if item.matched_product_id %} {% if item.matched_product_id %}
{% if item.match_type == 'exact_supplier_ref' %} {% if item.match_type == 'exact_supplier_ref' %}
<span class="badge bg-success text-white"> <span class="badge bg-success text-white">
@ -403,7 +485,7 @@
<i class="bi bi-stars me-1"></i>{% translate "Similarité trouvée" %} ({{ item.match_score|floatformat:0 }}%) <i class="bi bi-stars me-1"></i>{% translate "Similarité trouvée" %} ({{ item.match_score|floatformat:0 }}%)
</span> </span>
{% endif %} {% endif %}
<a href="{% url 'stock:product_detail' item.matched_product_id %}" target="_blank" class="small text-muted text-decoration-none ms-1" title="{% translate 'Voir la fiche produit' %}"> <a href="{% url 'stock:product_detail' item.matched_product_id %}" target="_blank" class="small text-muted text-decoration-none ms-1 product-detail-link" title="{% translate 'Voir la fiche produit' %}">
<i class="bi bi-box-arrow-up-right"></i> <i class="bi bi-box-arrow-up-right"></i>
</a> </a>
{% else %} {% else %}
@ -413,11 +495,14 @@
{% endif %} {% endif %}
</div> </div>
<!-- Sélecteur de produit existant --> <!-- Sélecteur de produit existant avec bouton de recherche modal -->
<div class="input-group input-group-sm">
<select name="item_{{ idx }}_product_id" <select name="item_{{ idx }}_product_id"
id="item_{{ idx }}_product_id" id="item_{{ idx }}_product_id"
class="form-select form-select-sm product-select-input {% if not item.matched_product_id %}border-warning{% endif %}" class="form-select form-select-sm product-select-input {% if not item.matched_product_id %}product-select-warning{% endif %}"
data-idx="{{ idx }}" data-idx="{{ idx }}"
data-item-ref="{{ item.reference|default:'' }}"
data-item-name="{{ item.name|default:'' }}"
onchange="handleProductSelection({{ idx }})"> onchange="handleProductSelection({{ idx }})">
{% if not item.matched_product_id %} {% if not item.matched_product_id %}
<option value="" selected>— {% translate "Sélectionner un produit existant..." %} —</option> <option value="" selected>— {% translate "Sélectionner un produit existant..." %} —</option>
@ -448,6 +533,13 @@
{% endfor %} {% endfor %}
</optgroup> </optgroup>
</select> </select>
<button type="button"
class="btn btn-outline-secondary btn-search-product"
title="{% translate 'Rechercher un produit (code ou libellé)' %}"
onclick="openProductSearchModal({{ idx }})">
<i class="bi bi-search"></i>
</button>
</div>
<div class="form-text small text-danger mt-1 {% if item.matched_product_id %}d-none{% endif %}" id="item_{{ idx }}_help"> <div class="form-text small text-danger mt-1 {% if item.matched_product_id %}d-none{% endif %}" id="item_{{ idx }}_help">
<i class="bi bi-exclamation-circle me-1"></i>{% translate "Produit requis pour commander cette ligne." %} <i class="bi bi-exclamation-circle me-1"></i>{% translate "Produit requis pour commander cette ligne." %}
</div> </div>
@ -525,12 +617,493 @@
</div> </div>
</form> </form>
{% endif %} {% endif %}
</div>
<!-- Modal Recherche de Produit Catalogue -->
<div class="modal fade" id="productSearchModal" tabindex="-1" aria-labelledby="productSearchModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content shadow border-0">
<div class="modal-header bg-light py-2 px-3 border-bottom">
<div>
<h5 class="modal-title fw-bold fs-6 mb-0 text-dark" id="productSearchModalLabel">
<i class="bi bi-search text-primary me-2"></i>{% translate "Rechercher un produit dans le catalogue" %}
</h5>
<div class="text-muted small mt-1" id="modal-target-item-info"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-3">
<!-- Champ de recherche live -->
<div class="input-group mb-2">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text"
id="modalProductSearchInput"
class="form-control border-start-0 border-end-0 ps-0"
placeholder="{% translate 'Rechercher par code (ex: PROD_114...), libellé, réf. fournisseur ou SKU...' %}"
autocomplete="off">
<button class="btn btn-outline-secondary border-start-0" type="button" id="modalProductSearchClear" title="{% translate 'Effacer' %}">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Suggestions rapides de mots-clés -->
<div class="d-flex align-items-center flex-wrap gap-1 mb-3" id="modalQuickKeywordsContainer">
<span class="small text-muted me-1"><i class="bi bi-lightbulb text-warning me-1"></i>{% translate "Suggestions :" %}</span>
<div id="modalQuickKeywordsList" class="d-inline-flex flex-wrap gap-1"></div>
</div>
<!-- Zone statut / chargement / vide -->
<div id="modalSearchLoading" class="text-center py-4 d-none">
<div class="spinner-border text-primary spinner-border-sm me-2" role="status"></div>
<span class="text-muted small">{% translate "Recherche en cours dans le catalogue..." %}</span>
</div>
<div id="modalSearchEmpty" class="text-center py-4 text-muted d-none">
<i class="bi bi-inbox fs-2 d-block mb-1 text-secondary opacity-50"></i>
{% translate "Aucun produit correspondant trouvé dans le catalogue." %}
</div>
<!-- Liste des résultats -->
<div id="modalSearchResults" class="list-group list-group-flush border rounded-3 overflow-auto" style="max-height: 380px;">
</div>
</div>
<div class="modal-footer py-2 px-3 bg-light d-flex justify-content-between">
<span class="small text-muted" id="modalResultsCount"></span>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block script %}
<script src="{% static 'common/select2.min.js' %}"></script>
<script> <script>
const PRODUCT_LOCATIONS_MAP = {{ product_locations_json|default:'{}'|safe }}; const PRODUCT_LOCATIONS_MAP = {{ product_locations_json|default:'{}'|safe }};
const PRODUCT_DETAIL_URL_TEMPLATE = "{% url 'stock:product_detail' 99999999 %}";
const PRODUCTS_AUTOCOMPLETE_URL = "{% url 'stock:products_autocomplete_api' %}";
document.addEventListener('DOMContentLoaded', function() { let currentModalTargetIdx = null;
let searchDebounceTimer = null;
// Normalisation pour recherche sans accent
function normalizeSearchText(str) {
if (!str) return '';
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
// Matcher personnalisé pour Select2 (multi-mots, insensible accents et ordre)
function matchProduct(params, data) {
if ($.trim(params.term) === '') {
return data;
}
if (typeof data.text === 'undefined') {
return null;
}
const term = normalizeSearchText(params.term);
const text = normalizeSearchText(data.text);
const tokens = term.split(/\s+/).filter(Boolean);
const matchesAll = tokens.every(token => text.includes(token));
if (matchesAll) {
return data;
}
if (data.children && data.children.length > 0) {
const matchChildren = [];
for (let i = 0; i < data.children.length; i++) {
const childMatch = matchProduct(params, data.children[i]);
if (childMatch) {
matchChildren.push(childMatch);
}
}
if (matchChildren.length > 0) {
const modifiedData = $.extend({}, data, true);
modifiedData.children = matchChildren;
return modifiedData;
}
}
return null;
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function initSelect2OnInputs() {
$('.product-select-input').each(function() {
const $el = $(this);
if ($el.hasClass('select2-hidden-accessible')) {
return;
}
$el.select2({
theme: 'bootstrap-5',
placeholder: '— ' + "{% translate 'Sélectionner un produit existant...' %}" + ' —',
allowClear: false,
width: '100%',
matcher: matchProduct
}).on('select2:select', function() {
const idx = $(this).data('idx');
handleProductSelection(idx);
}).on('select2:unselect', function() {
const idx = $(this).data('idx');
handleProductSelection(idx);
});
});
$(document).on('select2:open', () => {
const searchInput = document.querySelector('.select2-search__field');
if (searchInput) {
searchInput.focus();
}
});
}
function openProductSearchModal(idx) {
currentModalTargetIdx = idx;
const select = document.getElementById('item_' + idx + '_product_id');
const itemRef = select ? (select.getAttribute('data-item-ref') || '') : '';
const itemName = select ? (select.getAttribute('data-item-name') || '') : '';
// Afficher les infos de la ligne cible dans le modal
const infoEl = document.getElementById('modal-target-item-info');
if (infoEl) {
let html = '<span class="badge bg-secondary me-1">#' + (idx + 1) + '</span>';
if (itemRef) {
html += '<span class="badge bg-light text-dark border font-monospace me-1">' + escapeHtml(itemRef) + '</span> ';
}
html += '<span class="fw-semibold text-dark">' + escapeHtml(itemName) + '</span>';
infoEl.innerHTML = html;
}
// Générer les chips de mots-clés rapides
const quickList = document.getElementById('modalQuickKeywordsList');
if (quickList) {
quickList.innerHTML = '';
const keywords = [];
if (itemRef && itemRef.trim().length > 1) {
keywords.push(itemRef.trim());
}
const stopWords = new Set(['pour', 'avec', 'sans', 'dans', 'type', 'prix', 'par', 'les', 'des', 'une', 'sur']);
const words = itemName.split(/[\s,;\/\-—\(\)\+]+/).filter(w => {
const clean = w.trim().toLowerCase();
return clean.length >= 3 && !stopWords.has(clean);
});
words.forEach(w => {
if (keywords.length < 5 && !keywords.some(k => k.toLowerCase() === w.toLowerCase())) {
keywords.push(w);
}
});
keywords.forEach(kw => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'keyword-chip';
btn.textContent = kw;
btn.onclick = function() {
const searchInput = document.getElementById('modalProductSearchInput');
if (searchInput) {
searchInput.value = kw;
runModalSearch(kw);
}
};
quickList.appendChild(btn);
});
}
// Préremplir le champ de recherche
const searchInput = document.getElementById('modalProductSearchInput');
let initialQuery = '';
if (itemRef && itemRef.trim().length > 1) {
initialQuery = itemRef.trim();
} else {
const parts = itemName.split(/[\s,;\/\-—\(\)\+]+/).filter(w => w.trim().length >= 3);
if (parts.length > 0) {
initialQuery = parts.slice(0, 2).join(' ');
}
}
if (searchInput) {
searchInput.value = initialQuery;
}
const modalEl = document.getElementById('productSearchModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
runModalSearch(initialQuery);
}
function runModalSearch(query) {
const loading = document.getElementById('modalSearchLoading');
const empty = document.getElementById('modalSearchEmpty');
const resultsContainer = document.getElementById('modalSearchResults');
const countEl = document.getElementById('modalResultsCount');
if (loading) loading.classList.remove('d-none');
if (empty) empty.classList.add('d-none');
if (resultsContainer) resultsContainer.innerHTML = '';
if (countEl) countEl.textContent = '';
const url = PRODUCTS_AUTOCOMPLETE_URL + '?q=' + encodeURIComponent(query ? query.trim() : '');
fetch(url)
.then(response => {
if (!response.ok) throw new Error('Erreur réseau');
return response.json();
})
.then(data => {
if (loading) loading.classList.add('d-none');
const items = data.results || [];
if (items.length === 0) {
if (empty) empty.classList.remove('d-none');
if (countEl) countEl.textContent = '0 ' + "{% translate 'résultat' %}";
return;
}
if (countEl) {
countEl.textContent = items.length + ' ' + (items.length > 1 ? "{% translate 'résultats' %}" : "{% translate 'résultat' %}");
}
items.forEach(p => {
const itemDiv = document.createElement('div');
itemDiv.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center p-2 product-search-result-item';
const stockBadgeClass = (p.total_stock > 0) ? 'bg-success-subtle text-success border border-success-subtle' : 'bg-secondary-subtle text-muted border';
itemDiv.innerHTML = `
<div class="me-3 text-truncate">
<div class="d-flex align-items-center gap-1 mb-1 flex-wrap">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle font-monospace fw-bold">${escapeHtml(p.code)}</span>
${p.supplier_reference ? `<span class="badge bg-light text-dark border font-monospace small"><i class="bi bi-tag me-1"></i>${escapeHtml(p.supplier_reference)}</span>` : ''}
${p.sku && p.sku !== p.code ? `<span class="badge bg-light text-secondary border font-monospace small">SKU: ${escapeHtml(p.sku)}</span>` : ''}
${p.category ? `<span class="badge bg-secondary-subtle text-secondary small">${escapeHtml(p.category)}</span>` : ''}
<span class="badge ${stockBadgeClass} small">
<i class="bi bi-box-seam me-1"></i>${p.total_stock} ${escapeHtml(p.unit_display || '')}
</span>
</div>
<div class="fw-semibold text-dark text-truncate">${escapeHtml(p.text)}</div>
</div>
<div>
<button type="button" class="btn btn-sm btn-success px-3 text-nowrap">
<i class="bi bi-check-lg me-1"></i>{% translate "Associer" %}
</button>
</div>
`;
itemDiv.onclick = function() {
applyProductAssociation(p.id, p.code, p.text);
};
resultsContainer.appendChild(itemDiv);
});
})
.catch(() => {
if (loading) loading.classList.add('d-none');
if (resultsContainer) {
resultsContainer.innerHTML = '<div class="p-3 text-center text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>' + "{% translate 'Erreur lors de la recherche.' %}" + '</div>';
}
});
}
function applyProductAssociation(productId, productCode, productName) {
if (currentModalTargetIdx === null) return;
const idx = currentModalTargetIdx;
const select = document.getElementById('item_' + idx + '_product_id');
if (!select) return;
let existingOpt = select.querySelector('option[value="' + productId + '"]');
if (!existingOpt) {
const optText = productCode + ' — ' + productName;
const newOpt = new Option(optText, productId, true, true);
select.appendChild(newOpt);
}
select.value = productId;
if ($(select).hasClass('select2-hidden-accessible')) {
$(select).val(productId).trigger('change');
} else {
handleProductSelection(idx);
}
const modalEl = document.getElementById('productSearchModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
}
function handleProductSelection(idx) {
const select = document.getElementById('item_' + idx + '_product_id');
const cb = document.getElementById('item_' + idx + '_include');
const row = document.getElementById('item-row-' + idx);
const help = document.getElementById('item_' + idx + '_help');
const badgeContainer = document.getElementById('item_' + idx + '_badge_container');
if (select && select.value && select.value.trim() !== '') {
if (cb) cb.checked = true;
select.classList.remove('is-invalid', 'border-warning', 'product-select-warning', 'product-select-invalid');
if (row) row.classList.remove('table-warning-subtle');
if (help) help.classList.add('d-none');
const pid = parseInt(select.value, 10);
if (badgeContainer) {
const detailUrl = PRODUCT_DETAIL_URL_TEMPLATE.replace('99999999', pid);
badgeContainer.innerHTML = `
<span class="badge bg-success text-white">
<i class="bi bi-check-circle me-1"></i>{% translate "Produit associé" %}
</span>
<a href="${detailUrl}" target="_blank" class="small text-muted text-decoration-none ms-1 product-detail-link" title="{% translate 'Voir la fiche produit' %}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
`;
}
const targetLocId = PRODUCT_LOCATIONS_MAP[pid];
const locSelect = document.getElementById('item_' + idx + '_location');
if (locSelect && targetLocId) {
locSelect.value = targetLocId;
}
} else {
if (cb) cb.checked = false;
select.classList.add('border-warning', 'product-select-warning');
if (row) row.classList.add('table-warning-subtle');
if (help) help.classList.remove('d-none');
if (badgeContainer) {
badgeContainer.innerHTML = `
<span class="badge bg-warning text-dark">
<i class="bi bi-exclamation-triangle me-1"></i>{% translate "Non associé" %}
</span>
`;
}
}
updateTotals();
}
function toggleSupplierChoice() {
const isNew = document.getElementById('sup_choice_new') && document.getElementById('sup_choice_new').checked;
const cardNew = document.getElementById('container_create_supplier');
const cardExisting = document.getElementById('container_existing_supplier');
if (cardNew && cardExisting) {
if (isNew) {
cardNew.classList.remove('opacity-75', 'bg-light-subtle');
cardNew.classList.add('border-primary', 'shadow-sm', 'bg-white');
cardExisting.classList.remove('border-primary', 'shadow-sm', 'bg-white');
cardExisting.classList.add('opacity-75', 'bg-light-subtle');
} else {
cardExisting.classList.remove('opacity-75', 'bg-light-subtle');
cardExisting.classList.add('border-primary', 'shadow-sm', 'bg-white');
cardNew.classList.remove('border-primary', 'shadow-sm', 'bg-white');
cardNew.classList.add('opacity-75', 'bg-light-subtle');
}
}
}
function toggleSelectAllItems(checked) {
document.querySelectorAll('.item-include-cb').forEach(cb => {
cb.checked = checked;
});
updateTotals();
}
function validateOrderForm() {
// 1. Validation du fournisseur
const supChoiceNew = document.getElementById('sup_choice_new');
const supChoicePick = document.getElementById('sup_choice_pick');
if (supChoiceNew && supChoiceNew.checked) {
const nameInput = document.getElementById('id_new_supplier_name') || document.querySelector('input[name="new_supplier_name"]');
if (!nameInput || !nameInput.value.trim()) {
{% translate "Veuillez saisir le nom du nouveau fournisseur à créer." as msg_sup_name %}
alert("{{ msg_sup_name|escapejs }}");
if (nameInput) nameInput.focus();
return false;
}
} else if (supChoicePick && supChoicePick.checked) {
const supSelect = document.getElementById('id_supplier_id') || document.querySelector('select[name="supplier_id"]');
if (!supSelect || !supSelect.value.trim()) {
{% translate "Veuillez sélectionner un fournisseur existant dans la liste déroulante." as msg_sup_pick %}
alert("{{ msg_sup_pick|escapejs }}");
if (supSelect) supSelect.focus();
return false;
}
}
// 2. Validation des articles
const includeCbs = document.querySelectorAll('.item-include-cb:checked');
if (includeCbs.length === 0) {
{% translate "Veuillez cocher au moins une ligne d'article à inclure dans le bon de commande." as msg_no_item %}
alert("{{ msg_no_item|escapejs }}");
return false;
}
let unassociatedCount = 0;
let firstInvalid = null;
includeCbs.forEach(cb => {
const idx = cb.dataset.idx;
const select = document.getElementById('item_' + idx + '_product_id');
if (select && (!select.value || select.value.trim() === '')) {
unassociatedCount++;
select.classList.add('is-invalid', 'product-select-invalid');
if (!firstInvalid) firstInvalid = select;
}
});
if (unassociatedCount > 0) {
{% translate "Certaines lignes cochées ne sont associées à aucun produit existant. Veuillez sélectionner un produit dans la liste pour chaque ligne cochée, ou décocher les lignes non associées." as msg_unassoc %}
alert("{{ msg_unassoc|escapejs }}");
if (firstInvalid) {
if ($(firstInvalid).hasClass('select2-hidden-accessible')) {
$(firstInvalid).select2('open');
} else {
firstInvalid.focus();
}
}
return false;
}
return true;
}
function updateTotals() {
let totalHt = 0;
let totalTva = 0;
document.querySelectorAll('#items-review-table tbody tr').forEach(row => {
const cb = row.querySelector('.item-include-cb');
if (cb && cb.checked) {
const totalCell = row.querySelector('.item-total-cell');
if (totalCell) {
const amount = parseFloat(totalCell.getAttribute('data-total')) || 0;
const vatRate = parseFloat(totalCell.getAttribute('data-vat')) || 21;
totalHt += amount;
totalTva += amount * (vatRate / 100.0);
}
}
});
const htEl = document.getElementById('computed-total-ht');
const tvaEl = document.getElementById('computed-total-tva');
const ttcEl = document.getElementById('computed-total-ttc');
if (htEl) htEl.textContent = totalHt.toFixed(2) + ' €';
if (tvaEl) tvaEl.textContent = totalTva.toFixed(2) + ' €';
if (ttcEl) ttcEl.textContent = (totalHt + totalTva).toFixed(2) + ' €';
}
$(document).ready(function() {
// Zone drag & drop // Zone drag & drop
const dropZone = document.getElementById('drop-zone'); const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('id_pdf_file'); const fileInput = document.getElementById('id_pdf_file');
@ -599,144 +1172,52 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
// Initialisation affichage fournisseur si non identifié // Modal search input events
const modalInput = document.getElementById('modalProductSearchInput');
const modalClear = document.getElementById('modalProductSearchClear');
if (modalInput) {
modalInput.addEventListener('input', function() {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
runModalSearch(modalInput.value);
}, 250);
});
modalInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(searchDebounceTimer);
runModalSearch(modalInput.value);
}
});
}
if (modalClear && modalInput) {
modalClear.addEventListener('click', function() {
modalInput.value = '';
modalInput.focus();
runModalSearch('');
});
}
const modalEl = document.getElementById('productSearchModal');
if (modalEl) {
modalEl.addEventListener('shown.bs.modal', function() {
if (modalInput) {
modalInput.focus();
modalInput.select();
}
});
}
// Initialiser Select2
initSelect2OnInputs();
// Fournisseur
toggleSupplierChoice(); toggleSupplierChoice();
// Calcul initial des totaux // Totaux
updateTotals(); updateTotals();
}); });
function toggleSupplierChoice() {
const isNew = document.getElementById('sup_choice_new') && document.getElementById('sup_choice_new').checked;
const cardNew = document.getElementById('container_create_supplier');
const cardExisting = document.getElementById('container_existing_supplier');
if (cardNew && cardExisting) {
if (isNew) {
cardNew.classList.remove('opacity-75', 'bg-light-subtle');
cardNew.classList.add('border-primary', 'shadow-sm', 'bg-white');
cardExisting.classList.remove('border-primary', 'shadow-sm', 'bg-white');
cardExisting.classList.add('opacity-75', 'bg-light-subtle');
} else {
cardExisting.classList.remove('opacity-75', 'bg-light-subtle');
cardExisting.classList.add('border-primary', 'shadow-sm', 'bg-white');
cardNew.classList.remove('border-primary', 'shadow-sm', 'bg-white');
cardNew.classList.add('opacity-75', 'bg-light-subtle');
}
}
}
function toggleSelectAllItems(checked) {
document.querySelectorAll('.item-include-cb').forEach(cb => {
cb.checked = checked;
});
updateTotals();
}
function handleProductSelection(idx) {
const select = document.getElementById('item_' + idx + '_product_id');
const cb = document.getElementById('item_' + idx + '_include');
const row = document.getElementById('item-row-' + idx);
const help = document.getElementById('item_' + idx + '_help');
if (select && select.value && select.value.trim() !== '') {
if (cb) cb.checked = true;
select.classList.remove('is-invalid', 'border-warning');
if (row) row.classList.remove('table-warning-subtle');
if (help) help.classList.add('d-none');
// Adapter automatiquement l'emplacement de destination au produit sélectionné
const pid = parseInt(select.value, 10);
const targetLocId = PRODUCT_LOCATIONS_MAP[pid];
const locSelect = document.getElementById('item_' + idx + '_location');
if (locSelect && targetLocId) {
locSelect.value = targetLocId;
}
} else {
if (cb) cb.checked = false;
select.classList.add('border-warning');
if (row) row.classList.add('table-warning-subtle');
if (help) help.classList.remove('d-none');
}
updateTotals();
}
function validateOrderForm() {
// 1. Validation du fournisseur
const supChoiceNew = document.getElementById('sup_choice_new');
const supChoicePick = document.getElementById('sup_choice_pick');
if (supChoiceNew && supChoiceNew.checked) {
const nameInput = document.getElementById('id_new_supplier_name') || document.querySelector('input[name="new_supplier_name"]');
if (!nameInput || !nameInput.value.trim()) {
{% translate "Veuillez saisir le nom du nouveau fournisseur à créer." as msg_sup_name %}
alert("{{ msg_sup_name|escapejs }}");
if (nameInput) nameInput.focus();
return false;
}
} else if (supChoicePick && supChoicePick.checked) {
const supSelect = document.getElementById('id_supplier_id') || document.querySelector('select[name="supplier_id"]');
if (!supSelect || !supSelect.value.trim()) {
{% translate "Veuillez sélectionner un fournisseur existant dans la liste déroulante." as msg_sup_pick %}
alert("{{ msg_sup_pick|escapejs }}");
if (supSelect) supSelect.focus();
return false;
}
}
// 2. Validation des articles
const includeCbs = document.querySelectorAll('.item-include-cb:checked');
if (includeCbs.length === 0) {
{% translate "Veuillez cocher au moins une ligne d'article à inclure dans le bon de commande." as msg_no_item %}
alert("{{ msg_no_item|escapejs }}");
return false;
}
let unassociatedCount = 0;
let firstInvalid = null;
includeCbs.forEach(cb => {
const idx = cb.dataset.idx;
const select = document.getElementById('item_' + idx + '_product_id');
if (select && (!select.value || select.value.trim() === '')) {
unassociatedCount++;
select.classList.add('is-invalid');
if (!firstInvalid) firstInvalid = select;
}
});
if (unassociatedCount > 0) {
{% translate "Certaines lignes cochées ne sont associées à aucun produit existant. Veuillez sélectionner un produit dans la liste pour chaque ligne cochée, ou décocher les lignes non associées." as msg_unassoc %}
alert("{{ msg_unassoc|escapejs }}");
if (firstInvalid) firstInvalid.focus();
return false;
}
return true;
}
function updateTotals() {
let totalHt = 0;
let totalTva = 0;
document.querySelectorAll('#items-review-table tbody tr').forEach(row => {
const cb = row.querySelector('.item-include-cb');
if (cb && cb.checked) {
const totalCell = row.querySelector('.item-total-cell');
if (totalCell) {
const amount = parseFloat(totalCell.getAttribute('data-total')) || 0;
const vatRate = parseFloat(totalCell.getAttribute('data-vat')) || 21;
totalHt += amount;
totalTva += amount * (vatRate / 100.0);
}
}
});
const htEl = document.getElementById('computed-total-ht');
const tvaEl = document.getElementById('computed-total-tva');
const ttcEl = document.getElementById('computed-total-ttc');
if (htEl) htEl.textContent = totalHt.toFixed(2) + ' €';
if (tvaEl) tvaEl.textContent = totalTva.toFixed(2) + ' €';
if (ttcEl) ttcEl.textContent = (totalHt + totalTva).toFixed(2) + ' €';
}
</script> </script>
{% endblock %} {% endblock script %}

View file

@ -337,11 +337,16 @@ def products_autocomplete_api(request):
# Recherche par nom, code ou SKU # Recherche par nom, code ou SKU
search = request.GET.get("q", "").strip() search = request.GET.get("q", "").strip()
if search: if search:
products = products.filter( words = [w for w in search.split() if w]
models.Q(name__icontains=search) | q_filter = models.Q()
models.Q(code__icontains=search) | for w in words:
models.Q(sku__icontains=search) q_filter &= (
models.Q(name__icontains=w) |
models.Q(code__icontains=w) |
models.Q(sku__icontains=w) |
models.Q(supplier_reference__icontains=w)
) )
products = products.filter(q_filter)
# Limiter les résultats # Limiter les résultats
products = products[:50] products = products[:50]
@ -379,6 +384,7 @@ def products_autocomplete_api(request):
'text': p.name, # Format pour Select2 'text': p.name, # Format pour Select2
'code': p.code, 'code': p.code,
'sku': p.sku, 'sku': p.sku,
'supplier_reference': p.supplier_reference or '',
'category': p.category.name_fr if p.category else None, 'category': p.category.name_fr if p.category else None,
'total_stock': p.total_stock, 'total_stock': p.total_stock,
'reserved_quantity': p.reserved_quantity, 'reserved_quantity': p.reserved_quantity,