1232 lines
63 KiB
HTML
1232 lines
63 KiB
HTML
{% extends "base.html" %}
|
||
{% load i18n %}
|
||
{% load static %}
|
||
{% load short_name %}
|
||
|
||
{% block content %}
|
||
<!-- Select2 CSS -->
|
||
<link rel="stylesheet" href="{% static 'common/select2.min.css' %}" />
|
||
<link rel="stylesheet" href="{% static 'common/select2-bootstrap-5-theme.min.css' %}" />
|
||
<div class="container mt-5">
|
||
{# ── En-tête ─────────────────────────────────────────────── #}
|
||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||
<h1 class="h3">{% translate "Inventaire" %} #{{ inventory.id }}</h1>
|
||
<div class="d-flex align-items-center gap-2">
|
||
|
||
{# ── Badge de statut (cliquable pour les gestionnaires si autorisé) ── #}
|
||
{% if can_change_status and not inventory.is_validated and inventory.status != 'done' or can_change_status and not inventory.is_validated and can_validate %}
|
||
<div class="dropdown">
|
||
<button class="btn badge fs-6 dropdown-toggle border-0 d-inline-flex align-items-center gap-1
|
||
bg-{% if inventory.status == 'in_progress' %}primary{% elif inventory.status == 'done' %}info{% else %}secondary{% endif %}"
|
||
type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
||
title="{% translate 'Changer le statut' %}">
|
||
<i class="bi {% if inventory.status == 'draft' %}bi-circle{% elif inventory.status == 'in_progress' %}bi-play-fill{% else %}bi-flag-fill{% endif %}"></i>
|
||
{{ inventory.get_status_display }}
|
||
</button>
|
||
<ul class="dropdown-menu shadow-sm">
|
||
{% if inventory.status != 'draft' %}
|
||
<li>
|
||
<form method="post" action="{% url 'stock:inventory_change_status' inventory.pk %}">
|
||
{% csrf_token %}
|
||
<input type="hidden" name="status" value="draft">
|
||
<button type="submit" class="dropdown-item d-flex align-items-center gap-2">
|
||
<i class="bi bi-circle text-secondary"></i> {% translate "Brouillon" %}
|
||
</button>
|
||
</form>
|
||
</li>
|
||
{% endif %}
|
||
{% if inventory.status != 'in_progress' %}
|
||
<li>
|
||
<form method="post" action="{% url 'stock:inventory_change_status' inventory.pk %}">
|
||
{% csrf_token %}
|
||
<input type="hidden" name="status" value="in_progress">
|
||
<button type="submit" class="dropdown-item d-flex align-items-center gap-2">
|
||
<i class="bi bi-play-fill text-primary"></i> {% translate "En cours" %}
|
||
</button>
|
||
</form>
|
||
</li>
|
||
{% endif %}
|
||
{% if inventory.status != 'done' %}
|
||
<li>
|
||
<form method="post" action="{% url 'stock:inventory_change_status' inventory.pk %}">
|
||
{% csrf_token %}
|
||
<input type="hidden" name="status" value="done">
|
||
<button type="submit" class="dropdown-item d-flex align-items-center gap-2">
|
||
<i class="bi bi-flag-fill text-info"></i> {% translate "Terminé" %}
|
||
</button>
|
||
</form>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
</div>
|
||
{% else %}
|
||
<span class="badge fs-6 d-inline-flex align-items-center gap-1
|
||
bg-{% if inventory.status == 'validated' %}success{% elif inventory.status == 'in_progress' %}primary{% elif inventory.status == 'done' %}info{% else %}secondary{% endif %}">
|
||
<i class="bi {% if inventory.status == 'draft' %}bi-circle{% elif inventory.status == 'in_progress' %}bi-play-fill{% elif inventory.status == 'done' %}bi-flag-fill{% else %}bi-lock-fill{% endif %}"></i>
|
||
{{ inventory.get_status_display }}
|
||
</span>
|
||
{% endif %}
|
||
|
||
{% if inventory.is_editable %}
|
||
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addProductModal">
|
||
<i class="bi bi-plus-circle"></i> {% translate "Ajouter un produit" %}
|
||
</button>
|
||
{% endif %}
|
||
<div class="dropdown">
|
||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle d-inline-flex align-items-center gap-1"
|
||
type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||
<i class="bi bi-file-earmark-pdf"></i> {% translate "Export PDF" %}
|
||
</button>
|
||
<ul class="dropdown-menu shadow-sm">
|
||
<li>
|
||
<a class="dropdown-item d-flex align-items-center gap-2" href="{% url 'stock:inventory_pdf' inventory.pk %}?type=blank">
|
||
<i class="bi bi-file-earmark"></i> {% translate "Feuille de comptage vierge" %}
|
||
</a>
|
||
</li>
|
||
<li>
|
||
<a class="dropdown-item d-flex align-items-center gap-2" href="{% url 'stock:inventory_pdf' inventory.pk %}?type=complete">
|
||
<i class="bi bi-file-earmark-ruled"></i> {% translate "Tableau complet (comptages, commentaires, statut)" %}
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
{% if can_validate and not inventory.is_validated %}
|
||
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#confirmValidateModal">
|
||
<i class="bi bi-check-circle"></i> {% translate "Valider l'inventaire" %}
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{# ── Infos générales ────────────────────────────────────── #}
|
||
<div class="row align-items-start mb-3">
|
||
<div class="col-md-9">
|
||
<dl class="row">
|
||
<dt class="col-sm-3">{% translate "Emplacement" %}</dt>
|
||
<dd class="col-sm-9">{{ inventory.warehouse_location.name }}</dd>
|
||
<dt class="col-sm-3">{% translate "Entrepôt" %}</dt>
|
||
<dd class="col-sm-9">{{ inventory.warehouse_location.warehouse.name }}</dd>
|
||
<dt class="col-sm-3">{% translate "Responsable" %}</dt>
|
||
<dd class="col-sm-9">{{ inventory.performed_by|default:_("Non renseigné") }}</dd>
|
||
<dt class="col-sm-3">{% translate "Créé le" %}</dt>
|
||
<dd class="col-sm-9">{{ inventory.created_at }}</dd>
|
||
<dt class="col-sm-3">{% translate "Validé le" %}</dt>
|
||
<dd class="col-sm-9">{{ inventory.validated_at|default:_("Non validé") }}</dd>
|
||
{% if inventory.validated_by %}
|
||
<dt class="col-sm-3">{% translate "Validé par" %}</dt>
|
||
<dd class="col-sm-9">{{ inventory.validated_by.get_full_name|default:inventory.validated_by.username }}</dd>
|
||
{% endif %}
|
||
{% if inventory.validator_comment %}
|
||
<dt class="col-sm-3">{% translate "Commentaire validateur" %}</dt>
|
||
<dd class="col-sm-9">
|
||
<span class="text-info fw-semibold">{{ inventory.validator_comment }}</span>
|
||
{% if can_validate %}
|
||
<button type="button" class="btn btn-sm btn-link py-0" data-bs-toggle="modal" data-bs-target="#editValidatorCommentModal">
|
||
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
|
||
</button>
|
||
{% endif %}
|
||
</dd>
|
||
{% elif can_validate %}
|
||
<dt class="col-sm-3">{% translate "Commentaire validateur" %}</dt>
|
||
<dd class="col-sm-9">
|
||
<button type="button" class="btn btn-sm btn-outline-info py-0" data-bs-toggle="modal" data-bs-target="#editValidatorCommentModal">
|
||
<i class="bi bi-plus-circle"></i> {% translate "Ajouter un commentaire" %}
|
||
</button>
|
||
</dd>
|
||
{% endif %}
|
||
</dl>
|
||
</div>
|
||
{% if inventory.qr_code %}
|
||
<div class="col-md-3 text-center">
|
||
<p class="text-muted small mb-1">{% translate "QR Code" %}</p>
|
||
<img src="{{ inventory.qr_code.url }}" alt="QR Code Inventaire #{{ inventory.id }}"
|
||
style="width:110px;height:110px;cursor:pointer;"
|
||
class="img-fluid qr-code-thumb border rounded"
|
||
data-bs-toggle="modal" data-bs-target="#qrCodeModal"
|
||
data-qr-url="{{ inventory.qr_code.url }}"
|
||
data-qr-label="{% translate 'Inventaire' %} #{{ inventory.id }} — {{ inventory.warehouse_location.name }}">
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{# ── Résumé des écarts ──────────────────────────────────── #}
|
||
<div class="row mb-3">
|
||
<div class="col-md-3">
|
||
<div class="card text-center border-0 bg-light">
|
||
<div class="card-body py-2">
|
||
<div class="fs-4 fw-bold" id="summary-total">{{ items|length }}</div>
|
||
<small class="text-muted">{% translate "Articles" %}</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card text-center border-0 bg-light">
|
||
<div class="card-body py-2">
|
||
<div class="fs-4 fw-bold text-danger" id="summary-neg">0</div>
|
||
<small class="text-muted">{% translate "Écarts négatifs" %}</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card text-center border-0 bg-light">
|
||
<div class="card-body py-2">
|
||
<div class="fs-4 fw-bold text-success" id="summary-pos">0</div>
|
||
<small class="text-muted">{% translate "Écarts positifs" %}</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card text-center border-0 bg-light">
|
||
<div class="card-body py-2">
|
||
<div class="fs-4 fw-bold" id="summary-ok">0</div>
|
||
<small class="text-muted">{% translate "Sans écart" %}</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# ── Filtre écarts ──────────────────────────────────────── #}
|
||
<div class="card mb-3">
|
||
<div class="card-body py-2">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-auto">
|
||
<label class="form-label mb-0 small fw-semibold">{% translate "Filtrer les écarts" %}</label>
|
||
</div>
|
||
<div class="col-auto">
|
||
<select id="filter-type" class="form-select form-select-sm" style="width:auto">
|
||
<option value="all">{% translate "Tous" %}</option>
|
||
<option value="discrepancy">{% translate "Avec écart uniquement" %}</option>
|
||
<option value="no_discrepancy">{% translate "Sans écart uniquement" %}</option>
|
||
<option value="negative">{% translate "Écarts négatifs" %}</option>
|
||
<option value="positive">{% translate "Écarts positifs" %}</option>
|
||
<option value="threshold">{% translate "Écart absolu >" %}</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-auto" id="threshold-group" style="display:none">
|
||
<input type="number" id="filter-threshold" class="form-control form-control-sm" min="0" value="0" style="width:90px">
|
||
</div>
|
||
<div class="col-auto">
|
||
<select id="filter-validation" class="form-select form-select-sm" style="width:auto">
|
||
<option value="all">{% translate "Tous les statuts" %}</option>
|
||
<option value="validated">{% translate "Validés" %}</option>
|
||
<option value="not_validated">{% translate "Non validés" %}</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-auto">
|
||
<input type="text" id="filter-search" class="form-control form-control-sm" placeholder="{% translate 'Rechercher un produit…' %}" style="width:200px">
|
||
</div>
|
||
<div class="col-auto ms-auto">
|
||
<span class="badge bg-secondary" id="visible-count"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# ── Tableau des articles ───────────────────────────────── #}
|
||
{# Formulaire caché pour la validation par lot des articles filtrés #}
|
||
<form method="post" action="{% url 'stock:inventory_validate_items' inventory.pk %}" id="batch-validate-form" class="d-none">
|
||
{% csrf_token %}
|
||
<div id="batch-item-ids"></div>
|
||
</form>
|
||
|
||
<div class="table-responsive mt-2">
|
||
<table class="table table-bordered align-middle" id="inventory-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{% translate "Produit" %}</th>
|
||
<th>{% translate "Emplacement" %}</th>
|
||
<th class="text-end">{% translate "Qté attendue" %}</th>
|
||
<th class="text-end" style="width:140px">{% translate "Qté comptée" %}</th>
|
||
<th class="text-end" style="width:100px">{% translate "Écart" %}</th>
|
||
<th class="text-center" style="width:120px">{% translate "Statut" %}</th>
|
||
{% if can_validate and not inventory.is_validated %}
|
||
<th class="text-center" style="width:120px">
|
||
<button type="button" class="btn btn-sm btn-success" id="btn-validate-visible"
|
||
title="{% translate 'Valider tous les articles visibles dans le tableau' %}">
|
||
<i class="bi bi-check-all"></i> {% translate "Tout valider" %}
|
||
</button>
|
||
</th>
|
||
{% endif %}
|
||
{% if inventory.is_editable and can_validate %}
|
||
<th class="text-center" style="width:50px"></th>
|
||
{% endif %}
|
||
</tr>
|
||
</thead>
|
||
<tbody id="inventory-items-body">
|
||
{% for item in items %}
|
||
<tr class="{% if item.difference < 0 %}table-danger{% elif item.difference > 0 %}table-success{% endif %}"
|
||
data-diff="{{ item.difference }}"
|
||
data-validated="{{ item.is_validated|yesno:'1,0' }}"
|
||
data-product="{{ item.product.name|lower }}"
|
||
data-item-id="{{ item.pk }}">
|
||
<td>
|
||
<span class="fw-semibold">{{ item.product.name }}</span>
|
||
{% if item.comment %}
|
||
<br><small class="text-muted">
|
||
<i class="bi bi-person-fill me-1"></i>
|
||
<strong>{{ item.comment_by|short_name|default:"?" }}</strong>
|
||
— <span class="worker-comment-text">{{ item.comment }}</span>
|
||
</small>
|
||
{% endif %}
|
||
{% if item.validator_comment %}
|
||
<br><small class="text-info">
|
||
<i class="bi bi-person-badge-fill me-1"></i>
|
||
<strong>{{ item.validator_comment_by|short_name|default:"?" }}</strong>
|
||
— <span class="val-comment-text">{{ item.validator_comment }}</span>
|
||
</small>
|
||
{% endif %}
|
||
</td>
|
||
<td>{{ inventory.warehouse_location.name }}</td>
|
||
<td class="text-end td-expected">{{ item.expected_quantity }}</td>
|
||
<td class="td-counted">
|
||
<div class="d-flex align-items-center justify-content-end gap-1">
|
||
{% if inventory.is_editable and inventory.is_quantity_editable or can_validate and inventory.is_editable %}
|
||
<input type="number" class="form-control form-control-sm text-end input-counted-quantity"
|
||
value="{{ item.counted_quantity }}" min="0" style="width:90px"
|
||
data-item-id="{{ item.pk }}"
|
||
data-original-val="{{ item.counted_quantity }}"
|
||
data-update-url="{% url 'stock:inventory_item_update_api' inventory.pk item.pk %}">
|
||
{% else %}
|
||
<span class="text-end">{{ item.counted_quantity }}</span>
|
||
{% endif %}
|
||
{% if item.comment or item.validator_comment or inventory.is_editable or can_validate %}
|
||
<button type="button"
|
||
class="btn btn-sm btn-outline-secondary flex-shrink-0 btn-show-comment"
|
||
data-comment="{{ item.comment|escapejs }}"
|
||
data-comment-by-short-name="{{ item.comment_by|short_name|escapejs }}"
|
||
data-validator-comment="{{ item.validator_comment|escapejs }}"
|
||
data-validator-comment-by-short-name="{{ item.validator_comment_by|short_name|escapejs }}"
|
||
data-product="{{ item.product.name|escapejs }}"
|
||
data-item-id="{{ item.pk }}"
|
||
data-update-url="{% url 'stock:inventory_item_update_api' inventory.pk item.pk %}"
|
||
title="{% if can_validate %}{% translate 'Commentaires et validation' %}{% else %}{% translate 'Voir/ajouter un commentaire' %}{% endif %}">
|
||
<i class="bi {% if item.comment or item.validator_comment %}bi-chat-text-fill text-primary{% else %}bi-chat-text{% endif %}"></i>
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
<td class="text-end fw-bold td-difference {% if item.difference < 0 %}text-danger{% elif item.difference > 0 %}text-success{% endif %}">
|
||
{% if item.difference > 0 %}+{% endif %}{{ item.difference }}
|
||
</td>
|
||
<td class="text-center td-status">
|
||
{% if item.is_validated %}
|
||
<span class="badge bg-success" title="{{ item.validated_by.get_full_name|default:item.validated_by.username }} – {{ item.validated_at }}">
|
||
<i class="bi bi-check-lg"></i> {% translate "Validé" %}
|
||
</span>
|
||
{% else %}
|
||
<span class="badge bg-warning text-dark">
|
||
<i class="bi bi-hourglass-split"></i> {% translate "En attente" %}
|
||
</span>
|
||
{% endif %}
|
||
</td>
|
||
{% if can_validate and not inventory.is_validated %}
|
||
<td class="text-center td-toggle-validate-cell">
|
||
<button type="button"
|
||
class="btn btn-sm btn-toggle-validate {% if item.is_validated %}btn-success{% else %}btn-outline-success{% endif %}"
|
||
data-url="{% url 'stock:inventory_validate_item' inventory.pk item.pk %}"
|
||
title="{% if item.is_validated %}{% translate 'Annuler la validation' %}{% else %}{% translate 'Valider cet article' %}{% endif %}">
|
||
<i class="bi {% if item.is_validated %}bi-check-circle-fill{% else %}bi-check{% endif %}"></i>
|
||
</button>
|
||
</td>
|
||
{% endif %}
|
||
{% if inventory.is_editable and can_validate %}
|
||
<td class="text-center">
|
||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-item"
|
||
data-url="{% url 'stock:inventory_item_delete_api' inventory.pk item.pk %}"
|
||
title="{% translate 'Supprimer cette ligne' %}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</td>
|
||
{% endif %}
|
||
</tr>
|
||
{% empty %}
|
||
<tr id="empty-row">
|
||
<td colspan="8" class="text-center text-muted py-4">{% translate "Aucun article dans cet inventaire." %}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="mt-3">
|
||
<a href="{% url 'stock:stock_movements' %}?tab=inventories" class="btn btn-secondary">
|
||
<i class="bi bi-arrow-left"></i> {% translate "Retour à la liste" %}
|
||
</a>
|
||
</div>
|
||
|
||
{# ── Modal commentaire ────────────────────────────────── #}
|
||
<div class="modal fade" id="commentModal" tabindex="-1" aria-labelledby="commentModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="commentModalLabel">
|
||
<i class="bi bi-chat-text me-2"></i>
|
||
<span id="comment-modal-product"></span>
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="comment-modal-worker-section">
|
||
<label class="form-label fw-bold">
|
||
<i class="bi bi-person-fill me-1"></i>{% translate "Commentaire du magasinier" %}
|
||
<small class="text-muted fw-normal ms-1" id="comment-modal-worker-author"></small>
|
||
</label>
|
||
{% if inventory.is_worker_comment_editable and not can_validate %}
|
||
<textarea id="comment-modal-worker-input" class="form-control mb-2" rows="3" placeholder="{% translate 'Ajouter/modifier un commentaire...' %}"></textarea>
|
||
<div class="text-end mb-2">
|
||
<button type="button" id="btn-save-modal-worker-comment" class="btn btn-sm btn-secondary">
|
||
<i class="bi bi-floppy"></i> {% translate "Enregistrer" %}
|
||
</button>
|
||
</div>
|
||
{% else %}
|
||
<p id="comment-modal-text" class="mb-3 p-2 bg-light rounded" style="white-space:pre-wrap"></p>
|
||
{% endif %}
|
||
</div>
|
||
<hr id="comment-modal-divider">
|
||
<div id="comment-modal-validator-section">
|
||
<label class="form-label fw-bold">
|
||
<i class="bi bi-person-badge-fill me-1"></i>{% translate "Commentaire du validateur" %}
|
||
<small class="text-muted fw-normal ms-1" id="comment-modal-validator-author"></small>
|
||
</label>
|
||
{% if can_validate %}
|
||
<textarea id="comment-modal-validator-input" class="form-control mb-2" rows="3" placeholder="{% translate 'Ajouter/modifier une observation du validateur...' %}"></textarea>
|
||
<div class="text-end">
|
||
<button type="button" id="btn-save-modal-validator-comment" class="btn btn-sm btn-primary">
|
||
<i class="bi bi-floppy"></i> {% translate "Enregistrer" %}
|
||
</button>
|
||
</div>
|
||
{% else %}
|
||
<p id="comment-modal-validator-text" class="mb-0 p-2 bg-light rounded" style="white-space:pre-wrap"></p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# ── Modal Validation Globale avec Commentaire ─────────────── #}
|
||
{% if can_validate and not inventory.is_validated %}
|
||
<div class="modal fade" id="confirmValidateModal" tabindex="-1" aria-labelledby="confirmValidateModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<form method="post" action="{% url 'stock:inventory_validate' inventory.pk %}">
|
||
{% csrf_token %}
|
||
<div class="modal-header bg-success text-white">
|
||
<h5 class="modal-title" id="confirmValidateModalLabel">
|
||
<i class="bi bi-check-circle me-2"></i>
|
||
{% translate "Valider l'inventaire" %}
|
||
</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p>{% translate "Voulez-vous vraiment valider cet inventaire ? Les stocks seront ajustés définitivement." %}</p>
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">{% translate "Commentaire du validateur (optionnel)" %}</label>
|
||
<textarea class="form-control" name="validator_comment" rows="3" placeholder="{% translate 'Expliquer les écarts majeurs, remarques de validation...' %}">{{ inventory.validator_comment }}</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
|
||
<button type="submit" class="btn btn-success">{% translate "Confirmer et valider" %}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# ── Modal Commentaire Général Validateur ────────────────── #}
|
||
{% if can_validate %}
|
||
<div class="modal fade" id="editValidatorCommentModal" tabindex="-1" aria-labelledby="editValidatorCommentModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<form method="post" action="{% url 'stock:inventory_update_validator_comment' inventory.pk %}">
|
||
{% csrf_token %}
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="editValidatorCommentModalLabel">
|
||
<i class="bi bi-chat-text me-2"></i>
|
||
{% translate "Commentaire de validation général" %}
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<textarea class="form-control" name="validator_comment" rows="4" placeholder="{% translate 'Saisir un commentaire pour cette validation...' %}">{{ inventory.validator_comment }}</textarea>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
|
||
<button type="submit" class="btn btn-primary">{% translate "Enregistrer" %}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
{# ── Modal QR Code ───────────────────────────────────────────── #}
|
||
<div class="modal fade" id="qrCodeModal" tabindex="-1" aria-labelledby="qrCodeModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="qrCodeModalLabel">
|
||
<i class="bi bi-qr-code me-2"></i>
|
||
{% translate "QR Code" %} — <span id="qr-modal-label"></span>
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||
</div>
|
||
<div class="modal-body text-center py-4">
|
||
<img id="qr-modal-img" src="" alt="QR Code" class="img-fluid" style="max-width:320px;">
|
||
</div>
|
||
<div class="modal-footer justify-content-center gap-2">
|
||
<a id="qr-modal-download" href="" download class="btn btn-primary">
|
||
<i class="bi bi-download"></i> {% translate "Télécharger" %}
|
||
</a>
|
||
<button type="button" class="btn btn-secondary" onclick="printQrCode()">
|
||
<i class="bi bi-printer"></i> {% translate "Imprimer" %}
|
||
</button>
|
||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{# ── Modal Ajouter un produit ────────────────────────────── #}
|
||
{% if inventory.is_editable %}
|
||
<div class="modal fade" id="addProductModal" tabindex="-1" aria-labelledby="addProductModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="addProductModalLabel">
|
||
<i class="bi bi-plus-circle me-2"></i>
|
||
{% translate "Ajouter un produit" %}
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold" for="add-product-select">{% translate "Produit" %}</label>
|
||
<select id="add-product-select" class="form-select" style="width:100%"></select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold" for="add-product-qty">{% translate "Quantité comptée" %}</label>
|
||
<input type="number" id="add-product-qty" class="form-control" min="0" value="0">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold" for="add-product-comment">{% translate "Commentaire" %}</label>
|
||
<textarea id="add-product-comment" class="form-control" rows="2" placeholder="{% translate 'Commentaire optionnel...' %}"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
|
||
<button type="button" id="btn-submit-add-product" class="btn btn-primary">
|
||
<i class="bi bi-plus-lg"></i> {% translate "Ajouter" %}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endblock content %}
|
||
|
||
{% block script %}
|
||
<script src="{% static 'common/select2.min.js' %}"></script>
|
||
<script>
|
||
(function () {
|
||
const filterType = document.getElementById('filter-type');
|
||
const filterThreshold = document.getElementById('filter-threshold');
|
||
const thresholdGroup = document.getElementById('threshold-group');
|
||
const filterValidation = document.getElementById('filter-validation');
|
||
const filterSearch = document.getElementById('filter-search');
|
||
const visibleCount = document.getElementById('visible-count');
|
||
|
||
// Summary counters initially
|
||
recalculateSummary();
|
||
|
||
function applyFilters() {
|
||
const type = filterType.value;
|
||
const threshold = parseInt(filterThreshold.value, 10) || 0;
|
||
const validation = filterValidation.value;
|
||
const search = filterSearch.value.trim().toLowerCase();
|
||
let shown = 0;
|
||
|
||
thresholdGroup.style.display = type === 'threshold' ? '' : 'none';
|
||
|
||
const currentRows = document.querySelectorAll('#inventory-items-body tr[data-diff]');
|
||
currentRows.forEach(r => {
|
||
const diff = parseInt(r.dataset.diff, 10);
|
||
const absDiff = Math.abs(diff);
|
||
const validated = r.dataset.validated === '1';
|
||
const product = r.dataset.product;
|
||
let visible = true;
|
||
|
||
// Type filter
|
||
if (type === 'discrepancy' && diff === 0) visible = false;
|
||
if (type === 'no_discrepancy' && diff !== 0) visible = false;
|
||
if (type === 'negative' && diff >= 0) visible = false;
|
||
if (type === 'positive' && diff <= 0) visible = false;
|
||
if (type === 'threshold' && absDiff <= threshold) visible = false;
|
||
|
||
// Validation filter
|
||
if (validation === 'validated' && !validated) visible = false;
|
||
if (validation === 'not_validated' && validated) visible = false;
|
||
|
||
// Search filter
|
||
if (search && !product.includes(search)) visible = false;
|
||
|
||
r.style.display = visible ? '' : 'none';
|
||
if (visible) shown++;
|
||
});
|
||
visibleCount.textContent = shown + ' / ' + currentRows.length;
|
||
}
|
||
|
||
function recalculateSummary() {
|
||
const currentRows = document.querySelectorAll('#inventory-items-body tr[data-diff]');
|
||
let negCount = 0, posCount = 0, okCount = 0;
|
||
currentRows.forEach(r => {
|
||
const d = parseInt(r.dataset.diff, 10);
|
||
if (d < 0) negCount++;
|
||
else if (d > 0) posCount++;
|
||
else okCount++;
|
||
});
|
||
document.getElementById('summary-neg').textContent = negCount;
|
||
document.getElementById('summary-pos').textContent = posCount;
|
||
document.getElementById('summary-ok').textContent = okCount;
|
||
|
||
const totalCard = document.getElementById('summary-total');
|
||
if (totalCard) {
|
||
totalCard.textContent = currentRows.length;
|
||
}
|
||
}
|
||
|
||
filterType.addEventListener('change', applyFilters);
|
||
filterThreshold.addEventListener('input', applyFilters);
|
||
filterValidation.addEventListener('change', applyFilters);
|
||
filterSearch.addEventListener('input', applyFilters);
|
||
applyFilters();
|
||
|
||
// ── Toggle validation via AJAX ─────────────────────────────────────
|
||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||
|
||
document.getElementById('inventory-items-body').addEventListener('click', function (e) {
|
||
const btn = e.target.closest('.btn-toggle-validate');
|
||
if (!btn || btn.disabled) return;
|
||
|
||
const row = btn.closest('tr');
|
||
btn.disabled = true;
|
||
const savedHTML = btn.innerHTML;
|
||
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||
|
||
fetch(btn.dataset.url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (!data.success) {
|
||
alert(data.error || '{% translate "Une erreur est survenue." %}');
|
||
btn.innerHTML = savedHTML;
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// Update row data attribute (used by filters)
|
||
row.dataset.validated = data.is_validated ? '1' : '0';
|
||
|
||
// Update status badge
|
||
const statusCell = row.querySelector('.td-status');
|
||
if (data.is_validated) {
|
||
statusCell.innerHTML = `<span class="badge bg-success" title="${data.validated_by} – ${data.validated_at}"><i class="bi bi-check-lg"></i> {% translate "Validé" %}</span>`;
|
||
} else {
|
||
statusCell.innerHTML = `<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split"></i> {% translate "En attente" %}</span>`;
|
||
}
|
||
|
||
// Update toggle button
|
||
if (data.is_validated) {
|
||
btn.className = 'btn btn-sm btn-toggle-validate btn-success';
|
||
btn.title = '{% translate "Annuler la validation" %}';
|
||
btn.innerHTML = '<i class="bi bi-check-circle-fill"></i>';
|
||
} else {
|
||
btn.className = 'btn btn-sm btn-toggle-validate btn-outline-success';
|
||
btn.title = '{% translate "Valider cet article" %}';
|
||
btn.innerHTML = '<i class="bi bi-check"></i>';
|
||
}
|
||
btn.disabled = false;
|
||
|
||
// Re-apply filters so validation filter stays consistent
|
||
applyFilters();
|
||
})
|
||
.catch(() => {
|
||
btn.innerHTML = savedHTML;
|
||
btn.disabled = false;
|
||
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
|
||
});
|
||
});
|
||
|
||
// ── Bouton « Tout valider » dans le header du tableau ──────────────
|
||
const btnValidateVisible = document.getElementById('btn-validate-visible');
|
||
if (btnValidateVisible) {
|
||
btnValidateVisible.addEventListener('click', function () {
|
||
const currentRows = document.querySelectorAll('#inventory-items-body tr[data-diff]');
|
||
const ids = [];
|
||
currentRows.forEach(r => {
|
||
if (r.style.display !== 'none' && r.dataset.validated === '0') {
|
||
ids.push(r.dataset.itemId);
|
||
}
|
||
});
|
||
if (ids.length === 0) {
|
||
alert('{% translate "Aucun article non validé visible à valider." %}');
|
||
return;
|
||
}
|
||
if (!confirm('{% translate "Valider les" %} ' + ids.length + ' {% translate "article(s) visible(s) ?" %}')) {
|
||
return;
|
||
}
|
||
const container = document.getElementById('batch-item-ids');
|
||
container.innerHTML = '';
|
||
ids.forEach(id => {
|
||
const input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = 'item_ids';
|
||
input.value = id;
|
||
container.appendChild(input);
|
||
});
|
||
document.getElementById('batch-validate-form').classList.remove('d-none');
|
||
document.getElementById('batch-validate-form').submit();
|
||
});
|
||
}
|
||
|
||
// ── Bouton commentaire → modal ────────────────────────────────
|
||
const commentModal = new bootstrap.Modal(document.getElementById('commentModal'));
|
||
let activeCommentItemId = null;
|
||
let activeCommentUpdateUrl = null;
|
||
let activeCommentRow = null;
|
||
let activeCommentBtn = null;
|
||
|
||
// Rebuild the product-name cell with both comments + authors
|
||
function _updateProductCell(row, data) {
|
||
const cell = row.querySelector('td:first-child');
|
||
if (!cell) return;
|
||
const productName = (cell.querySelector('.fw-semibold') || {}).textContent || '';
|
||
let html = `<span class="fw-semibold">${escapeHtml(productName)}</span>`;
|
||
if (data.comment) {
|
||
html += `<br><small class="text-muted"><i class="bi bi-person-fill me-1"></i><strong>${escapeHtml(data.comment_by_short_name || '?')}</strong> \u2014 <span class="worker-comment-text">${escapeHtml(data.comment)}</span></small>`;
|
||
}
|
||
if (data.validator_comment) {
|
||
html += `<br><small class="text-info"><i class="bi bi-person-badge-fill me-1"></i><strong>${escapeHtml(data.validator_comment_by_short_name || '?')}</strong> \u2014 <span class="val-comment-text">${escapeHtml(data.validator_comment)}</span></small>`;
|
||
}
|
||
cell.innerHTML = html;
|
||
}
|
||
|
||
document.getElementById('inventory-items-body').addEventListener('click', function (e) {
|
||
const btn = e.target.closest('.btn-show-comment');
|
||
if (!btn) return;
|
||
|
||
activeCommentBtn = btn;
|
||
activeCommentRow = btn.closest('tr');
|
||
activeCommentItemId = btn.dataset.itemId;
|
||
activeCommentUpdateUrl = btn.dataset.updateUrl;
|
||
|
||
document.getElementById('comment-modal-product').textContent = btn.dataset.product;
|
||
|
||
// Worker comment section
|
||
const workerComment = btn.dataset.comment || '';
|
||
const workerAuthorEl = document.getElementById('comment-modal-worker-author');
|
||
if (workerAuthorEl) workerAuthorEl.textContent = btn.dataset.commentByShortName || '';
|
||
|
||
const workerInput = document.getElementById('comment-modal-worker-input');
|
||
if (workerInput) {
|
||
workerInput.value = workerComment;
|
||
} else {
|
||
const workerText = document.getElementById('comment-modal-text');
|
||
if (workerText) workerText.textContent = workerComment;
|
||
}
|
||
document.getElementById('comment-modal-worker-section').style.display = '';
|
||
|
||
// Validator comment section
|
||
const valComment = btn.dataset.validatorComment || '';
|
||
const valAuthorEl = document.getElementById('comment-modal-validator-author');
|
||
if (valAuthorEl) valAuthorEl.textContent = btn.dataset.validatorCommentByShortName || '';
|
||
|
||
const valInput = document.getElementById('comment-modal-validator-input');
|
||
if (valInput) {
|
||
valInput.value = valComment;
|
||
document.getElementById('comment-modal-validator-section').style.display = '';
|
||
} else {
|
||
const valText = document.getElementById('comment-modal-validator-text');
|
||
if (valText) {
|
||
valText.textContent = valComment;
|
||
document.getElementById('comment-modal-validator-section').style.display = valComment ? '' : 'none';
|
||
}
|
||
}
|
||
|
||
document.getElementById('comment-modal-divider').style.display = '';
|
||
|
||
commentModal.show();
|
||
});
|
||
|
||
// ── Enregistrer commentaire magasinier (modal) ────────────────
|
||
const btnSaveWorkerComment = document.getElementById('btn-save-modal-worker-comment');
|
||
if (btnSaveWorkerComment) {
|
||
btnSaveWorkerComment.addEventListener('click', function() {
|
||
const workerInput = document.getElementById('comment-modal-worker-input');
|
||
if (!workerInput || !activeCommentUpdateUrl) return;
|
||
|
||
btnSaveWorkerComment.disabled = true;
|
||
const originalHTML = btnSaveWorkerComment.innerHTML;
|
||
btnSaveWorkerComment.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||
|
||
const formData = new FormData();
|
||
formData.append('comment', workerInput.value);
|
||
|
||
fetch(activeCommentUpdateUrl, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRFToken': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||
body: formData,
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (!data.success) {
|
||
alert(data.error || '{% translate "Une erreur est survenue." %}');
|
||
return;
|
||
}
|
||
|
||
activeCommentBtn.dataset.comment = data.comment;
|
||
activeCommentBtn.dataset.commentByShortName = data.comment_by_short_name;
|
||
|
||
const icon = activeCommentBtn.querySelector('i');
|
||
const hasComment = (data.comment || '') !== '' || (activeCommentBtn.dataset.validatorComment || '') !== '';
|
||
icon.className = hasComment ? 'bi bi-chat-text-fill text-primary' : 'bi bi-chat-text';
|
||
|
||
_updateProductCell(activeCommentRow, {
|
||
comment: data.comment,
|
||
comment_by_short_name: data.comment_by_short_name,
|
||
validator_comment: activeCommentBtn.dataset.validatorComment || '',
|
||
validator_comment_by_short_name: activeCommentBtn.dataset.validatorCommentByShortName || '',
|
||
});
|
||
|
||
commentModal.hide();
|
||
})
|
||
.catch(() => { alert('{% translate "Erreur réseau. Veuillez réessayer." %}'); })
|
||
.finally(() => {
|
||
btnSaveWorkerComment.disabled = false;
|
||
btnSaveWorkerComment.innerHTML = originalHTML;
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Enregistrer commentaire validateur (modal) ────────────────
|
||
const btnSaveValComment = document.getElementById('btn-save-modal-validator-comment');
|
||
if (btnSaveValComment) {
|
||
btnSaveValComment.addEventListener('click', function() {
|
||
const valInput = document.getElementById('comment-modal-validator-input');
|
||
if (!valInput || !activeCommentUpdateUrl) return;
|
||
|
||
btnSaveValComment.disabled = true;
|
||
const originalHTML = btnSaveValComment.innerHTML;
|
||
btnSaveValComment.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||
|
||
const formData = new FormData();
|
||
formData.append('validator_comment', valInput.value);
|
||
|
||
fetch(activeCommentUpdateUrl, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRFToken': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||
body: formData,
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (!data.success) {
|
||
alert(data.error || '{% translate "Une erreur est survenue." %}');
|
||
return;
|
||
}
|
||
|
||
activeCommentBtn.dataset.validatorComment = data.validator_comment;
|
||
activeCommentBtn.dataset.validatorCommentByShortName = data.validator_comment_by_short_name;
|
||
|
||
const icon = activeCommentBtn.querySelector('i');
|
||
const hasComment = (activeCommentBtn.dataset.comment || '') !== '' || (data.validator_comment || '') !== '';
|
||
icon.className = hasComment ? 'bi bi-chat-text-fill text-primary' : 'bi bi-chat-text';
|
||
|
||
_updateProductCell(activeCommentRow, {
|
||
comment: activeCommentBtn.dataset.comment || '',
|
||
comment_by_short_name: activeCommentBtn.dataset.commentByShortName || '',
|
||
validator_comment: data.validator_comment,
|
||
validator_comment_by_short_name: data.validator_comment_by_short_name,
|
||
});
|
||
|
||
commentModal.hide();
|
||
})
|
||
.catch(() => { alert('{% translate "Erreur réseau. Veuillez réessayer." %}'); })
|
||
.finally(() => {
|
||
btnSaveValComment.disabled = false;
|
||
btnSaveValComment.innerHTML = originalHTML;
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Modal QR Code ─────────────────────────────────────────────
|
||
const qrCodeModal = document.getElementById('qrCodeModal');
|
||
if (qrCodeModal) {
|
||
qrCodeModal.addEventListener('show.bs.modal', function (e) {
|
||
const trigger = e.relatedTarget;
|
||
if (!trigger) return;
|
||
const url = trigger.dataset.qrUrl;
|
||
const label = trigger.dataset.qrLabel || '';
|
||
document.getElementById('qr-modal-img').src = url;
|
||
document.getElementById('qr-modal-download').href = url;
|
||
document.getElementById('qr-modal-label').textContent = label;
|
||
});
|
||
}
|
||
|
||
// ── Inline Editing - Quantités comptées ───────────────────────
|
||
document.getElementById('inventory-items-body').addEventListener('change', function (e) {
|
||
const input = e.target.closest('.input-counted-quantity');
|
||
if (!input) return;
|
||
|
||
const row = input.closest('tr');
|
||
const updateUrl = input.dataset.updateUrl;
|
||
const val = parseInt(input.value, 10);
|
||
|
||
if (isNaN(val) || val < 0) {
|
||
alert('{% translate "La quantité ne peut pas être négative ou invalide." %}');
|
||
input.value = input.dataset.originalVal || 0;
|
||
return;
|
||
}
|
||
|
||
input.disabled = true;
|
||
const formData = new FormData();
|
||
formData.append('counted_quantity', val);
|
||
|
||
fetch(updateUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: formData,
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
input.disabled = false;
|
||
if (!data.success) {
|
||
alert(data.error || '{% translate "Une erreur est survenue." %}');
|
||
input.value = input.dataset.originalVal || 0;
|
||
return;
|
||
}
|
||
|
||
input.dataset.originalVal = data.counted_quantity;
|
||
input.value = data.counted_quantity;
|
||
|
||
const diffCell = row.querySelector('.td-difference');
|
||
if (diffCell) {
|
||
const diff = data.difference;
|
||
row.dataset.diff = diff;
|
||
diffCell.textContent = (diff > 0 ? '+' : '') + diff;
|
||
diffCell.className = 'text-end fw-bold td-difference ' + (diff < 0 ? 'text-danger' : (diff > 0 ? 'text-success' : ''));
|
||
}
|
||
|
||
row.className = data.difference < 0 ? 'table-danger' : (data.difference > 0 ? 'table-success' : '');
|
||
|
||
recalculateSummary();
|
||
applyFilters();
|
||
})
|
||
.catch(() => {
|
||
input.disabled = false;
|
||
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
|
||
});
|
||
});
|
||
|
||
// ── Inline Editing - Commentaires magasinier ───────────────────
|
||
document.getElementById('inventory-items-body').addEventListener('change', function (e) {
|
||
const input = e.target.closest('.input-comment');
|
||
if (!input) return;
|
||
|
||
const row = input.closest('tr');
|
||
const updateUrl = input.dataset.updateUrl;
|
||
const val = input.value.trim();
|
||
|
||
input.disabled = true;
|
||
const formData = new FormData();
|
||
formData.append('comment', val);
|
||
|
||
fetch(updateUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: formData,
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
input.disabled = false;
|
||
if (!data.success) {
|
||
alert(data.error || '{% translate "Une erreur est survenue." %}');
|
||
input.value = input.dataset.originalVal || '';
|
||
return;
|
||
}
|
||
|
||
input.dataset.originalVal = data.comment;
|
||
input.value = data.comment;
|
||
|
||
const commentBtn = row.querySelector('.btn-show-comment');
|
||
if (commentBtn) {
|
||
commentBtn.dataset.comment = data.comment;
|
||
const icon = commentBtn.querySelector('i');
|
||
const hasComment = data.comment !== '' || (commentBtn.dataset.validatorComment || '') !== '';
|
||
if (icon) {
|
||
if (hasComment) {
|
||
icon.className = 'bi bi-chat-text-fill text-primary';
|
||
} else {
|
||
icon.className = 'bi bi-chat-text';
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {
|
||
input.disabled = false;
|
||
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
|
||
});
|
||
});
|
||
|
||
// ── AJAX Deletion of Lines ──────────────────────────────────────
|
||
document.getElementById('inventory-items-body').addEventListener('click', function (e) {
|
||
const btn = e.target.closest('.btn-delete-item');
|
||
if (!btn || btn.disabled) return;
|
||
|
||
{% translate "Voulez-vous vraiment supprimer cet article de l'inventaire ?" as delete_confirm_msg %}
|
||
if (!confirm('{{ delete_confirm_msg|escapejs }}')) {
|
||
return;
|
||
}
|
||
|
||
const row = btn.closest('tr');
|
||
btn.disabled = true;
|
||
const savedHTML = btn.innerHTML;
|
||
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||
|
||
fetch(btn.dataset.url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (!data.success) {
|
||
alert(data.error || '{% translate "Une erreur est survenue." %}');
|
||
btn.innerHTML = savedHTML;
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
row.remove();
|
||
|
||
const remainingRows = document.querySelectorAll('#inventory-items-body tr[data-diff]');
|
||
if (remainingRows.length === 0) {
|
||
const tbody = document.getElementById('inventory-items-body');
|
||
const emptyRow = document.createElement('tr');
|
||
emptyRow.id = 'empty-row';
|
||
emptyRow.innerHTML = `<td colspan="9" class="text-center text-muted py-4">{% translate "Aucun article dans cet inventaire." %}</td>`;
|
||
tbody.appendChild(emptyRow);
|
||
}
|
||
|
||
recalculateSummary();
|
||
applyFilters();
|
||
})
|
||
.catch(() => {
|
||
btn.innerHTML = savedHTML;
|
||
btn.disabled = false;
|
||
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
|
||
});
|
||
});
|
||
|
||
// ── Auto-complete Products & Dynamic addition ──────────────────
|
||
{% if inventory.is_editable %}
|
||
const productsList = {{ products_json|safe }};
|
||
const productSelect = document.getElementById('add-product-select');
|
||
if (productSelect) {
|
||
let options = '<option value="">-- {% translate "Sélectionner" %} --</option>';
|
||
productsList.forEach(p => {
|
||
options += `<option value="${p.id}">${p.code} – ${p.name}</option>`;
|
||
});
|
||
productSelect.innerHTML = options;
|
||
|
||
const addProductModalEl = document.getElementById('addProductModal');
|
||
if (addProductModalEl) {
|
||
addProductModalEl.addEventListener('shown.bs.modal', function () {
|
||
$('#add-product-select').select2({
|
||
theme: 'bootstrap-5',
|
||
dropdownParent: $('#addProductModal')
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
const btnSubmitAddProduct = document.getElementById('btn-submit-add-product');
|
||
if (btnSubmitAddProduct) {
|
||
btnSubmitAddProduct.addEventListener('click', function () {
|
||
const productSelect = document.getElementById('add-product-select');
|
||
const qtyInput = document.getElementById('add-product-qty');
|
||
const commentInput = document.getElementById('add-product-comment');
|
||
|
||
const productId = productSelect.value;
|
||
const countedQuantity = qtyInput.value;
|
||
const comment = commentInput.value;
|
||
|
||
if (!productId) {
|
||
alert('{% translate "Veuillez sélectionner un produit." %}');
|
||
return;
|
||
}
|
||
|
||
btnSubmitAddProduct.disabled = true;
|
||
const originalHTML = btnSubmitAddProduct.innerHTML;
|
||
btnSubmitAddProduct.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||
|
||
const formData = new FormData();
|
||
formData.append('product_id', productId);
|
||
formData.append('counted_quantity', countedQuantity);
|
||
formData.append('comment', comment);
|
||
|
||
fetch("{% url 'stock:inventory_add_item_api' inventory.pk %}", {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: formData,
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
btnSubmitAddProduct.disabled = false;
|
||
btnSubmitAddProduct.innerHTML = originalHTML;
|
||
|
||
if (!data.success) {
|
||
alert(data.error || '{% translate "Une erreur est survenue." %}');
|
||
return;
|
||
}
|
||
|
||
const modalEl = document.getElementById('addProductModal');
|
||
const modalInstance = bootstrap.Modal.getInstance(modalEl);
|
||
if (modalInstance) {
|
||
modalInstance.hide();
|
||
}
|
||
|
||
$(productSelect).val('').trigger('change');
|
||
qtyInput.value = '0';
|
||
commentInput.value = '';
|
||
|
||
const emptyRow = document.getElementById('empty-row');
|
||
if (emptyRow) {
|
||
emptyRow.remove();
|
||
}
|
||
|
||
const tbody = document.getElementById('inventory-items-body');
|
||
const tr = document.createElement('tr');
|
||
tr.className = data.difference < 0 ? 'table-danger' : (data.difference > 0 ? 'table-success' : '');
|
||
tr.dataset.diff = data.difference;
|
||
tr.dataset.validated = data.is_validated ? '1' : '0';
|
||
tr.dataset.product = data.product_name.toLowerCase();
|
||
tr.dataset.itemId = data.item_id;
|
||
|
||
let rowHTML = `
|
||
<td>
|
||
<span class="fw-semibold">${escapeHtml(data.product_name)}</span>
|
||
</td>
|
||
<td>{{ inventory.warehouse_location.name }}</td>
|
||
<td class="text-end td-expected">${data.expected_quantity}</td>
|
||
<td class="td-counted">
|
||
<div class="d-flex align-items-center justify-content-end gap-1">
|
||
<input type="number" class="form-control form-control-sm text-end input-counted-quantity"
|
||
value="${data.counted_quantity}" min="0" style="width:90px"
|
||
data-item-id="${data.item_id}"
|
||
data-original-val="${data.counted_quantity}"
|
||
data-update-url="${data.update_url}">
|
||
<button type="button"
|
||
class="btn btn-sm btn-outline-secondary flex-shrink-0 btn-show-comment"
|
||
data-comment="${escapeHtml(data.comment)}"
|
||
data-comment-by-short-name=""
|
||
data-validator-comment=""
|
||
data-validator-comment-by-short-name=""
|
||
data-product="${escapeHtml(data.product_name)}"
|
||
data-item-id="${data.item_id}"
|
||
data-update-url="${data.update_url}"
|
||
title="${data.can_validate ? '{% translate "Commentaires et validation" %}' : '{% translate "Voir/ajouter un commentaire" %}'}">
|
||
<i class="bi ${data.comment ? 'bi-chat-text-fill text-primary' : 'bi-chat-text'}"></i>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td class="text-end fw-bold td-difference ${data.difference < 0 ? 'text-danger' : (data.difference > 0 ? 'text-success' : '')}">
|
||
${data.difference > 0 ? '+' : ''}${data.difference}
|
||
</td>
|
||
<td class="text-center td-status">
|
||
<span class="badge bg-warning text-dark">
|
||
<i class="bi bi-hourglass-split"></i> {% translate "En attente" %}
|
||
</span>
|
||
</td>`;
|
||
|
||
if (data.can_validate) {
|
||
rowHTML += `
|
||
<td class="text-center td-toggle-validate-cell">
|
||
<button type="button"
|
||
class="btn btn-sm btn-toggle-validate btn-outline-success"
|
||
data-url="${data.validate_url}"
|
||
title="{% translate 'Valider cet article' %}">
|
||
<i class="bi bi-check"></i>
|
||
</button>
|
||
</td>`;
|
||
}
|
||
|
||
if (data.can_validate) {
|
||
rowHTML += `
|
||
<td class="text-center">
|
||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-item"
|
||
data-url="${data.delete_url}"
|
||
title="{% translate 'Supprimer cette ligne' %}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</td>`;
|
||
}
|
||
|
||
tr.innerHTML = rowHTML;
|
||
tbody.appendChild(tr);
|
||
|
||
recalculateSummary();
|
||
applyFilters();
|
||
})
|
||
.catch(() => {
|
||
btnSubmitAddProduct.disabled = false;
|
||
btnSubmitAddProduct.innerHTML = originalHTML;
|
||
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
|
||
});
|
||
});
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
return text
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
{% endif %}
|
||
|
||
})();
|
||
|
||
function printQrCode() {
|
||
const img = document.getElementById('qr-modal-img');
|
||
const label = document.getElementById('qr-modal-label').textContent;
|
||
const win = window.open('', '_blank');
|
||
win.document.write(
|
||
'<html><head><title>QR Code — ' + label + '</title>'
|
||
+ '<style>body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;font-family:sans-serif;}'
|
||
+ 'img{max-width:300px;}p{margin-top:1rem;font-size:1.1rem;}</style></head>'
|
||
+ '<body><img src="' + img.src + '" alt="QR Code"><p>' + label + '</p></body></html>'
|
||
);
|
||
win.document.close();
|
||
win.focus();
|
||
win.print();
|
||
}
|
||
</script>
|
||
{% endblock script %}
|