Compare commits
2 commits
9a85696d90
...
ea02b12ea5
| Author | SHA1 | Date | |
|---|---|---|---|
| ea02b12ea5 | |||
| b307791b90 |
13 changed files with 1097 additions and 196 deletions
|
|
@ -620,7 +620,7 @@ class UserContractAccessAdmin(admin.ModelAdmin):
|
|||
@admin.register(UserConfig)
|
||||
class UserConfigAdmin(admin.ModelAdmin):
|
||||
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")
|
||||
inlines = [UserThematicsInline, UserContractAccessInline, UserWarehouseAccessInline, UserWarehouseLocationAccessInline, UserCompanyTeamAccessInline, UserAssetTypeAccessInline, UserAssetAccessInline]
|
||||
filter_horizontal = ("linked_configs", "accessible_views")
|
||||
|
|
@ -637,6 +637,10 @@ class UserConfigAdmin(admin.ModelAdmin):
|
|||
'fields': ('accessible_views',),
|
||||
'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', {
|
||||
'fields': ('esign_can_edit', 'esign_can_manage_rcp', 'esign_can_validate_rcp', 'esign_organizations'),
|
||||
'description': "Permissions spécifiques pour l'application eSign / Signalisation."
|
||||
|
|
|
|||
|
|
@ -113,6 +113,8 @@ class Command(BaseCommand):
|
|||
'hide_intervention_map_by_default': cfg.hide_intervention_map_by_default,
|
||||
'auto_logout_hours': cfg.auto_logout_hours,
|
||||
'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)),
|
||||
'accessible_views': list(cfg.accessible_views.values_list('code', flat=True)),
|
||||
'linked_configs': list(cfg.linked_configs.values_list('user__username', flat=True)),
|
||||
|
|
|
|||
|
|
@ -199,6 +199,8 @@ class Command(BaseCommand):
|
|||
'hide_intervention_map_by_default': entry.get('hide_intervention_map_by_default', False),
|
||||
'auto_logout_hours': entry.get('auto_logout_hours'),
|
||||
'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,
|
||||
'company': company_obj,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
|
@ -305,6 +305,16 @@ class UserConfig(models.Model):
|
|||
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.).",
|
||||
)
|
||||
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(
|
||||
default=False,
|
||||
verbose_name="eSign : Peut éditer",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
# 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_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_manage_rcp = source_config.esign_can_manage_rcp
|
||||
target_config.esign_can_validate_rcp = source_config.esign_can_validate_rcp
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""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:
|
||||
config = user.config
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
if getattr(config, 'can_view_all_prices', False):
|
||||
return True
|
||||
|
||||
user_roles = set(config.roles.values_list("name", flat=True))
|
||||
if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS):
|
||||
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:
|
||||
return True
|
||||
|
||||
cid = getattr(contract_id, 'id', contract_id)
|
||||
return UserContractAccess.objects.filter(
|
||||
user_config=config,
|
||||
contract_id=contract_id,
|
||||
contract_id=cid,
|
||||
can_view_prices=True
|
||||
).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.
|
||||
"""
|
||||
if getattr(user, 'is_superuser', False):
|
||||
return True
|
||||
|
||||
try:
|
||||
config = user.config
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
if getattr(config, 'can_view_all_posts', False):
|
||||
return True
|
||||
|
||||
user_roles = set(config.roles.values_list("name", flat=True))
|
||||
if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS):
|
||||
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:
|
||||
return True
|
||||
|
||||
cid = getattr(contract_id, 'id', contract_id)
|
||||
return UserContractAccess.objects.filter(
|
||||
user_config=config,
|
||||
contract_id=contract_id,
|
||||
contract_id=cid,
|
||||
can_view_posts=True
|
||||
).exists()
|
||||
|
||||
|
|
|
|||
|
|
@ -723,3 +723,37 @@ class ContractGeojsonEndpointTests(TestCase):
|
|||
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))
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -220,6 +220,9 @@ class ControlQuantityInlineFormSet(BaseInlineFormSet):
|
|||
|
||||
qs = Contract.objects.none()
|
||||
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(
|
||||
user_config=user.config, can_view_posts=True
|
||||
).values_list("contract_id", flat=True)
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ def intervention_detail(request, intervention_id):
|
|||
can_check_intervention = True
|
||||
|
||||
# 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
|
||||
contract_posts = list(
|
||||
InterventionContractPost.objects.filter(intervention=intervention)
|
||||
|
|
@ -702,7 +702,10 @@ def intervention_detail(request, intervention_id):
|
|||
"contract_posts": contract_posts,
|
||||
"total_general": total_general,
|
||||
"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,
|
||||
'allowed_transitions': json.dumps(allowed_transitions),
|
||||
'add_note_form': InterventionNoteForm(),
|
||||
|
|
|
|||
|
|
@ -72,6 +72,30 @@
|
|||
padding: 0.75rem 1rem;
|
||||
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>
|
||||
{% endblock extra_head %}
|
||||
|
||||
|
|
@ -358,14 +382,28 @@
|
|||
</div>
|
||||
{% 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>
|
||||
{% for p in products %}
|
||||
<option value="{{ p.id }}" {% if item.matched_product_id == p.id %}selected{% endif %}>
|
||||
{{ p.name }} ({{ p.code }})
|
||||
{{ p.code }} — {{ p.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
<!-- Quantité, Prix unitaire, Emplacement -->
|
||||
|
|
@ -416,10 +454,64 @@
|
|||
</div>
|
||||
</div>
|
||||
</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 %}
|
||||
|
||||
<script>
|
||||
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() {
|
||||
// ── 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 ──
|
||||
updateMobileTotals();
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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() {
|
||||
const isNew = document.getElementById('sup_choice_new') && document.getElementById('sup_choice_new').checked;
|
||||
const boxCreate = document.getElementById('box-create-supplier');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,88 @@
|
|||
|
||||
{% 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 %}
|
||||
<div class="container-fluid py-3">
|
||||
<!-- Fil d'Ariane & Titre -->
|
||||
|
|
@ -341,7 +423,7 @@
|
|||
<input type="checkbox" id="master-select-checkbox" class="form-check-input" checked onchange="toggleSelectAllItems(this.checked)">
|
||||
</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: 120px;" class="text-end">{% translate "Prix net HT" %}</th>
|
||||
<th style="width: 90px;" class="text-end">{% translate "TVA" %}</th>
|
||||
|
|
@ -383,8 +465,8 @@
|
|||
</td>
|
||||
|
||||
<!-- Rapprochement Catalogue Produit (Exclusif aux produits existants) -->
|
||||
<td>
|
||||
<div class="mb-1 d-flex align-items-center gap-1">
|
||||
<td class="col-correspondence">
|
||||
<div class="mb-1 d-flex align-items-center gap-1" id="item_{{ idx }}_badge_container">
|
||||
{% if item.matched_product_id %}
|
||||
{% if item.match_type == 'exact_supplier_ref' %}
|
||||
<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 }}%)
|
||||
</span>
|
||||
{% 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>
|
||||
</a>
|
||||
{% else %}
|
||||
|
|
@ -413,11 +495,14 @@
|
|||
{% endif %}
|
||||
</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"
|
||||
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-item-ref="{{ item.reference|default:'' }}"
|
||||
data-item-name="{{ item.name|default:'' }}"
|
||||
onchange="handleProductSelection({{ idx }})">
|
||||
{% if not item.matched_product_id %}
|
||||
<option value="" selected>— {% translate "Sélectionner un produit existant..." %} —</option>
|
||||
|
|
@ -448,6 +533,13 @@
|
|||
{% endfor %}
|
||||
</optgroup>
|
||||
</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">
|
||||
<i class="bi bi-exclamation-circle me-1"></i>{% translate "Produit requis pour commander cette ligne." %}
|
||||
</div>
|
||||
|
|
@ -525,12 +617,493 @@
|
|||
</div>
|
||||
</form>
|
||||
{% 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>
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
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();
|
||||
|
||||
// Calcul initial des totaux
|
||||
// Totaux
|
||||
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>
|
||||
{% endblock %}
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -337,11 +337,16 @@ def products_autocomplete_api(request):
|
|||
# Recherche par nom, code ou SKU
|
||||
search = request.GET.get("q", "").strip()
|
||||
if search:
|
||||
products = products.filter(
|
||||
models.Q(name__icontains=search) |
|
||||
models.Q(code__icontains=search) |
|
||||
models.Q(sku__icontains=search)
|
||||
words = [w for w in search.split() if w]
|
||||
q_filter = models.Q()
|
||||
for w in words:
|
||||
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
|
||||
products = products[:50]
|
||||
|
|
@ -379,6 +384,7 @@ def products_autocomplete_api(request):
|
|||
'text': p.name, # Format pour Select2
|
||||
'code': p.code,
|
||||
'sku': p.sku,
|
||||
'supplier_reference': p.supplier_reference or '',
|
||||
'category': p.category.name_fr if p.category else None,
|
||||
'total_stock': p.total_stock,
|
||||
'reserved_quantity': p.reserved_quantity,
|
||||
|
|
|
|||
Loading…
Reference in a new issue